HalcyonFM

webssrfcommand-injectiondeserializationcredential-exposure

A community radio station that stacks five vulnerabilities, each one individually mitigated, into a single unbroken chain from an anonymous visitor to command execution.

Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.

Recon

A single open port behind nginx, and the usual pattern for this platform: the default response is a redirect that names the vhost you’re supposed to be using. Once that’s in the hosts file you get a station homepage with a schedule, show pages and a “submit your demo” form.

The demo form has a preview step that fetches a user-supplied artwork or feed URL server-side. That’s the attack surface, and the client bundle tells you how to use it: the request body carries a developer flag, and the embedded comment explains that the full response body is only returned when that flag is set.

Flipping it converts a blind fetcher into a full-read SSRF — you get the status, the content type and the complete body back. That single toggle is the difference between port guessing and actually reading internal applications.

From SSRF to an internal API

Pointing the fetcher at loopback and sweeping ports surfaced an undocumented internal API alongside the public app. Its spec document was served over the same interface, which handed over the whole surface: a user listing endpoint, a password-change endpoint (POST-only), and a diagnostics “stream probe” whose entire job is to shell out to a network client and report reachability.

The password change was the goal but it required POST, and the SSRF only issues GET. The diagnostics endpoint was the pivot — and it was vulnerable.

Command injection

The stream probe concatenated the caller-supplied URL straight into a shell command string and ran it. A trailing ; sleep confirmed injection by shifting the response time; because the endpoint returns the raw stdout of the whole pipeline, any injected command’s output comes back in the JSON response. That’s a comfortable RCE — you get results, not just a shell.

The source even carried a comment explaining that the parameter was considered trusted because the API only listens on loopback. Loopback is not authentication.

The /proc scrubbing trap

This is the best lesson on the box. The API reads its database credentials from environment variables and then deliberately removes them from its own environment, specifically so that a shell spawned by the diagnostics endpoint can’t recover them. Sensible instinct, wrong assumption.

On Linux, /proc/<pid>/environ is a static snapshot written by the kernel at execve(). Removing a variable from the running process updates the live environment — it does not update that snapshot. The original values stay readable for the lifetime of the process, so reading the service’s own /proc entry handed back the database password intact.

Then the trap reverses direction: with command execution on the host, I no longer needed the SSRF to reach the internal API — I could make the POST to the password-change endpoint directly over loopback and reset the administrator’s password.

The hidden console

The container’s /etc/hosts listed a second vhost that the public site never links to — a staff playout console. Logging in with the freshly reset administrator password worked, which is the payoff for chaining the previous two steps: the SSRF found the service, the injection reset the account.

Its playlist import endpoint deserialised the uploaded bundle with the language’s native object-serialisation format and — critically — rendered the deserialised object back into the response. That combination turns insecure deserialization from a blind exploit into one where the command’s output is printed on the page. Upload, read the flag inline.

Note the format detail: the bundle was base64-wrapped before deserialization, matching the export format the app itself produced. Reading the export endpoint first told me exactly what the import endpoint expected.

Takeaways

  • /proc/<pid>/environ is a snapshot, not a view. Scrubbing secrets from the live environment does nothing to it. Never rely on it as a mitigation — and read it when you have code execution.
  • A “developer” or “debug” flag in a request body can upgrade a blind primitive into a full-read one. Grep client bundles for them.
  • Loopback binding is not authentication, and a service that shells out with caller input is command execution regardless of where it listens.
  • Chaining beats depth: the SSRF found the endpoint, but the injection was what actually reset the password. Keep both primitives alive.
  • Insecure deserialization is much easier to exploit when the app echoes the deserialised object back to you — check whether the result is rendered.
  • Five partial mitigations still add up to a full compromise. Each step here was “mostly handled”, and the chain walked straight through.