// HackTheBox

Bedside

pdfminer.six CMap pickle RCE (CVE-2025-64512) → LFI pivot to an internal service → MONAI checkpoint pickle RCE to root

HackTheBoxMediumLinux Jul 19, 2026 9 min read

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:

  1. Image conversion (tiff/bmp/dcm→png?) → ImageMagick delegate RCE (ImageTragick / MSL / SVG), or a DICOM-parser bug.
  2. PDF parsing via pdfminer.six (per the X-Powered-By header).
  3. Archive extraction → Zip Slip / path traversal to write a webshell into the webroot.
  4. 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/png is 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.png saves as up1.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.zip does 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:

  1. Stage the payload. Upload malicious.pickle.gz. The .gz extension 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 runs os.system(payload).
  2. Trigger the load. Upload trigger.pdf: a Type0 / CIDFontType2 font whose /Encoding is a PDF name using #2F-escaped slashes (#2F is the PDF name-encoding for /) that decodes to the absolute path /var/www/research.bedside.htb/uploads/malicious. pdfminer’s _load_data appends .pickle.gz, os.path.join lets the absolute name escape the cmap directory, and pickle.load runs on our staged file → arbitrary callable → os.system(payload).
  3. 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 to loot/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.pycheckpoint_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/ownmachine fully pwned (30 + 35 pts).

← All writeups