CriticalDOM XSS → Account Takeover· 5 min read

A Payload With No Parentheses

The login flow executed a javascript: URL it should have rejected. A WAF tried to stop it — so I wrote a payload the WAF couldn't read.

Target anonymized · a SaaS video platform

This is my favourite kind of bug: a chain where each link is individually "known", and the interesting part is the shape of the payload that threads them all. I'll describe the technique and keep the exploit defanged — the point is the reasoning, not a copy-paste weapon.

The spark

The login flow took a redirect parameter, tucked it into the OAuth state as a "return to" value, and — after the round-trip — handed that value straight to the client-side router as somewhere to navigate. A value that starts life as a URL parameter and ends up as a navigation target is a scheme-confusion bug waiting to happen.

Digging in

The router didn't reject a javascript: scheme, so in principle a javascript: "return to" would execute. In practice a WAF sat in front and blocked the obvious things: javascript: followed by a parenthesis or backtick, and the string document..

So the real problem became a language puzzle: can you express a working payload without parentheses, without backticks, and without the blocked keywords? JavaScript, it turns out, will let you — you can trigger evaluation through an error handler and carry the actual code in a property the page already exposes, so the payload never contains a single character the filter is looking for.

The WAF wasn't matching behaviour, it was matching strings. Write a payload that behaves the same but reads differently, and the filter has nothing to grab.

The exploit (defanged)

Conceptually: deliver a keyword-free javascript: value through the redirect parameter, let it execute once the OAuth round-trip echoes it back, and have it read the OAuth refresh token the app kept in localStorage. With a refresh token you can mint fresh access tokens at will — which is a persistent, silent takeover if the victim was already logged in. A permissive CORS reflection made the stolen token even easier to use against the API. I'm deliberately not publishing the working payload or stealer.

Impact

Full account takeover — silent for an already-authenticated victim, and seamless for one who simply logs in as normal, because the payload rides along through their real login.

The fix

Validate the scheme (allow only relative/same-origin returns), sign and verify the OAuth state so an echoed value can't be trusted blindly, stop keeping long-lived tokens where script can read them, and treat the WAF as defence-in-depth — not the control.

dom-xsswaf-bypassoauthaccount-takeover

All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.