← All posts

When Path Filters Meet Hard Links to Symlinks in CPython

Every archive path can look safe while the filesystem object reached at extraction time crosses the boundary.

An archive manifest feeds two linked filesystem entries inside a boundary while a symbolic-link path escapes to an external file
The filter approved the names. The kernel operated on the objects those names reached.

A tar extraction filter can verify that every member name and link target remains inside the destination directory, yet extraction can still modify a file outside that directory and expose its contents.

This is not an arithmetic mistake in path normalization and it does not require a missed ../ segment. Every archive pathname can look safe. The failure appears because Python's tarfile filter and the filesystem operation disagree about what a hard link to a symbolic link means.

I discovered and reported this behavior in CPython's standard library. It is tracked as CVE-2026-82049. This post explains the primitive, the validation gap, the impact, and why the repair had to align filter semantics with the operating system call that performs extraction.

The security boundary

Untrusted archive extraction has a long history of path traversal bugs. An absolute member such as /etc/shadow, or a relative name such as ../../../../etc/shadow, can make a naive extractor write beyond its intended destination.

PEP 706 added extraction filters to Python 3.12. The built-in tar filter rejects absolute paths and paths that escape the destination after resolution. The stricter data filter also rejects links outside the destination, special files, and unsafe ownership or permission details.

The intended boundary is stronger than a ban on suspicious spelling: extracting into a designated directory should not let an archive modify or expose objects elsewhere in the filesystem.

The published record identifies CPython 3.13 and earlier as affected, with the affected range ending before 3.14.0b1. I verified the behavior on CPython 3.13.15 on Linux.

The primitive: two kinds of link

A symbolic link is its own directory entry and inode. It stores a path string, and the operating system resolves that string when another operation dereferences the link. Its target does not need to exist when the symlink is created.

A hard link is another name for an existing inode. It does not retain the pathname used to create it, cannot normally cross filesystem boundaries, and generally requires the source object to exist.

Tar represents both with a linkname field. For a symbolic-link member, linkname is the target string. For a hard-link member, it identifies another archive member whose content the new entry should share.

The edge case is a hard-link member whose named archive target is itself a symbolic link.

Fig. 01Path reference versus object identity
A symlink preserves directions. A hard link preserves identity.

Where the model breaks

Consider two members. First, link_target is a symbolic link to /outside/sensitive_file. Second, link_entry is a tar hard link whose linkname is link_target.

The filter examines link_entry as a relationship between two archive paths. The name to create is under the extraction root. Its linkname also names an entry under that root. At this level, the hard-link relationship appears internal.

Both names pass the boundary checkPYTHON
# Conceptual validationsource = dest / member.linkname   # /tmp/dest/link_targettarget = dest / member.name       # /tmp/dest/link_entryassert source.is_relative_to(dest)assert target.is_relative_to(dest)

The vulnerable extraction path then called os.link(source, target) with semantics that did not follow the final symbolic link. On Linux, that creates a second hard link to the symlink inode itself. link_entry therefore becomes another symbolic link carrying the same external target string.

That detail is important. The primitive does not immediately hard-link the external file's inode. It duplicates the symlink object that reaches the external file. Later operations cross the boundary when they dereference the new entry.

Fig. 02Two correct-looking models, one unsafe gap
Filter sees paths
INSIDElink_entryname
INSIDElink_targetlinkname
Accepted
Filesystem sees objects
NEW NAMElink_entrysymlink inode 773
OLD NAMElink_targetsymlink inode 773
External target survives
The filter approved an in-tree relationship. The link call reproduced an out-of-tree indirection.

From an internal name to an external object

After creating an extracted entry, tarfile restores metadata such as mode bits and modification time. Calls such as chmod() and utime() normally follow a symbolic link. Applied to link_entry, they operate on /outside/sensitive_file, not on an inert object inside /tmp/dest.

The extracted entry also becomes a read path to the external referent. Any application that serves, indexes, uploads, or otherwise exposes the extraction tree can disclose the outside file through what appears to be an in-tree name.

The filter validated the pathnames it modeled. The filesystem later operated on a different object identity reached through those names. That semantic gap was the bypass.

Fig. 03The dereference happens after validation
  1. 01Archivehard link names symlink
  2. 02os.linkduplicates symlink inode
  3. 03Metadatachmod / utime follow
  4. 04Outsidefile changed or read
The pathname stays inside. The later operation follows the replicated symlink beyond it.

Impact

The vulnerability is classified as CWE-59, Improper Link Resolution Before File Access. Its CVSS v4.0 score is 8.4 High: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N.

  • Integrity, high: metadata restoration can change permissions and timestamps on an existing file outside the extraction directory.
  • Confidentiality, high: the extracted tree can contain a usable path to the external file, exposing its contents to downstream consumers.
  • Availability, none in the scored vector: the demonstrated primitive does not inherently destroy or deny access to the target.

This is not direct remote code execution. It does not inject commands, and creating the link does not overwrite the external file with attacker-chosen bytes. Exploitation also requires a victim or automated process to extract a crafted archive. The concrete effects are external metadata modification and object exposure through the extraction tree.

Fig. 04What the primitive crosses
VI:HIntegrity

Permissions and timestamps on an external file can change.

VC:HConfidentiality

The extracted tree can expose the external file's contents.

Not shownCode execution

No command execution or attacker-controlled content overwrite.

CVSS 4.0: 8.4 High. The demonstrated impact is object exposure and metadata mutation.

The fix: make validation and extraction mean the same thing

The remediation makes hard-link extraction follow the symbolic-link target explicitly. That may sound counterintuitive until the two semantic layers are separated.

A tar hard link denotes another archive member's content. The filter validates that resolved archive relationship. The vulnerable implementation instead hard-linked the symlink directory entry, preserving a path that could later escape. By following the symlink when creating the hard link, extraction now links the referent the filter reasoned about rather than cloning the indirection it did not.

CPython issue #157190 tracks the report. PR #157191 introduced the core repair and regression coverage, with PR #157192 carrying the maintained-branch work. Commit b38be2e records the explicit change under the title Follow symlinks when extracting tarfile hard links.

The security property is not that following symlinks is universally safer. It is that the validator and the consuming filesystem primitive must resolve the same object. A link operation with different semantics from the filter reopens the boundary even when both components appear correct in isolation.

Fig. 05The repair is semantic alignment
VALIDATEResolve archive targetWhich content does this member denote?
CREATEFollow the same targetLink the referent, not the symlink object.
Safety comes from validating and using the same filesystem object.

The broader lesson

The security boundary is not the normalized pathname. It is the filesystem object reached when the operation executes.

A pathname is a traversal recipe. Once that traversal encounters a symbolic link, the lexical relationship between the string and the final object can disappear. Once a hard link is created, the source pathname disappears from the relationship entirely; only shared inode identity remains.

Filesystem operations also have asymmetric link behavior. open(), chmod(), chown(), utime(), link(), and their platform variants have different defaults and flags for following symlinks. A filter cannot prove safety using one resolution model if the later system call executes another.

The same trap applies to package managers, container runtimes, build systems, backup tools, and any service that materializes an untrusted tree. Normalizing names is necessary. It is not sufficient. Safe extraction requires agreement from archive semantics, path validation, link creation, and every metadata operation that follows.

Timeline and credits

  • 8 September 2026: CPython issue #157190 opened with the hard-link-to-symlink extraction-filter bypass.
  • 10 September 2026: the remediation was merged across CPython branches.
  • 14 September 2026: the Python Software Foundation published the security announcement and CVE-2026-82049 was published.

Reporter: Jing Qian (Civitasmass). Coordinator: Stan Ulbrych. Remediation reviewer: Petr Viktorin.

SourcesCVE record · CVE-2026-82049CPython issue #157190 · Extraction-filter bypassCPython PR #157191 · Core remediationCPython PR #157192 · Maintained-branch backportCPython commit b38be2e · Follow symlinks for tar hard linksPEP 706 · Filter for tarfile.extractall

By Jing Qian (Civitasmass)Permanent link