Python tarfile: 5 Ways Files Escape the Destination
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:
- The GitHub Actions supply-chain post-mortem answers "how do I pin my dependencies" — the dependency-locking layer.
- The xz backdoor post-mortem answers "the tarball does not match the git repo" — the artifact-provenance layer.
- This one answers a narrower and lower question: regardless of what is inside the archive, the act of extracting it can write files outside your destination directory. That is the extraction layer, and neither post above touches it.
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
- Windows 11 (NTFS), CPython 3.13.14 (MSC v.1944 64 bit)
- Standard library
tarfile/zipfile/shutilonly, zero third-party dependencies - All malicious archives hand-built with
tarfile.open(mode="w") - Primary sources: PEP 706 (Petr Viktorin, created 2023-02-09, Final, Python-Version 3.12) and the Extraction filters section of
docs.python.org/3/library/tarfile.html
Five ways out, each reproduced
I set the extraction directory to , so ../../ESCAPED.txt should theoretically land at . Results:
| Technique | no filter (= fully_trusted) | filter="tar" | filter="data" |
|---|---|---|---|
member named ../../ESCAPED.txt | written out, lands at the temp root | raises OutsideDestinationError | raises OutsideDestinationError |
member named /tmp/ABSOLUTE.txt | written to C:\tmp\ABSOLUTE.txt (drive root) | strips the leading /, stays inside | strips the leading /, stays inside |
| symlink pointing outside | silent no-op | silent no-op | raises LinkOutsideDestinationError |
| hardlink pointing outside | raises KeyError: "linkname ... not found" | same | raises LinkOutsideDestinationError |
| FIFO / character device | silently skipped | silently skipped | raises 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:
errorlevel=1(the default): raisesOutsideDestinationError, anda_good.txtis already on disk — partial extraction, exactly as the docs say: "extractall() may leave the archive partially extracted; it is the user's responsibility to clean up".errorlevel=0: no exception at all.../b_escape.txtis skipped andc_after.txtstill gets extracted. Withdebug=0not even stderr gets a line; onlydebug=1printstarfile: OutsideDestinationError '...' which is outside the destination.
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:
| Expression | Result | Looks like it is inside? |
|---|---|---|
commonprefix(['C:\\tmp\\abc', 'C:\\tmp\\abcd']) | C:\tmp\abc | yes (but wrong) |
commonpath(['C:\\tmp\\abc', 'C:\\tmp\\abcd']) | C:\tmp | correct |
'C:\\tmp\\abcd'.startswith('C:\\tmp\\abc') | True | yes (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 archive | after the data filter |
|---|---|
0o4755 (setuid) | 0o755 |
0o2750 (setgid) | 0o750 |
0o1777 (sticky) | 0o755 |
0o666 | 0o644 |
directory 0o777 | None (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
- Everything was measured on Windows 11 with CPython 3.13.14. The symlink degradation path is Windows-specific: on POSIX
os.symlinkusually succeeds, so the "silent disappearance" chain does not trigger, though "the link points outside the destination" is then a more direct hazard. - I did not test Python 3.14's new default on a 3.14 interpreter. The 3.14 change is sourced from the docs' "Changed in version 3.14" note and PEP 706.
- The docs do not present filters as a finish line either: "Even with
filter='data', tarfile is not suited for extracting untrusted files without prior inspection", and the predefined filters do not prevent denial-of-service attacks. - This article is about the extraction step only. Verifying what the archive actually contains — hashes, signatures, provenance — is a separate layer.
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: