Inked

webhost-header-injectionpassword-resetsstirce

A tattoo studio’s booking site that declared its own vulnerability in a CSS comment. The obvious path was a decoy — the real way in was a poisoned password-reset email and a templating engine that would happily run shell commands for you.

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

Recon

One nginx reverse proxy routing purely on the Host header, and behind it two separate applications: a public shop with a booking form, and a staff console with its own login. Both are invisible until the vhost names are in your hosts file.

Two details paid off immediately, and neither came from a scanner:

  • The staff console’s CSS carried a comment labelling one section as the vulnerable surface — the author leaving breadcrumbs. I followed it, and it was a red herring (see below), but it told me the shape of the challenge.
  • The console’s JavaScript pointed at a second API host that the UI never mentions directly. Always read the client bundle once you’re authenticated; the interesting backend is rarely the one you logged in to.

The decoy: stored XSS

Everything pointed at stored XSS through the booking form, and it was real: the console rendered client notes as raw HTML, an automated browser was visiting submissions, and the CSS comment named the section.

It was still the wrong path — the bot’s network couldn’t reach my callback address. Thirty payloads, zero callbacks. If you can’t receive the callback, the bug is untestable, not unexploitable. I noted it, stopped, and moved on, which in retrospect was the right call.

Password-reset poisoning with a helpful mail daemon

The staff login had a “forgot password” link, and the reset URL was built from the request’s host header rather than a configured base URL. Injecting an alternate host into that header made the app generate a reset link pointing at my listener.

That alone is a known technique, but the interesting part was what happened next: the studio’s internal mail daemon pre-fetches URLs in outgoing messages (link previews). It requested the poisoned link within seconds of the reset request being submitted — so the reset token arrived at my listener without the user ever opening the email.

That’s what makes this pattern worth remembering: it isn’t just developer oversight on the web side. It works because an internal service follows links, which turns a low-key bug into an instant, no-interaction token theft.

With the token, resetting the staff account’s password and logging into the console was mechanical.

The real bug: server-side template rendering

Authenticated, the booking notes were rendered server-side through a templating engine before being returned as HTML fragments. A trivial arithmetic expression in the note field came back evaluated — text-book server-side template injection.

The puzzle was getting output out, not execution. The API wraps rendering in output buffering, so functions that print to stdout have their output discarded — the template receives nothing. Only a callable that returns its output as a string survives.

That turns into an argument-count problem. Each template filter invokes its callback with a fixed number of arguments — one filter passes one, another passes two, another passes three — and you need a function whose signature matches that count and whose return value is the data you want. One of PHP’s process-execution builtins has exactly the right shape, and one filter passes exactly that many arguments, so the command’s output flows straight back into the template output.

Standard payloads fail for exactly this reason: the well-known “register an undefined callback” macro trick is gone in newer builds, and arrow functions can reference template functions but not arbitrary language callables. Arg-count alignment is the whole technique — enumerate the filters, count their arguments, and find a callable that fits.

Dead ends worth recording

  • JWT alg:none — the server validates the signature even when the header claims no algorithm. Always test it, but don’t assume it works.
  • SQL injection in the style field — an automated agent reported a time-based confirmation; careful re-timing showed the delay was network jitter. A textbook reason to re-verify anything time-based by hand.
  • Password brute force — a hundred common passwords, no lockout, no hit. The real password simply wasn’t in the wordlist, which is itself a useful signal: stop guessing and find the intended path.

Takeaways

  • Read the application’s own artefacts before scanning: response headers, source comments, and client-side JS files pointed at every step of this chain.
  • Password-reset host-header poisoning is underrated — especially when an internal mail service follows links. No user interaction required.
  • In template injection, getting output is a separate problem from getting execution. Output buffering kills every stdout-based payload; look for a callable whose return value is the data.
  • Match the callback’s argument count to the filter that invokes it. That’s the difference between a payload that errors and one that prints.
  • Chasing a signposted decoy costs time — but confirm it quickly, note it, and leave.