osu!gaming CTF 2024 Write-up

osu!gaming CTF 2024 Write-up

Ching367436 Bamboofox 社長

I participated in osu!gaming CTF with TakeKitsune , and here is my write-up of the part I solved.

OSU

sanity-check-2

Gawr Gura - Kyoufuu All Back

Your task is to play A difficulty and get at least 70% accuracy in std mode. Submit your replay osr file to server in base64 format.

Solution

As a beginner in osu!, I use the No Fail (mod) to play first and obtain the osr file. Then, I edit the replay using OsuReplayEditor to remove any misses or mods.

sanity-check-3

Gawr Gura - Kyoufuu All Back

Alright, it’s gaming time. SS the top diff and submit your replay to the server in base64 format. (Play nomod, please don’t use mods like DT or HR)

nc chal.osugaming.lol 7278

Solution

First, use Auto (mod) to play it to obtain a perfect osr file (osu!(lazer) does not support exporting auto played osr files, so we need to use osu! Instead). However, even autoplay cannot achieve SS (check the video below).

So I used osu-replay-parser to add a 20ms delay to the auto-played osr, and it worked.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from osrparse import Replay

# parse from a path
replay = Replay.from_path("at.osr")

# anti AT detection
replay.username = 'ch'

# remove the auto play mods
replay.mod_combination = 0

# add some time
replay.replay_data[1].time_delta += 20

# write to a new file
replay.write_path("at-modified.osr")

Malware

I stumbled upon RedLine Stealer malware while searching for an osu! cheat tool. Interestingly, I had recently reversed it at GCC 2024 .

osu-cheat

Crypto

secret-map

Here’s an old, unfinished map of mine (any collabers?). I tried adding an new diff but it seems to have gotten corrupted - can you help me recover it?

Downloads: [Alfakyun. - KING.osz](https://ctf.osugaming.lol/uploads/2cdc85778a40b176f4541bc782650cf933dd9997083d69e928cd9b4b85e0c189/Alfakyun . - KING.osz)

Solution

We first decompress the osz file using 7z x file.osz.

1
2
3
4
5
6
7
.
└── extracted
├── Alfakyun. - KING (QuintecX) [ryuk eyeka's easy].osu
├── audio.mp3
├── bg.jpg
├── enc.py
├── flag.osu.enc

In enc.py, the osu file is encrypted using XOR with a random 16-byte key. However, since the osu file starts with osu file format v14, the key can be obtained using it.

1
2
3
4
5
6
7
8
9
10
11
# enc.py
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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# sol.py
with open('flag.osu.enc', 'rb') as f:
encrypted_data = f.read()

known_pt = b'osu file format v14'[:16]
key = bytes([encrypted_data[i] ^ known_pt[i] for i in range(16)])

def decrypt(data):
return bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])

decrypted_data = decrypt(encrypted_data)

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

After obtaining the osu file, I repackaged it into osz using 7z a -tzip -mm=Deflate -mx=9 example.osz extracted and imported it into osu!. Here are the results:

korean-offline-mafia

I’ve been hardstuck for years, simply not able to rank up… so I decided to try and infiltrate the Korean offline mafia for some help. I’ve gotten so close, getting in contact, but now, to prove I’m part of the group, I need to prove I know every group member’s ID (without giving it away over this insecure communication). The only trouble is… I don’t! Can you help?

nc chal.osugaming.lol 7275

Downloads: server.py

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

To solve the challenge, simply send n for every verification.

Web

pp-ranking

can you get to the top of the leaderboard? good luck getting past my anticheat…

https://pp-ranking.web.osugaming.lol

Downloads: pp-ranking.zip

In order to retrieve the flag, we must reach the top of the leaderboard.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get("/rankings", (req, res) => {
let ranking = [...baseRankings];
if (req.user) ranking.push(req.user);

ranking = ranking
.sort((a, b) => b.performance - a.performance)
.map((u, i) => ({ ...u, rank: `#${i + 1}` }));

let flag;
if (req.user) {
if (ranking[ranking.length - 1].username === req.user.username) {
ranking[ranking.length - 1].rank = "Last";
} else if (ranking[0].username === req.user.username) {
flag = process.env.FLAG || "osu{test_flag}";
}
}
res.render("rankings", { ranking, flag });
});

We can submit osr files to be ranked.

submit-rank

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.post("/api/submit", requiresLogin, async (req, res) => {
const { osu, osr } = req.body;
try {
const [pp, md5] = await calculate(osu, Buffer.from(osr, "base64"));
if (req.user.playedMaps.includes(md5)) {
return res.send("You can only submit a map once.");
}
if (anticheat(req.user, pp)) {
// ban!
users.delete(req.user.username);
return res.send("You have triggered the anticheat! Nice try...");
}
req.user.playCount++;
req.user.performance += pp;
req.user.playedMaps.push(md5);
return res.redirect("/rankings");
} catch (err) {
return res.send(err.message);
}
});

Below are the steps involved in the scoring process.

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
import { StandardRuleset } from "osu-standard-stable";
import { BeatmapDecoder, ScoreDecoder } from "osu-parsers";
import crypto from "crypto";

const calculate = async (osu, osr) => {
const md5 = crypto.createHash("md5").update(osu).digest("hex");
const scoreDecoder = new ScoreDecoder();
const score = await scoreDecoder.decodeFromBuffer(osr);

if (md5 !== score.info.beatmapHashMD5) {
throw new Error(
"The beatmap and replay do not match! Did you submit the wrong beatmap?",
);
}
if (score.info._rulesetId !== 0) {
throw new Error("Sorry, only standard is supported :(");
}

const beatmapDecoder = new BeatmapDecoder();
const beatmap = await beatmapDecoder.decodeFromBuffer(osu);

const ruleset = new StandardRuleset();
const mods = ruleset.createModCombination(score.info.rawMods);
const standardBeatmap = ruleset.applyToBeatmapWithMods(beatmap, mods);
const difficultyCalculator =
ruleset.createDifficultyCalculator(standardBeatmap);
const difficultyAttributes = difficultyCalculator.calculate();

const performanceCalculator = ruleset.createPerformanceCalculator(
difficultyAttributes,
score.info,
);
const totalPerformance = performanceCalculator.calculate();

return [totalPerformance, md5];
};

export default calculate;

And it has an anti-cheat mechanism.

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
// anticheat.js
const THREE_MONTHS_IN_MS = 3 * 30 * 24 * 60 * 1000;

const anticheat = (user, newPP) => {
const pp = parseInt(newPP);
if (user.playCount < 5000 && pp > 300) {
return true;
}

if (+new Date() - user.registerDate < THREE_MONTHS_IN_MS && pp > 300) {
return true;
}

if (
+new Date() - user.registerDate < THREE_MONTHS_IN_MS &&
pp + user.performance > 5_000
) {
return true;
}

if (user.performance < 1000 && pp > 300) {
return true;
}

return false;
};

export default anticheat;

Solution

To bypass the anti-cheat mechanism, we can make our score Infinity. This will cause pp = parseInt(newPP) in anticheat.js to become NaN. All comparisons involving NaN will always return false, so we can bypass the anti-cheat mechanism.

I randomly selected a song from here and downloaded the osz and osr (from rank). I then unzipped the osz file to obtain the osu file, modified OverallDifficulty of the osu file to 1000000000, and updated the beatmap hash in the osr to match the new one of the osu file. Finally, I uploaded the modified files to the server and achieved a rank of 1.

pp-ranking

pp-ranking-rank

By the way, the variable pp can easily become infinity due to the presence of multiple Math.pow functions in the formula.

powers

stream-vs

how good are you at streaming? i made a site to find out! you can even play with friends, and challenge the goat himself
https://stream-vs.web.osugaming.lol/

This challenge requires us to beat cookiezi in the game, but cookiezi will always get the exact bpm (see the scoring algorithm below).

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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// stream-vs.js (frontend)
const $ = document.querySelector.bind(document);

const ws = new WebSocket(
location.origin.replace("https://", "wss://").replace("http://", "ws://"),
);
let username;
$("form").onsubmit = (e) => {
e.preventDefault();
username = $("[name='username']").value;
ws.send(JSON.stringify({ type: "login", data: username }));
localStorage.key1 = $("#key1").value;
localStorage.key2 = $("#key2").value;
};

window.onload = () => {
$("#key1").value = localStorage.key1 || "z";
$("#key2").value = localStorage.key2 || "x";
};

$("#host-btn").onclick = () => {
ws.send(JSON.stringify({ type: "host" }));
};

$("#challenge-btn").onclick = () => {
ws.send(JSON.stringify({ type: "challenge" }));
};

$("#join-btn").onclick = () => {
ws.send(JSON.stringify({ type: "join", data: prompt("Game ID:") }));
};

$("#start-btn").onclick = () => {
$("#start-btn").style.display = "none";
ws.send(JSON.stringify({ type: "start" }));
};

ws.onopen = () => {
console.log("connected to ws!");
};

let session;
ws.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);
if (type === "login") {
$("form").style.display = "none";
$("#menu").style.display = "block";

const params = new URLSearchParams(location.search);
if (params.has("id")) {
ws.send(JSON.stringify({ type: "join", data: params.get("id") }));
}
} else if (type === "join") {
session = data;
$("#menu").style.display = "none";
$("#lobby").style.display = "block";
$("#gameId").innerText = `Game ID: ${session.gameId}`;
$("#gameURL").innerText = location.origin + "/?id=" + session.gameId;
$("#gameURL").href = "/?id=" + session.gameId;

if (session.host === username) {
$("#start-btn").style.display = "block";
}

$("#users").innerHTML = "";
for (const user of session.users) {
const li = document.createElement("li");
li.innerText = user;
$("#users").appendChild(li);
}
} else if (type === "game") {
session = data;
run(session);
} else if (type === "results") {
session = data;
$("#results").innerHTML = "";
$("#message").innerHTML =
session.round < session.songs.length - 1
? "The next round will start soon..."
: "";
$("#gameURL").parentElement.style.display = "none";
for (let i = 0; i < session.results.length; i++) {
const h5 = document.createElement("h5");
h5.innerText = `Song #${i + 1} / ${session.songs.length}: ${session.songs[i].name} (${session.songs[i].bpm} BPM)`;
$("#results").appendChild(h5);

const ol = document.createElement("ol");
for (let j = 0; j < session.results[i].length; j++) {
const li = document.createElement("li");
li.innerText = `${session.results[i][j].username} - ${session.results[i][j].bpm.toFixed(2)} BPM | ${session.results[i][j].ur.toFixed(2)} UR`;
if (j === 0) li.innerText += " 🏆";
ol.appendChild(li);
}
$("#results").appendChild(ol);
}
} else if (type === "message") {
$("#message").innerText = data;
} else if (type === "error") {
alert(data);
}
};

let clicks = new Set(),
pressed = [],
recording = false;
document.onkeydown = (e) => {
if (
(e.key === $("#key1").value || e.key == $("#key2").value) &&
recording &&
!pressed.includes(e.key)
) {
pressed.push(e.key);
clicks.add(performance.now());
}
};
document.onkeyup = (e) => {
pressed = pressed.filter((p) => p !== e.key);
};

const fadeOut = (audio) => {
if (audio.volume > 0) {
audio.volume -= 0.01;
timer = setTimeout(fadeOut, 100, audio);
}
};

// algorithm from https://ckrisirkc.github.io/osuStreamSpeed.js/newmain.js
const calculate = (start, end, clicks) => {
const clickArr = [...clicks];
const bpm =
Math.round((((clickArr.length / (end - start)) * 60000) / 4) * 100) / 100;
const deltas = [];
for (let i = 0; i < clickArr.length - 1; i++) {
deltas.push(clickArr[i + 1] - clickArr[i]);
}
const deltaAvg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
const variance = deltas
.map((v) => (v - deltaAvg) * (v - deltaAvg))
.reduce((a, b) => a + b, 0);
const stdev = Math.sqrt(variance / deltas.length);

return { bpm: bpm || 0, ur: stdev * 10 || 0 };
};

// scoring algorithm
// first judge by whoever has round(bpm) closest to target bpm, if there is a tie, judge by lower UR
/*
session.results[session.round] = session.results[session.round].sort((a, b) => {
const bpmDeltaA = Math.abs(Math.round(a.bpm) - session.songs[session.round].bpm);
const bpmDeltaB = Math.abs(Math.round(b.bpm) - session.songs[session.round].bpm);
if (bpmDeltaA !== bpmDeltaB) return bpmDeltaA - bpmDeltaB;
return a.ur - b.ur
});
*/

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const run = async (session) => {
clicks.clear();
$("#lobby").style.display = "none";
$("#game").style.display = "block";
$("#bpm").innerText = `BPM: 0`;
$("#ur").innerText = `UR: 0`;

$("#song").innerText =
`Song #${session.round + 1} / ${session.songs.length}: ${session.songs[session.round].name} (${session.songs[session.round].bpm} BPM)`;
const audio = new Audio(`songs/${session.songs[session.round].file}`);
audio.volume = 0.1;
audio.currentTime = session.songs[session.round].startOffset;
await new Promise((r) => (audio.oncanplaythrough = r));

for (let i = 5; i >= 1; i--) {
$("#timer").innerText = `Song starting in ${i}...`;
await sleep(1000);
}

const timer = setInterval(() => {
$("#timer").innerText =
`Time remaining: ${(session.songs[session.round].duration - (audio.currentTime - session.songs[session.round].startOffset)).toFixed(2)}s`;
}, 100);

audio.play();
// delay to start tapping
await sleep(1000);
let start = +new Date();
recording = true;

// delay to collect initial samples
await sleep(500);
while (
audio.currentTime - session.songs[session.round].startOffset <
session.songs[session.round].duration
) {
const { bpm, ur } = calculate(start, +new Date(), clicks);
$("#bpm").innerText = `BPM: ${bpm.toFixed(2)}`;
$("#ur").innerText = `UR: ${ur.toFixed(2)}`;
await sleep(50);
}

let end = +new Date();
recording = false;
$("#timer").innerText = `Time remaining: 0s`;
fadeOut(audio);
clearInterval(timer);

ws.send(
JSON.stringify({
type: "results",
data: { clicks: [...clicks], start, end },
}),
);
$("#message").innerText = "Waiting for others to finish...";
$("#game").style.display = "none";
$("#lobby").style.display = "block";
};

Solution

I replaced the stream-vs.js with my own version using Burp Suite.

burp-replace

In my stream-vs.js, I use JavaScript to play the game for me.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function click() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "x" }));
document.dispatchEvent(new KeyboardEvent("keyup", { key: "x" }));
}

function getClickInterval(bpm, duration) {
// bpm = Math.round(((clickArr.length / (end - start) * 60000)/4) * 100) / 100;
return 60 / bpm / 4 + 0.0003;
}

function startClicking(bpm, duration) {
let interval = getClickInterval(bpm, duration * 1000);
let timer = setInterval(click, interval * 1000);
setTimeout(() => clearInterval(timer), duration * 1000 + 10000);
}

The startClicking function will be called at the beginning of each round. At the end of each round, end will be adjusted so the bpm will become exact.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const run = async (session) => {
// [...]
const timer = setInterval(() => {
$("#timer").innerText =
`Time remaining: ${(session.songs[session.round].duration - (audio.currentTime - session.songs[session.round].startOffset)).toFixed(2)}s`;
}, 100);

startClicking(round_bmp, round_duration + 3);

audio.play();
// [...]
while (
audio.currentTime - session.songs[session.round].startOffset <
session.songs[session.round].duration
) {
// [...]
}

// make end so that the bpm becomes the same as the target bpm
let end = start + ((clicks.size / round_bmp) * 60 * 1000) / 4;
recording = false;
$("#timer").innerText = `Time remaining: 0s`;
// [...]
};
  • Title: osu!gaming CTF 2024 Write-up
  • Author: Ching367436
  • Created at : 2024-03-04 10:22:16
  • Link: https://blog.ching367436.me/osu-gaming-ctf-2024-write-up/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments