Logo
GPN CTF 2025: "variants"

GPN CTF 2025: "variants"

July 1, 2025
5 min read
index

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: image

It appears to be the flag, but mangled somehow.

Binary Ninja tells us that this mangled flag data is referenced by the following function: image

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: image

Take a look at the loop here that steps through each byte of a mangled chunk, and XOR decrypts the mangled byte: image

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) & 0xff
j = 1 -> j << 4 = 16 -> (key >> 16) & 0xff
j = 2 -> j << 4 = 32 -> (key >> 32) & 0xff
j = 3 -> j << 4 = 48 -> (key >> 48) & 0xff
j = 4 -> j << 4 = 64 -> (key >> 64) & 0xff
j = 5 -> j << 4 = 80 -> (key >> 80) & 0xff
j = 6 -> j << 4 = 96 -> (key >> 96) & 0xff
j = 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) & 0xff
j = 1 -> j << 4 = 16 -> (key >> 16) & 0xff
j = 2 -> j << 4 = 32 -> (key >> 32) & 0xff
j = 3 -> j << 4 = 48 -> (key >> 48) & 0xff
j = 4 -> j << 4 = 64 -> (key >> 0) & 0xff
j = 5 -> j << 4 = 80 -> (key >> 16) & 0xff
j = 6 -> j << 4 = 96 -> (key >> 32) & 0xff
j = 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.