Zip slip: reproducing archive path traversal in a local extractor
Zip slip is a path traversal flaw in archive extraction: the code joins each member name from an uploaded archive to the destination folder and never checks where the result lands. In our local test, a hand-written extractor wrote outside its destination for all five hostile archives we sent (a ../ name and an absolute name in both a zip and a tar, plus a tar with a symlink member). With a resolve-and-refuse check switched on, the same endpoint answered 400 to all five, wrote nothing, and still extracted the three legitimate archives.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
What zip slip is, and what this fixture covers
The zip slip vulnerability is archive path traversal. An attacker controls the member names inside a zip, tar or similar archive, and an extractor that trusts them turns ../outside/x.txt into a write outside the folder it was meant to fill. The name comes from Snyk's 2018 research, and CWE-22 lists the zip case in its examples.
It is the weakness from our path traversal post with the hostile path hidden inside a file instead of a URL. The defense is the same: resolve the path, then compare it with the allowed folder. Archives add one twist. A member can be a symlink, and a later member can write through it.
- Target: two short-lived HTTP servers on
127.0.0.1, on ports the operating system picks, withPOST /upload/zipandPOST /upload/tar. - Layout:
verify.pycreates a temporary folder withuploads/as the destination and a sibling folderoutside/that no member should reach. The runner deletes it at the end. - Archives: eight, built in memory from synthetic marker text. Three are legitimate and five are hostile.
- Extractor: a hand-written loop over the members, not
extractall, in both modes.
The server refuses a --root folder whose name does not start with zipslip- and writes only below it. Test only systems you own or have written permission to test. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system. Upload handling in general is out of scope; this post covers only the extraction step.
Run the downloadable fixture
Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts each server in turn and uploads every archive with curl --data-binary, which makes sixteen real requests. After each upload it lists the temporary folder to see which files appeared and where. It then extracts the same archives with Python's own zipfile and tarfile in separate sandboxes.
Our recorded command was python3 verify.py --output recorded-run. It exited successfully with {"checks": 16, "stdlib_runs": 16, "passed": true, "failures": []}. That means every upload matched its expected outcome, including the intentionally vulnerable ones, and the temporary folder was gone afterwards. The run on 11 November 2026 used Python 3.15.2 and curl 8.7.1 on macOS ARM64. We ran the script again on the system Python 3.9.6, which has no tar extraction filters. That output is in recorded-run-py39.
Observed results: eight archives, two modes
Each archive went to the vulnerable server and then to the fixed server. The three legitimate archives are the positive controls. One of them, docs/../notes.txt, contains a .. segment but resolves inside the destination, so it also checks that the fix does not reject every dot-dot. Policy expects 200 for those three and 400 for the five hostile archives.
| Archive | Member names | Vulnerable mode | Fixed mode |
|---|---|---|---|
| zip, legitimate | docs/readme.txt | 200: inside | 200: inside |
| zip, inner dot-dot | docs/../notes.txt | 200: uploads/notes.txt | 200: uploads/notes.txt |
zip, ../ | ../outside/pwned-zip-dotdot.txt | 200: written to outside/ | 400: nothing written |
| zip, absolute | <tmp>/outside/pwned-zip-absolute.txt | 200: written to outside/ | 400: nothing written |
| tar, legitimate | reports/summary.txt | 200: inside | 200: inside |
tar, ../ | ../outside/pwned-tar-dotdot.txt | 200: written to outside/ | 400: nothing written |
| tar, absolute | <tmp>/outside/pwned-tar-absolute.txt | 200: written to outside/ | 400: nothing written |
| tar, symlink | link to ../outside, then link/pwned-tar-symlink.txt | 200: uploads/link made, file written to outside/ | 400: nothing written |
In the table, <tmp> is the run's random temporary folder. Vulnerable mode answered 200 to every archive, hostile or not, so the status code alone proves nothing. The evidence is the file system. After the five hostile uploads, outside/ held five marker files that no upload should have created. In the symlink case, the extractor made uploads/link and then wrote the second member through it, so the marker landed in outside/ although that member's own name looked harmless.
Verify a zip slip finding before you report it
List the members first. For archives you built, python3 -m zipfile -l upload.zip and python3 -m tarfile -l upload.tar show them. Look for names that start with / or contain .., and for link members. The executed command for the recorded vulnerable zip upload, on port 51194, was this, reformatted across lines:
curl --noproxy '*' --silent --show-error --include \
--http1.1 --connect-timeout 1 --max-time 3 --request POST \
--header 'Content-Type: application/octet-stream' \
--data-binary @vulnerable-zip-dotdot.zip \
http://127.0.0.1:51194/upload/zip
The recorded response was 200 with {"extracted": ["../outside/pwned-zip-dotdot.txt"], "status": "ok"}. That is weak evidence alone, because the legitimate archive got the same status. We report a finding only when three facts hold together:
- The archive lists a member whose name or link target resolves outside the destination.
- After the upload, the marker file exists at the outside path with the marker content. Here
verify.pyreadoutside/pwned-zip-dotdot.txtand compared its text with the text it had put in the archive. - A control archive with plain names extracted inside the destination in the same session.
On a real system you rarely see the file system. Ask the owner to check an agreed folder, and never write over a file that exists. Record the endpoint, member name, destination, observed outside path and control result, in the layout used in our API authorization testing post. Here the impact is five synthetic files in a sibling folder. Whether such a write reaches code execution depends on what sits at the target path, and this experiment does not show that.
What Python's zipfile and tarfile already do
The standard library helps. The zipfile documentation says an absolute member name loses its leading slashes and .. components are removed. The tarfile extraction filters section says the default filter became data in Python 3.14, and that it was equivalent to fully_trusted before. We ran the hostile tars through extractall:
| Hostile tar | 3.15.2, no filter argument | 3.15.2, filter='data' | 3.15.2, filter='fully_trusted' | 3.9.6, no filter argument |
|---|---|---|---|---|
../ name | Refused | Refused | Escaped | Escaped |
| Absolute name | Written inside uploads/ | Written inside uploads/ | Escaped | Escaped |
| Symlink member | Refused | Refused | Escaped | Escaped |
The refusals were OutsideDestinationError for the ../ name and LinkOutsideDestinationError for the symlink. For the absolute name, the filter dropped the leading slash and extracted the file inside uploads/.
The zipfile.extractall call never wrote outside uploads/ on either Python version, but it refused nothing. It rewrote names. ../outside/pwned-zip-dotdot.txt landed at uploads/outside/pwned-zip-dotdot.txt, and the legitimate docs/../notes.txt landed at uploads/docs/notes.txt, not uploads/notes.txt. A server built on it returns success for a hostile archive, so a 200 after a hostile upload is not a finding until you see where the file went.
Tar extraction therefore depends on the Python version. The Python documentation recommends setting filter='data' explicitly for code that must also run on 3.13 and lower, where the default is less secure. It also warns that even the 3.14 default does not prevent all insecure behavior. Our fixture uses a hand-written loop because many applications need custom handling, such as renaming or size caps.
Fix: resolve each path, refuse anything outside
Both modes share one extraction loop. They differ only in the function that turns a member name into a target path. Vulnerable mode joins. Fixed mode resolves the result and refuses anything outside the destination:
def inside(dest, path):
real_dest = os.path.realpath(dest)
return os.path.commonpath([real_dest, os.path.realpath(path)]) == real_dest
def target_for(mode, dest, name):
if mode == "vulnerable":
return os.path.join(dest, name)
target = os.path.join(dest, name)
if not inside(dest, target):
raise Refused(name)
return target
An absolute member name defeats the join itself: os.path.join(dest, "/abs/path") returns /abs/path. realpath collapses .. segments and follows existing symlinks before the comparison. A link member gets its own check in link_ok, because its target resolves relative to the folder that holds the link. Fixed mode first makes a dry pass over all members, so when that pass catches a hostile member late in the archive, no earlier file is left behind. We prefer refusing over stripping, which is what zipfile does, because stripping turns a hostile name into a file whose name the uploader chose.
The runner restarted the server in fixed mode on port 51203 and uploaded the same eight archives. All five hostile uploads returned 400, for example 400 with {"error": "member_outside_destination", "member": "../outside/pwned-zip-dotdot.txt"}. The symlink case named link -> ../outside. No refusal left a new file, so uploads/link was never created. The three legitimate archives still returned 200 and wrote inside uploads/.
Inspect the evidence bundle
- Machine-readable results: sixteen upload cases and sixteen standard-library runs with member names, expected and observed outcomes, and new file paths.
- Exact recorded curl commands: the ports and temporary folders belong to the finished run.
- Python 3.9.6 results: the same script without tar extraction filters.
- SHA-256 manifest: an integrity check, not independent attestation.
- Instructions and limitations, server source and verification source.
The fixture is also on GitHub: web-security-fixtures. All archives and marker contents are synthetic.
What this experiment does not cover
We did not test Windows paths or drive letters, hard link members, device files, zip entries that carry symlink mode bits, case-insensitive file systems, Unicode normalization, or formats such as jar, rar, 7z and cpio. We did not test decompression bombs, size limits, a second writer racing the check, or chains of several symlink members. Standard-library behavior was observed on Python 3.15.2 and 3.9.6 only; we did not run 3.12 or 3.13.
For a real engagement, agree the marker file name and the folder where the owner will look, and pair every hostile upload with a control.
Excession Labs runs this kind of archive-handling test in its security assessments, with a harmless marker file and a control archive for each hostile one.