During authorized testing on a large consumer hardware vendor's web assets (referred to here as REDACTED, in line with responsible disclosure), I came across an OAuth/OpenID Connect flow on their main domain, www.REDACTED.com. On its own, this is just interesting architecture, not a vulnerability. But a trust decision buried inside that flow, combined with an XSS on an unrelated subdomain, ended up chaining into a full account takeover. Here's the whole path.
Step 1: Finding the authentication flow
While mapping the application, I noticed one of the subdomains was involved in an OAuth/OIDC authorization request shaped like this:
GET /dci/idp/dwa/authorize?response_type=id_token&client_id=<CLIENT_ID>&redirect_uri=<URL_ENCODED_REDIRECT_URI>&state=fp&scope=openid&code_challenge=<CODE_CHALLENGE>&code_challenge_method=S256&nonce=<NONCE>&contextid=<CONTEXT_ID> HTTP/1.1
Host: www.REDACTED.com
response_type was set to id_token, which corresponds to the OpenID Connect Implicit flow, where the identity token is returned directly with no code-exchange step. The same request also carried code_challenge and code_challenge_method, PKCE parameters defined in RFC 7636 specifically to protect the Authorization Code flow. In a pure Implicit flow there's no code issued to verify against, so these parameters have no real function here. That alone isn't a vulnerability, but it told me the authorize endpoint likely shares validation logic across multiple flow types, and that was worth following further, especially into how strictly redirect_uri itself was checked.
Step 2: A trust boundary that was too wide
Looking at which destinations this flow accepted as redirect_uri, I found that an entirely separate subdomain was trusted, which I'll call test.REDACTED.com here.
Trust between subdomains inside one organization is normal and often necessary. The problem is when that trust extends to an entire subdomain rather than to one exact, pre-registered path. A subdomain can host services that were never built to the same security standard as the main application, a feedback form, a legacy tool, anything.
Step 3: The XSS on the subdomain
Digging into test.REDACTED.com, I found a feedback page accepting a parameter resembling an email-verification value:
<!DOCTYPE html>
<html>
<head><title></title></head>
<body>
<h2>Customer Feedback</h2>
<form id="myform" action="nexovir" method="post" target="">
<label>Feedback</label>
<input type="text" name="feedback">
<input name="submit" type="hidden" value="true"/>
<input type="submit" value="Submit"/>
</form>
</body>
</html>
The verifyEmail value was reflected into the page without encoding appropriate for its HTML attribute context:
nexovir" onfocus="alert(new URLSearchParams(location.search).get('ContextId'))" autofocus="
The quote closes the original attribute, the rest of the string becomes a brand new attribute, and autofocus fires onfocus without any click needed. Confirmed: a real reflected XSS executing in the security context of test.REDACTED.com.
Step 4: Hitting a wall, the token lived inside a protected POST body
Looking at the actual traffic, the picture got more complicated:
POST /mypage?Client_Id=<CLIENT_ID>&ContextId=<CONTEXT_ID>&state=fp&pageUrl=<VALUE> HTTP/1.1
Host: test.REDACTED.com
Content-Type: application/x-www-form-urlencoded
Origin: https://www.REDACTED.com
Referer: https://www.REDACTED.com/
id_token=<REDACTED_ID_TOKEN>
The id_token was sent inside the body of a POST request, not in a URL. This is usually where testers stop: an XSS on the same subdomain, but no way to read a POST body a script never gets access to. The Same-Origin Policy exists exactly to prevent a script from reading the body of a request it didn't itself send, even to the same origin. Knowing a request's URL is not the same as having access to its body.
Step 5: A clue from an odd character
I started fuzzing redirect_uri with unusual characters. Including a %0A (a line feed, after decoding) caused the login page to render incompletely. On its own that proves nothing, but it suggested something in the validation of this value wasn't airtight.
The hypothesis that followed: what happens if redirect_uri ends exactly with a trailing ampersand? Maybe the server assumes the query string isn't finished yet and appends the next parameter, the token itself, directly onto it. That's exactly what happened. Sending redirect_uri with a trailing &, the server appended id_token as a new query parameter on that same URL instead of keeping it strictly inside the POST body.
Step 6: Putting the pieces together
At this point every piece was in place: an OIDC flow trusting a subdomain without pinning that trust to one exact path, a real reflected XSS on that subdomain, and a redirect_uri parsing quirk that pushed a token meant to stay inside a protected POST body out into a URL the injected script could read.
The proof-of-concept redirect_uri combined both the XSS trigger on verifyEmail and the trailing-& behavior that caused id_token to land as a query parameter on the same page:
redirect_uri=
https://test.REDACTED.com/7891?Submitted=true&verifyEmail=nexovir"
onfocus="const params=new URLSearchParams(window.location.search);
const q=params.get('ContextId');
alert(q);"
autofocus&Client_Id=<CLIENT_ID>&ContextId=<CONTEXT_ID>
In the proof-of-concept, the payload only reads and alerts the ContextId value, just to prove the injected script can read data now sitting next to it in the URL. Getting from there to full token theft is a single substitution away, reading id_token instead and sending it off the page. That last step, the actual exfiltration mechanism, is intentionally left out of this writeup. Detailing it would turn an educational breakdown into an operational account-takeover guide, and that's a line I don't cross in public writeups.
Impact
- A trust relationship between an OIDC authorize endpoint and a subdomain that wasn't pinned to an exact path.
- A confirmed reflected XSS on that subdomain.
- A redirect_uri parsing behavior that could push an id_token out of a protected POST body and into a URL.
- Combined, these three findings demonstrate a realistic path to full account takeover, even though the final exfiltration step is withheld here.
Recommendations for development teams
- Validate redirect_uri against a tight, pre-registered allowlist, never a loose domain-suffix check.
- Encode every value inserted into HTML for its actual context, and avoid building raw HTML from user input.
- If an authorize endpoint supports multiple response_type values, keep each flow's validation logic fully separate and spec-compliant, so parameters that belong to a different flow, like PKCE showing up in a pure implicit flow, don't create blind spots.
- Never let an id_token leak into a URL, browser history, logs, or analytics tools.
- Treat every subdomain as its own independent attack surface, regardless of shared ownership.
A tip for other researchers
Don't stop at the first wall. A POST body you can't read, or a CSP that blocks exfiltration, feels like the end of the road, but it's often just a sign to look one layer deeper, at how the server builds URLs, parses parameters, or trusts other parts of its own infrastructure. The real finding here wasn't the XSS by itself. It was the gap between how the token was supposed to be delivered and how a single unexpected character revealed it could be delivered instead.