← Back to Home

Python tarfile: 5 Ways Files Escape the Destination

Pythontarfilearchive security

TL;DR: Python 3.12 added a filter argument to tarfile.extractall(), but on 3.12 and 3.13 the default is still full trust with only a DeprecationWarning. Python 3.14 flips the default to data. I built five escape techniques and ran them locally: a ../../ member really landed two levels above the destination, and an absolute /tmp/x member landed at C:\tmp\x on Windows. filter='tar' is not a safe middle ground either — on Windows a symlink member silently vanishes with no exception and no log line. Writing filter="data" explicitly is the only line here that actually matters.

This article contains no affiliate links and recommends no products.

Why I started digging

I am on Windows 11 with CPython 3.13.14, which is the last generation where the default is still "trust everything". PEP 706 says the default changes in 3.14, and I wanted to know how bad things actually are before that switch, so instead of quoting second-hand blog posts I built the archives myself.

The setup is deliberately boring: malicious tars are constructed with tarfile.open(mode="w") — no GNU tar, no root, no third-party packages — and every extraction target is a subdirectory nested two levels inside a tempfile.mkdtemp() root. That way a ../../x member, if it escapes, escapes into a temp root I own and never touches a system directory. Every number below comes from that harness.

Which layer this article answers

Two earlier posts on this site also cover "the archive is not trustworthy", and they sit on different decision layers, so let me draw the line before anything else:

If you remember one sentence: pinning versions and verifying hashes answer "is this the package you meant to fetch", while filter answers "where does it end up on disk".

Test environment

Five ways out, each reproduced

I set the extraction directory to /work/sub, so ../../ESCAPED.txt should theoretically land at /. Results:

Techniqueno filter (= fully_trusted)filter="tar"filter="data"
member named ../../ESCAPED.txtwritten out, lands at the temp rootraises OutsideDestinationErrorraises OutsideDestinationError
member named /tmp/ABSOLUTE.txtwritten to C:\tmp\ABSOLUTE.txt (drive root)strips the leading /, stays insidestrips the leading /, stays inside
symlink pointing outsidesilent no-opsilent no-opraises LinkOutsideDestinationError
hardlink pointing outsideraises KeyError: "linkname ... not found"sameraises LinkOutsideDestinationError
FIFO / character devicesilently skippedsilently skippedraises SpecialFileError

Look at row two. On Windows an absolute member is not "blocked" — os.path.join(dest, "/tmp/x") simply discards dest, and the file lands at C:\tmp\. I watched that file appear during the run and deleted it afterward.

Look at row three too. With filter="tar", a symlink member that points outside is neither created nor reported: extractall() returns normally, the exit code is 0, and the member is simply absent. That is worse than an exception, because there is no signal at all.

Troubleshooting: five real errors and fixes you can copy

Error 1 — `OutsideDestinationError` (the one you hit right after adding a filter)

Full text as produced locally:

tarfile.OutsideDestinationError: '../../ESCAPED.txt' would be extracted to
'C:\Users\xuany\AppData\Local\Temp\archlab_base_16k82zgj\t1_data\ESCAPED.txt',
which is outside the destination

Cause: a member name contains .., and the data filter applies the documented rule "Refuse to extract files whose absolute path (after following symlinks) would end up outside the destination".

Fix: do not swallow it with a bare except. This exception is telling you the archive is hostile. Inspect the members first:

import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
    for m in tf.getmembers():
        print(m.name, "->", m.linkname if (m.issym() or m.islnk()) else "")

Error 2 — `DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives...`

Text as emitted on 3.13:

DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives
and reject files or modify their metadata. Use the filter argument to control
this behavior.

Cause: PEP 706 states that on Python 3.12–3.13 omitting filter emits this warning while behaving like 'fully_trusted'; from 3.14 the default becomes 'data'.

Fix: pass filter explicitly. Do not paper over it with warnings.simplefilter("ignore") — under -W error in CI this warning becomes a build failure:

import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
    tf.extraction_filter = tarfile.data_filter   # 3.12+ global fallback
    tf.extractall("dest", filter="data")

The bundled CLI supports --filter as well:

python -m tarfile -l bundle.tar                    # list members first
python -m tarfile -e bundle.tar dest --filter data # then extract under data

Error 3 — the symlink that disappears silently (no exception, no log, exit code 0)

Symptom: the archive contains link.sh, the destination does not, and nothing is printed.

Cause: three code paths stack up. makelink_with_filter() tries os.symlink, which on Windows raises OSError: [WinError 1314] A required privilege is not held by the client (I reproduced it by calling os.symlink directly). That is caught by symlink_exception = (AttributeError, NotImplementedError, OSError), so tarfile falls back to "find the link target and copy it as a regular file". Then _find_link_target() cannot find it and raises ExtractError("unable to resolve link inside archive"). Finally _handle_nonfatal_error() only re-raises when errorlevel > 1, and TarFile.errorlevel defaults to 1, so it calls self._dbg(1, ...) — and TarFile.debug defaults to 0, so _dbg prints nothing.

Fix: with default settings you get no signal, so reconcile the counts yourself:

import tarfile, os
with tarfile.open("bundle.tar", mode="r:*") as tf:
    tf.extractall("dest", filter="data")
    want = {m.name for m in tf.getmembers() if m.isfile()}
got = {os.path.relpath(os.path.join(dp, f), "dest").replace("\\", "/")
       for dp, dn, fn in os.walk("dest") for f in fn}
print("in archive but missing on disk:", sorted(want - got))

One counter-intuitive contrast: a symlink pointing inside the archive does not vanish — it degrades into a copy. I built alias.txt -> real.txt, and under all three filters the result was islink=False with contents equal to real.txt. That is exactly what PEP 706 means by "On systems that don't support links, tarfile will, in most cases, fall back to creating regular files".

Error 4 — `ValueError: filter 'xxx' not found`

Symptom: tf.extractall(dest, filter="full_trusted") (one ly short) →

ValueError: filter 'full_trusted' not found

A non-string fails the same way: filter=123 → ValueError: filter 123 not found.

Cause: only three built-in names are accepted — 'fully_trusted', 'tar', 'data' — or a callable. PEP 706: "Any other string will cause a ValueError."

Fix: use the function form so your editor and type checker catch typos:

import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
    tf.extractall("dest", filter=tarfile.data_filter)   # function, not a string

Error 5 — with `errorlevel=0` files quietly go missing and extraction continues

Cause: FilterError is treated as fatal, yet it is still governed by errorlevel. I tested an archive with a_good.txt, ../b_escape.txt and c_after.txt:

Fix: do not drop errorlevel to 0 just to make the run go green. If you need tolerance, keep the accounting explicit:

import tarfile
skipped = []
def guarded(member, path):
    try:
        return tarfile.data_filter(member, path)
    except tarfile.FilterError as e:
        skipped.append((member.name, type(e).__name__))
        return None          # returning None skips the member
with tarfile.open("bundle.tar", mode="r:*") as tf:
    tf.extractall("dest", filter=guarded)
print("skipped:", skipped)

The exception hierarchy is worth memorising while you are writing except clauses (read from __bases__ locally): TarError ← FilterError ← {AbsolutePathError, OutsideDestinationError, AbsoluteLinkError, LinkOutsideDestinationError, SpecialFileError}. Catching only tarfile.TarError merges "the archive is corrupt" with "the filter refused it".

Why hand-written "is it inside" checks keep being wrong

A lot of people skip filters and write the check themselves, and the most common attempt uses os.path.commonprefix, which compares characters, not path components. Measured locally:

ExpressionResultLooks like it is inside?
commonprefix(['C:\\tmp\\abc', 'C:\\tmp\\abcd'])C:\tmp\abcyes (but wrong)
commonpath(['C:\\tmp\\abc', 'C:\\tmp\\abcd'])C:\tmpcorrect
'C:\\tmp\\abcd'.startswith('C:\\tmp\\abc')Trueyes (also wrong)

abc and abcd are sibling directories, yet commonprefix returns C:\tmp\abc and reports "inside". Use commonpath, and resolve symlinks first:

import os
def is_within(directory, target):
    d = os.path.realpath(directory)
    t = os.path.realpath(target)
    return os.path.commonpath([d, t]) == d

This is precisely why PEP 706 puts CVE-2007-4559 in its Motivation. In its own words, the docs already warned to "never extract archives from untrusted sources without prior inspection", but "it's not clear what kind of inspection should be done. Indeed, it's quite tricky to do such an inspection correctly".

`data` changes metadata too, not just paths

This is clearer if you never touch the filesystem — call tarfile.data_filter() and read the returned TarInfo (measured locally):

mode in the archiveafter the data filter
0o4755 (setuid)0o755
0o2750 (setgid)0o750
0o1777 (sticky)0o755
0o6660o644
directory 0o777None (chmod skipped)

uid, gid, uname and gname are all set to None as well. The documented rules are "Clear high mode bits (setuid, setgid, sticky) and group/other write bits" plus "Set the owner read and write permissions". So if a backup-restore job switches to data, restored permissions and ownership are *supposed* to differ from the original machine. That is not a bug.

And here is something no filter handles: duplicate member names. I packed three members all named dup.txt (contents FIRST, SECOND, THIRD). Both filter="data" and the default produce THIRD — last write wins, silently. Count them yourself:

from collections import Counter
import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
    dup = [n for n, c in Counter(m.name for m in tf.getmembers()).items() if c > 1]
print("duplicate members:", dup)

zipfile plays by different rules — do not copy-paste

zipfile.ZipFile.extractall() has no filter parameter (its signature is just path, members, pwd), because it has sanitised member names for a very long time. I built ../../ESCAPED_ZIP.txt, C:/ESCAPED_ZIPDRV.txt and /abs/ESCAPED_ZIPABS.txt; after extractall() all three landed inside the destination. None escaped.

These lines from the source are the dividing line between zipfile and tarfile:

drive, root, arcname = os.path.splitroot(arcname)
invalid_path_parts = ('', os.path.curdir, os.path.pardir)
arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
                           if x not in invalid_path_parts)

zipfile still loses information, though: I built a member carrying the Unix symlink bit (external_attr = 0o120777 << 16) and got an 11-byte regular file whose contents were the string /etc/passwd, not a link — CPython's zipfile never creates symlinks.

shutil.unpack_archive() sits on both sides of the fence: it does have filter=None (since 3.12), it emits the same DeprecationWarning when you omit it, and passing filter="data" blocks the escape correctly. Also note that ZipFile._ignore_invalid_names defaults to False, meaning invalid-looking names are flattened rather than dropped.

What I write now

import tarfile

def safe_extract(archive, dest):
    with tarfile.open(archive, mode="r:*") as tf:
        names = [m.name for m in tf.getmembers()]
        bad = [n for n in names
               if n.startswith(("/", "\\")) or ".." in n.split("/")]
        if bad:
            raise SystemExit(f"refusing to extract, suspicious members: {bad}")
        tf.extractall(dest, filter="data")

mode="r:*" lets tarfile detect the compression, and the explicit filter="data" keeps behaviour identical across 3.12 through 3.14 — which matters, because without it the same code produces different results on 3.13 and 3.14.

FAQ

Q: Should I add filter="data" right now?

A: If you are on Python 3.12 or 3.13, yes. Otherwise the default silently changes on the day you upgrade to 3.14, and you have already been eating the DeprecationWarning for two releases.

Q: Will filter="data" break my backup restore?

A: It will change it. data clears setuid/setgid/sticky and the group/other write bits, and drops uid/gid (see the table above). Backup and restore want filter="tar" or fully_trusted, provided you trust the archive.

Q: I added filter="data" and files are still missing. Why?

A: Three candidates: a symlink silently failed to degrade on Windows (Error 3), errorlevel=0 skipped refused members without a sound (Error 5), or duplicate member names overwrote each other. The first two raise nothing.

Q: Does the same apply to zipfile?

A: There is no filter to pass — zipfile flattens .., drive letters and leading slashes on its own. Still reconcile the member count after extracting.

Limits of this article

Related reading: if you want to keep going down the "the package is not what it looks like" path, see the xz backdoor post-mortem and the GitHub Actions supply-chain post-mortem. On the Python side there is also PEP 440 version pinning pitfalls and Python logging: records that never arrive.

👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home