XS-Leaks: Turning the Browser Into a Side Channel
10 min read
August 8, 2026

Table of contents
👋 Introduction
Hey everyone!
Last week we hijacked upgradeable contracts. This week, back to the browser, and a bug class that steals data the same-origin policy swears it protects.
You know the same-origin policy stops your site from reading gmail.com. It does. What it never promised is to hide the side effects of loading gmail.com. Your page can still embed another origin’s resources and watch what happens: did it load or error, how many frames did it spawn, how long did it take, was it already cached. Each of those is a yes or no answer about the victim’s private state, extracted without ever reading a single byte of the response.
That is an XS-Leak, and it is far more powerful than it sounds. One boolean per request feels like nothing. Chain one boolean per character and you exfiltrate a victim’s private search results, their inbox contents, whether they are an admin, all from a random tab they left open. It is blind injection, except the oracle is a browser behavior and the target is a site you cannot read.
This week: the oracle that makes it all work, error events as the simplest leak, frame counting through a cross-origin window, the single socket pool that turns every browser into a timer, and the cache probe that leaked private data across Google.
Let’s get into it 👇
🔐 The Oracle Behind the Origin Boundary
Start with what the same-origin policy actually does. It blocks you from reading a cross-origin response body, so you cannot fetch() another site and inspect its HTML. It does not block you from embedding that site’s resources with a <script>, <img>, or <iframe>, and it does not hide a small set of cross-window properties like window.length.
That gap is the entire attack surface. An “oracle” here is any observable browser behavior that changes based on the victim’s state on the target site. The server returns a different status when you are logged in. A search page renders a different number of frames when it has results. A response takes longer when the query matches. None of that reveals the response content directly. It reveals one bit.
One bit sounds useless until you make it a question you control. Is the first character of the secret an a? The browser answers yes or no through a side effect. Then you ask about b. This is the same blind boolean extraction from Issue 42 on NoSQL injection, lifted out of the database and into the browser, working cross-origin against a site you have no access to. The rest of this issue is five different ways to build that oracle.
🚦 Error Events: The Simplest Oracle
The cleanest leak needs no timing and no math. An endpoint returns success or failure depending on the victim’s state, and the browser hands you that difference for free as an onload versus onerror event.
Embed the target as a script, image, or stylesheet. If it returns a 200 for an authorized victim and a 404 or a content-type the parser rejects for anyone else, the two events split cleanly into a boolean.
function probe(url) {
const s = document.createElement('script');
s.src = url;
s.onload = () => report('TRUE'); // resource loaded for this victim
s.onerror = () => report('FALSE'); // 404 / wrong type / not authorized
document.head.appendChild(s);
}
// An endpoint that 200s only for the logged-in or authorized user
probe('https://target.example/api/private-resource');
The XS-Leaks Wiki documents real cases using this to deanonymize users through endpoints that errored only for the wrong account. Status code, content type, or a parse failure all trigger the same clean split. You are not reading the response. You are reading whether the browser was happy with it.
🖼️ Frame Counting
Here is where it gets less obvious. window.length, the number of child frames in a window, is readable across origins. The same-origin policy leaves it exposed, so if you hold a reference to a cross-origin window, you can count its frames even though you cannot read a thing inside them.
Why does that leak anything? Because pages render a different number of frames in different states. A search with results embeds result widgets. A profile with a connection shows an extra frame. The count is the oracle.
const win = window.open('https://target.example/search?q=secret');
setTimeout(() => report(win.length + ' frames'), 2000); // count = state
The Wiki cites this leaking private repository existence on GitHub and private data on Facebook. Open the target with a query, count the frames, and the number tells you whether the query matched, no response reading required. The realization that sticks: a property as innocent as “how many iframes” is enough to turn a search box into a data-exfiltration oracle.
🔌 Timing and the Single Socket Pool
Timing leaks are obvious in principle. A response that takes longer when the query matches is an oracle. The problem is noise, and browsers have throttled the precise timers that used to make timing reliable. So attackers found a clock the browser cannot take away: its own connection limit.
A browser shares one global socket pool across all requests, capped at around 256 sockets in Chrome. Fill 255 of them with requests that hang open, spend socket 256 on the target request, then fire a 257th. That last request cannot even start until a socket frees up, so the moment it finally proceeds tells you exactly when the target request finished.
// Saturate the global pool so the target's socket becomes the bottleneck
for (let i = 0; i < 255; i++)
fetch(`https://blackhole${i}.example/`, { mode: 'no-cors', cache: 'no-store' });
// socket 256 -> target request; a 257th request's start time leaks the target's timing
This works even where every timing API is blocked, because it relies on a browser-wide resource limit, not on reading any clock the page is allowed to see. Feed that timing oracle into XS-Search and you extract secrets character by character: query ?q=a, measure, query ?q=b, compare. Slower means the search matched. The browser became a stopwatch you never handed it.
💾 Cache Probing
The last oracle asks a different question: not “what is the response,” but “has the victim seen this before.” A resource sitting in the victim’s cache means they already loaded it, which means they visited that page, which often means they were authorized to.
Evict a marker resource, force the victim’s browser to request it in the background, then measure whether it came from cache or network. A cache hit returns almost instantly. An AbortController set to a tight timeout turns that into a boolean: cache beats the timeout, network does not.
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 40); // cached load finishes under 40ms, network doesn't
fetch('https://target.example/marker.png', { signal: ctrl.signal, mode: 'no-cors' })
.then(() => report('CACHED -> victim visited it'))
.catch(() => report('MISS -> they did not'));
The researcher terjanq used exactly this to run XS-Search across Google products, leaking private emails, auth tokens, and credit card numbers by forcing background searches and checking whether a marker got re-cached. That disclosure is a big reason Google shipped cache partitioning and Fetch Metadata. The cache is not just a speed feature. It is a log of everywhere the victim has been.
🧭 Navigation and Window References
Sometimes the secret is not in a response at all. It is whether the target navigated. A page that redirects logged-in users to a dashboard, or triggers a download only for authorized ones, leaks that decision through the browser’s own bookkeeping.
Hold a window.open() reference to the target and the browser exposes small tells. Reading win.origin throws a cross-origin error the moment the window navigates away from your control, which tells you a redirect happened. history.length grows when a navigation adds an entry. An iframe firing onload twice means it navigated mid-load.
const win = window.open('https://target.example/download?id=42');
setTimeout(() => {
try { win.origin; report('same context -> no redirect'); }
catch (e) { report('threw -> the page redirected / navigated'); }
}, 1500);
The XS-Leaks Wiki catalogs these navigation oracles. A redirect is a decision the server made about the victim, and that decision leaks even when the destination stays hidden. You are not reading where they went. You are reading that they went somewhere.
🎯 Key Takeaways
The shift to carry out of this issue: the same-origin policy protects response contents, not response behavior. Any time a target’s status code, frame count, load time, or cache state changes with the victim’s private state, you have a cross-origin oracle, whether or not you can read a single byte. When you test an authenticated app, stop asking “can I read this response” and start asking “does this response behave differently for different users.” That question finds XS-Leaks.
The power is in the chaining. A single boolean is a curiosity. The moment you can phrase the secret as a sequence of yes or no questions, one per character, the oracle becomes full data extraction. Frame counting and connection-pool timing are the most reliable oracles to reach for, because they survive the timer throttling that killed naive timing attacks.
What makes this class dangerous is how little it asks of the victim. They do not click a malicious link inside the target app or install anything. They just have a session open in another tab while they visit a page you control. That is the same drive-by precondition as CSRF, pointed at reading data instead of writing it.
On defense, the fixes are layered and each one severs specific oracles. Fetch Metadata headers let the server see a request is cross-site and return an empty response, so the oracle carries no user data. SameSite cookies strip the victim’s session from cross-site requests, so probes hit the app logged out. COOP severs the window reference that frame counting needs, and it pairs with the framing controls from Issue 20 on CSP.
There is a blunt instrument worth knowing too. The XSinator research measured 34 XS-Leaks across dozens of browser and OS combinations and found that disabling third-party cookies blocks most of them outright, because it strips the victim’s session from the cross-site probe before any oracle can differentiate their state. Browsers are moving that way by default, which quietly closes a large slice of this attack class.
The decision tree for a defender: authenticated endpoint, add SameSite and Fetch Metadata so cross-site probes see nothing. Page that opens or is opened by other windows, add COOP to kill the reference. Sensitive cached resources, set Cache-Control: no-store or partition them. If a response can differ per user, assume someone will turn that difference into an oracle, and close it before they do.
Practice:
- XS-Leaks Wiki (xsleaks.dev) - the canonical catalog of every technique, oracle, and defense in this issue
- XSinator - runs the full XS-Leak browser test suite against your own browser, live
- XSinator source (RUB-NDS) - the test cases behind the suite, formal model of 34 XS-Leaks
- terjanq: massive XS-Search over Google - real cache-based extraction of private emails and tokens
- OWASP XS-Leaks Cheat Sheet - consolidated defense checklist
- web.dev: Fetch Metadata - building a resource isolation policy against cross-site probes
- web.dev: COOP and COEP - cross-origin isolation and why it severs window-reference leaks
- MDN: Same-Origin Policy - exactly what SOP blocks and what it leaves open
Thanks for reading, and happy hunting!
— Ruben
Other Issues
Previous Issue
💬 Comments Available
Drop your thoughts in the comments below! Found a bug or have feedback? Let me know.