SekaiCTF 2022 Challenges

Tue Oct 04 2022


SekaiCTF 2022 Writeups

Hi there :)

Recently my team some friends over at Project Sekai CTF asked if I could write some web challenges for their inaugural CTF, SekaiCTF 2022. While I'm not officially a Project Sekai team member (yet? 🙃), I love inflicting distress on unsuspecting victims through contrived challenges writing web challenges so I decided to help them out.

I wrote three web challenges for the CTF, Crab Commodities, Safelist, and Obligatory Calc. Crab Commodities was a server-side web challenge written in Rust, while the Safelist and Obligatory Calc were a couple of classic contrived client-side web challenges.

These were designed to be the harder web challenges in the CTF, and they definitely served their purpose. Safelist ended up getting 3 solves, and Obligatory Calc actually was the hardest challenge in the CTF, ending up with only 1 solve. Both of these challenges required some fairly novel techniques and out of the box thinking.

From the survey responses, I'm glad that the feedback from players seemed very positive, both towards my challenges and the CTF as a whole. SekaiCTF was a blast to organize, and definitely shoutout to the other organizers who made some cool challenges and the awesome CTFd theme!

Sorry for the delay on this writeup, I was kind of busy in the days after the CTF. Anyway, onto the writeups!


web/Crab Commodities

Solves: 30

Difficulty: Hard

Recently, I've been trying to learn Rust. I think one of the best ways to learn a new programming language is to try and create projects using it, which is why I decided to make another Rust web challenge after receiving good feedback from web/rustshop from corCTF 2022.

Crab Commodities was a server-side web challenge written in Rust, where you played a simple commodity trading simulator game. If anyone remembers the old game "Drug Wars", this was a basic clone. You could buy commodities and hold them to sell later, preferably at a time when the price was higher.

You started out with $30,000, and the goal of the challenge was to somehow increase your money to $2,000,000,000 to buy the flag. However, you only had 7 days to do this before the game would end, and you could only buy or sell once a day. Obviously, no amount of playing this game and trading normally would get you the flag.

So, we need to look at the source code and find some sort of infinite money glitch.

There were two bugs in the code:

  1. Underflow/Overflow in most arithmetic operations
  2. Race conditions literally everywhere

One way to get infinite money is if we can find a way to underflow/overflow something related to our money.

For some context, there were two types of "items" you could buy: market items, like the commodities listed, and upgrades.

Checking out the code, in the route for buying items, we see this code:

let price = item.price * body.quantity; if user.game.money.get() < price as i64 { return web::Json(APIResult { success: false, message: "Not enough money for that purchase", }); }

While we can't control item.price, we can control body.quantity. The Rust app is built in release mode, so there are actually no integer underflow/overflow checks. If we make body.quantity high enough, item.price * body.quantity will overflow to a negative number. With a negative price, we would actually gain money from this purchase.

There's only one small problem. There's an if-statement before that checks that our quantity isn't too high:

if (!user.game.has_upgrade("Storage Upgrade") && body.quantity > Game::BASE_STORAGE) || user.game.get_max_storage() < slots_used + body.quantity { // fail }

There are two checks here. It first checks that our quantity isn't above Game::BASE_STORAGE if we don't have a "Storage Upgrade". Then it checks that our used amount + the new quantity fits in our max storage.

If the first check wasn't there, we could overflow here as well. We could overflow slots_used + body.quantity to be negative, which would allow us to purchase past our max storage. But, we can't get past the first check, since no arithmetic operation happens to body.quantity, and we don't start out with a "Storage Upgrade".

So, there are two options left: try to find a way to get a "Storage Upgrade", or try to find another place to underflow/overflow. The intended solution was to try and get a "Storage Upgrade", but I actually missed the overflow check in another vital spot.

Players realized that there was no overflow check when buying upgrades, so you could buy 32767 "Storage Upgrades" and overflow your money. This wasn't intended, I just missed the check here. Once you did this overflow, you would have enough money to overflow with other products instead.

As I mentioned, the intended path was to get a "Storage Upgrade", which cost $100,000, something impossible to get in 7 days. To get this, we'll have to use race conditions.

The important values in this application were stored behind a LockHelper, a custom struct which I'll show below:

// thread-safe lock system // avoids clones and copies #[derive(Debug, Clone, Default)] pub struct LockHelper<T> { value: Arc<Mutex<T>>, } impl<T: Clone> LockHelper<T> { pub fn get(&self) -> T { let mutex = self.value.lock().unwrap(); mutex.clone() } pub fn set(&self, val: T) { let mut mutex = self.value.lock().unwrap(); *mutex = val; } pub fn from(val: T) -> LockHelper<T> { LockHelper::<T> { value: Arc::new(Mutex::new(val)), } } }

This custom struct allows for values in the User struct to be shared cross threads. You can't just allow different threads to mutably change shared state, since Rust prevents data races. So, a mutex has to be used to ensure that only one thread has access to change the value at a time.

Using a mutex or some other synchronization primitive makes sense to make sure that critical sections don't have race conditions. But, do you notice what's wrong with this code?

The LockHelper is used all the time like this:

user.game.money.set(user.game.money.get() - price as i64);

Do you see the problem? It's kind of subtle, but there's actually a huge problem with LockHelper and how its used. This code works like this:

  1. user.game.money.get(): get the money value
    • Lock
    • Read value
    • Unlock
  2. user.game.money.set(): set the money value based on the previously read value
    • Lock
    • Set value
    • Unlock

This usage is not thread-safe at all! Usually, you would use the mutex to lock over the entire critical section. This would make it so that only one thread could run a critical section at a time, and any other threads would have to wait for the user to finish being updated until they could also get into the critical section.

But, with LockHelper, there is basically no race condition protection. Rust prevents data races, not race conditions.

This seems like a perfect time to use the Loan feature! Once per game, we can get a loan of $37,500. To prevent us from getting another loan, it adds our loan to the upgrades list once we've gotten it. Then, when the player asks for a second loan, it checks the list and realizes we have already gotten a loan, and refuses to give us the money.

Since the upgrades list is backed by a LockHelper, we have the possibility for a race condition so we can potentially get multiple loans if we get lucky with our timings. Here's the relevant code:

// upgrade checks if user.game.has_upgrade("Loan") && item.name == "Loan" { return web::Json(APIResult { success: false, message: "You can't take out another loan", }); } if user.game.has_upgrade("More Commodities") && item.name == "More Commodities" { return web::Json(APIResult { success: false, message: "You already have access to all commodities", }); } if user.game.money.get() < price as i64 { return web::Json(APIResult { success: false, message: "Not enough money", }); } let mut upgrades = user.game.upgrades.get(); upgrades.extend(vec![item].repeat(body.quantity as usize)); if upgrades.len() > 32767 { return web::Json(APIResult { success: false, message: "Too many upgrades purchased", }); } user.game.upgrades.set(upgrades); if price != 0 { user.game.money.set(user.game.money.get() - price as i64); }

If we just get past the user.game.has_upgrade("Loan") check before the app adds our "Loan" item to the list with the line upgrades.extend(vec![item].repeat(body.quantity as usize));, we can sneak another loan in.

Easy, right? But, actually, Rust is too 🚀 blazing fast 🚀 to get this race to work. So instead, we have to abuse the upgrade mechanism to make our race delay longer. Here's how the upgrades are checked:

pub fn count_upgrades(&self, name: &str) -> i32 { self.upgrades.get().iter().filter(|&item| item.name == name).count() as i32 } pub fn has_upgrade(&self, name: &str) -> bool { self.count_upgrades(name) > 0 }

So, upgrades are stored in a vector, and then to check whether we have an upgrade, it loops through the entire list. The upgrade vector has a max length of 32767, so...

If we buy many upgrades, there's a chance that looping through the upgrade list will enlarge the race window just enough for us to sneak a couple extra loans in there. Luckily, we can use the donation feature for this! If we donate $5, we will add 5 donation entries to our list, If we donate $30,000, we will add 30,000 donation entries to our list.

It turns out that by donating all our money, and then racing getting a loan, we can get 3 or more loans in one run! Now, we will have more than $100,000, allowing us to get the storage upgrade, and then underflowing the price to give ourselves infinite money.

Doing this, and then buying the "Flag" upgrade, we solve the challenge!

SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}

My solve scripts are here.

web/Safelist

Solves: 3

Difficulty: Expert

The writeup for the previous challenge was pretty long, but that's because that app was so complex. Safelist on the other hand is simple, but the techniques required to solve are anything but. In Safelist, you have a simple app where you can just add notes to a list.

You can put any text and create a new entry in the list, which will be saved to the server which you can view later. The client receives the list from the server, runs DOMPurify on each post, then adds it to innerHTML. So, we have HTML injection, but no JS execution.

This is a client-side challenge, and the admin bot has the flag in one of the list entries. Our goal is to somehow retrieve the flag from the site.

Checking the server-side code, we see this code in the middleware:

res.locals.nonce = crypto.randomBytes(32).toString("hex"); // Surely this will be enough to protect my website // Clueless res.setHeader("Content-Security-Policy", ` default-src 'self'; script-src 'nonce-${res.locals.nonce}' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; `.trim().replace(/\s+/g, " ")); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); res.setHeader("Document-Policy", "force-load-at-top");

That's a lot of security headers. The important one here is the CSP, where basically we can't use anything that's not already located on the site. Let's look at the other routes:

app.post("/create", (req, res) => { let { text } = req.body; if (!text || typeof text !== "string") { return res.end("Missing 'text' variable") } req.user.list.push(text.slice(0, 2048)); req.user.list.sort(); res.redirect("/"); }); app.post("/remove", (req, res) => { let { index } = req.body; if (!index || typeof index !== "string") { return res.end("Missing 'index' variable"); } index = parseInt(index); if (isNaN(index)) { return res.end("Missing 'index' variable"); } req.user.list.splice(index, 1); res.redirect("/"); }); app.get("/", (req, res) => { res.render("home", { list: encodeURIComponent(JSON.stringify(req.user.list)) }); });

There's a /create endpoint, which takes our message, limits it to 2048 chars, then adds it to our list and sorts. Then there's also the /remove endpoint, letting us remove an entry from our list entirely, shifting the rest of the elements over.

That's basically the entire app, much less code than the previous challenge. So, how can we get the flag?

This is a classic XS-Leaks style challenge. If you don't know what XS-Leaks are, I highly recommend that you read the wiki here. But essentially, we need to abuse some sort of browser side-channel to read the flag.

Usually, XS-Leak attacks can leak one bit of information at a time through some sort of oracle. So, we need to find an oracle in the challenge. Typically, these sorts of challenges will have some sort of "search" functionality, and then we can try and somehow detect the difference from a positive and negative search.

But, there's no search functionality. Obviously, we want to somehow use our HTML injection in some way. The site has no CSRF protection, so we can arbitrary create and remove entries, but not read any responses from the server. We want to somehow create a method where if a condition is true, our HTML tags are still on the page, else, our HTML tags aren't on the page.

We can do this by abusing the sort! Imagine two scenarios:

  1. Our entry is alphabetically before the flag entry
    • Then the list look something like this: ["OUR ENTRY", "FLAG"]
    • By deleting the first entry (index 0), we will be left with: ["FLAG"]
  2. Our entry is alphabetically after the flag entry
    • Then the list look something like this: ["FLAG", "OUR ENTRY"]
    • By deleting the first entry (index 0), we will be left with: ["OUR ENTRY"]

This works perfect! By abusing the sort and deleting a constant index, we have two different scenarios that we can potentially leak. Now, how do we leak whether our entry is on the page?

Obviously, we need to do something special with the HTML tags. There is no way to leak something like an HTTP request past the CSP with DOMPurify, so what do we do?

Well, this was the heart of the challenge, finding some sort of vector to leak whether our tags were present. All solvers (and me), did this differently. You can find some other solutions here:

I'll describe my solution: a modification of the connection pool leak. One attack on the XS-Leaks wiki is the connection pool leak, which I recommend that you read before we continue.

But basically, there is a global pool of sockets used to communicate with servers, and once they're all used up, we have to wait for one to free before another request can be made.

Normally, you aren't supposed to be able to read the network timing of a request, especially a request with cookies. But, the connection pool leak lets us do just that. We first block all sockets except for one, then we:

  1. start a timer
  2. make a request to the target site with cookies (that we shouldn't be able to time)
  3. make a request where we can run code once it finishes

The second request that we can time can only start once the request to the target site finishes. So, we wait for the second request to finish, then we check the change in time since the timer started. This change in time will contain the network timing for the request to the target site!

This leak is useful if the oracle we want to leak changes the load speed from the server, for example, if it takes a lot longer to load the page if we are logged in, we can detect that difference with this leak.

But, this leak in its current state doesn't actually help us with our oracle. We can only leak the network timing for one request, the request to the target site, but that doesn't change even if our HTML is present or not.

This is where my simple modification to this leak comes in: instead of making one request in step 3, we make multiple. Then, with our HTML injection, we load many <img> tags that make requests to some place on the website, like /js/purify.js.

What does this do? Well, the target site is going to make many requests when it loads, and our leak script will also make many requests (that we can time). There is only one socket remaining, so what will happen? Well, both sites will fight over the remaining socket.

If our HTML is not present, the time our leak script will measure will be the time it takes for the target page to load, as well as all the times for the requests it makes to finish.

If our HTML is present, the time our leak script will measure will be the time it takes for the target page to load, the time for however many <img> tags to load, as well as the times for all the requests it makes to finish.

There is an obvious and detectable difference here! This attack essentially measures the "network congestion" of the global socket pool. If a background site is making many requests, the sockets in the global socket pool will be in contention with the requests from our leak scripts.

Using this modified XS-Leak, we can detect whether our HTML is on the page, and by extension, whether a certain character is alphabetically before or after the flag. This is all we need to leak the flag, character by character.

SEKAI{xsleakyay}

Take a look at my solve scripts here.

web/Obligatory Calc

Solves: 1

Difficulty: Master

Obligatory Calc was the last web challenge, and the hardest one by far. Just like Safelist, the code was fairly small (at least, much smaller than the Rust challenge). The site implements a calculator using mathjs, where we can find the results of arbitrary math expressions.

We type out an expression in the top box, then hit "calculate" to see the result. It also saves our calculator history in the results section. This is a client-side XSS challenge, so there's a section to report a link to the admin bot.

This code was inspired by terjanq's challenge so-xss, which I highly recommend you check out!

First, let's take a look at the CSP: default-src 'self'; object-src 'none'; base-uri 'none'; script-src * 'unsafe-inline';

Surprisingly, 'unsafe-inline' is present in the script-src directive! This means that if we can inject arbitrary HTML tags somewhere, we instantly get JS execution. But sadly, the challenge isn't that simple...

The only place we can inject HTML is here in the client-side:

window.results.replaceChildren( ...results.map(h => Object.assign(document.createElement('li'), { innerHTML: h })) );

If we can somehow sneak some HTML into the results array, it will be set as the innerHTML for a new li tag. If we had access to setting the user's cookies, this would be easy. Sadly, we can't mess with cookies, so we'll have to find a way to set results some other way. Let's take a look at the enclosing function:

window.onmessage = (e) => { if (e.source == window.calc.contentWindow && e.data.token == window.token) { let results = [ e.data.result, ...[...window.results.children].map(li => li.innerHTML) ]; window.results.replaceChildren( ...results.map(h => Object.assign(document.createElement('li'), { innerHTML: h })) ); document.cookie = "__Host-results=" + encodeURIComponent(JSON.stringify(results)) + "; secure; path=/"; } };

Okay so, this site works with postMessage communicating between two windows. The main page has an iframe to /calc, and when you enter an expression to be calculated, it is sent to the iframe running mathjs through a postMessage, evaluated, and sent back via another postMessage.

There are then two checks: e.source == window.calc.contentWindow && e.data.token == window.token to ensure that the received postMessage message comes from a trusted source. If it passes these checks, we set the innerHTML, and then set our results cookie so that we can keep the history on refresh.

From the client-side code, we can basically only run arbitrary mathjs expressions through the ?expr= URL parameter. How can we use this?

The first idea is, can we get the /calc window to send a postMessage back with malicious HTML? Well, the answer is no. We can't get arbitrary JS execution through mathjs, and while we can get arbitrary output from mathjs, it is sanitized before the message is sent.

When we want to evaluate a math expression, this is called:

let result = `${math.evaluate(e.data.expr)}`; e.source.postMessage({ token, result: sanitize(result) }, "*");

The sanitize function looks like this:

const sanitize = (data) => { if (window.Sanitizer) { // let's use the new Sanitizer API! let li = document.createElement("li"); li.setHTML(data); return li.innerHTML; } // fallback to DOMPurify return DOMPurify.sanitize(data); };

So, while our output is being set as innerHTML, it either goes through the new Sanitizer API, or DOMPurify.

I was kind enough to provide a hint that no 0-days were needed, so this should have told the players that there were no vulnerabilities in the Sanitizer API, DOMPurify, or mathjs.

So, getting a message with unsafe contents from /calc seems like a no go. Can we somehow bypass the checks?

The first check e.source == window.calc.contentWindow checks that the postMessage's source is the contentWindow of window.calc. window.calc is defined on the page load. Here's the entire window.onload function:

window.onload = () => { window.onmessage = (e) => { if (e.source == window.calc.contentWindow && e.data.token == window.token) { let results = [ e.data.result, ...[...window.results.children].map(li => li.innerHTML) ]; window.results.replaceChildren( ...results.map(h => Object.assign(document.createElement('li'), { innerHTML: h })) ); document.cookie = "__Host-results=" + encodeURIComponent(JSON.stringify(results)) + "; secure; path=/"; } }; window.calc = document.getElementById("calc"); window.results = document.getElementById("results"); window.calc.contentWindow.postMessage({ token: window.token }, location.origin); let params = new URLSearchParams(location.search); if (params.has("expr")) { send(params.get("expr")); } document.getElementById("form").addEventListener("submit", (e) => { e.preventDefault(); send(new FormData(e.target).get("expr")); }); document.getElementById("clear").addEventListener("click", () => { document.cookie = "__Host-results=[]; secure; path=/"; location.href = "/"; }); };

window.calc = document.getElementById("calc"), and #calc comes from <iframe src="/calc" id="calc"></iframe>. So, this basically checks whether the postMessage's source is the iframe.

To make things even worse, it also checks that window.token matches the token provided in the message. window.token comes from:

const getCookie = (name) => { const parts = `; ${document.cookie}`.split(`; ${name}=`); return parts.pop().split(';').shift(); }; window.token = getCookie("__Host-token") || [...crypto.getRandomValues(new Uint8Array(32))].map(v=>(v%0xFF).toString(16)).join("");

So, the token either comes from the __Host-token cookie, or if there is no cookie value for some reason, from some randomly generated value. Then, on page load, this token is sent to /calc:

window.calc.contentWindow.postMessage({ token: window.token }, location.origin);

And then /calc will send it back in its result messages. Looking at the code closely, there actually is no way to leak the token directly from a message. The main window only sends the token to pages on location.origin, and the /calc window will only reply to messages from the same location.origin.

Since we can't spoof the origin of our message, we can't exfiltrate the token.

Well, this seems bad. Both checks seem fairly unpassable, but since we know mathjs is a dead end, we know we have to somehow pass them. Let's start with the first check.

e.source == window.calc.contentWindow

If you're paying attention, you might notice that we use the == operator instead of ===. For some reason, we do the comparison using the type coercing equality operator. So, this gives us a hint that we might need to mess with the types of these values.

First, can we mess with window.calc.contentWindow? Well, window.calc is set with document.getElementById("calc"), so what happens if we use mathjs to output the HTML <div id=calc></div>? Unfortunately, this classic DOM clobbering vector doesn't work.

document.getElementById takes the first instance of the element in the page, so even if we inject another tag with id=calc, it will still target the iframe since that appears before our output. Is there another thing we can do?

Well, since that DOM clobbering vector didn't work, why don't we try clobbering document.getElementById instead? This is the 3rd time I've used this technique in a challenge 🙃

Surprisingly, unlike DOMPurify, the Sanitizer API allows you to clobber things in the document, so we can actually clobber document.getElementById with <img name=getElementById />. Reading the Sanitizer API spec here, we see that the Sanitizer API is not configured to protect against DOM clobbering attacks in its default state, which is perfect for us.

Okay, so if we clobber with <img name=getElementById />, we now get an error when the page loads, stopping it from setting window.calc. But since the iframe with that id still exists on the page, window.calc still references our iframe. But now, we can combine this with the clobbering from before! If we inject <img name=getElementById /><div id=calc></div>, window.calc will point to an HTML collection of our iframe and div, and window.calc.contentWindow will be undefined.

Okay, so we can change window.calc.contentWindow now. Can we change e.source to be undefined as well? Well, no. e.source is always set to the source of the postMessage, and the postMessage has to come from somewhere, so e.source can't be undefined. Can we change window.calc.contentWindow to a window element somehow? Well, no, since we can't inject another iframe onto the page because the Sanitizer API blocks it.

Do you remember that we're using the type coercing equality operator? Well, unlike the identity operator (===), null == undefined is true! And in Chrome, you actually can get e.source to be null!

If you send a message from a window, and immediately close it, e.source will become null since the window which once existed no longer does. It looks a little bit like this:

let iframe = document.createElement('iframe'); document.body.appendChild(iframe); window.target = window.open("http://localhost:8080/"); await new Promise(r => setTimeout(r, 2000)); // wait for page to load iframe.contentWindow.eval(`window.parent.target.postMessage("A", "*")`); document.body.removeChild(iframe);

We first create an iframe, and once the page loads, we send a message from the iframe and then immediately remove it. If you check the received message, you'll notice that e.source is null. Perfect!

So, with this, the comparison e.source == window.calc.contentWindow does null == undefined, which is true, passing the first check.

e.data.token == window.token

This was the second and harder check. e.data.token comes from our messagae, and window.token is created when the page loads. Let's see how it works again:

const getCookie = (name) => { const parts = `; ${document.cookie}`.split(`; ${name}=`); return parts.pop().split(';').shift(); }; window.token = getCookie("__Host-token") || [...crypto.getRandomValues(new Uint8Array(32))].map(v=>(v%0xFF).toString(16)).join("");

So window.token either comes from our cookie, or from randomly generated values. Well, can we mess with the getCookie function? Yes! I've used this exact same getCookie function before, and it is interesting since it is vulnerable to more DOM clobbering.

If we load the HTML <img name=cookie>, document.cookie gets clobbered to an HTML tag, which will make getCookie("__Host-token") return null. Hm, but if it returns null, it will fallback to the randomly generated values, which we can't predict either...

So, there are two options for us:

  1. we either need to somehow leak the token
  2. we need to somehow force the token to be a specific value

We know that option 1 is basically a no-go. We can't intercept the token in any of the messages sent, and we can't read any variables in the client-side.

So, we have to go with option 2. If we go to the fallback function, we lose since we definitely can't predict crypto.getRandomValues.

This leaves us with another two choices:

  1. Somehow get getCookie to return a value we know that is defined
  2. Somehow get getCookie to error

The second choice is actually very interesting. If we somehow cause an error in getCookie, the window.token assignment will fail, and it will be undefined. We can't do option 1 since we can't control the exact value of document.cookie (at least, not with what we're given), so we have to find some way to get getCookie to error.

We actually can do this! If we inject the HTML <form name=cookie><input id=toString></form>, document.cookie will point to the outer form, document.cookie.toString will point to the inner input tag, and so then, the call document.cookie.toString() will fail, since that won't be a function.

There's only one problem: this doesn't work. Remember, we are getting our HTML injections from the message handler, which first sets the HTML, and then the cookie. We need these injections to be present on page load, so they have to be set in the cookie. Let's look at that code again:

window.results.replaceChildren( ...results.map(h => Object.assign(document.createElement('li'), { innerHTML: h })) ); document.cookie = "__Host-results=" + encodeURIComponent(JSON.stringify(results)) + "; secure; path=/";

So, we first set the HTML on the page, and then set the cookie. There's a major problem here. The first line sets the HTML, and replaces document.cookie with our form. Then, the second line assigns to document.cookie. But this doesn't work anymore, since document.cookie no longer points to the actual browser cookie, but our HTML element!

If we use any sort of vector to DOM clobber document.cookie, it can't be saved to our cookie after. Since we need our vector to be on the page as it loads (since window.token loads immediately), DOM clobbering document.cookie is actually a dead-end.

Okay, so what do we do then? Well, do you remember what I said at the top of the writeup? This challenge was inspired by so-xss...

In so-xss, the solution was to open a window reference from a sandboxed null origin iframe. This makes it so the opened window also has a null origin, which you could do bypass some checks.

Something cool about null origin pages, any accesses to document.cookie actually fail! You get this error:

Uncaught DOMException: Failed to read the 'cookie' property from 'Document': The document is sandboxed and lacks the 'allow-same-origin' flag.

Which totally makes sense. So now, if you open the target page with a null origin, document.cookie will error on being read, which will make window.token undefined. So now, we don't even have to send a token as part of our payload.

This bypasses the second check, allowing us to get any HTML we want, including unsafe tags, onto the page to get JS execution. And even though we have a null origin, our cookies were still sent along, so our flag will be in the page source for us to read.

Now, the only problem is chaining these two checks together, and this actually caused me quite a bit of trouble. But, by messing with things, this should be doable. I actually ended up doing some really contrived method that used iframe timings and onload.

My solve scripts are here, and you can see terjanq's (the only solver) solution here:

SEKAI{i_shou1d_h4ve_stuck_to_my_ti84_in5tead}


I always enjoy writing CTF challenges, and I'm glad the feedback showed that the players seemed to enjoy them. I hope you enjoyed the challenges and learned something from either playing them or reading this writeup!

I had a lot of fun helping to organize SekaiCTF this year, and maybe I'll be back next year too 🙃

Thanks for reading :)