/Encoding at an attacker-controlled .pickle.gz, and when the library loads that CMap it pickle.load()s it into code execution. Uploading the file isn't enough; a background watcher only ever feeds real *.pdf to the parser, so the payload is split in two — the malicious pickle plus a plain trigger PDF that references it. That lands a shell in a container. From there an internal image-viewer on port 3000 turns out to be a static file server with a path traversal, which reads the developer user's SSH key straight off the host. And the clinic's proudest feature — a self-learning MONAI trainer that developer can run as root — restores checkpoints with torch.load(weights_only=False), which is pickle again. Plant a checkpoint, run the trainer, get root. Same bug, second dose. The whole chain is driven from macOS — no reverse shell, request/response only.
Two ports, and the web root leaks a hostname in its title — the usual cue to fuzz virtual hosts rather than paths.
Both versions are current — no version-level CVE to throw. That's the tell: the bug won't be in what the clinic runs on, it'll be in what the clinic built. The front page is a static brochure with nothing that takes input, so I fuzzed vhosts, filtering the redirect the main host serves for names that don't resolve:
One hit: research.bedside.htb. To keep everything scriptable from macOS I never touched /etc/hosts — every request just carries the vhost by hand:
That X-Powered-By header is hand-set — no framework writes it — and it names the whole game: pdfminer.six. The portal itself is a file-upload form ("upload X-rays, CT scans, research documents to feed the clinic's AI model training"), with a notice listing accepted formats and two lines that matter more than they look: "Collections can be uploaded as archives" and "Certain file formats may be converted to standardized formats." An upload that accepts archives and quietly converts files is an upload that parses a lot behind the scenes. Parsing is where things break.
Before touching the exploit I made the upload tell me its own rules. Sending a file the whitelist rejects leaks two useful things — the real accepted set, and the absolute storage path:
Two facts fall out. First, the whitelist advertised seven formats to the public but the backend actually accepts gz and zip too — the undocumented "archives." Second, the filter doesn't trust the extension: a .gz that isn't a real gzip stream is rejected on content, and the complaint leaks /var/www/research.bedside.htb/uploads. That path is the missing piece for the exploit I was about to build, and it was handed to me in an error message.
(The archive-symlink trick — pack a symlink to /etc/passwd, let the server extract it, request it back — goes nowhere here: the server stores archives whole and never extracts them. Worth trying, dead end.)
pdfminer.six loads CMap (character-map) resources by name. Internally, CMapDB maps a name to a file and does roughly pickle.loads(gzip.open(name + ".pickle.gz").read()). The vulnerability: a Type0 font's /Encoding can name that CMap, and the name is attacker-controlled — including absolute paths. So a PDF that references /Encoding /…uploads#2Fsh (where #2F is a PDF-escaped /) makes pdfminer load and unpickle /var/www/research.bedside.htb/uploads/sh.pickle.gz. A pickle with a __reduce__ returning (os.system, (cmd,)) is code execution the moment that font is instantiated.
Every prerequisite is already in hand: the whitelist lets me upload a real .gz, and the MIME error handed me the exact directory the PDF must name. But my first attempt failed, and the failure is the whole lesson of this box.
The obvious move is luigigubello's polyglot: a single file that is both a valid gzip carrying the pickle and a valid self-referencing PDF. I uploaded it, set a listener, and… nothing. The CVE only fires when something actually parses the PDF, and simply uploading a file doesn't feed it to pdfminer. Some background job had to pick it up — and if that job only touches real PDFs, a file named .pickle.gz sits ignored forever. I needed to change the shape of the payload before I'd even seen the code that would eventually confirm it.
So I split the payload in two. The malicious pickle keeps its .pickle.gz name so pdfminer will load it as a CMap. A separate, ordinary-looking trigger.pdf points its /Encoding at that pickle. The background job scans for real PDFs, finds trigger.pdf, parses it, follows the CMap reference to my pickle, and detonates. Here's the builder — __reduce__ is the payload, the PDF is a minimal Type0 font whose encoding is the pickle's absolute path with slashes escaped:
A live reverse shell landing in my terminal is the single loudest thing you can do, so I never used one. Instead I proved execution out-of-band: have the payload curl a marker back to a listener I control. The target pulls — request/response, nothing inbound.
Code execution as datawrangler on a host that calls itself data-wrangler. The ~30-second delay is the confession: my upload didn't trigger anything — a scheduled job woke up, found a new PDF, and processed it. (An early marker written straight into the web root had failed silently: the watcher runs as datawrangler and can't write the www-data-owned uploads/ dir. The callback sidesteps file permissions entirely.)
Container recon, exfiltrated the same way — pipe command output into a POST my listener saves to disk:
That confirms the whole theory: the loop globs *.pdf every 30 s and feeds each to pdf2txt.py — pdfminer straight into the CVE. It also explains a wall from earlier: process_pdf() explicitly rejects symlinks and non-regular files, so the archive-symlink idea was never going to work. The developer thought about that specific attack. They didn't think about a PDF pointing at a pickle. Output lands in /datastore/staging — a directory outside /app, and datawrangler is in the dataops group that owns it. Shared storage between containers is exactly the kind of seam worth prying.
The container is bare but has curl and python3. Reading /proc/net/route gives the Docker gateway (172.17.0.1); a tiny socket sweep from inside finds a port the external nmap never saw because it only listens on the bridge: 3000. It serves "Bedside Clinic - Image Viewer," a React app for MRI slices — the kind of thing that hides a path traversal in its slice-fetch API.
Except the API is fake. Its own JavaScript generates slices with Math.random() — "// For demo". There is no /api/slice, no file parameter, nothing server-side to attack through the app. I almost sank an hour into an endpoint that never existed. The real flaw is one layer down: the app is decoration, but the thing serving it is still a real static file server, and static servers have their own classic bug. Aim the traversal at the server root instead of guessing app routes — with --path-as-is so curl doesn't helpfully collapse the ../ before sending:
This isn't the container's /etc/passwd — it has developer, _laurel, polkitd, users the minimal container doesn't. The port-3000 service reads files off the host filesystem, and the traversal reads any of them. An /etc/passwd entry is an introduction, not a way in — but arbitrary host file read means I can just ask for developer's SSH key and the user flag. The key isn't at id_ed25519; a quick sweep of candidate names finds it at id_rsa:
An unencrypted ed25519 key (stored as id_rsa), comment developer@bedside. Fix its perms and walk straight onto the host, out of the container for good:
One command, no password: a MONAI training script run as root. Reading it, the vulnerable line stands out immediately — it resumes from the newest checkpoint using MONAI's CheckpointLoader, which calls torch.load(…, weights_only=False) under the hood, and torch.load deserializes with pickle. The same wound as the foothold, wearing a different coat: feed it a malicious .pt and its __reduce__ runs as root.
The plan writes itself — plant a malicious checkpoint in /datastore/checkpoints/, run the trainer, get root — but the script has guards that turn a one-liner into a fight.
The trainer never reaches the checkpoint loader unless it has data to train on. It scans /datastore/processed/ for valid images and exits early if empty. And developer can't write /datastore at all — it's datawrangler:dataops, and developer isn't in dataops. But my container shell is. The shared /datastore volume is the seam: datawrangler writes the files, developer pulls the trigger. Two shells, one exploit. So I seed a valid PNG into processed/ — with data present, the trainer skips promotion-from-staging entirely and marches to the loader.
A raw pickle won't do. torch.load expects a real torch archive: a ZIP containing archive/data.pkl (the pickled object graph) plus archive/version. So I build one properly — a ZIP whose data.pkl is a dict-shaped payload whose __reduce__ runs chmod u+s /bin/bash. No torch needed on either side; pure zipfile + pickle on macOS:
Both files reach the box through the portal — no need to host anything. scan.png is already allowed; evil.pt is a valid ZIP, so I upload it renamed evil.zip and it sails past the MIME check. Then a single pickle-RCE payload (fired the same two-file way) copies them out of uploads/ into /datastore as datawrangler, and drops a cleanup loop so the watcher's stray .txt output can never break the pipeline:
With a valid checkpoint in place and the pipeline held clean, run the sudo trainer from the developer shell. torch.load unpickles my checkpoint as root and __reduce__ fires:
That TypeError looks like another failure. It isn't. os.system returns an integer exit code, and torch's loader expected a dict, so it raised on the return value — but that return value only exists because the command already ran. The exception fires after the payload, not instead of it. One line down, the s bits on /bin/bash are set, and /bin/bash -p keeps euid=0. Root.
Bedside runs on a single anti-pattern repeated: deserializing data you don't control. pdfminer did it with pickle to load a font map; the MONAI trainer did it again with torch.load to restore a checkpoint. Neither is exotic — both are the default, convenient way to do a normal job, and both hand code execution the moment the input turns hostile. If you take one thing from this box, let it be that pickle.loads, torch.load(weights_only=False) and yaml.load are not file readers. They are code executors that happen to accept files. Set weights_only=True, use safetensors, and validate before you deserialize.
gz/zip, the storage path in a MIME error, the *.pdf glob in the watcher, the "// For demo" comment that exposed the port-3000 API as fake. Every sign was in the source or the error message, not in my assumptions.developer couldn't write /datastore, but a second identity that shared the volume could. The privesc was a two-shell coordination, not a single command.Two flags, one clinic that trusted its machines exactly twice too often.