Root me (Self XSS - Race Condition)

Summary

We abuse a self‑XSS sink on /profile that uses innerHTML with a username from /api/me. The admin’s secret is already rendered in the HTML, so if we race a /login update between page render and the /api/me response, our payload runs on the admin page and reads the secret. We exfiltrate it via a webhook.


Start

The homepage shows login + report. The report takes a URL and sends it to a headless admin bot. That’s enough: we don’t need a normal user session.


The sink (/profile)

When logged in, /profile renders HTML like:

Key details:

  • innerHTML is a DOM‑XSS sink.

  • username comes from /api/me (untrusted).

  • The admin secret is already in the DOM before the fetch callback runs.

This is a self‑XSS, but the report bot makes it exploitable.

Test XSS

Now we should test the XSS locally to make sure it's working

Since we know the sink is in the username we should set the username and secret to the following


The race condition

The race is between two independent requests that affect the same session cookie at different times:

  1. /profile HTML render (admin session)

  • The admin bot opens /profile using its existing session cookie.

  • Server renders the page with the admin secret already in the HTML.

  1. /api/me fetch (client‑side JS)

  • After the page loads, JS runs:

  • That fetch happens after HTML render, which creates a timing window.

  1. Our cross‑origin /login update

  • From our webhook page we send a form POST to /login with text/plain.

  • The server responds with Set‑Cookie for the session containing our payload username.

  • This overwrites the cookie in the browser’s jar even if the POST had no cookies.

  1. /api/me now sees the new cookie

  • When /profile makes its /api/me request, it uses the latest cookie.

  • If we timed it right, /api/me returns our XSS payload as the username.

  1. DOM‑XSS executes on the admin page

  • The HTML still contains the admin secret (from step 1).

  • Our payload runs inside innerHTML, reads the secret from the DOM, and exfiltrates it.


Timeline view


Why this is a race

There is a narrow window between:

  • HTML render (secret already visible)

  • /api/me response (username injected)

If /login happens:

  • Too early → /profile renders with our session, secret is gone (fail).

  • Too late → /api/me still returns admin username, no XSS (fail).

  • Just right → HTML has admin secret, username becomes our payload (win).

That’s why we add a small delay (e.g., ~40ms) and sometimes fire multiple submits in a burst to “catch” the window.


Bypassing CORS on /login

/login expects JSON, and cross‑origin JSON fetch will preflight and fail.

But text/plain form posts are simple requests (no preflight), and the server still parses JSON from the raw body.

We can craft a form like:

Body becomes:

That is valid JSON (the trailing = becomes part of the secret).


Hosting the exploit

The report only allows URLs matching ^http(s)://.

Webhook.site lets you set the default response content, so we host our attacker HTML directly there and submit that URL to /report.


Final payload

Why the beacons:

  • stage=boot → our page loaded

  • stage=submitted → race request sent

  • stage=xss → payload ran on /profile

  • flag=... → secret exfiltrated


Running it

  1. Set the webhook’s default response to the HTML above.

  2. Submit the webhook URL in /report.

  3. Wait for the admin bot to visit.


Webhook evidence

You’ll see:

Last updated

Was this helpful?