Does zipfile.ZipFile.extractall stop archive members from escaping the destination?
answer
- One module sanitises, the other filters
- Names are cleaned, metadata is ignored
- Parent-directory components never survive
- Link members arrive as ordinary files
- The danger is the hand-rolled extraction loop
basics
~20 sYes, for member names: ZipFile.extract and extractall strip drive letters and leading separators and remove every parent-directory component, so members land under the destination. They do not recreate symlink members, do not apply stored permission bits, and do not limit decompressed size.
solid answer
~40 s`zipfile` sanitises rather than filters. `ZipFile.extract()`, which `extractall()` calls per member, strips a drive or share prefix and leading separators from the member name and removes all `..` components, so `../../etc/cron.d/x` is written to `etc/cron.d/x` **inside** the destination. Unlike `tarfile`, `zipfile` has no filter API and needs none for links: a member marked as a symlink in `ZipInfo.external_attr` is extracted as an ordinary file whose contents are the link target, and stored Unix permission bits are not applied, so an archived executable comes out non-executable. What is *not* covered is volume — a small archive can declare gigabytes — and none of the sanitising applies if you skip `extract()` and join `ZipInfo.filename` onto a destination yourself.
code
python · 16 linesimport io
import pathlib
import tempfile
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../../escape.txt", "pwned")
zf.writestr("/abs/rooted.txt", "pwned")
with tempfile.TemporaryDirectory() as dest:
with zipfile.ZipFile(buf) as zf:
print("stored names:", zf.namelist())
zf.extractall(dest)
root = pathlib.Path(dest)
print("written:", sorted(str(p.relative_to(root)) for p in root.rglob("*")))go deeper
Recall that extractall cleans member names so files land under the destination you gave it, and that this says nothing about how much data comes out. Know that zipfile and tarfile do not behave the same way.
Explain the mechanics: leading separators and drive prefixes stripped, every parent-directory component removed, symlink and permission metadata ignored. Be able to say why zipfile therefore has no filter API while tarfile needed one.
Point at where escapes actually come from in real code - the streaming loop that joins ZipInfo.filename itself - and describe the containment check you would write there with realpath and commonpath, plus unpacking into a scratch directory that nothing else reads.
Set the standard: one shared, reviewed extraction helper for untrusted uploads, a rule against hand-rolled loops, and an explicit position on what an attacker choosing paths inside your destination could still overwrite in the services you own.
## Two archive modules, two different security stories `tarfile` and `zipfile` sit next to each other in the standard library and are routinely spoken of as interchangeable, but their extraction risks are not the same, and the mitigations do not transfer. `tarfile` needed the PEP 706 extraction filters because tar is a *filesystem* format: symlinks, hard links, device nodes, ownership and modes are all faithfully reproduced. `zipfile` never reproduced most of that, so it took a narrower route — it cleans member names and ignores the rest of the metadata. ## What extraction actually does to a member name `ZipFile.extractall()` loops over the archive and calls `ZipFile.extract()` for each member, and it is `extract()` that carries the protection. Before writing, it: * strips a Windows drive letter or UNC share prefix from the member name; * strips leading separators, so `/abs/rooted.txt` becomes `abs/rooted.txt`; * removes **every** `..` component, so `../../etc/cron.d/x` becomes `etc/cron.d/x`; * on Windows, replaces characters that are illegal in a filename with underscores. The result is joined onto the destination. So the classic escape-by-name against `extractall()` does not escape: the attacker gets to choose a path *inside* your destination, including creating deep subdirectories and overwriting a file you put there, but not a path above it. That last part still matters — if your destination is a directory the service reads configuration or templates from, overwriting inside it is quite enough. ## Symlinks and permissions are dropped, not honoured A zip member can record a Unix mode in the high half of `ZipInfo.external_attr`, including the symlink type bits. CPython's extractor ignores that field on the way out: * A member flagged as a symlink is written as a **regular file whose content is the target path**. There is therefore no escape-by-link against `zipfile`, and also no round-trip: an archive of a tree containing symlinks does not unpack back into symlinks. * Stored permission bits are not applied either. A member archived as mode `0o755` extracts with your process's default file permissions, which regularly surprises people unpacking a release archive and finding the entry-point script non-executable. This is why `zipfile` has no `filter` parameter and why looking for one is a sign the candidate has mapped `tarfile`'s API onto the wrong module. ## Where real escapes still come from Almost every reported zip path-escape in Python code comes from **not using `extract()`**. Streaming extraction is a reasonable thing to want — you may need to inspect or transform bytes on the way through — and the shape people reach for is: ```python with zipfile.ZipFile(upload) as zf: for info in zf.infolist(): target = os.path.join(dest, info.filename) # no sanitising happens here os.makedirs(os.path.dirname(target), exist_ok=True) with zf.open(info) as src, open(target, "wb") as out: shutil.copyfileobj(src, out) ``` `os.path.join()` will happily resolve an absolute member name to a path outside `dest`, and `..` components survive. If you must stream, do the containment check yourself: resolve the joined path with `os.path.realpath()` and require that `os.path.commonpath()` of the result and the resolved destination is the destination — the same test `tarfile`'s filter applies internally. Two other details belong in the same loop: refuse a member whose name is empty or is a bare directory you did not expect, and create the parent directory only after the path has been validated, so a rejected member cannot leave stray directories behind. ## What containment does not buy you Name sanitising says nothing about *how much* comes out. `ZipInfo.file_size` is a number in the archive's own headers, written by whoever built the archive; a few hundred kilobytes of deflated zeros expand to hundreds of megabytes, and nesting archives multiplies that. Bounding output is a separate discipline from bounding paths, and neither module does it for you. Nor does it help with what happens *after* extraction. Handing an extracted path to something that opens it by name, or writing into a directory another process scans, reintroduces risk that no extractor can see. Unpack untrusted archives into a fresh temporary directory that nothing else watches, validate the contents there, and only then move what you wanted. ## A checklist for reviewing an extraction call When this code comes past you in review, four questions settle it. Is the destination a directory whose contents nothing else trusts, or is it a working directory a service reads templates and configuration from? Does the code call `ZipFile.extract` or `ZipFile.extractall`, or does it build paths itself? Is there any bound on how much comes out? And does a failure part-way through leave half an archive behind, or does the whole attempt vanish? Only the second of those is answered by the standard library; the other three are yours, and the first is the one most often skipped, because a member landing anywhere *inside* a destination the service later reads is a compromise even though nothing escaped.
- Why is a hand-rolled loop over infolist riskier than calling extractall?Because the sanitising lives inside ZipFile.extract, not in the archive object. If you join ZipInfo.filename onto a destination yourself, absolute names and parent-directory components go straight through and you have reinstated the escape. When you need to stream members, resolve the joined path with os.path.realpath and require os.path.commonpath of it and the resolved destination to equal the destination before opening the output file.
- The archive was built on Linux with an executable entry-point script. Why is it not executable after extraction?Because zipfile does not apply the Unix mode stored in ZipInfo.external_attr; extracted files get the process default permissions. If the executable bit matters, read the stored mode from the high half of external_attr yourself and chmod deliberately, applying it only to members you have already decided to trust rather than to whatever the archive asks for.
saying these in an interview costs you the question
- Thinks zipfile raises an error when a member name contains ..
- Assumes extractall recreates symlink members as symlinks
- Believes ZipFile.extract restores the stored Unix permission bits
- Looks for a filter argument on ZipFile.extractall
- Joins ZipInfo.filename onto the destination by hand and calls it safe
- Says containment of names also prevents a decompression bomb