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:
$env:ORRERY_DAY = "22501"
.\orrery.exeThe 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:
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:
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 forwardsThe program stores the result as a number:
absorbed = 0
reflected = 1
deflected = 2 + x + (10 * y)For example, a ray that exits through (6,9) is stored as:
2 + 6 + (10 * 9) = 98Parsing the serial
The serial is exactly eight characters long. It uses this custom alphabet:
0123456789ABCDEFGHJKMNPQRSTVWXYZThe 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:
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:
checksum = packed & 0x3ff;
payload = packed >> 10;The checksum uses FNV-1a:
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:
name_hash = fnv1a(uppercase_name) & 0x3fffffff;The payload is combined with this name hash:
planet_data = payload ^ name_hash;This gives five six-bit values. Each value represents one position in the 8x8 interior grid:
x = (value & 7) + 1;
y = (value >> 3) + 1;For the layout I found, the five values were:
0, 1, 2, 3, 10Those values correspond to:
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:
(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:
C(64, 5) = 7,624,512 layoutsThat 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:
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)
breakThe matching layout was:
0, 1, 2, 3, 10Generating the serial
After finding the layout, I generated a serial for the name TEST.
For this name:
name hash = 0x32d739e5
planet data = 0x000420ca
payload = 0x32d3192f
checksum = 0x0b0Packing the payload and checksum together produces:
0xcb4c64bcb0Converting that value through the custom alphabet gives:
SD669F5GRunning it against the original binary:
$env:ORRERY_DAY = "22501"
.\orrery.exe TEST SD669F5Gproduces:
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:
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:
0x1400030ac call memcmp
0x1400030b1 test eax, eax
0x1400030b5 jne 0x140003169memcmp 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.