
Documents CVE-2026-65320, a tar-slip path traversal in fastcore's untar_dir(), with a harmless proof-of-concept demonstrating arbitrary file write and code execution.
untar_dir()Severity: Critical, CVSS 3.1 9.8
Vector (v3.1): CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Affected: fastcore <= 2.2.30
Fixed in: Not fixed at time of writing
CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory, 'Path Traversal')
Component: fastcore.xtras.untar_dir() → fastcore.xtras._unpack() → shutil.unpack_archive()
High-risk runtime: Python < 3.14 (Python 3.14 makes the safe data tar filter the default)
Reported by: Rahul Karne
CNA: VulnCheck
fastcore.xtras.untar_dir(fname, dest) is a documented helper that untars an archive "into dest".
Internally, it hands the archive to the standard library's shutil.unpack_archive() without an extraction filter, without validating archive member paths, and without any post-extraction containment check.
As a result, a TAR archive whose members contain ../ traversal sequences or absolute paths can write files outside the directory the caller asked fastcore to extract into.
A caller who reasonably expects untar_dir(fname, dest) to confine everything to dest instead gets arbitrary file write anywhere the process can write.
Arbitrary file write under the authority of the process calling untar_dir().
Depending on what is overwritten, this can result in:
The proof of concept demonstrates the full write → import → code-execution chain end to end, entirely inside a harmless demo directory.
fastcore is a foundational dependency across the fast.ai ecosystem, including:
fastainbdevghapiIt receives tens of millions of downloads per month.
untar_dir() is the extraction primitive behind the common "download an archive, then unpack it" workflow, which follows the same general pattern as fastai's untar_data.
Any code path that feeds untar_dir() an archive whose contents are not fully trusted is exposed.
The vulnerable code exists in fastcore/xtras.py:
def _unpack(fname, out):
import shutil
shutil.unpack_archive(str(fname), str(out)) # <-- no filter, no validation
ls = out.ls()
return ls[0] if len(ls) == 1 else out
def untar_dir(fname, dest, rename=False, overwrite=False, uid=-1, gid=-1):
"untar `file` into `dest` ..."
import tempfile, shutil
dest = Path(dest)
with tempfile.TemporaryDirectory() as d:
out = Path(d) / remove_suffix(Path(fname).stem, '.tar')
out.mkdir()
...
src = _unpack(fname, out) # extraction escapes `out` here
...
shutil.move(str(src), dest)
...
return dest
untar_dir() creates an internal temporary extraction directory:
<tempdir>/<random>/<archive-stem>/
It then calls _unpack(), which forwards the archive directly to:
shutil.unpack_archive()
For TAR archives, this ultimately calls tarfile.extractall().
On Python < 3.14, the default extraction behavior is fully_trusted, meaning traversal members can be honored.
For example, an archive member such as:
../../../some/other/dir/file
is written relative to the extraction directory and can therefore escape it.
Nothing in fastcore:
filter="data" where supported... path components.untar_dir() treats shutil.unpack_archive() as though it were a safe, sandboxed archive extractor.
It is not.
Python's standard library documentation warns that extracting untrusted archives may create files outside the requested destination and recommends safe extraction filtering for TAR archives.
fastcore neither passes an appropriate extraction filter nor implements an equivalent containment guard for the Python versions it supports.
It also does not warn callers of untar_dir() about this security-sensitive behavior.
TAR members can contain arbitrary path strings.
On affected Python versions, tarfile.extractall() does not automatically neutralize .. traversal sequences by default.
Writing a traversal member relative to fastcore's temporary extraction directory is therefore sufficient to escape that directory and write anywhere the process has permission to write.
A reliable primitive is relative traversal such as:
../../../target/file
This is the technique used by the proof of concept.
The dangerous input is the archive contents, not the destination path chosen by the developer.
Archives are frequently obtained from external or semi-trusted locations, including:
Any service or tool that downloads an archive from an untrusted or semi-trusted source and subsequently processes it using untar_dir() may therefore be reachable.
The PoC also includes a variant that serves the crafted archive from an attacker-controlled HTTP endpoint to demonstrate this delivery path.
Successful exploitation requires:
The application calls fastcore.xtras.untar_dir() on an archive whose contents are attacker-influenced, such as an uploaded, downloaded, or otherwise untrusted archive.
The runtime uses Python < 3.14, or another configuration where a safe TAR extraction filter is not enforced.
For escalation from arbitrary file write to code execution, the process later imports or loads a file from a location reachable by the traversal, such as:
Arbitrary file write and configuration/data modification require only conditions 1 and 2.
poc_fastcore_cna_demo.py invokes the real fastcore.xtras.untar_dir() API.
The demonstration is intentionally harmless.
Every file created by the PoC remains inside a single demo directory specified using:
--demo-root
The PoC does not modify real system files and does not access cloud services or credentials.
Prints the environment details and vulnerable source code for _unpack() and untar_dir().
Creates a demonstration layout containing:
service_app/plugins/
Creates:
attacker_supplied_upload.tar
containing archive members designed to point outside the intended extraction root, including traversal members and:
service_app/plugins/startup_hook.py
def auto_extract_uploaded_archive(received_archive):
return untar_dir(
received_archive,
intended_root,
overwrite=True
)
Verifies that files were written outside the intended extraction directory.
Simulates a service reload by importing the archive-written:
startup_hook.py
The harmless startup hook creates:
SERVICE_RELOAD_MARKER
This demonstrates the complete chain: