// HackTheBox
Bedside
pdfminer.six CMap Pickle RCE (CVE-2025-64512) → LFI-Pivot zu einem internen Dienst → MONAI-Checkpoint Pickle RCE zu Root
Aufklärung
VPN steht: tun0 = <LHOST> (US Machines VIP+ 289), MTU 1300. Ziel erreichbar.
nmap -sV -sC
| Port | State | Service | Version |
|---|---|---|---|
| 22/tcp | open | ssh | OpenSSH 10.0p2 Debian 7+deb13u4 |
| 80/tcp | open | http | Apache 2.4.68 (Debian) — “Bedside Clinic - bedside.htb” |
| 3000/tcp | filtered | ppp | (firewalled — likely internal app: Gitea/Node/Grafana) |
Drei Dinge rahmen die Box ein. Der Web-Root bewirbt den vhost bedside.htb, der in
/etc/hosts wandert. Port 3000 ist gefiltert statt geschlossen — dort ist etwas
gebunden, aber eine Firewall verwirft externe Pakete, also ist er wahrscheinlich erst
nach einem Erstzugang (direkt oder via SSRF) erreichbar, ein starker Hinweis darauf,
dass der Weg durch die Web-App zu dem führt, was auf 3000 lebt. Und die Software ist
sehr frisch (OpenSSH 10.0p2, Apache 2.4.68), also ist dies eine aktuelle Box — der
interessante Bug ist wahrscheinlich eine aktuelle CVE und keine verstaubte. Die Web-App
ist das Einzige, was wir anfassen können, also beginnt die Enumeration dort.
Web-Enumeration
Virtual-Host-Discovery
Beim Fuzzen des Host-Headers gegen die Top-5000-Subdomain-Liste leitet jeder
unbekannte vhost per 301 zurück auf bedside.htb (den Default-vhost) — bis auf einen,
der 200 antwortet:
- TREFFER:
research.bedside.htb→ 200 (zu/etc/hostshinzugefügt).
research.bedside.htb — das Bedside Research Portal
Der neue vhost ist ein „Research Portal” mit einem vielsagenden Response-Header:
Server: Apache/2.4.68 und X-Powered-By: pdfminer.six. Dieser Header nennt eine
Python-Bibliothek zur PDF-Textextraktion, also gibt es irgendwo in der Pipeline ein
Python-Backend, das PDFs parst.
Das Hauptfeature des Portals ist ein Datei-Upload-Formular (POST /, multipart,
Feld uploadFile). Die Seite gibt an, jpeg, jpg, png, bmp, tiff, dcm
(DICOM-Medizinbilder) und pdf zu akzeptieren, und dass „Collections can be uploaded
as archives” — was impliziert, dass auch ZIP akzeptiert wird. Entscheidend ist die
Warnung, dass „certain file formats may be converted to standardized formats before
being used for AI training”, was uns sagt, dass es eine serverseitige
Konvertierungspipeline gibt, die alles Hochgeladene verarbeitet.
Angriffsfläche / Hypothesen
Eine Konvertierungspipeline für medizinische Dateien ist ein reichhaltiges Ziel. Bevor man sie anfasst, die zu berücksichtigenden Erstzugang-Kandidaten:
- Bildkonvertierung (tiff/bmp/dcm→png?) → ImageMagick-Delegate-RCE (ImageTragick / MSL / SVG), oder ein DICOM-Parser-Bug.
- PDF-Parsing über pdfminer.six (laut
X-Powered-By-Header). - Archiv-Extraktion → Zip Slip / Path Traversal, um eine Webshell in den Webroot zu schreiben.
- TIFF/DICOM → eine Library-CVE in der Verarbeitung medizinischer Bilder.
Den Upload-Handler abklopfen
Das Anstochern des Handlers legt fest, was er tatsächlich erzwingt:
- Wo Dateien landen: eine Fehlermeldung leakt das Upload-Verzeichnis,
/var/www/research.bedside.htb/uploads, und Dateien werden unter/uploads/<name>wieder web-ausgeliefert. - Erweiterungs-Allowlist (ebenfalls geleakt):
jpeg, jpg, png, bmp, tiff, dcm, pdf, gz, zip. - MIME-Validierung: ein als
image/pnggekennzeichneter Textkörper wird mit „MIME type mismatch” abgewiesen — der Upload muss also echte Magic-Bytes tragen, die zum angegebenen Typ passen, nicht bloß die richtige Erweiterung. - Der Dateiname wird per
basenamegenommen: das Hochladen von../up1.pngspeichert alsup1.png, ein Path-Traversal-Dateiname wird also entfernt. Das schließt Zip-Slip-über-Dateiname beim Upload selbst aus. - Keine offensichtliche synchrone Konvertierung: valide png/bmp/pdf-Uploads erzeugen keine sofortige konvertierte Ausgabe, und innerhalb von 60s feuert nichts.
- Archive werden nicht an Ort und Stelle extrahiert: ein unter
/uploads/normal.zipabgelegtes ZIP bekommt seine Member nicht nach/uploads/geschrieben — falls also überhaupt extrahiert wird, geschieht das anderswo, im Hintergrund. - Die App-Oberfläche ist winzig: nur
/(Upload) und/uploads/; ein Content-Scan findet nichts weiter.
Arbeitshypothese
Alles deutet auf einen Hintergrund-„AI-training”-Job hin, der hochgeladene
medizinische Dateien in ein standardisiertes Format konvertiert — pdfminer.six für PDFs
(laut X-Powered-By), irgendeine Bild-Library für tiff/dcm/bmp und Archiv-Extraktion
für die zip/gz-„Collections”. Der Erstzugang ist daher am wahrscheinlichsten eine
bösartige Datei, die eine RCE oder Datei-Lese-Operation im Konverter auslöst, oder
ein Zip-Slip bei der Archiv-Extraktion. Die Aufklärung bestätigt die Bausteine; der
konkrete Konverter-Bug ist das, was als Nächstes zu recherchieren ist — und es stellt
sich als der PDF-Pfad heraus.
Erstzugang — CVE-2025-64512 (pdfminer.six CMap Pickle RCE)
CVE-2025-64512 ist ein Arbitrary-File-Pickle-Deserialisierungs-Bug in der
CMap-Behandlung von pdfminer.six. Eine CMap sagt einer PDF-Schriftart, wie
Zeichencodes auf Glyphen abgebildet werden, und pdfminer cached CMaps als
Python-Pickle-Dateien. Seine _load_data-Routine baut den Cache-Pfad, indem sie
den /Encoding-Namen der Schriftart nimmt, .pickle.gz anhängt und ihn durch
os.path.join schickt — aber sie schränkt diesen Namen nie ein, sodass ein
absoluter Pfad in /Encoding das vorgesehene cmap-Verzeichnis vollständig verlässt
und pdfminer dazu bringt, eine beliebige .pickle.gz-Datei unserer Wahl per
pickle.load zu laden. Pickle-Deserialisierung von Angreiferdaten ist beliebige
Codeausführung.
Die Upload-Allowlist und die Pipeline liefern uns genau die zwei Primitiven, die dies benötigt — einen Ort, um ein Pickle abzulegen, und ein PDF, das darauf zeigt. Die Kette:
- Den Payload platzieren.
malicious.pickle.gzhochladen. Die.gz-Erweiterung steht auf der Allowlist und die Datei trägt echte gzip-Magic-Bytes, also passiert sie die MIME-Validierung. Sie landet unter/var/www/research.bedside.htb/uploads/malicious.pickle.gz. Das Pickle löst beim Laden zu einem Callable auf, deros.system(payload)ausführt. - Den Load auslösen.
trigger.pdfhochladen: ein Type0/CIDFontType2-Font, dessen/Encodingein PDF-Name mit#2F-escapten Slashes ist (#2Fist die PDF-Namenskodierung für/), der zum absoluten Pfad/var/www/research.bedside.htb/uploads/maliciousdecodiert. pdfminers_load_datahängt.pickle.gzan,os.path.joinlässt den absoluten Namen aus dem cmap-Verzeichnis ausbrechen, undpickle.loadläuft auf unserer platzierten Datei → beliebiger Callable →os.system(payload). - Auf den Konverter warten. Der Hintergrund-„AI-training”-Konverter (pdfminer.six,
laut
X-Powered-By) nimmt das PDF etwa 60-90s nach dem Upload auf und der Payload läuft.
Die Reverse-Shell verbindet sich zurück zu 10.129.41.49:51100 und landet als
datawrangler@data-wrangler in cwd /app. Der Hostname data-wrangler ist nicht
der Boxname, also ist dies ein Konverter-Container/-Service und nicht der Host
selbst — und das ist der Aussichtspunkt, von dem aus der extern gefilterte Port 3000
erreichbar sein sollte.
Eine operative Feinheit: die vom Konverter gestartete bash -i bekommt sofort ein EOF
(sie wird reaped, sobald der Konvertierungsjob endet), sodass die interaktive Shell
schnell stirbt. Die Lösung ist, sich nicht mehr auf die Prozesslebensdauer des
Konverters zu verlassen — Loot über HTTP exfiltrieren und eine mit setsid -f
abgekoppelte Reconnect-Shell verwenden, die den Job überlebt.
Lateral Movement — LFI auf internem Port 3000
Von innerhalb des Containers ist Port 3000 erreichbar. Was auch immer dort gebunden
ist, hat einen Path-Traversal-Local-File-Read-Bug: ein Request, der mit ../-Sequenzen
aus seinem statischen Asset-Verzeichnis herausläuft, liest beliebige Dateien:
$ curl http://127.0.0.1:3000/vendor/react.js/../../../../../../../../etc/<path>?raw=1&module=1
Die Traversal beginnt bei einem vendor/react.js-Asset-Pfad und steigt zur
Dateisystemwurzel auf, sodass <path> jede beliebige Datei ist. /etc/passwd
bestätigt, dass es funktioniert, und nennt zwei erwähnenswerte Konten: developer
(uid 1000, eine echte Login-Shell) und datawrangler (uid 988, container-only, in
der Gruppe dataops). Dieselbe LFI genutzt, um Dateien aus developers Home zu lesen:
/home/developer/user.txt→ die User-Flag./home/developer/.ssh/id_rsa→ der private SSH-Schlüssel von developer, lokal gespeichert unterloot/developer_id_rsa.
Root — MONAI CheckpointLoader Pickle RCE
1. Als developer mit dem gestohlenen Schlüssel einloggen
Der via LFI erlangte Schlüssel liefert eine saubere, eigenständige SSH-Session als
developer — der Pivot durch den Container ist nicht mehr nötig:
$ ssh -i loot/developer_id_rsa developer@10.129.41.49
2. sudo-Rechte inspizieren
$ sudo -l
Der einzige Eintrag ist:
(ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py
Zu beachten ist, dass nach dem Skript in der sudoers-Regel keine Argumente stehen,
sodass sie nur den nackten Aufruf matcht. Fügt man irgendetwas hinzu (--help, einen
Pfad usw.), matcht es nicht mehr und fällt auf eine Passwortabfrage zurück — wir können
es nur exakt so ausführen, wie es geschrieben steht.
3. Verstehen, was der Trainer lädt
/opt/trainer/bedside_trainer.py ist ein MONAI-Trainer (MONAI ist ein
PyTorch-basiertes Framework für medizinische Bildgebung). Er lädt die neueste
*.pt-Checkpoint-Datei in /datastore/checkpoints/ über
monai.handlers.CheckpointLoader, was torch.load(..., weights_only=False) aufruft.
Ein PyTorch-Checkpoint ist ein Pickle, und weights_only=False bedeutet, dass
torch.load beliebige Objekte entpickelt — dieselbe Deserialisierungs-RCE-Bugklasse wie
beim Erstzugang, jetzt als root über sudo erreichbar.
Der Haken ist, dass developer keine direkten Rechte auf /datastore hat (gehört
datawrangler:dataops). Aber zwei Fakten machen es möglich: root (über den per sudo
gestarteten Trainer) hat Zugriff, und /datastore ist ein geteiltes Volume
zwischen dem datawrangler-Container und dem bedside-Host. Ein von der Container-Seite
abgelegter Checkpoint ist hier für den Trainer sichtbar — deshalb war der
Container-Erstzugang wichtig.
4. Den bösartigen Checkpoint platzieren
Ein bösartiger Checkpoint (exploit/gen_checkpoint.py → checkpoint_epoch_malicious.pt)
wird als rohes pickle.dump(RCE()) geschrieben, wobei RCE.__reduce__
os.system(payload) zurückgibt. Er ist bewusst kein echter torch/zip-Checkpoint —
ein einfaches Pickle reicht, um beim Laden zu feuern. Er wurde von der Container-Seite
unter /datastore/checkpoints/checkpoint_epoch_malicious.pt platziert, zusammen mit
einem Seed-Bild unter /datastore/processed/seed.png (der Trainer benötigt mindestens
eine verarbeitete Probe, sonst beendet er sich, bevor er den Checkpoint-Load-Schritt
überhaupt erreicht).
5. Den Trainer als root ausführen
$ ssh developer@10.129.41.49 sudo /usr/bin/python3 /opt/trainer/bedside_trainer.py
Der Trainer nimmt den neuesten Checkpoint auf — unseren. Wenn
torch.serialization._legacy_load ihn verarbeitet, entpickelt er das Feld „magic
number” zuerst, und dieses erste Unpickle ist unser RCE-Objekt: __reduce__
feuert und os.system(payload) läuft als root, bevor der anschließende
Magic-Number-Vergleich RuntimeError: Invalid magic number; corrupt file? auslöst. Der
Traceback ist kosmetisch — der Payload wurde bereits ausgeführt.
6. Payload und Zurücklesen
Der Payload kopiert die Root-Flag an einen für alle lesbaren Ort:
$ cp /root/root.txt /tmp/.rootflag_out; chmod 644 /tmp/.rootflag_out
(Die Reverse-Shell-Hälfte des Payloads zeigte auf eine veraltete VPN-IP aus einer
vorigen Session und verband sich nie — es spielte keine Rolle, die Datei-Exfil-Hälfte
reichte aus.) Als developer zurücklesen:
$ ssh developer@... cat /tmp/.rootflag_out
→ die Root-Flag.
Beide Flags wurden über POST /api/v5/machine/own eingereicht — Maschine vollständig
gepwnt (30 + 35 Punkte).