Hello!
In this brief write-up, I will document my unintended solution for GPN CTF 2025’s rev/variants
.
variants - 478 pts (2 solves)
A true cosmopolitan should speak many languages. And I do… but everywhere I go, people seem to understand different things…
Could you help me find a consensus they can all agree on? Someone might even give you a flag for it.
Unintended Solution
Crypto in a Rev Challenge?
If we open up the binary in Binary Ninja and click around for a bit, we can see the following data:
It appears to be the flag, but mangled somehow.
Binary Ninja tells us that this mangled flag data is referenced by the following function:
This function appears to implement some sort of XOR cipher, where the flag is broken up into chunks of 8 bytes. Every other chunk of 8 bytes is decrypted with some unique XOR key to get the flag (I refer to these chunks as “mangled” chunks). The remaining chunks that are not mangled are just in plaintext. Here, I’ve reassigned some names and types to make the decompilation cleaner:
Take a look at the loop here that steps through each byte of a mangled chunk, and XOR decrypts the mangled byte:
The byte chosen as the XOR key for each mangled byte is (key >> (j << 4)) & 0xff
. Notice that j
ranges from 0
to 7
and key
is right-shifted by j << 4
. Here’s a table of j
-values and their associated key
shifts:
j = 0 -> j << 4 = 0 -> (key >> 0) & 0xffj = 1 -> j << 4 = 16 -> (key >> 16) & 0xffj = 2 -> j << 4 = 32 -> (key >> 32) & 0xffj = 3 -> j << 4 = 48 -> (key >> 48) & 0xffj = 4 -> j << 4 = 64 -> (key >> 64) & 0xffj = 5 -> j << 4 = 80 -> (key >> 80) & 0xffj = 6 -> j << 4 = 96 -> (key >> 96) & 0xffj = 7 -> j << 4 = 112 -> (key >> 112) & 0xff
Notice that key
is only a 64-bit variable though… So, what happens if a 64-bit variable is shifted by a shift that is greater than 64?
A weird quirk of the x86-64 architecture is that if we are shifting a 64-bit value, say x >> N
, then the shift that the CPU actually performs is x >> (N % 64)
. This design choice of the architecture dates all the way back to the late 1970s, where Intel used it as an optimization technique when adding variable-shift instructions to their 16-bit 8086 CPU.
What this means for us, though, is that bytes 0-3 and bytes 4-7 are XORed by the same XOR key. To see why this is true, here’s the same table as before, but accounting for the % 64
in the key
shift:
j = 0 -> j << 4 = 0 -> (key >> 0) & 0xffj = 1 -> j << 4 = 16 -> (key >> 16) & 0xffj = 2 -> j << 4 = 32 -> (key >> 32) & 0xffj = 3 -> j << 4 = 48 -> (key >> 48) & 0xffj = 4 -> j << 4 = 64 -> (key >> 0) & 0xffj = 5 -> j << 4 = 80 -> (key >> 16) & 0xffj = 6 -> j << 4 = 96 -> (key >> 32) & 0xffj = 7 -> j << 4 = 112 -> (key >> 48) & 0xff
Because bytes 0-3 and bytes 4-7 are XORed by the same key, we now have a mathematical relationship between these two groups of 4 bytes. From here, guessing the flag becomes a viable strategy.
Guessing the Flag
I wrote a small Python CLI program to help me with guessing the flag:
import string
flag = b'\xcfZeM\xdcLP?_ONlY_te8B\x85\xf5ag\xa9\xdefLAg_70_Y\x8a\xd5\xebq\xa1\xf3\xc2Fav0ur1t\x83\'\x92\xfd\xff\x08\xf3\xab}\x00\x00\x00\x00\x00\x00\x00'chunks = [flag[i:i+8] for i in range(0, len(flag), 8)]
known = [ (0, 0, 'G'), (0, 1, 'P'), (0, 2, 'N'), (0, 3, 'C'),]for i, j, c in known: index = i * 8 + j key = ord(c) ^ flag[index] chunk = bytearray(chunks[i]) chunk[j] = ord(c) chunk[(j + 4) % 8] = chunks[i][(j + 4) % 8] ^ key chunks[i] = bytes(chunk)
charset = string.ascii_letters + string.digits + string.punctuation + ' '
while True: for i, chunk in enumerate(chunks): print(f'Chunk {i}: {chunk.hex()} {chunk}')
chunk_index = int(input('> ').strip()) if chunk_index % 2 != 0: print('Chunk is not mangled') continue
index = int(input('char index: ').strip()) other_index = (index + 4) % 8
chunk = chunks[chunk_index] print() print(f'Chunk {i}: {chunk.hex()} {chunk}') print(' ' * len(f'Chunk {i}: ') + ' ' * (min(index, other_index)) + '^^' + ' ' * 4 + '^^')
print('possibilities:') for char in charset: key = ord(char) ^ chunk[index] if chr(chunk[other_index] ^ key) not in charset: continue
new_chunk = bytearray(chunk) new_chunk[index] = ord(char) new_chunk[other_index] = chunk[other_index] ^ key print(f' {char} -> {new_chunk.hex()} {new_chunk}')
new_char = input('new char: ').strip() key = ord(new_char) ^ chunk[index] new_chunk = bytearray(chunk) new_chunk[index] = ord(new_char) new_chunk[other_index] = chunk[other_index] ^ key chunks[chunk_index] = bytes(new_chunk) print()
This Python program automates the process of using one mangled byte to compute its other mathematically-related byte.
With a bit of guesswork, we can use this program to figure out that the flag is one of the following:
GPNCTF{1_ONlY_te1l_thIs_fLAg_70_my_vERy_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1l_thIs_fLAg_70_mY_vEry_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1l_thIs_fLAg_70_My_veRy_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1l_thIs_fLAg_70_MY_very_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1L_this_fLAg_70_my_vERy_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1L_this_fLAg_70_mY_vEry_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1L_this_fLAg_70_My_veRy_Fav0ur1t3_PeOp13}GPNCTF{1_ONlY_te1L_this_fLAg_70_MY_very_Fav0ur1t3_PeOp13}
Because of the properties of ASCII, we cannot actually deduce the case for 3 of the mathematically-related pairs of flag characters. However, there are only 8 possibilities for the flag here, so we can just try submitting all of them…
GPNCTF{1_ONlY_te1l_thIs_fLAg_70_My_veRy_Fav0ur1t3_PeOp13}
Intended Solution
The binary actually implemented some form of a variant of Sudoku that used the APE Loader to split parts of the game logic into different variants of the program that run on different machines.
The intended solution was to dump all of these variants of the program, reverse engineer the rules of the Sudoku variant, then solve the puzzle.