Overview
web/osu-css
CSS is Turing complete, right??? Here’s a evidence that it is!
Try
First, I try to open all details elements.
document.querySelectorAll("#n").forEach(d => d.setAttribute("open", true));
Advanced Anti-Cheat System detected that I used a script. It’s insane!
Bypass the Anti-Cheat System
I thought I should implement a perfect autoplay.
const summaries = document.querySelectorAll("summary");const details = document.querySelectorAll("details#n");
const sortedDetails = Array.from(details).sort((a, b) => { const aDelay = parseFloat(getComputedStyle(a.querySelector("summary > div")).animation.split(" ")[3]); const bDelay = parseFloat(getComputedStyle(b.querySelector("summary > div")).animation.split(" ")[3]); return aDelay - bDelay;});
async function sleep(ms) { console.log(`Sleeping for ${ms} ms`); return new Promise((resolve) => setTimeout(resolve, ms));}
function generateJob(detailsElement) { const summaryDiv = detailsElement.querySelector("summary > div"); const style = getComputedStyle(summaryDiv); const animation = style.animation; const animationParts = animation.split(" "); const delay = parseFloat(animationParts[3]) * 1000; // convert to ms
return new Promise(async (resolve) => { if (delay - 3500 > (2 * 60 -13.7) * 1000) { // 13.7 is important! console.log(`Skipping detail with delay ${delay} ms`); resolve(); return; } await sleep(delay - 3500); detailsElement.setAttribute("open", true); resolve(); });}
(async () => { const startButton = document.getElementById("start"); startButton.parentElement.setAttribute("open", true); await sleep(5000); const audio = document.querySelector("audio"); audio.volume = 0.1; audio.play();
const jobs = [];
for (const detail of sortedDetails) { jobs.push(generateJob(detail)); } await Promise.all(jobs);})();This script perfectly simulates autoplay by opening the details elements according to their animation delays.
The details elements contain a large number of fake entries and need to be filtered/bypassed. Also, the last few should be ignored.
kaijuu/怪獣怪獣怪獣怪獣怪獣怪獣怪獣
Overview
Open the difficulty KaijuuKaijuuKaijuu in osu!, you can see the kaijuu looking left and right.
Solve
Open the difficulty KaijuuKaijuuKaijuu, extract all [HitObjects] that are over 40 characters, and save the resulting text after removing the first element like 420,296,14214,6,0,B|.
const file = Bun.file("kaijuu.txt");const text = await file.text();
let bits = [];for (let line of text.split("\n")) { if (line.startsWith("4")) { console.log(1); bits.push(1); } else { console.log(0); bits.push(0); }}
let sevenBits = [];for (let i = 0; i < bits.length; i += 7) { let byte = 0; for (let j = 0; j < 7; j++) { byte = (byte << 1) | bits[i + j]; } sevenBits.push(byte);}
console.log(sevenBits.map(byte => String.fromCharCode(byte)).join(""));osu/files
It’s easy.
The files are osu!lazer files. osu!lazer uses SHA-256 hashes to identify files. Document is here.
Solve
import { readdirSync, readFileSync, statSync } from 'fs';
const files = readdirSync('./files', {recursive: true});
files.forEach(file => { if (statSync(`./files/${file}`).isDirectory()) return;
const data = readFileSync(`./files/${file}`); const hash = calculateHash(data); const fileName = file.split('\\').pop(); console.log(`File: ${fileName}, Hash: ${hash}`); if (fileName !== hash) { console.error(`Hash mismatch for file ${fileName}: expected ${fileName}, got ${hash}`); }});
function calculateHash(data: Buffer): string { const crypto = require('crypto'); return crypto.createHash('sha256').update(data).digest('hex');}The flag is the name of the file that had a hash mismatch.
rev/bleh
Overview
Here is are 3,844 executables.

Open in Binary Ninja.

Looks like this program have 32KB of encrypted data and decrypts it at runtime.
This is the result of decoding bleh1:

Ahhhhhhhh. The fact that it says JFIF means this is a jpeg file and everything needs to be decoded.
Extraction
First, extract the encrypted data from the binary using Binary Ninja’s Python API.

Then, decrypt the extracted data using Rust(I love Rust!).
fn solve_section(block: &[u8], key: &[u8]) -> Result<Vec<u8>, String> { // initial state in var_a0 let mut state: i32 = 0x1337;
let mut flag_bytes: Vec<u8> = Vec::with_capacity(32);
for i in 0..32 { let mut found_char = false;
for input_byte_guess in 0..=255u8 { let val1 = (input_byte_guess as i32).wrapping_add(6); let val2 = (key[i] as i32).wrapping_add(128); let val3 = state.wrapping_sub(128);
let xor_result = val1 ^ val2;
let state_next = val3.wrapping_add(xor_result);
let calculated_ciphertext_byte = (state_next & 0xFF) as u8;
if calculated_ciphertext_byte == block[i] { flag_bytes.push(input_byte_guess); state = state_next;
found_char = true; break; } }
if !found_char { return Err(format!("No bytes found: {}", i)); } }
Ok(flag_bytes)}
fn main() { let key = b"PL4YING_CTFS_ISNTBETTER_THAN_OSU";
let extracted_data = include_bytes!("../extracted_data.bin");
let mut flag_bytes: Vec<u8> = Vec::with_capacity(32);
println!("Solving...");
for section in 0..extracted_data.len() / 32 { let block = &extracted_data[section * 32..(section + 1) * 32];
match solve_section(block, key) { Ok(mut ans) => { flag_bytes.append(&mut ans); print!("."); } Err(e) => { println!("\nError solving section {}: {}", section, e); return; } } }
match String::from_utf8(flag_bytes.clone()) { Ok(flag) => { println!("\nNicely done."); println!("Flag: {}", flag);
let non_v = flag.replace("v", "");
let binary_data = hex::decode(non_v).expect("Failed to decode hex");
std::fs::write("flag.bin", &binary_data).expect("Failed to write file"); println!("Flag binary data saved to flag.bin"); } Err(_) => { println!("\nCould not decode flag as UTF-8."); } }}Open the decrypted binary, and you’ll see the flag there.
rev/tosu-1
Overview
It have tosu.exe that is a simple osu! clone using imGui.
I was playing from the start and just couldn’t clear it, but then I found this note about 6 hours before the competition ended, and I was like, “Wait, what??”. Once I switched things up, I managed to clear it super easily!
Note: If your solver isn’t working and you’re confident that it seems correct but still can’t get the flag, try achieving the perfect score with no reflection of it in the GUI. Essentially, you should have a perfect score without ever touching the circles, and the counts on the final screen should all be zero except the miss count, which should be 1080. (It will make sense if you’re stuck in that place)
Solve
This is the analysis result of the miss judgment part from Binary Ninja.
*(rbx + i) = 4 shows that the judgment result is 4: Miss, which resets the combo to 0 and bumps up the miss counter.
Since we’re passing the judgment result and the index of the hit object to sub_140004e60, this function is probably doing some kind of fraction decoding.
By setting *(rbx + i) and the third argument of the function to 3 (300 Hit), all misses turn into 300 Hit.

0x4EDE: 04 -> 030x4EF8: 04 -> 03