the osu! anticheat
2025-12-29
Reverse engineering osu!auth
This post documents my past work on osu!auth, the osu! anticheat.
This is not an attack on the team osu!. They are doing an outstanding job at protecting the game and will continue to do so for many years to come. Everything in this post reflects older versions of osu!auth and information I learned through reverse engineering it at the time. This post was reviewed by team osu! prior to publication, and they raised no objections to it being published.
Please note that this is not meant to be a "bypass guide". I am explaining how I approached reverse engineering a real anticheat, what I learned, and why I kept going even after I stopped caring about cheats. Please do not contact me for help about anything related to bypassing the osu! anticheat, I will not help.
Scope and intent
Everything described here is historical, This does not reflect how osu!auth works now.
The code shown later in this post is intentionally incomplete. I only include what is necessary to explain. I removed parts that could help with bypassing the anticheat. If you are looking for a how-to, this is the wrong post.
A reality check on reversing osu!auth
At the time I worked on this, osu!auth was easy to reverse engineer if you understood how the game worked and had decent knowledge of reverse engineering and assembly.
osu!auth used Oreans Code Virtualizer. Virtualizing the anti-cheat is a good idea. The problem was coverage. Many parts of the code were not obfuscated in any way. Those parts were enough to reconstruct token formats and detections and fully understand the whole anticheat.
Cheat developers noticed this quickly. Every serious provider wrote their own tooling to parse the token osu!auth sends with score submissions. All of those tools worked in roughly the same way.
Dumping and static analysis
The first stage is pretty easy. osu!auth.dll contains a compressed payload referred to as osu!ac. At runtime, it decompresses this payload and manually maps it into a PAGE_EXECUTE memory region via NtMapViewOfSection, using an undocumented allocation flag: SEC_NO_CHANGE (0x00400000). This effectively locks the page’s protection, causing VirtualProtect calls to fail when attempting to modify it.
I dumped osu!auth memory and focused on PAGE_EXECUTE regions, you can easily do that with basic winapi knowledge or even by using things like System Informer.
After I dumped it, I just loaded the osu!ac module into IDA Pro, and IDA's Lumina feature helped immediately. It identified known functions such as OpenSSL routines. That made it easy to know where to focus.
From there, I searched for interesting calls to AES encryption functions.
Observing token encryption
At score submission, osu!auth encrypts a binary token using AES-ECB.
I did not hook OpenSSL itself, because there are a lot of calls to encrypt a bunch of things, I assume it's related to osu!auth logs. Instead, I hooked a method that called the AES-ECB function on score submission. That gave me access to the token buffer before encryption.
The hook looked something like this:
__declspec(naked) void core::detours::h_encrypt_token() {
__asm {
pushad
pushfd
mov ecx, esi
mov esi, ebx
push ecx
push esi
call core::detours::log_token
add esp, 8
popfd
popad
call eax
add esp, 0x10
jmp detours::j_encrypt_token
}
}
At this point, I had the raw token.
That still did not mean I understood it. The token was a packed binary structure that only the osu! team fully knew, and on top of that, it was obfuscated.
Entropy and flags
I uploaded token samples to binvis.io and looked at the entropy of the token.
Most of the token looked random, but when no flags existed, entropy stayed high in the beginning, and when flags appeared, entropy dropped.
I also noticed that the end of the token partially looked like code.
The bytecode section
I searched for the bytes that look like code from the end of the token. That worked.
The token contained opcode-only snippets of around 25 managed functions. No operands. Just opcodes.
I assumed this existed to detect hooks or tampering on game functions, they could be checking for functions that begin with E9 (relative jmp) for example.
Before each snippet, I saw 8 bytes. 2 integers. They repeated before every function.
I ignored those numbers at first. It wasn't much of a priority for now, and we'll revisit them later.
Breaking pseudo random number generation
The beginning of the token still looked random. and I doubted it was encrypted again, because I tried hooking all crypto functions, but that didn't help.
Instead, someone suggested that I try looking for pseudo random number generators.
I used IDA to look for common numbers used in PRNG algorithms, and I hooked all PRNG functions and forced them to return constant values (0 or 1, whatever one that didn't crash the game)
That changed everything.
The token became readable. Flags stood out. Some flags even appeared in plain text at the time, such as strings like Aim Assist or menu_key, this was eventually obfuscated too though.
Hooking PRNG functions also exposed another section.
Score and gameplay information
I think this is the most important section, it contained score data. After hooking PRNG functions, I could clearly see:
- The beatmap hash
- AR pre-empt value
- Playback rate vector size
- Actual playback rate
- Sprite or transformation counts
- Three values that looked like request success percentages
At this point, I could tell whether a token contained flags. I still did not know what each flag meant, though. It is worth mentioning that the vector sizes of both playback rates and sprite counts can also be used to detect both time-warp and AR changing cheats, because the anticheat pushes values on an interval.
The single big anticheat data struct
I kept seeing three numbers before a new section. The value 6 and two integers.
I searched memory for those values by doing a memory scan on Cheat Engine.
That worked. I found a really weird structure which also had a pointer to the same encrypted information that the section included.
I used Cheat Engine's dissect structure feature on that structure.
This struct was the biggest breakthrough.
It contained vectors, pointers, and metadata that made the anticheat much easier to understand and reverse engineer, you can pretty much understand everything it does by looking at references to that structure.
Hardware identifiers
Remember that pointer in the struct from earlier that had the same encrypted data? I suspected hardware identifiers.
I loaded a debugger before osu!auth initialized and set a write breakpoint on that pointer.
When it hit, I traced backward.
I forced the two random integers before the section to zero and removed the breakpoint. The section stayed random but became stable across restarts.
I sent a helper executable to a friend that automated this process. Their dumped section differed from mine but stayed stable across restarts.
That confirms my suspicions. they were hardware identifiers.
Tracing the writes showed OpenSSL SHA1 usage. The input to SHA1 included motherboard data, some registry values, and other hardware identifiers.
The HWID data structure was simple; A 4 bytes integer used to identify the type of hardware identifier, and the SHA1 hash of the entry.
Reversing hardware encryption
Decrypting the hardware section took time, while I didn't need to decrypt it, it still felt necessary.
Calling the encryption function again decrypted the data. That strongly suggested a simple XOR-based encryption algorithm.
It took me a long time of messing with virtualized code, but eventually I noticed that it did a bunch of rotating operations on the two integers used as an encryption key.
Another person was able to figure this algorithm out before me, and confirmed that rotate operations are indeed a big part of the algorithm.
I made it encrypt an empty buffer (full of zeros) and messed with the encryption keys, and eventually the algorithm became clear:
- The two integers are both keys, and they both go through shift and rotate operations
- Each shift yields one byte of the XOR mask.
- Eight bytes come from each key
- That produces a 16 byte mask
- That mask was then applied to the HWID section, which should always be 4096 bytes.
That fully explained the hardware identifier section.
Flags section
I originally reversed the flags section in a bad way.
I noticed that XORing certain bytes produced readable characters. Repeating the operation broke them again.
Eventually, I realized it was an encrypted string. I didn't really understand how it was encrypted, but someone told me to focus on the last 6 bytes of each flag. Thanks to that person, I realized that the struct was actually much simpler than I thought:
- size (int32)
- content
- key (a 6 bytes XOR mask)
The key was six random bytes. The stored size included the key. The real content size was size - 6.
Decryption was as simple as just applying the XOR mask to the content.
Later, I realized I could reverse the code that generated flags directly. I eventually did. This brute force phase was unnecessary. It turns out that even the methods to write the flags were obfuscated but still easy to reverse engineer.
Symbol decryption
osu!auth also decrypts game symbols at runtime.
At some point, a friend of mine suspected that the anticheat still had the decryption keys for the game's symbols. Another friend of mine (xxCherry) later confirmed this after noticing that the EAZ symbol dictionary was inlined in the osu!ac module.
After further investigation, we noticed that OpenSSL was used during symbol decryption. So we decided to hook the OpenSSL functions that would be used to decrypt eazfuscator symbols to dump the keys. At that time, I realized that those keys turned out to be inlined and referenced in the same large anticheat data structure that I mentioned earlier.
Symbol decryption was not virtualized nor mutated, which made this part of the anticheat very easy to reverse-engineer.
Now remember those two integers before opcodes from earlier? Upon hooking the symbol decryption functions, you would see that osu! auth hashed the decrypted names using CRC32, and that section was simply using the CRC32 hash of type name and method name.
What now?
At some point, I stopped working on cheats, and I sent team osu! all the information I have, and in later updates, they removed keys from the big struct and the playback rate vectors appear to be encrypted now.
I tested playback rate vectors by playing with DT and scanning for three consecutive 1.5 values. I found nothing that way, I eventually found them in another way, but some obfuscation/encryption was used that resulted in them being around 6k bytes each, and all its code is properly virtualized now.
This isn't where I stopped reverse engineering the anticheat, but I'm now on the good side. I usually send feedback whenever team osu! updates the anticheat.
Why I kept reversing it
I kept reversing osu!auth after I stopped maintaining cheats for one reason.
Curiosity.
Reverse engineering is fun to me. I wanted to still see what changed and how it worked, and I used that knowledge to also help the game against cheaters.
External cheats and detection
External cheats are hard to detect mainly because osu! allows memory readers like gosumemory and tosu.
You can detect memory readers cleanly. The problem is false positives.
I think a great approach would be to provide an official interface to read in-game values without having to read the memory, and giving those projects lower precision values so that it wouldn't be abused for cheats.
Once you do that, you can start obfuscating game values more, and instead of detecting cheats, you can entirely break them.
No need for drivers, mass data collection, or anything invasive like what other anticheats do.
Token parsing code
Here's how I was parsing the osu! auth token structure.
This is not the full implementation. I intentionally removed logic that could help with bypassing the anticheat.
I include this code only to demonstrate how the token was parsed after all types of obfuscation applied to the token were removed.
interface MethodEntry {
typeNameCrc: number;
methodNameCrc: number;
bytecode: Buffer;
}
interface HardwareEntry {
name: string;
data: Buffer;
}
interface ScoreSection {
beatmapHash: string;
preempt: number;
totalPlaybackRateVectorCount: number;
realPlaybackRate: number;
totalSpriteVectorCount: number;
spriteCount: number;
success1: number;
success2: number;
success3: number;
crcCount: number;
expectedCrcCount: number;
crcMatches: boolean;
key1: Buffer;
key2: Buffer;
}
export class Token {
private reader!: BinaryReader;
public version!: number;
public timestamp!: number;
public winver!: string;
public authHash!: string;
public osuHash!: string;
public detectionCount!: number;
public detections!: any[];
public rawHardwareIds!: Buffer;
public hardwareIds!: HardwareEntry[];
public score!: ScoreSection;
public methods!: MethodEntry[];
public tokenHash!: string;
constructor(buffer: Buffer) {
this.parse(buffer);
}
private parse(buffer: Buffer) {
this.reader = new BinaryReader(buffer);
this.readHeader();
this.readDetectionsBlock();
this.readHardwareBlock();
this.readScoreBlock();
this.readPaddingBlock();
this.readBytecodeBlock();
this.readTokenHash();
}
private readHeader() {
this.version = this.reader.readUint16();
this.timestamp = this.reader.readUint64();
this.winver = this.reader.readString(false);
this.reader.readBytes(2);
this.authHash = this.reader.readBytes(0x10).toString("hex");
this.reader.readBytes(2);
this.osuHash = this.reader.readBytes(0x10).toString("hex");
}
private readDetectionsBlock() {
this.detectionCount = this.reader.readUint32(true);
const blockEnd = this.reader.position + 0xfa0;
const detections: any[] = [];
for (let i = 0; i < this.detectionCount; i++) {
const dataType = this.reader.readUint32(false);
if (dataType !== DataTypes.Uint32) {
throw new Error(`Expected Uint32 (got ${dataType})`);
}
const detectionId = this.reader.readUint32(true);
detections.push({ detectionId, values: this.readDetectionValues() });
}
if (this.reader.position > blockEnd) {
throw new Error(
`Detections passed expected block: pos=${this.reader.position}, end=${blockEnd}`
);
}
this.reader.readBytes(blockEnd - this.reader.position);
this.detections = detections;
}
private readDetectionValues(): any[] {
const out: any[] = [];
for (;;) {
const type = this.reader.readUint32(false) as DataTypes;
if (type === DataTypes.Break) break;
switch (type) {
case DataTypes.Uint32Encrypted:
out.push(this.reader.readUint32(true));
break;
case DataTypes.Uint32:
out.push(this.reader.readUint32(false));
break;
case DataTypes.Uint64:
out.push(this.reader.readUint64(false));
break;
case DataTypes.AsciiString:
out.push(this.reader.readString(true));
break;
case DataTypes.WideString:
out.push(this.reader.readString(true, true));
break;
case DataTypes.UnknownBuffer:
out.push(this.reader.readUnknownBuffer());
break;
default:
throw new Error(`Unknown data type in flags: ${type}`);
}
}
return out;
}
private readHardwareBlock() {
const length = this.reader.readUint32();
const entryCount = this.reader.readUint32();
const key1 = this.reader.readUint32();
const key2 = this.reader.readUint32();
const section = this.reader.readBytes(length);
this.rawHardwareIds = decryptHwidSection(section, key1, key2);
}
private readScoreBlock() {
const length = this.reader.readUint32();
const end = this.reader.position + length;
const beatmapHash = this.reader.readBytes(0x10).toString("hex");
const preempt = this.reader.readUint16();
const totalPlaybackRateVectorCount = this.reader.readUint32();
const realPlaybackRate = this.reader.readBytes(4).readFloatLE();
const totalSpriteVectorCount = this.reader.readUint32();
const spriteCount = this.reader.readBytes(4).readFloatLE();
this.reader.readUint32(); // unknown value, always 0
const success1 = this.reader.readUint8();
const success2 = this.reader.readUint8();
const success3 = this.reader.readUint8();
const crcCount = this.reader.readUint32();
const expectedCrcCount = this.reader.readUint32();
const crcMatches = this.reader.readUint32() === 1;
this.reader.readBytes(12); // 3 unknown integers, always 0
if (this.reader.position > end) {
throw new Error(
`Score section passed expected length: pos=${this.reader.position}, end=${end}`
);
}
this.reader.readBytes(end - this.reader.position); // padding
const key1 = this.reader.readBytes(8);
const key2 = this.reader.readBytes(8);
this.score = {
beatmapHash,
preempt,
totalPlaybackRateVectorCount,
realPlaybackRate,
totalSpriteVectorCount,
spriteCount,
success1,
success2,
success3,
crcCount,
expectedCrcCount,
crcMatches,
key1,
key2,
};
}
private readPaddingBlock() {
this.reader.readBytes(this.reader.readInt32());
}
private readBytecodeBlock() {
this.reader.readUint32(false); // unknown value 1
this.reader.readUint32(false); // unknown value 2
const methodCount = this.reader.readUint32();
const methods: MethodEntry[] = [];
for (let i = 0; i < methodCount; i++) {
const typeNameCrc = this.reader.readUint32();
const methodNameCrc = this.reader.readUint32();
const byteLength = this.reader.readUint32();
methods.push({
typeNameCrc,
methodNameCrc,
bytecode: this.reader.readBytes(byteLength),
});
}
this.methods = methods;
}
private readTokenHash() {
this.tokenHash = this.reader.readString(true);
}
}
Again, this is intentionally incomplete.
Final notes
What I like about the osu! anticheat is how on top of all of this, it's still effective and privacy-respecting. I personally think that the amount of cheaters in osu! is currently at an all-time low, especially considering the downtime of all major cheat providers.
osu!auth has great anti tampering, more detections, proper obfuscation, etc. now. This post does not describe its current state at all.
This was about learning, curiosity, and understanding how you can reverse engineer a real anticheat.
If you enjoy reverse engineering, you already understand why this was worth writing.