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:
/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.
/api/me fetch (client‑side JS)
After the page loads, JS runs:
That fetch happens after HTML render, which creates a timing window.
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.
/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.
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
Set the webhook’s default response to the HTML above.
Submit the webhook URL in /report.
Wait for the admin bot to visit.
Webhook evidence
You’ll see:

Last updated
Was this helpful?