aptitude-test

Event
BrunnerCTF 2026
Category
rev
Published
Tags
#cipher #oracle #anti-cheat #windows

Reversing the score file cipher, working out the plaintext layout and spawn generation, then using the submission server itself as an oracle to land a score over 9000 without tripping the anti-cheat.

rev9 min read

On this page

Accepted on the fourth submission: score 9515, 570,942 bytes.

At BrunnerCorp, we test that all prospective candidates have the core competencies required before submitting an application. One such competency is the ability to meet harder and harder goals within increasingly unrealistic timelines as a deadline approaches. […] We only consider applicants with a score of over 9000. Please note that all forms of cheating will be detected and result in immediate disqualification.

BrunnerCorpAptitudeTest.exe
PE32+ x86-64 console, Rust, 2,840,999 bytes
sha256 4224cba8bc107860037c6afa025087fd105549f8080b8f7d7eb421206c7b73ba
ImageBase 0x140000000

An aim-trainer with an anticheat wrapper. You play, it writes aim-score.bin, you upload it. The description is a spec: the timelines really do become unrealistic, and “over 9000” is not a Dragon Ball joke, it’s an arithmetic threshold that falls out of the difficulty ramp.


1. Recon

The binary is Rust and not stripped in the useful sense, the COFF symbol table survives with 6200 entries, including the whole anticheat crate:

anticheat::game::{run, window_proc, STATE}
anticheat::container::{open_package, read_package_header, derive_nonce, read_chunk, ...}
anticheat::integrity::{load_and_run, verify_header, validate_and_execute_package, Protector::*}
anticheat::hash::{Fnv64::*, hash64, StreamCipher::apply}
anticheat::loader::{PayloadVm::*, launch_stream}
anticheat::vm::{build_program, execute}
anticheat::anti_debug::windows::{peb_being_debugged, ntdll_hook_indicators, ...}
anticheat::input::{BotDetector::{push, score, looks_automated}, demo_score}
anticheat::strings::{xor_decode, xor_decode_wide}
anticheat::decoy::{decoy_path, decoy_branch, decoy_checksum}

Source paths leak too: /mnt/d/CTF/BrunnerCTF/2026/BrunnerCorp Pre-Employment Aptitude Test/src/anticheat/src/.

Gotcha: the symbol values are section-relative, not RVAs. VA = ImageBase + 0x1000 + value. window_proc reads as 0x1c9d0 but actually lives at 0x14001d9d0. objdump’s own labels are the authority; my first pass was off by 0x1000 on every address.

The 1.4 MB decoy

There is a 1,421,071-byte encrypted payload at the end of the file, beginning at 0x15aa7b, entropy 8.00, with trailer magic HACEX001. Together with container::*, integrity::validate_and_execute_package, loader::PayloadVm and vm::execute it looks exactly like the intended target, an encrypted embedded payload with chunked stream encryption and FNV-keyed nonces.

It is not on the critical path. game::run and window_proc are in the outer binary, and window_proc is what writes aim-score.bin. The overlay, the VM, the anti-debug suite and decoy::* are all there to pull you into a much longer fight than the challenge actually requires. Checking where the score file is produced before attacking the container saves hours.


2. The score file cipher

aim-score.bin is written at the end of window_proc. Two string xrefs give it away:

0x14001e90b  lea rdi, [rip+0xa303e]   ; "BrunnerAimTrainer-static-key"
0x14001e98b  lea rcx, [rip+0xa2fda]   ; "aim-score.bin"
0x14001e997  call std::fs::File::create
0x14001e9c1  call std::io::Write::write_all

The loop just above it is a 2×-unrolled XOR:

movabs r8, 0x4924924924924925   ; magic divide-by-28  (key length)
mov    sil, 0x11                ; counter, += 0x22 per 2 bytes
...
lea    edx, [rsi-0x11]
xor    dl,  BYTE PTR [r10+rax*1]      ; key[i % 28]
xor    BYTE PTR [rcx+r10*1], dl
movzx  eax, BYTE PTR [r10+rax*1+0x1]  ; key[(i+1) % 28]
xor    al,  sil
xor    BYTE PTR [rcx+r10*1+0x1], al

0x4924924924924925 is the unsigned-division magic for 28 (the key length): q = mulhi(n>>2, magic) >> 1. The counter advances 0x22 per two bytes, so per byte it’s 0x11, and the even branch subtracts 0x11 back off, meaning both halves reduce to the same expression:

out[i] = in[i] ^ KEY[i % 28] ^ ((0x11 * i) & 0xff)
KEY = "BrunnerAimTrainer-static-key"   (28 bytes)

It is an involution, so the same function encodes and decodes.

There is no MAC. I grepped window_proc’s whole extent for call targets and none of them resolve to hash64, Fnv64::* or StreamCipher::apply. So nothing has to be recomputed after editing. This is the single fact that makes the challenge tractable.


3. Plaintext layout

From the Vec::extend chain at 0x1e246 to 0x1e6bb:

offsizefield
014"BRUNNER-AIM-1\0"
148seed (STATE+0xd8)
224client width (STATE+0xe8, min 100)
264client height (STATE+0xec, min 100)
304score (STATE+0x90)
344click count (STATE+0x18)
384spawn count (STATE+0x30)
4224·nclicks: {t_ms:u64, x:u32, y:u32, w:u32, h:u32}
36·mspawns: {spawn_ms:u64, x:u32, y:u32, radius:u32, w:u32, h:u32, expire_ms:u64}

The spawn struct is 40 bytes in memory (lea rdx,[rdi+rdi*4], *8) but serialises as 36, there’s padding at +0x1c that is not written.

I got two of these labels wrong on the first pass, and the server corrected both (see section 5): offset 14 is the seed, not the score, and offset 30 is the score, not the miss count.


4. Spawn generation

The server re-derives the entire target sequence from the seed, so this has to be reproduced exactly. From 0x1eae6 to 0x1edc0:

PRNG: xorshift64 (state STATE+0xe0, seeded from the u64 at STATE+0xd8):

x ^= x << 13;  x ^= x >> 7;  x ^= x << 17

Note the binary also contains SplitMix64 (0xbf58476d1ce4e5b9, 0x94d049bb133111eb), used elsewhere, and a nice red herring if you grep for PRNG constants and stop there.

Per target, with t = elapsed / 3600s clamped to 1.0:

radius = trunc(32 + t*(3 - 32))
s = xorshift64(s);  x = (s & 0xffffffff) % (w - 2*radius) + radius
s = xorshift64(s);  y = (s & 0xffffffff) % (h - radius - 70) + 70
lifetime = 1.25 + t*(0.01 - 1.25)   seconds
interval = 0.75 + t*(0.15 - 0.75)   seconds

The +70 is add ebp, 0x46; the w - 2*radius is sub r8d,r10d twice.

The constants come from the STATE initialiser in game::run (0x1d4d8), built on the stack then blitted. Mapping rsp+0x90 → STATE+0x08:

STATEvaluemeaning
+0x380xe10 = 3600game duration, seconds
+0x48/0x500, 750000000interval start 0.75 s
+0x58/0x600, 150000000interval end 0.15 s
+0x68/0x701, 250000000lifetime start 1.25 s
+0x78/0x800, 10000000lifetime end 0.01 s
+0x88/0x8c32, 3radius start / end

The durations are (u64 secs, u32 nanos) pairs recombined as secs + nanos/1e9.

“Over 9000”

The game is one hour long and the spawn interval shrinks 0.75 s to 0.15 s, so the number of targets is

N = ∫₀³⁶⁰⁰ dt / (0.75 - 0.6·t/3600) = (3600/0.6)·ln(0.75/0.15) = 6000·ln 5 ≈ 9657

Score is one point per hit. 9000 is ~93% of a perfect hour, that’s the actual gate, and it’s why the description harps on deadlines. It also means reaction time is budgeted: since the next target spawns interval after your click, a react time r gives 6000·ln((0.75+r)/(0.15+r)), so 8100 at 60 ms, 9064 at 20 ms, 9500 at 5 ms. The generator truncates the interval to whole milliseconds each step, which pushes the real counts slightly higher: 8207, 9084 and 9515 respectively. No human clears this. Cheating is the intended solution.


5. The server as an oracle

I never got this right by pure static analysis, the upload endpoint returned a precise error each time, and each one corrected a specific wrong assumption. Worth recording, because the error progression is the solve path:

  1. Invalid score file: invalid spawn timing

My first forgery treated the 36-byte array as “hit records” and stuffed arbitrary values in, producing expire_ms = 344 against spawn_ms = 1100. It is the spawn table, and expire = spawn + lifetime must hold with spawn_ms monotonic.

  1. spawn 0 radius does not match seed/rules

Spawns are not free-form, they are re-derived from the seed. This is what revealed that offset 14 is the seed (and, in hindsight, obvious: +0xd8 is adjacent to the live xorshift state at +0xe0, and nothing in the binary ever writes +0xd8 because both are initialised together). Fixing this required the full section 4 reconstruction. t=0 → radius=32 is the first thing checked.

  1. [CHEATING DETECTED]: file claims score 58, replay produced score 49

Two bugs at once, and the most informative message of the three:

  • It echoed 58, which was my miss count at offset 30, so offset 30 is the score.
  • The replay found only 49/9672. My spawns were on a fixed timer; the real recurrence is click-relative, STATE+0xa8 is rewritten by Instant::now() on the click path, so spawn[i+1] = click[i] + interval(t). On a fixed timer the timeline desynchronises after the first target and only re-aligns by coincidence, which is exactly what 49 hits out of 9672 looks like.

Also: array 1 is clicks, not mouse movement. I had been emitting approach samples for realism; each one is a click that lands outside the target and scores as a miss. Correct output is exactly one click per spawn, dead centre.


6. Final generator

KEY   = b"BrunnerAimTrainer-static-key"
MAGIC = b"BRUNNER-AIM-1\x00"
crypt = lambda b: bytes(c ^ KEY[i%28] ^ ((0x11*i)&0xff) for i,c in enumerate(b))

def xorshift64(x):
    x ^= (x << 13) & M64;  x ^= x >> 7;  x ^= (x << 17) & M64
    return x & M64

t_ms, s = 0, seed
while t_ms < 3600_000:
    t      = min(1.0, t_ms / 3600_000)
    radius = max(1, int(32 + (3-32)*t))
    s = xorshift64(s); x = (s & 0xffffffff) % (w - 2*radius) + radius
    s = xorshift64(s); y = (s & 0xffffffff) % (h - radius - 70) + 70
    life   = int((1.25 + (0.01-1.25)*t) * 1000)
    spawns.append((t_ms, x, y, radius, w, h, t_ms + life))
    click  = t_ms + max(1, min(5, life-1))
    clicks.append((click, x, y, w, h))
    t_ms   = click + max(1, int((0.75 + (0.15-0.75)*t) * 1000))

Output: 9515 spawns, 9515 clicks, score 9515, 570,942 bytes. Verified before upload, 1:1 click/spawn, every click inside its radius and within [spawn, expire], spawn_ms strictly monotonic, byte accounting exact.

Accepted. The BotDetector never fired on the uploaded file: 5 ms reaction times and pixel-perfect centre clicks pass without complaint, so input::BotDetector is only wired into the local game loop, not the server-side validator. The server checks seed-derivation, spawn causality and replay score, nothing about input plausibility.

Tools: forge2.py (generator), forge.py decode <file> (inspector). Note that forge.py still carries the first-pass field labels, so it prints the seed under score and the spawn count under misses. Read the layout table above for the real mapping.


7. Takeaways

  • Find where the artefact is produced before attacking the crypto. The 1.8 MB HACEX001 container, the payload VM and the anti-debug suite are a complete, self-consistent rabbit hole. The score file is written in plaintext by the outer binary and never touches any of it.
  • Check for a MAC early. Grepping window_proc for calls to the hash module took one command and determined the entire strategy, with a MAC this becomes a key-recovery problem; without one it’s a serialisation exercise.
  • A validating server is a free oracle. Three error strings pinned down the spawn semantics, the seed field and the score field, each of which I had wrong, and none of which I would have found quickly in the disassembly.
  • Read the flavour text as a spec. “Harder and harder goals within increasingly unrealistic timelines” is a literal description of the three interpolated ramps, and “over 9000” is about 93% of the theoretical maximum 6000·ln 5 ≈ 9657.
  • Verify symbol-address conventions. Section-relative COFF values cost me a wrong 0x1000 on every lookup until objdump’s own labels contradicted my table.
  • The advertised threat model wasn’t the real one. “All forms of cheating will be detected” plus a whole BotDetector in the binary implies input-plausibility analysis. The validator actually only checks that the replay is internally consistent with the seed. Superhuman play is fine; inconsistent play is not.