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:
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.
HTTP redirected to https://cohort.htb/, so I added the hostname locally:
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:
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 403Direct 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:
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:
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:
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.
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.
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.
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:
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.
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.