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, with POST /upload/zip and POST /upload/tar.
  • Layout: verify.py creates a temporary folder with uploads/ as the destination and a sibling folder outside/ 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.

Observed HTTP status and file placement from sixteen local uploads
ArchiveMember namesVulnerable modeFixed mode
zip, legitimatedocs/readme.txt200: inside200: inside
zip, inner dot-dotdocs/../notes.txt200: uploads/notes.txt200: uploads/notes.txt
zip, ../../outside/pwned-zip-dotdot.txt200: written to outside/400: nothing written
zip, absolute<tmp>/outside/pwned-zip-absolute.txt200: written to outside/400: nothing written
tar, legitimatereports/summary.txt200: inside200: inside
tar, ../../outside/pwned-tar-dotdot.txt200: written to outside/400: nothing written
tar, absolute<tmp>/outside/pwned-tar-absolute.txt200: written to outside/400: nothing written
tar, symlinklink to ../outside, then link/pwned-tar-symlink.txt200: 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.py read outside/pwned-zip-dotdot.txt and 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 archives extracted with tarfile.extractall
Hostile tar3.15.2, no filter argument3.15.2, filter='data'3.15.2, filter='fully_trusted'3.9.6, no filter argument
../ nameRefusedRefusedEscapedEscaped
Absolute nameWritten inside uploads/Written inside uploads/EscapedEscaped
Symlink memberRefusedRefusedEscapedEscaped

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

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.