Back to posts

Reverse engineering Orrery: solving the five-planet serial

Working through Orrery's hidden 8x8 planet grid, probe physics, custom serial format, and an AI-assisted solver that turns the survey into a valid serial.

On this pageInitial lookUnderstanding the gridParsing the serialRecovering the planet positionsBrute-forcing the surveyGenerating the serialThe tempting branch patch

Initial look

Running orrery.exe without arguments prints a telescope survey instead of asking for a serial immediately.

I used ORRERY_DAY to keep the challenge on a fixed day while solving it:

snippet.powershell
$env:ORRERY_DAY = "22501"
.\orrery.exe

The program prints 32 probe results. Each probe can be absorbed, reflected, or deflected to another edge coordinate.

A quick note before getting into it: this write-up was AI-assisted. I used AI to help translate the harder parts of the Binary Ninja decompilation, check my understanding of the probe simulation, and verify the solver. I still stepped through the binary and tested the final serial myself.

file
orrery.exe
format
PE32+ / x86-64
analysis tool
Binary Ninja
original SHA-256
B98A16BC879F602672C7B7C5A64E0AA4A4C9824BC9D1DFFBE6BEE0C95AC669AD
main
0x140002b60
survey table
0x1400051a0
file offset
0x29a0
original crackme
View on crackmes.one

The challenge description says that five planets are hidden somewhere inside an 8x8 grid. The program does not store the planet positions directly. Instead, it stores the results that probes would produce.

The probes are ordered like this:

snippet.txt
top edge:       (1,0) through (8,0)
left/right:     (0,1), (9,1), (0,2), (9,2), ...
bottom edge:    (1,9) through (8,9)

Understanding the grid

The program uses a 10x10 grid. The outside border is made up of coordinates 0 and 9, while the planets can only be placed between them.

A ray starts on the border and moves into the grid.

The important rules are:

snippet.txt
if the next cell contains a planet:
    absorbed

if both diagonal cells contain planets:
    reflected

if the ray is currently on the border and either diagonal contains a planet:
    reflected

if only the first diagonal contains a planet:
    deflect one direction

if only the second diagonal contains a planet:
    deflect the other direction

otherwise:
    continue forwards

The program stores the result as a number:

snippet.txt
absorbed  = 0
reflected = 1
deflected = 2 + x + (10 * y)

For example, a ray that exits through (6,9) is stored as:

snippet.txt
2 + 6 + (10 * 9) = 98

Parsing the serial

The serial is exactly eight characters long. It uses this custom alphabet:

snippet.txt
0123456789ABCDEFGHJKMNPQRSTVWXYZ

The program uses strchr to find each serial character inside that alphabet. The difference between the returned pointer and the start of the alphabet gives the character's numeric value.

Each character contributes five bits:

snippet.c
packed = (packed << 5) | character_index;

Eight characters give us 40 bits in total.

The lowest 10 bits are a checksum. The remaining 30 bits contain the actual data:

snippet.c
checksum = packed & 0x3ff;
payload = packed >> 10;

The checksum uses FNV-1a:

snippet.c
hash = 0x811c9dc5;

for each byte in the payload:
    hash = (hash ^ byte) * 0x01000193;

Only the lowest 10 bits of the resulting hash are compared with the checksum from the serial.

Recovering the planet positions

The name is also involved in the serial.

The program first trims whitespace and converts the name to uppercase. It then hashes the name with FNV-1a and keeps the lowest 30 bits:

snippet.c
name_hash = fnv1a(uppercase_name) & 0x3fffffff;

The payload is combined with this name hash:

snippet.c
planet_data = payload ^ name_hash;

This gives five six-bit values. Each value represents one position in the 8x8 interior grid:

snippet.c
x = (value & 7) + 1;
y = (value >> 3) + 1;

For the layout I found, the five values were:

snippet.txt
0, 1, 2, 3, 10

Those values correspond to:

snippet.txt
value 0  -> (1,1)
value 1  -> (2,1)
value 2  -> (3,1)
value 3  -> (4,1)
value 10 -> (3,2)

So the planets are located at:

snippet.txt
(1,1), (2,1), (3,1), (4,1), (3,2)

Brute-forcing the survey

There are only 64 possible interior cells, and the challenge uses five planets:

snippet.txt
C(64, 5) = 7,624,512 layouts

That is large enough that doing it manually would be annoying, but small enough to brute-force with a script.

I copied the probe behaviour into Python and compared every possible layout against the survey table:

keygen.py
from itertools import combinations

TABLE_OFFSET = 0x29a0
DAY_ZERO = 0x50ce
DAY = 22501


def occupied(planets, x, y):
    return 1 <= x <= 8 and 1 <= y <= 8 and (x, y) in planets


def trace(planets, x, y, dx, dy):
    while True:
        fx = x + dx
        fy = y + dy

        if occupied(planets, fx, fy):
            return 0

        a = occupied(planets, fx + dy, fy + dx)
        b = occupied(planets, fx - dy, fy - dx)

        if a and b:
            return 1

        if (x in (0, 9) or y in (0, 9)) and (a or b):
            return 1

        if a:
            x, y = x - dy, y - dx
            dx, dy = -dy, -dx
        elif b:
            x, y = x + dy, y + dx
            dx, dy = dy, dx
        else:
            x, y = fx, fy

        if x in (0, 9) or y in (0, 9):
            return 2 + x + (10 * y)


def survey(planets):
    result = []

    for x in range(1, 9):
        result.append(trace(planets, x, 0, 0, 1))

    for y in range(1, 9):
        result.append(trace(planets, 0, y, 1, 0))
        result.append(trace(planets, 9, y, -1, 0))

    for x in range(1, 9):
        result.append(trace(planets, x, 9, 0, -1))

    return bytes(result)


with open("orrery.exe", "rb") as file:
    file.seek(TABLE_OFFSET + (DAY - DAY_ZERO) * 32)
    expected = file.read(32)


for values in combinations(range(64), 5):
    planets = {
        ((value & 7) + 1, (value >> 3) + 1)
        for value in values
    }

    if survey(planets) == expected:
        print("Found layout:", values)
        print("Coordinates:", planets)
        break

The matching layout was:

snippet.txt
0, 1, 2, 3, 10

Generating the serial

After finding the layout, I generated a serial for the name TEST.

For this name:

snippet.txt
name hash  = 0x32d739e5
planet data = 0x000420ca
payload     = 0x32d3192f
checksum    = 0x0b0

Packing the payload and checksum together produces:

snippet.txt
0xcb4c64bcb0

Converting that value through the custom alphabet gives:

snippet.txt
SD669F5G

Running it against the original binary:

snippet.powershell
$env:ORRERY_DAY = "22501"
.\orrery.exe TEST SD669F5G

produces:

snippet.txt
system charted - ORRERY{8ADB122E}

The final fingerprint is also calculated with FNV-1a. It uses the five sorted planet values followed by the four little-endian bytes of the name hash:

snippet.python
fingerprint_input = bytes([0, 1, 2, 3, 10])
fingerprint_input += (0x32d739e5).to_bytes(4, "little")

This is why the name matters. Changing the name changes both the serial and the final fingerprint.

The tempting branch patch

The final survey comparison happens here:

snippet.asm
0x1400030ac    call    memcmp
0x1400030b1    test    eax, eax
0x1400030b5    jne     0x140003169

memcmp returns zero when the simulated probe results match the table. The jne branch goes to the failure message when the result is not zero.

It would be possible to patch this branch and force execution into the fingerprint code. However, the program still calculates the final value from the decoded planet data and the name hash afterwards.

So skipping the comparison does not produce a proper solution. The useful approach here is writing a small keygen that reconstructs the planets from the survey and then creates a valid serial for the chosen name.