[Blog](https://excessionlabs.ai/blog/) · 11 November 2026 · [Excession Labs](https://excessionlabs.ai/about/)

# Zip slip: reproducing archive path traversal in a local extractor

Source page: https://excessionlabs.ai/blog/zip-slip/

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.

On this page: [Scope](https://excessionlabs.ai/blog/zip-slip/#scope) · [Reproduce the run](https://excessionlabs.ai/blog/zip-slip/#reproduce) · [Observed results](https://excessionlabs.ai/blog/zip-slip/#results) · [Verify the finding](https://excessionlabs.ai/blog/zip-slip/#finding) · [What Python already does](https://excessionlabs.ai/blog/zip-slip/#stdlib) · [Fix and retest](https://excessionlabs.ai/blog/zip-slip/#fix) · [Limits](https://excessionlabs.ai/blog/zip-slip/#limits)

## 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](https://security.snyk.io/research/zip-slip-vulnerability), and [CWE-22](https://cwe.mitre.org/data/definitions/22.html) lists the zip case in its examples.

It is the weakness from our [path traversal](https://excessionlabs.ai/blog/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](https://excessionlabs.ai/aup/) 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](https://excessionlabs.ai/blog/evidence/zip-slip/fixture.py) and [verify.py](https://excessionlabs.ai/blog/evidence/zip-slip/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

| 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](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run/vulnerable-zip-dotdot.http) 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](https://excessionlabs.ai/blog/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](https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile.extract) says an absolute member name loses its leading slashes and `..` components are removed. The [tarfile extraction filters](https://docs.python.org/3/library/tarfile.html#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 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"}`](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run/fixed-zip-dotdot.http). The symlink case named [`link -> ../outside`](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run/fixed-tar-symlink.http). 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](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run/results.json): sixteen upload cases and sixteen standard-library runs with member names, expected and observed outcomes, and new file paths.

- [Exact recorded curl commands](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run/commands.sh): the ports and temporary folders belong to the finished run.

- [Python 3.9.6 results](https://excessionlabs.ai/blog/evidence/zip-slip/recorded-run-py39/results.json): the same script without tar extraction filters.

- [SHA-256 manifest](https://excessionlabs.ai/blog/evidence/zip-slip/SHA256SUMS): an integrity check, not independent attestation.

- [Instructions and limitations](https://excessionlabs.ai/blog/evidence/zip-slip/README.txt), [server source](https://excessionlabs.ai/blog/evidence/zip-slip/fixture.py) and [verification source](https://excessionlabs.ai/blog/evidence/zip-slip/verify.py).

The fixture is also on GitHub: [web-security-fixtures](https://github.com/Excession-Labs/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](https://excessionlabs.ai/security-assessment/), with a harmless marker file and a control archive for each hostile one.
