// HackTheBox

Bedside

pdfminer.six CMap Pickle RCE (CVE-2025-64512) → LFI-Pivot zu einem internen Dienst → MONAI-Checkpoint Pickle RCE zu Root

HackTheBoxMittelLinux 19. Juli 2026 8 Min. Lesezeit

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/hosts hinzugefü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:

  1. Bildkonvertierung (tiff/bmp/dcm→png?) → ImageMagick-Delegate-RCE (ImageTragick / MSL / SVG), oder ein DICOM-Parser-Bug.
  2. PDF-Parsing über pdfminer.six (laut X-Powered-By-Header).
  3. Archiv-Extraktion → Zip Slip / Path Traversal, um eine Webshell in den Webroot zu schreiben.
  4. 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/png gekennzeichneter 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 basename genommen: das Hochladen von ../up1.png speichert als up1.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.zip abgelegtes 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:

  1. Den Payload platzieren. malicious.pickle.gz hochladen. 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, der os.system(payload) ausführt.
  2. Den Load auslösen. trigger.pdf hochladen: ein Type0/CIDFontType2-Font, dessen /Encoding ein PDF-Name mit #2F-escapten Slashes ist (#2F ist die PDF-Namenskodierung für /), der zum absoluten Pfad /var/www/research.bedside.htb/uploads/malicious decodiert. pdfminers _load_data hängt .pickle.gz an, os.path.join lässt den absoluten Namen aus dem cmap-Verzeichnis ausbrechen, und pickle.load läuft auf unserer platzierten Datei → beliebiger Callable → os.system(payload).
  3. 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 unter loot/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.pycheckpoint_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).

← Alle Writeups