← Back to all writeups
Rev

gol

376 points • Reversible cellular automaton • Solved during pingCTF 2026

This binary looks like a cursed Game of Life simulator with aggressively unserious symbol names, threads everywhere, and two giant boards embedded in .rodata. The key observation is that the checker is reversible: it evolves a second-order Life-like automaton forward, compares the last two states against the embedded boards, and can therefore be run backward to recover the original packed input bytes directly.

Challenge file

The download is a single 64-bit Linux ELF and, unlike many rev tasks, it is not stripped. That does not make the code readable, but it does make the important pieces easier to spot. strings immediately shows three suspicious blobs named GAME, KRILLYOUSELF, and TheOnePieceIsReal, plus a lot of huge function names.

main is short: it reads one string with scanf("%s"), passes it to TheStrongDecideTheNatureOfSin, and prints yipee! only if that function returns nonzero. So the whole check lives in that one giant function and the helpers it calls.

How the input is packed

The first useful helper is AGoodPartyRequiresABloodSacrifice. It allocates a 64 x 48 byte board and converts the user input into bitplanes. For the first 64 input bytes, bit 0 goes into row 0, bit 1 into row 1, and so on up to row 7. The remaining rows start as zero.

In other words, column x of the first eight rows stores the byte at position x in little-endian bit order. That matters later because once we reverse the automaton, decoding the original input is just a matter of reassembling those eight rows back into bytes.

The worker threads

TheStrongDecideTheNatureOfSin allocates one cell structure for every board position, wires cells together with mailbox objects, and then spawns one thread per cell using thrd_create with BlueHouseGTechExec as the thread entry.

Statically the graph builder is ugly, so I took a quick dynamic shortcut: I ran the binary in a Linux container with a tiny LD_PRELOAD shim that logged which cells were connected to which mailboxes. For a center cell, the incoming edges were:

(-2,-2) (-2,0) (-2,2)
( 0,-2)        ( 0,2)
( 2,-2) ( 2,0) ( 2,2)

So this is not normal Life on the immediate Moore neighborhood. Each cell samples the eight cells two steps away.

The update rule

Inside BlueHouseGTechExec, each worker gathers those eight neighbor bits, prepends the current cell bit, and uses the resulting 9-bit index to read the GAME lookup table. The binary actually multiplies the index by two, so only every other byte of GAME is meaningful.

If you tabulate the live outputs, the rule collapses to standard Conway Life semantics:

The twist is that the binary is second-order. The next state is the Life result xor the previous state:

board[t + 1] = life(board[t]) ^ board[t - 1]

That recurrence is reversible. If you know two consecutive boards, you can recover the older one with board[t - 1] = life(board[t]) ^ board[t + 1].

The target boards are embedded in the binary

During initialization, each cell stores two target bits taken from the large ASCII-art blobs KRILLYOUSELF and TheOnePieceIsReal. The worker compares its final two states against those targets before returning.

The end condition is therefore:

board[0x5379] == KRILLYOUSELF
board[0x537A] == TheOnePieceIsReal

Once that clicked, the solve stopped being a simulation problem and became a reversal problem. We already know the last two boards, because the binary ships them in plaintext as #/space art.

Running the automaton backward

I parsed both ASCII-art boards into 64 x 48 bitmaps and repeatedly applied the reverse step:

older = life(previous) ^ current
current, previous = previous, older

Repeating that 0x537A - 1 times lands on the initial board built from the flag. At that point, the picture is exactly what AGoodPartyRequiresABloodSacrifice would have produced from the user input: the first eight rows contain packed bytes and everything below is zero.

Decoding the flag

Reassembling the first eight rows column by column gives the original input bytes directly. The recovered byte string starts with a valid flag and then zero padding:

ping_500hoursofmindglorbingaction\x00\x00\x00...

Flag: ping_500hoursofmindglorbingaction

Minimal solver

The final solver only needs the binary itself. It extracts the rule table and the two ASCII-art boards from .rodata, runs the reverse recurrence, and decodes the packed bytes from the reconstructed initial state.

rule = GAME[::2]
previous = KRILLYOUSELF
current = TheOnePieceIsReal

for _ in range(0x537A - 1):
    older = life(previous) ^ current
    current, previous = previous, older

flag = unpack_first_8_rows(previous)

Downloads

solve.py

Standalone solver that extracts the lookup table and both target boards from the provided ELF, reverses the second-order automaton, and reconstructs the original input bytes.

Download solver