Quintec writeups and other thoughts

osu!gaming CTF 2024 Postmortem

Hi everyone! It’s been a while. I’ve been busy with school and other hobbies (check out my anime piano YT) over the past year, not having much time to do CTF-related things. But that all changed this year, when me and a group of a few friends who were both CTF enthusiasts and osu! players decided to create osu!gaming CTF: a CTF themed around the popular rhythm game osu!. I was excited by the potential of this unique concept and quickly got on board—I don’t think there’s been any event I’ve put nearly as much effort into recently.

This CTF is very different than any other I’ve authored challenges for, given its unique themeing and therefore unique targeted audience. The primary goal (at least for me) was to get osu! players interested and excited about cybersecurity, while creating fun osu! themed challenges along the way. Getting CTF players interested in osu! was a bonus (and it seems like it did happen, according to the Discord!). This allowed for interesting and unique challenge ideas, some successful, others not as much. We also ended up giving a lot more hints this CTF than normal as a result, though we tried to keep it mostly to the challenges with more solves.

So I’m going to do something a little different this time: below you will find solutions to all my challenges, but also thoughts and reflections on how I created it in the context of this CTF.

Before I start, I just want to say we are reading all the survey responses and appreciate all the feedback that was given. Topics like the difficulty curve, amount of osu! theming, and beginner friendliness had differing opinions from many different people and we will work hard to balance it well if we host a next iteration of this event. My thoughts below are just a small portion of things we are taking into consideration.

crypto/base727

This challenge was mostly a joke that was literally “written” in the last 5 minutes. Both screenshots in the description are true. But it felt like a decent starting point for beginners—read some code and figure out how to reverse it to decrypt it.

Solution

Just reverse the encoding. The encoding function takes your string, and calculates a number from it treating it as base 256 using char codes, then converts it to base 727. All of these steps are reversible, and tbh you can probably ask ChatGPT to do it—here’s one solution:

import binascii

def decode_base_727(encoded_string):
    base = 727
    decoded_value = 0

    for char in encoded_string:
        decoded_value = decoded_value * base + ord(char)

    decoded_string = ""
    while decoded_value > 0:
        decoded_string = chr(decoded_value % 256) + decoded_string
        decoded_value //= 256

    return decoded_string

encoded_string = input("Enter the encoded string: ")
decoded_string = decode_base_727(binascii.unhexlify(encoded_string).decode())
print("Decoded string:", decoded_string)

web/mikufanpage

Honestly, I liked the concept of this challenge a lot. Besides the fact that, well, Miku, I thought it was a pretty good introduction to a basic web technique (LFI) that anyone could try and solve, since it essentially boiled down to understanding only 2 lines of code. And I did see (and help) many people go from nothing to figuring out how to solve this challenge, which makes me happy.

Solution

The website is a tribute to Miku, that, amid a bunch of rainbow flashing <marquee>s, has a slideshow of Miku pictures. Looking in the source code, the Miku pictures come from the /image endpoint:

app.get("/image", (req, res) => {
    if (req.query.path.split(".")[1] === "png" || req.query.path.split(".")[1] === "jpg") { // only allow images
        res.sendFile(path.resolve('./img/' + req.query.path));
    } else {
        res.status(403).send('Access Denied');
    }
});

Looking at this, we see we can read any file we want, but it seems to check if it has the extension png or jpg. There’s just one problem: the first index of a split is not necessarily the end of the string, if there are multiple periods. We can put this idea together with the fact that we can use relative directories (due to path.resolve) to solve with a URL such as https://mikufanpage.web.osugaming.lol/image?path=.png./../flag.txt.

crypto/korean-offline-mafia

Not too much to say about this one. I mostly made this challenge because I liked the description I wrote. But I also did try to keep it relatively accessible to beginners: the operations used are very simple and don’t rely on any external libraries.

Solution

This challenge implements a very basic form of ZKP that allows you to prove you know a secret number \(a\) (or a list of them) given \(a^2 \pmod{n}\), relying on the hardness of square root \(\pmod{n}\). The scheme works perfectly fine if you do know the secrets: you give the server \(x \equiv r^2 \pmod{n}\), then given a mask you give the server \(r \cdot a_1\dots a_k \pmod{n}\), and the server checks that \((r \cdot a_1\dots a_k)^2 \equiv r^2 \cdot a_1^2 \dots a_k^2 \pmod{n}\). However, the problem is the mask, or “challenge” that the server generates with random.randbits to make you prove you know the secrets. Python’s random module uses the Mersenne Twister, a pseudorandom number generator that is reversible given enough inputs. So after enough tries, you can predict the mask that the server will give you. Why is this a problem? Well, the server verifies your number by squaring it and comparing it with a product of squares with a second number \(x\) that you provide. So if you know a bit in the mask is 1, then you know the server will multiply by \(a_i^2\), so you can negate that by multiplying \(x\) by \(\left(a_i^2\right)^{-1} \pmod{n}\) first, so you can achieve equality without ever knowing what \(a_i\) is. Here’s a sample solution that uses randcrack (POW not included):

import secrets
from pwn import *
from randcrack import RandCrack
from Crypto.Util.number import *

rc = RandCrack()

sh = remote('chal.osugaming.lol', 7275)
print(sh.recvuntil(b'solution: '))
sh.sendline(input().encode())

sh.recvuntil(b'n = ')
n = int(sh.recvline().decode().strip())
sh.recvuntil(b'vs = ')
vs = [int(num) for num in sh.recvline().decode().strip()[1:-1].split(',')]

for _ in range(624):
	r = secrets.randbelow(n)
	x = pow(r, 2, n)

	sh.recvuntil(b': ')
	sh.sendline(str(x).encode())
	sh.recvuntil(b': ')
	mask = int(sh.recvline().decode().strip(), 2)
	rc.submit(mask)
	sh.recvuntil(b': ')
	sh.sendline(str(x).encode())

for _ in range(10):
	mask = '{:032b}'.format(rc.predict_getrandbits(32))
	#print('preddd', mask)
	y = secrets.randbelow(n)
	x = pow(y, 2, n)
	for i in range(32):
		if mask[i] == '1':
			x = (x * inverse(vs[i], n)) % n

	sh.recvuntil(b': ')
	sh.sendline(str(x).encode())
	sh.recvuntil(b': ')
	mask = sh.recvline().decode().strip()

	sh.recvuntil(b': ')
	sh.sendline(str(y).encode())
sh.interactive()

crypto/secret-map

This was supposed to be a simple osu! themed challenge on a basic CTF topic (xor). I’m not too sure why this got so few solves relative to korean-offline-mafia: maybe I should’ve provided more context for CTF players about how osu! beatmaps work, even if the information was all on the wiki.

Solution

All osu! beatmap .osz files are just .zip files: so we can unzip it and see that inside there is a flag.osu.enc file along with a enc.py file, in addition to the rest of a normal beatmap folder. We can see that the enc.py file was used to encrypt some flag.osu map:

import os

xor_key = os.urandom(16)

with open("flag.osu", 'rb') as f:
    plaintext = f.read()

encrypted_data = bytes([plaintext[i] ^ xor_key[i % len(xor_key)] for i in range(len(plaintext))])

with open("flag.osu.enc", 'wb') as f:
    f.write(encrypted_data)

This was encrypted with xor, which is a function that is reversible with the same key. However, not only that, if you know what parts of it decrypt to, you can get the key by xoring the plaintext with the ciphertext. If you check the osu!wiki, you can see that all .osu files begin with osu file format v14 (at least the current version of .osu files do). There is also another .osu file in the beatmap folder that begins with this header to show you this. Therefore we can decrypt the entire .osu file by using this known plaintext to recover the key, and from there it is just reading the flag from the map.

with open("flag.osu.enc", 'rb') as f:
    encrypted_data = f.read()

known_plaintext = b"osu file format "

xor_key = bytes([encrypted_data[i] ^ known_plaintext[i % len(known_plaintext)] for i in range(len(known_plaintext))])

decrypted_data = bytes([encrypted_data[i] ^ xor_key[i % len(xor_key)] for i in range(len(encrypted_data))])

with open("flag.osu", 'wb') as f:
    f.write(decrypted_data)

osu/sanity-check-3

This challenge took far longer to write than I expected. Following sanity-check-2, where editing replay metadata was sufficient to get the flag (and the win condition was much easier), I wanted to create a challenge where the replay data actually had to be accurate. However, this proved to be much more difficult than I originally thought due to a variety of quirks in how osu! gameplay is calculated. I’ll spare you all the details and share just one bit of unexpected information I found while creating this challenge: osu! sliderends are not judged at the time of the sliderend, but 36ms earlier (or at the middle of the slider, if the slider is less than 72ms long). This makes calculating sliderends very annoying, since you have to trace back 36 ms from the end of the slider, which means you actually have to calculate the full slider curve instead of just using the slider end position.

Anyways, challenge creation difficulty aside, I do think this is a point where the beginner experience could have been made better. We had countless tickets with people struggling with base64 encoding and proof of work, which could have been all avoided if we had just provided a website to submit the replay files on instead. But this is a double-edged sword: we do want competitors to get experience with concepts such as base64 encoding and CTF tools such as pwntools, which the website solution would bypass. Just some food for thought…

Solution

This challenge would be solvable using osu!’s Auto mod, after editing metadata to be correct, if not for one small problem: Auto actually sliderbreaks on the last slider. If we look in the editor, we can notice that this happens because the last circle in the map occurs exactly at the last slider’s reverse point. This sliderbreaks because osu!’s Auto tries to hit every object exactly as it’s timed, with 0 UR, but this means that it will guaranteed not be following the slider at the reverse tick, resulting in a sliderbreak. To fix this, note that you can hit the circle up to ~30ms earlier/later than it actually is timed and still get a 300, so if we hit the circle first and then return to the slider before the reverse slider tick, we can SS the map. A simple way to make this change is to edit the map by moving the last circle ~10ms, taking the Auto replay of that, and then changing the beatmap hash (since it changed due to changing the map).

osu/eat-healthy

This challenge was just intended to show something fun about osu!. To be honest it’s not great design, especially for a CTF, but I hope it was somewhat interesting at least. I think I will try to make less of these “osu! knowledge” challenges if we host a second iteration, or maybe hint them more explicitly so both osu! players and non osu! players can solve them.

Solution

The description and title hint towards the fact that it is not an osu! standard map at all—it’s actually an osu! CTB map. (The song choice also somewhat hints towards this, though not strictly necessary to know—a catch map of this song is included in one of the tutorial packs, and the most played map of this song is in CTB mode.) If you switch the gamemode to CTB in the editor, and then you play the map, you will see letters that spell out the flag.

crypto/roll

Ok, honestly, this challenge I do regret a bit. I didn’t realize that osu! IRC was not immediately available to new accounts, so you had to use the web chat, introducing quite a bit of variable latency. What’s more, for some people the web chat didn’t work at first either for some reason, and the rate-limiting I implemented didn’t work at first due to changes in the Rust IRC crate. I tried too hard to make the !roll gimmick realistic with the IRC bot, and it ended up just making the challenge unnecessarily annoying to solve for some people. So to those people I do apologize, and I hope some people were still able to enjoy this challenge.

Solution

As I stated above, the challenge is a osu! IRC bot that allows you to challenge it to a !roll game. But beating it isn’t enough to get the flag—reading the source code, you can see that you actually have to get exactly one higher than the bot roll (to “tilt” it), and you have to do it 5 times in a row. So how can we do this? Looking at the source code for the roll function:

fn get_roll() -> i32 {
    let seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let mut rng = SmallRng::seed_from_u64(seed);
    rng.gen_range(1..101)
}

The code seeds an PRNG with the current time in seconds. This means that for the most part we should be able to predict the output of any !roll command we send, so we can send a !roll command at the right time by searching through the next few timestamps until we find one with the right !roll we need. There will be delay with the server, but it should be fairly consistent (if you are connecting using IRC and not the website) and you should be able to calculate it based on the result of your rolls. Here’s a sample solution based on the bot code:

use futures::prelude::*;
use irc::client::prelude::*;
use log::{error, info, LevelFilter};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use simple_logger::SimpleLogger;
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::Arc;

fn get_seed_for_target(target: i32) -> u64 {
    let mut seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() + 3;
    let mut rng = SmallRng::seed_from_u64(seed);
    while rng.gen_range(1..101) != target {
        seed += 1;
        rng = SmallRng::seed_from_u64(seed);
    }
    println!("Seed for {}: {}", target, seed);
    seed
}


struct Bot {
    client: Client,
    sender: Option<Arc<Sender>>,
}

impl Bot {
    async fn run(&mut self) -> irc::error::Result<()> {
        self.client.identify()?;
        let mut stream = self.client.stream()?;
        self.sender = Some(Arc::new(self.client.sender()));
        while let Some(message) = stream.next().await.transpose()? {
            let Some(target) = message.response_target() else {
                info!("Failed to find target for message: {:#?}", message);
                continue;
            };
            match &message.command {
                Command::QUIT(_) => continue,
                Command::PRIVMSG(_, msg) => {
                    info!("{:?} {:?}", message.prefix, message.command);

                    if msg.starts_with("I rolled") {
                        self.handle_roll(msg.to_string(), target);
                    }

                    if msg.starts_with("Bet") {
                        self.handle_roll(msg.to_string().split_whitespace().skip(6).map(|word| format!("{} ", word)).collect(), target);
                    }
                }
                _ => info!("{:?} {:?}", message.prefix, message.command),
            }
        }
        Ok(())
    }

    fn handle_roll(&mut self, msg: String, _target_user: &str) {
        let target = msg
            .split_whitespace()
            .nth(3).unwrap();
        let target = target[..target.len() - 1].parse::<i32>().unwrap();
        let seed = get_seed_for_target(target + 1);

        let sender = self.sender.as_ref().unwrap().clone();
        tokio::spawn(async move {
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_millis() as u64;
            let sleep_dur = seed * 1000 - now + 750;
            tokio::time::sleep(tokio::time::Duration::from_millis(sleep_dur)).await;
            sender.send_privmsg("QuintecX", format!("!roll {}", target));
        });
    }
}

#[tokio::main]
async fn main() -> irc::error::Result<()> {
    SimpleLogger::new()
        .with_level(LevelFilter::Info)
        .init()
        .unwrap();
    let mut bot = Bot {
        client: Client::new("config.toml").await?,
        sender: None,
    };

    match bot.run().await {
        Ok(_) => info!("Bot exited successfully"),
        Err(e) => error!("Bot exited with error: {}", e),
    }

    Ok(())
}

P.S. Learn Rust. Rust is cool. If you learned Rust to solve this challenge I consider that a massive win.

osu/futsuu

I also expected this challenge to get way more solves than it actually did. It is another combination of a basic encoding (morse) with osu! game mechanics, similar to crypto/secret-map, which seems to be a bad combination. This particular challenge does seem quite guessy without prior osu! knowledge. Trying to create challenges for osu! players that could be solved without too much security knowledge but still be interesting for CTF players is a hard balance.

Solution

The map provided is a taiko map. Given the fact that taiko maps use two types of drum sounds—don and kat—you might think to try some sort of two symbol encoding. What’s more, the song, Frums - Xnor Xnor Xnor, is a song famous for having hidden meaning in morse code throughout the song. This might lead you to try decrypting the taiko map as morse code, using the finishes as character separators, which gives you the flag.

Conclusion

Wow, this was a long post. Overall, I enjoyed hosting this CTF a lot and I hope that everyone who played was able to have fun and learn something new. :D It was fun to see osu! players get excited about cybersecurity. Well, that’s all from me for now. To quote osu! when you quit the game,

see you next time…