Cohort
A small analytics SPA whose only interesting feature is a URL validator — and that validator turns out to be the thread that unwinds the whole host.
Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.
Recon
A plain scan gives SSH and one nginx web tier; port 80 redirects to TLS and the certificate handily names the vhost.
The site is a single-page app, and unknown paths all return the same HTML — so content discovery has to discriminate on size and content rather than status codes. That habit is what surfaced a second page, and behind it a second application that the marketing site never links to.
Its JavaScript bundle was obfuscated and wrapped in an AES-GCM blob with the key sitting in the page. Unwrapping it was worth the effort: the decrypted client contained exactly one security-relevant request — a POST to a URL-validator endpoint. No credentials, no admin routes, no hidden API. The validator was the attack surface.
The interesting part
The validator fetches a user-supplied URL server-side and returns the status, the content type and a few KB of the response body. That makes it a non-blind SSRF, which changes the game completely — you can read internal pages, not merely infer which ports are open.
Literal loopback values were rejected. But the check ran against the hostname text, not against the address the operating system would actually connect to — so alternative IPv4 encodings of the same destination sailed straight through. The lesson is the ordering, not the specific value: apply SSRF policy after DNS resolution and canonicalisation, never to the attacker’s string. (The same ordering mistake is what makes redirects and DNS rebinding work.)
That gave a loopback read primitive, and response/error differences turned it into a port oracle.
The pivot that actually mattered
The edge proxy had a loopback-only status endpoint — reachable only from the machine itself. Asking for it through the SSRF made the proxy believe the request was local, and its upstream inventory leaked an internal hostname that was never meant to be public: a notebook service listening on a loopback port.
Because the public certificate was a wildcard and nginx had a matching server block with WebSocket proxying enabled, learning that name was enough to reach the loopback service from outside over 443. A service that was only listening on localhost became internet-facing — not by breaking in, but by knowing what to ask for.
Foothold
The notebook app gated its UI behind an access token — but its terminal WebSocket route did not perform the same check. No token, no session cookie, no CSRF value: connect, send a resize frame, send a command, and you get a PTY running as the service account. No authentication was recovered or needed.
The general shape is worth remembering: one real-time endpoint missing the authorisation the
rest of the application enforces. Worth enumerating every /ws route in anything that
authenticates “everywhere else”.
Local enumeration — and a genuine dead end
Standard enumeration came up deliberately boring: no passwordless sudo, no custom SUID binaries, no capabilities, no writable service definitions, nothing of interest in groups.
Two things consumed real time and were not the path:
- A container-tooling signal that looked exploitable but was fixed in the installed build.
- A kernel race the host was technically still affected by — but a separate mitigation had made the published primitive impractical. Adapting it produced plausible candidates and never a stable one.
The honest lesson is below, and it’s the most valuable part of this box.
Root
A system package daemon runs as root and is D-Bus-activatable — it starts on demand even when the service looks inactive. Its installed version sat inside the affected range of a recently disclosed advisory.
The bug is a transaction-state TOCTOU, and it is deterministic rather than a nanosecond CPU race — it’s event-loop ordering:
- Queue a first, harmless operation with a simulate flag. That needs no authorisation and moves the transaction into a runnable state.
- Queue a second operation on the same transaction that overwrites the cached flags and the cached package path with an attacker-built package.
- The state machine rejects the backwards state transition — but does not roll back the cached values.
- The queued idle callback then reads the modified values and performs a real installation as root.
The authentication error that arrives afterwards is cosmetic: the package’s maintainer script has already run. Ship a package whose post-install script drops a SUID shell and you’re root. Cleanup was just removing the two proof packages.
What I’d do differently
Root took far longer than it should have — and not because the exploit was hard. Once the daemon was correlated with its advisory, exploitation took about two seconds. The delay was route ranking:
- A root daemon reachable over the system bus, with a known version, should outrank a narrow kernel race.
- Recency matters. Heuristics tuned to older privilege-escalation classes silently miss something disclosed last quarter.
- Sunk cost is real — exact symbols and measurable behaviour made the kernel path feel close, so I kept tuning it.
- New workflow, adopted after this box: once you have a local shell, enumerate root D-Bus services and package versions first, correlate them with current advisories, and test deterministic user-space authorisation bugs before starting memory-corruption research.
Takeaways
- Validate SSRF policy after resolving the destination — not on the attacker’s text.
- Non-blind SSRF is a far bigger primitive than blind: a body preview reads internal pages.
- A loopback-only service stops being loopback-only the moment the edge proxies it by name.
- Audit real-time/WebSocket routes separately; authorisation rarely covers all of them.
- Check installed root daemons against recent advisories before writing a kernel exploit.
- “Race” doesn’t require nanosecond timing — event-loop ordering bugs are TOCTOU too.