// HackTheBox
Bedside
pdfminer.six CMap pickle RCE (CVE-2025-64512) → LFI pivot to an internal service → MONAI checkpoint pickle RCE to root
Recon
VPN up: tun0 = <LHOST> (US Machines VIP+ 289), MTU 1300. Target reachable.
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) |
Three things frame the box. The web root advertises the vhost bedside.htb, which goes
into /etc/hosts. Port 3000 is filtered rather than closed — something is bound
there, but a firewall drops external packets, so it’s probably only reachable after a
foothold (directly or via SSRF), a strong hint that the path runs through the web app
to whatever lives on 3000. And the software is very fresh (OpenSSH 10.0p2, Apache
2.4.68), so this is a recent box — the interesting bug is likely a recent CVE rather
than a dusty one. The web app is the only thing we can touch, so that’s where enumeration
starts.
Web enumeration
Virtual-host discovery
Fuzzing the Host header against the top-5000 subdomain list, every unknown vhost
301-redirects back to bedside.htb (the default vhost) — except one that answers
200:
- HIT:
research.bedside.htb→ 200 (added to/etc/hosts).
research.bedside.htb — the Bedside Research Portal
The new vhost is a “Research Portal” with a telling response header:
Server: Apache/2.4.68 and X-Powered-By: pdfminer.six. That header names a Python
PDF text-extraction library, so there’s a Python backend that parses PDFs somewhere in
the pipeline.
The portal’s main feature is a file-upload form (POST /, multipart, field
uploadFile). The page says it accepts jpeg, jpg, png, bmp, tiff, dcm (DICOM
medical images), and pdf, and that “Collections can be uploaded as archives” —
implying ZIP is accepted too. Crucially, it warns that “certain file formats may be
converted to standardized formats before being used for AI training,” which tells
us there’s a server-side conversion pipeline chewing on whatever we upload.
Attack surface / hypotheses
A medical-file conversion pipeline is a rich target. Before touching it, the candidate foothold vectors worth keeping in mind:
- Image conversion (tiff/bmp/dcm→png?) → ImageMagick delegate RCE (ImageTragick / MSL / SVG), or a DICOM-parser bug.
- PDF parsing via pdfminer.six (per the
X-Powered-Byheader). - Archive extraction → Zip Slip / path traversal to write a webshell into the webroot.
- TIFF/DICOM → a library CVE in medical-image processing.
Probing the upload handler
Poking the handler pins down what it actually enforces:
- Where files land: an error message leaks the upload directory,
/var/www/research.bedside.htb/uploads, and files are web-served back at/uploads/<name>. - Extension allowlist (also leaked):
jpeg, jpg, png, bmp, tiff, dcm, pdf, gz, zip. - MIME validation: sending a text body labelled
image/pngis rejected with “MIME type mismatch” — so the upload must carry real magic bytes matching its claimed type, not just the right extension. - Filename is taken by
basename: uploading../up1.pngsaves asup1.png, so a path-traversal filename is stripped. That rules out Zip-Slip-via-filename on the upload itself. - No obvious synchronous conversion: valid png/bmp/pdf uploads produce no immediate converted output, and nothing fires within 60s.
- Archives aren’t extracted in place: a ZIP stored at
/uploads/normal.zipdoes not get its members written into/uploads/— so if extraction happens at all, it happens elsewhere, in the background. - App surface is tiny: only
/(upload) and/uploads/; a content scan finds nothing else.
Working theory
Everything points to a background “AI-training” job that converts uploaded medical
files to a standardized format — pdfminer.six for PDFs (per X-Powered-By), some image
library for tiff/dcm/bmp, and archive extraction for the zip/gz “collections.” The
foothold is therefore most likely a malicious file that triggers RCE or file-read
inside that converter, or a Zip-Slip on the archive extraction. The recon confirms
the pieces; the specific converter bug is what to research next — and it turns out to
be the PDF path.
Foothold — CVE-2025-64512 (pdfminer.six CMap pickle RCE)
CVE-2025-64512 is an arbitrary-file pickle-deserialization bug in pdfminer.six’s
CMap handling. A CMap tells a PDF font how to map character codes to glyphs, and
pdfminer caches CMaps as Python pickle files. Its _load_data routine builds the
cache path by taking the font’s /Encoding name, appending .pickle.gz, and passing
it through os.path.join — but it never constrains that name, so an absolute path
in /Encoding escapes the intended cmap directory entirely and makes pdfminer
pickle.load any .pickle.gz file we choose. Pickle deserialization of attacker data
is arbitrary code execution.
The upload allowlist and the pipeline hand us exactly the two primitives this needs — a place to drop a pickle, and a PDF to point at it. The chain:
- Stage the payload. Upload
malicious.pickle.gz. The.gzextension is on the allowlist and the file carries real gzip magic bytes, so MIME validation passes. It lands at/var/www/research.bedside.htb/uploads/malicious.pickle.gz. The pickle, when loaded, resolves to a callable that runsos.system(payload). - Trigger the load. Upload
trigger.pdf: a Type0 / CIDFontType2 font whose/Encodingis a PDF name using#2F-escaped slashes (#2Fis the PDF name-encoding for/) that decodes to the absolute path/var/www/research.bedside.htb/uploads/malicious. pdfminer’s_load_dataappends.pickle.gz,os.path.joinlets the absolute name escape the cmap directory, andpickle.loadruns on our staged file → arbitrary callable →os.system(payload). - Wait for the converter. The background “AI-training” converter (pdfminer.six, per
X-Powered-By) picks up the PDF roughly 60-90s after upload and the payload runs.
The reverse shell connects back to 10.129.41.49:51100, landing as
datawrangler@data-wrangler in cwd /app. The hostname data-wrangler is not
the box name, so this is a converter container/service rather than the host itself
— and that’s the vantage point from which the externally filtered port 3000 should be
reachable.
One operational wrinkle: the bash -i the converter spawns gets an immediate EOF (it’s
reaped as soon as the conversion job finishes), so the interactive shell dies quickly.
The fix is to stop relying on the converter’s process lifetime — exfiltrate loot over
HTTP and use a setsid -f detached reconnect shell that outlives the job.
Lateral movement — LFI on internal port 3000
From inside the container, port 3000 is reachable. Whatever is bound there has a
path-traversal local-file-read bug: a request that walks out of its static asset
directory with ../ sequences reads arbitrary files:
$ curl http://127.0.0.1:3000/vendor/react.js/../../../../../../../../etc/<path>?raw=1&module=1
The traversal starts from a vendor/react.js asset path and climbs to filesystem root,
so <path> is any file we want. /etc/passwd confirms it works and names two accounts
of interest: developer (uid 1000, a real login shell) and datawrangler (uid
988, container-only, in the dataops group). Using the same LFI to read files out of
developer’s home:
/home/developer/user.txt→ the user flag./home/developer/.ssh/id_rsa→ developer’s private SSH key, saved locally toloot/developer_id_rsa.
Root — MONAI CheckpointLoader pickle RCE
1. Log in as developer with the stolen key
The LFI’d key gives a clean, standalone SSH session as developer — no need to keep
pivoting through the container anymore:
$ ssh -i loot/developer_id_rsa developer@10.129.41.49
2. Inspect sudo rights
$ sudo -l
The one entry is:
(ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py
Note there are no arguments after the script in the sudoers rule, so it only matches
the bare invocation. Adding anything (--help, a path, etc.) fails to match and falls
back to a password prompt — we can only run it exactly as written.
3. Understand what the trainer loads
/opt/trainer/bedside_trainer.py is a MONAI trainer (MONAI is a PyTorch-based
medical-imaging framework). It loads the newest *.pt checkpoint in
/datastore/checkpoints/ via monai.handlers.CheckpointLoader, which calls
torch.load(..., weights_only=False). A PyTorch checkpoint is a pickle, and
weights_only=False means torch.load will unpickle arbitrary objects — the same
deserialization-RCE class of bug as the foothold, now reachable as root via sudo.
The catch is that developer has no direct permissions on /datastore (owned
datawrangler:dataops). But two facts make it work: root (via the sudo’d trainer) does
have access, and /datastore is a shared volume between the datawrangler container
and the bedside host. A checkpoint dropped from the container side is visible to the
trainer here — which is why the container foothold mattered.
4. Stage the malicious checkpoint
A malicious checkpoint (exploit/gen_checkpoint.py → checkpoint_epoch_malicious.pt)
is written as a raw pickle.dump(RCE()), where RCE.__reduce__ returns
os.system(payload). It is deliberately not a real torch/zip checkpoint — a plain
pickle is enough to fire on load. It was staged from the container side at
/datastore/checkpoints/checkpoint_epoch_malicious.pt, along with a seed image at
/datastore/processed/seed.png (the trainer needs at least one processed sample or it
exits before it ever reaches the checkpoint-load step).
5. Run the trainer as root
$ ssh developer@10.129.41.49 sudo /usr/bin/python3 /opt/trainer/bedside_trainer.py
The trainer picks up the newest checkpoint — ours. When
torch.serialization._legacy_load handles it, it unpickles the “magic number” field
first, and that first unpickle is our RCE object: __reduce__ fires and
os.system(payload) runs as root before the subsequent magic-number comparison
raises RuntimeError: Invalid magic number; corrupt file?. The traceback is cosmetic —
the payload has already executed.
6. Payload and readback
The payload copies the root flag somewhere world-readable:
$ cp /root/root.txt /tmp/.rootflag_out; chmod 644 /tmp/.rootflag_out
(The reverse-shell half of the payload was pointed at a stale VPN IP from a prior
session and never connected — it didn’t matter, the file-exfil half was enough.) Read
it back as developer:
$ ssh developer@... cat /tmp/.rootflag_out
→ the root flag.
Both flags were submitted via POST /api/v5/machine/own — machine fully pwned
(30 + 35 pts).