CHIP-8 SYSTEM v1.0
RAM CHECK... 4096 BYTES OK
LOADING ROM: main.bin [3282 bytes] [||||||||||||||||]
OLD CASSETTE
cat background.txt
A 3 KB mystery binary, a timer built to waste a trillion CPU cycles, and a PRNG cycle 34 steps long that collapses everything down to one modular lookup. This is how you read a flag off a 1970s virtual computer without waiting until the heat death of the universe.
You get a file called main.bin — 3,282 bytes, no extension, no readme, just a blob. The flavour text says: "I found this old cassette tape in my drawer XD". That sentence is doing a lot of work.
The binary is a CHIP-8 ROM — a program for an interpreted virtual machine from the 1970s, historically loaded onto hobbyist computers from cassette tapes. CHIP-8 has a tiny instruction set (~35 opcodes), a 64×32-pixel display, 16 eight-bit registers (V0–VF, with VF reserved as the carry/borrow flag register), and programs that load at memory address 0x200. It's practically a toy. This one hides a flag inside it.
The ROM draws 32 characters to screen, one at a time. Each character's sprite data is XOR-encrypted using two PRNG registers. Between each draw, the PRNG advances by an exponentially increasing number of steps — eventually reaching ~1.1 trillion steps per character. The challenge title is also the hint: this is about time, specifically about not wasting all of it.
The PRNG state is two 8-bit registers (VA and VB). That's 256 × 256 = 65,536 possible states. By the pigeonhole principle, it must cycle within that many steps. Find the cycle length, and a trillion-step timer becomes a single modular division. No simulation needed.
step_01 --identify
The first instinct is to run file (the Unix file-type command). It says data. Not helpful. Run xxd instead and look at the first two bytes:
$ xxd main.bin | head -1
00000000: 00e0 1280 a6a6 a6a6 a6a6 a6a6 a6a6 a6a6 ................
00 E0 is the CHIP-8 opcode CLS — clear the screen. The bytes 12 80 immediately after it form a jump instruction to 0x280. The flood of 0xA6 bytes starting at offset 4 is sprite data in memory, never executed as code.
CHIP-8 is an interpreted language designed for 1970s hobbyist machines. Think of it as the simplest possible virtual machine: one 64×32 pixel screen, 16 eight-bit registers (V0–VF, with VF reserved as the flag register), a small stack, and about 35 opcodes. Programs fit in a few kilobytes and were distributed on — you guessed it — cassette tape.
step_02 --emulate
Write a minimal Python CHIP-8 emulator — just enough to decode opcodes and log draw calls. Set the delay timer to always return zero so you don't wait forever. Running it confirms the ROM draws characters:
Cycle 163 → 'T'
Cycle 517 → 'TH'
Cycle 1770 → 'THE'
Cycle 6675 → 'THEM'
Cycle 26092 → 'THEM?'
...stalls
Good — the ROM definitely draws the flag. Bad — zeroing the timer breaks the decryption. The PRNG advances during the delay loop; each delay iteration triggers one PRNG step. Skip the delay, and the PRNG state diverges from what it would be during actual execution, causing garbage output from character 6 onwards.
step_03 --disassemble
Load the binary into a CHIP-8 disassembler. The structure falls out quickly:
The sprite data is split across 8 subtable banks in memory:
subtable_bases = [
0x400, 0x460, 0x4C0, 0x520,
# 0x580 skipped — ROM layout leaves a 128-byte gap here (unused/alignment)
0x600, 0x660, 0x6C0, 0x720
]
step_04 --crypto
Each of the 32 character draws follows the same pattern. The subroutine at 0x2C0 maintains two 8-bit registers, VA and VB, which together form the PRNG state. Before each draw, the PRNG has been stepped some number of times. Then:
plaintext_byte = memory[subtable_bases[VA & 7] + char_offset] ^ VA ^ VB
VA & 7 picks which of the 8 subtables to use (bottom 3 bits of VA). The encrypted sprite byte is XOR'd with both VA and VB to recover the plaintext. XOR is its own inverse — same key decrypts as encrypts — so if you know VA and VB at the moment of each draw, you can recover every character.
XOR is a bitwise operation where the same key both encrypts and decrypts. If ciphertext = plaintext XOR key, then plaintext = ciphertext XOR key. The "key" here is the pair (VA, VB). Predict those two bytes at each draw, and the entire cipher falls apart immediately.
step_05 --the-wall
The delay between each character draw is controlled by a timer. The timer values scale exponentially as the ROM progresses:
Character | PRNG steps between draws | Simulation (1B iters/s)
--------------------------------------------------------------
1 | 4 | instant
2 | 16 | instant
3 | 64 | instant
10 | 1,048,576 | ~1ms
15 | 1,073,741,824 | ~1 second
16–32 | ~1,100,000,000,000 | ~18 minutes each
Characters 16 through 32 each need roughly 1.1 trillion PRNG steps. Even in optimized C, that's 18 minutes per character. 17 late characters × 18 minutes = over 5 hours of runtime.
step_06 --cycle_detect
Walk the PRNG forward from its initial state, recording every (VA, VB) pair seen. The ROM initializes VA to 0xA7 and VB to 0xC3 at address 0x2XX before the first draw. The real algorithm at offset 0x2C0 is more involved than a simple LCG:
CONSTS = [0xA9, 0x5C, 0xD3, 0x76]
def step_prng(mem, va, vb):
v2, v3 = va, vb
v0 = mem[0x800 + vb]
v0 ^= vb
v0 ^= CONSTS[vb >> 6]
carry = 1 if vb + v0 > 0xFF else 0
vb = (vb + v0) & 0xFF
va = (va + carry) & 0xFF
for _ in range(5):
c3 = (v3 >> 7) & 1
v3 = (v3 << 1) & 0xFF
c2 = (v2 >> 7) & 1
v2 = (v2 << 1) & 0xFF
v2 |= c3
v3 |= c2
va ^= v2
vb ^= v3
return va & 0xFF, vb & 0xFF
This matches the CONSTS table, the mem[0x800 + vb] lookup, and the 5-iteration bit-shuffle used in solve.py. Cycle properties are identical: 65,536 possible states, first repeat at step 329, cycle length 34.
step_07 --decrypt
Every late character advances the PRNG exactly 17 positions in the cycle. One division.
timer_per_late_char = 255 * 4_294_967_295 # = 1,095,216,660,225
advance = 1_095_216_660_225 % 34 # = 17
Combine the cycle formula with the XOR and read the flag:
# PSEUDOCODE
flag = []
cumulative = 0
for i in range(32):
VA, VB = state_at(cumulative)
subtable = subtable_bases[VA & 7]
plaintext = memory[subtable + i] ^ VA ^ VB
flag.append(chr(plaintext))
cumulative += timer_steps_for_char(i)
print("".join(flag))
cat takeaways.txt
ls -lah downloads/
Everything needed to review, solve, and deploy the challenge is available below.