I played THEM?!CTF 2026 with my team HazyJane from May 29 to June 1. The challenges spanned five categories and five decades of computing — from a 1970s CHIP-8 cassette tape ROM to modern PE64 binaries with custom VMs, from digging through 432 dangling git commits to parsing Shakespearean steganography. Each one demanded a genuinely different approach.
We placed 172nd with 900 points. We solved 9 challenges: Aviation History (OSINT), Git-Art (misc), Some Play’s Libretto (misc), Zero Disguise (misc), Entropy Core (rev), Eyes Chico (rev), Ancient Signals (rev), Old Cassette (rev), and Sanity 2 (sanity). The reverse engineering challenges were the deepest — custom bytecode VMs, control-flow-flattened emulators, and one binary whose flag decryption key was the FNV hash of its own code.
I used a three-layer toolchain. Opencode Plan reads challenge files, identifies the binary type, and decomposes the work into sub-tasks. Each sub-task goes to a specialised subagent that calls GhidraAssistMCP tools by name. Deepseek V4 Flash powers all reasoning — both Plan’s decomposition logic and each subagent’s tool-calling decisions.
Methodology: GhidraAssistMCP + Opencode + Deepseek V4 Flash
GhidraAssistMCP is an MCP server that exposes Ghidra as callable tools. get_code returns decompiled C for a given address. get_functions lists every function in the binary with address, size, and calling convention. search_bytes finds byte patterns across segments. xrefs traces every caller of a function. variables renames local variables to meaningful names. get_data_at reads raw bytes at an address. struct defines C structures that Ghidra applies to stack variables. Each call returns structured data the agent feeds into its mental model.
The workflow for every challenge followed the same pattern. Plan enumerates the binary (list functions, find strings, check segments), decompiles key functions (get code, rename variables, define structs), cross-references every caller and dispatch table, and synthesises the full algorithm. Subagents execute steps in parallel where possible, calling GhidraAssistMCP tools as needed. Plan hands the understood mechanism to Build, which writes the solution script and extracts the flag.
Challenge 1: Old Cassette (rev, 100pts)
Author: SISUBENY. Description: “I found this old cassette tape in my drawer XD” — file: main.bin.
main.bin was 3,282 bytes. file main.bin returned data. The first four bytes were 00 E0 (CHIP-8 CLS opcode) and 12 80 (JP 0x280). CHIP-8 programs load at address 0x200, so the jump target 0x280 skips a padding region. The standard CHIP-8 hex font sat at file offset 0x580.
All 16 opcode categories were present. The flag was not stored as data. It was computed through a state machine cipher. The core step function at CHIP-8 subroutine 0x2C0 implemented a 5-phase mixing round that took two state bytes (VA, VB) and produced a new pair. Initial state was (VA=0xA7, VB=0xC3). The state machine traced out a single cycle in 65,536-state space. Thirty-two decoding code blocks lived at CHIP-8 addresses 0x900-0xDBF. Blocks 1-16 were counter-driven with advancement powers of 4 (1, 4, 16, 64, up to 4^15). Blocks 17-32 used a fixed CAC step of 255 × (256^4 - 1). Each character was decoded as flag_char = memory[base + offset] ^ VA ^ VB, where base selection used VA & 0x07 to map to 8 bases: 0x400, 0x460, 0x4C0, 0x520, 0x600, 0x660, 0x6C0, 0x720.
The trickiest part was discovering the state machine. I had dead ends trying MSX CAS format reconstruction, XOR with simple repeating keys, and emulator keypress simulation (which only showed a splash screen). The insight came from tracing the 32 repeating code blocks and realising each one advanced a hidden state by a different step. Subroutine 0x2C0 was the engine.
#!/usr/bin/env python3
"""
Reproduce the flag decoding from the CHIP-8 ROM main.bin
Flag: THEM?!CTF{0LD_T4P3_N3V3R_D1E5K7}
"""
import struct
with open('main.bin', 'rb') as f:
rom = f.read()
memory = [0] * 4096
for i, b in enumerate(rom):
if 0x200 + i < 4096:
memory[0x200 + i] = b
# Load standard CHIP-8 font
font = [
0xF0,0x90,0x90,0x90,0xF0,0x20,0x60,0x20,0x20,0x70,0xF0,0x10,0xF0,0x80,0xF0,
0xF0,0x10,0xF0,0x10,0xF0,0x90,0x90,0xF0,0x10,0x10,0xF0,0x80,0xF0,0x10,0xF0,
0xF0,0x80,0xF0,0x90,0xF0,0xF0,0x10,0x20,0x40,0x40,0xF0,0x90,0xF0,0x90,0xF0,
0xF0,0x90,0xF0,0x10,0xF0,0xF0,0x90,0xF0,0x90,0xF0,0xE0,0x90,0xE0,0x90,0xE0,
0xF0,0x80,0x80,0x80,0xF0,0xE0,0x90,0x90,0x90,0xE0,0xF0,0x80,0xF0,0x80,0xF0,
0xF0,0x80,0xF0,0x80,0x80,
]
for i, b in enumerate(font):
memory[i] = b
# Key schedule table (derived from the program's key initialization)
keys = {0x00: 0xA9, 0x40: 0x5C, 0x80: 0xD3, 0xC0: 0x76}
def step_2c0(VA, VB):
"""Simulate the ChaCha-like core step at subroutine 0x2C0."""
V2 = VA
V3 = VB
I = 0x800 + VB
V0 = memory[I]
V0 ^= VB
V8 = VB & 0xC0
V0 ^= keys[V8]
result = VB + V0
VF = 1 if result > 0xFF else 0
VB = result & 0xFF
result = VA + VF
VF = 1 if result > 0xFF else 0
VA = result & 0xFF
for _ in range(5):
result = V3 + V3
V6 = 1 if result > 0xFF else 0
V3 = result & 0xFF
result = V2 + V2
V7 = 1 if result > 0xFF else 0
V2 = result & 0xFF
V2 |= V6
V3 |= V7
VA ^= V2
VB ^= V3
return VA, VB
# Build state transition path starting from (0xA7, 0xC3)
start = (0xA7, 0xC3)
seen = {}
path = [start]
seen[start] = 0
pos = 1
while True:
VA, VB = path[-1]
state = step_2c0(VA, VB)
if state in seen:
cycle_start = seen[state]
cycle_len = pos - cycle_start
break
seen[state] = pos
path.append(state)
pos += 1
def advance_by(state, N):
"""Advance the state machine by N steps."""
p = seen[state]
if p + N < len(path):
return path[p + N]
remaining = N - (len(path) - p)
remaining %= cycle_len
return path[cycle_start + remaining]
# Block definitions extracted from the ROM at addresses 0x900-0xDBC
blocks_1_10 = [
(0x01, 0x00, 0x00, 0x00, 0x54), # T
(0x04, 0x00, 0x00, 0x00, 0x21), # H
(0x10, 0x00, 0x00, 0x00, 0x0D), # E
(0x40, 0x00, 0x00, 0x00, 0x36), # M
(0x00, 0x01, 0x00, 0x00, 0x31), # ?
(0x00, 0x04, 0x00, 0x00, 0x4B), # !
(0x00, 0x10, 0x00, 0x00, 0x01), # C
(0x00, 0x40, 0x00, 0x00, 0x41), # T
(0x00, 0x00, 0x01, 0x00, 0x25), # F
(0x00, 0x00, 0x04, 0x00, 0x3F), # {
]
blocks_11_16 = [
(0x00, 0x00, 0x10, 0x00, 0x55), # 0
(0x00, 0x00, 0x40, 0x00, 0x35), # L
(0x00, 0x00, 0x00, 0x01, 0x19), # D
(0x00, 0x00, 0x00, 0x04, 0x33), # _
(0x00, 0x00, 0x00, 0x10, 0x49), # T
(0x00, 0x00, 0x00, 0x40, 0x29), # 4
]
blocks_17_20 = [0x5A, 0x23, 0x54, 0x1D] # P 3 _ N
blocks_21_30 = [0x4E, 0x17, 0x48, 0x11, 0x42, 0x0B, 0x3C, 0x05, 0x36, 0x5F] # 3 V 3 R _ D 1 E 5 K
blocks_31_32 = [0x30, 0x59] # 7 }
CAC_STEP = 255 * (256**4 - 1) # Fixed advance for CAC blocks
bases = [0x400, 0x460, 0x4C0, 0x520, 0x600, 0x660, 0x6C0, 0x720]
def decode_char(VA, VB, offset):
base = bases[VA & 0x07]
I = base + offset
enc = memory[I]
dec = enc ^ VA ^ VB
return dec, I, enc
print('=== Full Flag Decoding ===')
current = (0xA7, 0xC3)
flag = ''
for idx, (v9, vc, vd, ve, offset) in enumerate(blocks_1_10):
step = v9 + vc*256 + vd*65536 + ve*16777216
current = advance_by(current, step)
VA, VB = current
dec, I, enc = decode_char(VA, VB, offset)
ch = chr(dec) if 32 <= dec <= 126 else f'[{dec:02X}]'
flag += ch
print(f' {idx+1:2d}: step={step:>12} VA=0x{VA:02X} VB=0x{VB:02X} I=0x{I:04X} enc=0x{enc:02X} -> {repr(ch)}')
for idx, (v9, vc, vd, ve, offset) in enumerate(blocks_11_16):
step = v9 + vc*256 + vd*65536 + ve*16777216
current = advance_by(current, step)
VA, VB = current
dec, I, enc = decode_char(VA, VB, offset)
ch = chr(dec) if 32 <= dec <= 126 else f'[{dec:02X}]'
flag += ch
print(f' {idx+11:2d}: step={step:>12} VA=0x{VA:02X} VB=0x{VB:02X} I=0x{I:04X} enc=0x{enc:02X} -> {repr(ch)}')
for group_name, offsets in [('Row y=7 (cont)', blocks_17_20),
('Row y=0x0D', blocks_21_30),
('Row y=0x13', blocks_31_32)]:
for idx, offset in enumerate(offsets):
current = advance_by(current, CAC_STEP)
VA, VB = current
dec, I, enc = decode_char(VA, VB, offset)
ch = chr(dec) if 32 <= dec <= 126 else f'[{dec:02X}]'
flag += ch
print(f' VA=0x{VA:02X} VB=0x{VB:02X} I=0x{I:04X} enc=0x{enc:02X} -> {repr(ch)}')
print(f'\nFLAG: {flag}')
FLAG: THEM?!CTF{0LD_T4P3_N3V3R_D1E5K7}
Challenge 2: Ancient Signals (rev, 100pts)
Author: smartfella. Description: “Our field agents recovered a secure comms package from a sophisticated audio player. Can you crack it?” — files: player.exe, transmission.dat.
player.exe was a 617KB PE64 binary compiled with GCC 15.2.0 using the miniaudio library. GhidraAssistMCP_get_functions returned 1622 functions. The window procedure at 0x14002d770 handled WM_CREATE, WM_COMMAND (PLAY TRANSMISSION button), and WM_HSCROLL.
The RIFF validation function at 0x1400032d0 checked that 4 bytes equal “RIFF”. An RDTSC anti-debug check looped 100 NOPs: if the cycle delta exceeded 0x200000, it corrupted a key by adding 0x13. The audio used LCG streaming decryption: state = state * multiplier + increment.
The flag decryption was the clever part. It used the FNV-1a 32-bit hash of the RIFF validation function code itself — the 80 bytes at 0x1400032d0-0x140003320. FNV parameters were offset=0x811c9dc5, prime=0x01000193. The encrypted flag bytes were at offsets 4-58 in transmission.dat. XOR with hash: flag[i] = encrypted[i] ^ hash_bytes[i % 4]. The solve was entirely static:
#!/usr/bin/env python3
def fnv1a_32(data):
h = 0x811c9dc5
for byte in data:
h ^= byte
h = (h * 0x01000193) & 0xFFFFFFFF
return h
riff_code = bytes([
0x55, 0x48, 0x89, 0xe5, 0x48, 0x89, 0x4d, 0x10,
0x48, 0x8b, 0x45, 0x10, 0x0f, 0xb6, 0x00, 0x3c,
0x52, 0x75, 0x34, 0x48, 0x8b, 0x45, 0x10, 0x48,
0x83, 0xc0, 0x01, 0x0f, 0xb6, 0x00, 0x3c, 0x49,
0x75, 0x25, 0x48, 0x8b, 0x45, 0x10, 0x48, 0x83,
0xc0, 0x02, 0x0f, 0xb6, 0x00, 0x3c, 0x46, 0x75,
0x16, 0x48, 0x8b, 0x45, 0x10, 0x48, 0x83, 0xc0,
0x03, 0x0f, 0xb6, 0x00, 0x3c, 0x46, 0x75, 0x07,
0xb8, 0x01, 0x00, 0x00, 0x00, 0xeb, 0x05, 0xb8,
0x00, 0x00, 0x00, 0x00, 0x5d, 0xc3, 0x66, 0x90
])
hash_val = fnv1a_32(riff_code)
hash_bytes = hash_val.to_bytes(4, 'little')
encrypted = bytes([
0xd5, 0x54, 0x52, 0xe7, 0xbe, 0x3d, 0x54, 0xfe,
0xc7, 0x67, 0x26, 0xc7, 0xe0, 0x7b, 0x26, 0xc4,
0xb2, 0x43, 0x70, 0xcf, 0xf5, 0x68, 0x26, 0xc4,
0xe6, 0x43, 0x65, 0x9b, 0xe2, 0x77, 0x65, 0x9a,
0xed, 0x70, 0x24, 0xce, 0xde, 0x2d, 0x79, 0xf5,
0xf5, 0x54, 0x24, 0xe7, 0xbe, 0x3d, 0x54, 0x9d,
0xc7, 0x43, 0x6f, 0xee, 0xc5, 0x58, 0x6a
])
flag = bytes(encrypted[i] ^ hash_bytes[i % 4] for i in range(len(encrypted)))
flag_str = flag.decode('ascii', errors='ignore').rstrip('\x00')
print(flag_str)
THEM?!CTF{1mag1n3_gett1ng_r1ckr0ll3d_1n_tH3M?!C7F_xDDD}
Challenge 3: Entropy Core (rev, 100pts)
Author: SISUBENY. Description: “coreCoreCORE” — file: entropy_core.exe.
entropy_core.exe was a 137KB PE64 compiled with GCC/MinGW. It ran a custom bytecode VM with roughly 30 opcodes, 16 × 64-bit registers, 65536 bytes of scratch memory, and a stack pointer initialized to 0xFFF0. The bytecode blob was 284 bytes at 0x140005260.
GhidraAssistMCP_search_bytes for 9E 37 79 B9 7F 4A 7C 15 found the golden ratio constant 0x9E3779B97F4A7C15 at 0x140003000. The RC4 key was b"EntropyCoreV1!\x00\x00" (16 bytes). Initial state: R10 = 0xCAFEBABEDEADBEEF.
The per-character transformation was:
state = (state ^ (char * 0x0101010101010101) + (rc4_byte * (pos+1))) * 0x9E3779B97F4A7C15
state = state ^ (state ROL 23)
check: (state & 0xFF) == expected_hash[pos]
Thirty-six expected hash bytes were stored. Some positions had 2-3 matching characters (e.g., pos 10: ‘1’, ‘E’, ‘f’), so a DFS backtracking solver handled ambiguity:
#!/usr/bin/env python3
import sys
def rol(x, n, bits=64):
return ((x << n) | (x >> (bits - n))) & ((1 << bits) - 1)
class RC4:
def __init__(self, key):
self.S = list(range(256))
self.i = 0
self.j = 0
j = 0
for i in range(256):
j = (j + self.S[i] + key[i % len(key)]) & 0xFF
self.S[i], self.S[j] = self.S[j], self.S[i]
def copy(self):
import copy
return copy.deepcopy(self)
def prng(self):
self.i = (self.i + 1) & 0xFF
self.j = (self.j + self.S[self.i]) & 0xFF
self.S[self.i], self.S[self.j] = self.S[self.j], self.S[self.i]
return self.S[(self.S[self.i] + self.S[self.j]) & 0xFF]
expected = bytes([
0xbe, 0x8b, 0x85, 0x03, 0x16, 0xa3, 0x6e, 0x74,
0x81, 0xbb, 0x76, 0x4a, 0x30, 0x27, 0xa8, 0x6d,
0x11, 0xbe, 0x98, 0x66, 0xe0, 0x05, 0xf1, 0xa4,
0xbc, 0xa7, 0x05, 0xd3, 0x13, 0x9f, 0x11, 0x91,
0x70, 0xf5, 0xf4, 0x6d
])
def transform(r10, char_val, rc4_byte, counter):
temp = r10 ^ (char_val * 0x0101010101010101)
temp = (temp + (rc4_byte * (counter + 1))) & 0xFFFFFFFFFFFFFFFF
temp = (temp * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF
res = temp ^ rol(temp, 23)
return res
key = b"EntropyCoreV1!" + b"\x00\x00"
solution = None
def dfs(pos, r10, rc4_state, path, max_depth=36):
global solution
if pos >= max_depth:
solution = bytes(path)
return True
rc4 = rc4_state.copy()
rc4_byte = rc4.prng()
matches = []
for c in range(256):
res = transform(r10, c, rc4_byte, pos)
if (res & 0xFF) == expected[pos]:
matches.append((c, res))
matches.sort(key=lambda x: (0 if 32 <= x[0] < 127 else 1, x[0]))
for c, new_r10 in matches:
new_path = path + [c]
if dfs(pos + 1, new_r10, rc4, new_path, max_depth):
return True
return False
rc4_init = RC4(key)
r10_init = 0xCAFEBABEDEADBEEF
print("Starting DFS solver...")
result = dfs(0, r10_init, rc4_init, [], 36)
if result:
print("Found solution!")
print("Flag:", solution.decode('latin-1'))
else:
print("No solution found with DFS approach.")
Flag: THEM?!CTF{Entr0py_C0r3_VM_S0_Funny!}
Challenge 4: Eyes Chico (rev, 100pts)
Author: Amiineecmoii. Description: “The eyes, Chico, they never lie” — file: 1983.exe.
1983.exe was a 22KB PE64 binary compiled with MinGW GCC 15.2.0. It implemented a custom VM with control flow flattening and mutating register permutation. Instructions were 3 bytes (opcode, operand_A, operand_B). Fifteen-plus opcodes were discovered (0x01-0x23), including FLAG_GEN at 0x23.
The VM had 8 virtual registers with dynamic permutation remapping every instruction. Computed dispatch used uVar32 = (iVar41 * 2 ^ uVar53 ^ state[uVar46] ^ uVar44) & 3. The PRNG at FUN_140001450 used 4 × 64-bit state with the golden ratio constant 0x9e3779b97f4a7c15.
Seven slots existed, each with lane-a{slot} and lane-b{slot} key strings. Byte combining used XOR of two PRNG streams, rotation, and match-byte XOR. A critical bug involved pre-increment vs post-increment capture of uVar52. A 113-byte flag buffer was assembled via a permutation table at DAT_1400050c0.
The flag was self-referential: “REVERSE EXECUTION VM WITH MUTATING REGISTERS AND CONTROL FLOW FLATTENING MAKES STATIC ANALYSIS PAINFUL”. The key data addresses were at 0x402120 (permutation indices), 0x403040 (lane lengths), 0x403060 (keys A), 0x4031c0 (keys B), 0x403320 (XOR consts), and 0x403398 (XOR consts 2).
import struct
MASK64 = 0xFFFFFFFFFFFFFFFF
def prng(key, string_val, output_len):
uVar14 = 3
uVar9 = 0x7465646279746573
uVar12 = 0x6c7967656e657261
uVar13 = 0x646f72616e646f6d
uVar8 = 0x736f6d6570736575
for i in range(32):
bVar1 = key[i]
uVar8 = (uVar8 ^ (i * 0x100 + bVar1)) & MASK64
uVar5 = (bVar1 * 0x1000193 ^ uVar14) & MASK64
uVar14 = (uVar14 + 0x11) & MASK64
uVar13 = (uVar13 + uVar5) & MASK64
shift = ((i % 7) + 1) & 0x3f
uVar12 = (uVar12 ^ (uVar13 >> shift)) & MASK64
bVar2 = i & 7
rolled = ((bVar1 << bVar2) | (bVar1 >> (8 - bVar2))) & 0xFF
uVar9 = (uVar9 + rolled) & MASK64
if output_len <= 0:
return b''
result = bytearray()
uVar5 = 0
uVar14_carry = 0
NEG_CONST = (-0x5a5a5a5a5a5a5a5b) & MASK64
while uVar5 < output_len:
uVar11 = (uVar14_carry + uVar5 + 0x9e3779b97f4a7c15 ^ uVar9) & MASK64
str_pos = (uVar14_carry + uVar5) % 7
uVar7_byte = string_val[str_pos]
uVar8 = (uVar8 + (uVar14_carry ^ uVar13 ^ uVar7_byte)) & MASK64
uVar9 = (uVar9 ^ (uVar7_byte * 2 + NEG_CONST + uVar8)) & MASK64
sVar6 = (uVar5 & 7) << 3
uVar7 = ((uVar7_byte << sVar6) & MASK64)
uVar7 = (uVar7 + uVar12) & MASK64
uVar12 = ((uVar12 << 7) | (uVar12 >> 0x39)) & MASK64
uVar12 = (uVar12 + uVar11) & MASK64
uVar13 = (uVar13 ^ uVar7) & MASK64
out_byte = ((uVar12 ^ uVar8 ^ uVar13 ^ uVar9) >> sVar6) & 0xFF
result.append(out_byte)
old_uVar5 = uVar5
uVar5 += 1
if (old_uVar5 & 0x1f) == 31:
uVar14_carry = (uVar14_carry + 1) & MASK64
return bytes(result)
lengths_data = bytes.fromhex("1000000010000000100000001100000010000000100000001000000000000000")
lengths = [struct.unpack_from('<I', lengths_data, i*4)[0] for i in range(7)]
keys_a = bytes.fromhex(
"4dd52c0ce35fc0f464d6e52dc3f9fb3af283ed5f1082f340fe690517c0f9b3e0"
"73a90663e0b424b4c8b809c2744faa57882e340183086b93be8776941de82070"
"6a4c12598317b0e52c4deaa98667e2c9b15ce695dcb0e3ea691570d09443a0a6"
"f8c0ee35b9b7005b373745a3bd89cfc9615f6271f287c199371c1449a2f0e6c0"
"36ffd82a787bcd5507d62d08481f987b40f2a53c363d16c000eb29bfe5cf9f9c"
"1b28ffa0a5709653eb598af1d6dff96985498378318b3a000f0700427f4e07f2"
"b88ad05100d603f05f26318491ca28a84a8e675f38a9c015a3b544efcd60a32f"
)
keys_b = bytes.fromhex(
"d72cf6fca80c9174c4375ed6f8f4df430b77e475d04e8ec285c8731df364b7b5"
"58cefdde65a56dbe57247f1968fb0b70ae8dd382ba449dd549fdad67ee9bdb09"
"90a32f3f9106b2616ba4c9ac5af14a1b34b45b7ce18381a8cdad1b9f9d76a37c"
"58ccadb25d6a6c2d77b4a3ded427b402b925173d87844e440007c41e127e7075"
"cb1941a2670bf7622e72552536f4a3225f2a88a5327397f9700834d3ce6565a2"
"9ad487769df74a975c8445e290ecad469a4adeacd601b097725956a639585c31"
"aa63352aeb8c4d98ca89119e688e6eba14235e4abfbbcdd2db99a0817c27875e"
)
xor_consts = bytes.fromhex(
"c9e3525a584fcc60245de3c9491bf188"
"006376a6e1d1072224c286fa35ef78ca"
"5500aa01524e7c3dad4567326037074b"
"5179005bf169cd2d454f395dbd55c8f3"
"607f416750f44fdbaa588c34a52c4940"
"ee27a0bc00d8c831d80c58fe41fb99e2"
"bebf046b7b008f84e034d2bdfacd271c"
"14d9bb9b7c8100000000000000000000"
)[:119]
xor_consts2 = bytes.fromhex(
"794829be87478b1cbd1f324519c628de"
"5c4bfa607fbe4de3ea8c2f7064556efb"
"1ba7f60b5557d696c72a72e01620688a"
"71e88a70ebd9b9ba35d3fb1bba6afaef"
"e9bd9317cde154ef39b8792cb07527b0"
"133b0ebf8a61ed23c16166647fcd0d6b"
"41e34a973114486e79f7ef25d0f1caa3"
)
perm_data = bytes.fromhex(
"4f000000050000004e0000005000000068000000280000000400000059000000"
"0f0000006c000000150000006b000000630000001a0000000300000060000000"
"0000000013000000670000001b0000003b0000003d0000000700000006000000"
"1100000066000000240000004100000027000000090000004d00000026000000"
"2300000000000000470000001d00000046000000540000006900000001000000"
"40000000310000005d000000530000005e0000005b000000220000000c000000"
"390000003400000000000000300000004c00000002000000120000006f000000"
"44000000100000005a0000005800000016000000420000002f0000002b000000"
"350000006a000000200000002a0000002100000052000000480000006e000000"
"360000003e00000008000000190000000d00000070000000490000004b000000"
"510000001e000000290000005f000000000000004a000000430000002d000000"
"0e0000001c000000330000003f000000450000001f000000570000005c000000"
"380000005600000037000000620000003a000000000000006d00000061000000"
"32000000000000000a0000001400000064000000170000002e0000002c000000"
"18000000250000000b00000055000000650000003c0000000000000000000000"
)
flag = [0] * 113
for slot in range(7):
length = lengths[slot]
key_a = keys_a[slot*32 : slot*32+32]
key_b = keys_b[slot*32 : slot*32+32]
str_a = b"lane-a" + bytes([slot])
str_b = b"lane-b" + bytes([slot])
prng1 = prng(key_a, str_a, length)
prng2 = prng(key_b, str_b, length)
bVar47 = slot ^ length ^ 0x41
uVar52 = slot * 3
uVar55 = slot + length
output_bytes = bytearray(length)
for lVar42 in range(length):
uVar55 -= 1
# CRITICAL: capture uVar52 BEFORE increment (matches C code)
uVar39 = uVar52
uVar52 = uVar52 + 7
pos2 = uVar55 % length
pos1 = uVar39 % length
xor_const = xor_consts[slot * 0x11 + lVar42]
bVar45 = (prng2[pos2] ^ bVar47 ^ xor_const ^ prng1[pos1]) & 0xFF
shift = ((slot + lVar42) % 7) + 1
rotated = ((bVar45 >> shift) | (bVar45 << (8 - shift))) & 0xFF
match_byte = (slot * 0x11 + 0x2d + (lVar42 + 0x0b) * lVar42) & 0xFF
match_byte ^= xor_consts2[(lVar42 & 0xf) + slot * 0x10]
final_byte = rotated ^ match_byte
output_bytes[lVar42] = final_byte
bVar47 = (xor_const + bVar47 + ((slot * 0x0d) ^ lVar42) + xor_consts2[((lVar42 * 3) & 0xf) + slot * 0x10]) & 0xFF
perm_base = slot * 0x44
for lVar42 in range(length):
dest_idx = struct.unpack_from('<I', perm_data, perm_base + lVar42 * 4)[0]
if dest_idx < 113:
flag[dest_idx] = output_bytes[lVar42]
flag_bytes = bytes(flag)
print('Flag:', bytes(flag))
print('Hex:', flag_bytes.hex())
Flag: b"THEM?!CTF{R3V3R53_3X3CU710N_VM_W17H_MU7471NG_R3G1573R5_4ND_C0N7R0L_FL0W_FL4773N1NG_M4K35_57471C_4N4LY515_P41NFUL}"
Challenge 5: Git-Art (misc, 100pts)
Author: tegeda. Description: “I got this repo from a famous painter, he said to search for the flag where it’s been buried.” — file: Git-art.zip.
Git-art.zip contained a git repository. README.md said “Find the lost painting. The artist left no traces.” Only one reachable commit existed. git fsck --lost-found revealed 12 dangling commits. Ten were “Irrelevant noise 1-10” red herrings. Two commits titled “Update 208-4” led to a chain of roughly 432 commits.
The eureka moment came when I realised the X and Y in “Update X-Y” were pixel coordinates. X ranged 0-208 (209 columns), Y ranged 0-6 (7 rows). 209 × 7 = 1,463 pixels. Each commit was one “on” pixel in a bitmap. The chain of ~432 commits represented the lit pixels. Extracting all coordinates and rendering the bitmap spelled out ASCII text:
import subprocess
import os
import re
def get_all_commits(repo_path):
os.chdir(repo_path)
result = subprocess.run(
["git", "rev-list", "--all"],
capture_output=True, text=True
)
return result.stdout.strip().split()
def parse_pixel(msg):
m = re.match(r'Update (\d+)-(\d+)', msg.strip())
if m:
return int(m.group(1)), int(m.group(2))
return None
commits = get_all_commits(".")
pixels = {}
for commit in commits:
msg = subprocess.run(
["git", "log", "--format=%s", "-1", commit],
capture_output=True, text=True
).stdout.strip()
coord = parse_pixel(msg)
if coord:
pixels[coord] = True
width = max(x for x, y in pixels) + 1
height = max(y for x, y in pixels) + 1
print(f"Dimensions: {width}x{height}")
for y in range(height):
line = ""
for x in range(width):
line += "#" if pixels.get((x, y)) else " "
print(line)
# # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # #
# # # # # ##### # # # # # # # # # # # # ##### # # # # #
# # # # # # # ## # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # #
# # # # # # # ## # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # #
The ASCII art read “THAT IS A GOOD ASCII ART”: THEM?!CTF{THAT_IS_A_GOOD_ASCII_ART}. The “artist left no traces” hint was perfectly literal — the painting was never on any branch. All commits were orphaned and dangling, only discoverable via git fsck. The 10 “Irrelevant noise” commits were a nice red herring to waste time.
Challenge 6: Some Play’s Libretto (misc, 100pts)
Author: smartfella. Description: “Convert to leetspeak before submitting flag” — file: score.txt.
score.txt was a roughly 15KB theatrical play script. Caesar cipher ROT6 decoded the character names: “Ligyi” became “ROMEO”, “Dofcyn” became “JULIET”, “Nby” became “The”. Roman numerals were also Caesar-shifted: C=I, CC=II, CCC=III, CP=IV.
The script had 24 scenes with choose-your-own-adventure branching. Scene XXIII “The Revelation” contained 33 sections delimited by “Thou art nothing!” / “Speak thy mind!”. Each section had noun groups with adjective counts (0-6). Unique adjective counts set bits in a 7-bit mask forming an ASCII character. Groups containing “thyself” were excluded from the count.
The decoded message was “bro might actually be shakespeare” — a reference to the Shakespeare authorship question. The leetspeak mapping used 7 substitutions: s->5, o->0, e->3, a->4, t->7, g->6, i->1.
The decode script:
#!/usr/bin/env python3
"""
solve.py -- Self-contained solution for "Some Play Libretto" CTF challenge.
"""
import os
import re
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SCORE_PATH = os.path.join(SCRIPT_DIR, "score.txt")
LEET_MAP = str.maketrans({
"s": "5", "o": "0", "e": "3",
"a": "4", "t": "7", "g": "6", "i": "1",
})
def caesar_decode(text: str, shift: int = 6) -> str:
out = []
for ch in text:
if "a" <= ch <= "z":
out.append(chr((ord(ch) - ord("a") + shift) % 26 + ord("a")))
elif "A" <= ch <= "Z":
out.append(chr((ord(ch) - ord("A") + shift) % 26 + ord("A")))
else:
out.append(ch)
return "".join(out)
def strip_determiner(text: str) -> str:
t = text.strip()
if t.startswith("the sum of "):
t = t[11:]
if t.startswith("an "):
t = t[3:]
elif t.startswith("a "):
t = t[2:]
return t.strip()
def extract_counts(line: str) -> set[int]:
counts: set[int] = set()
line = re.sub(r"(?i)^thou\s+art\s+", "", line.strip()).rstrip("!.").strip()
if not line or "nothing" in line.lower():
return counts
clauses = re.split(r"\s+and\s+", line)
for clause in clauses:
clause = clause.strip()
if not clause:
continue
clause = strip_determiner(clause)
if not clause:
continue
if re.search(r"\bthyself\b", clause, re.IGNORECASE):
continue
words = re.findall(r"[a-zA-Z]+", clause)
if words:
counts.add(len(words) - 1)
return counts
def main() -> None:
with open(SCORE_PATH, "r") as f:
ciphertext = f.read()
plaintext = caesar_decode(ciphertext, 6)
start = plaintext.find("Scene XXIII: The Revelation")
end = plaintext.find("Scene XXIV: The Exeunt")
if start == -1 or end == -1:
raise RuntimeError("Could not locate Scene XXIII")
scene_23 = plaintext[start:end]
sections = []
for block in scene_23.split("Thou art nothing!"):
idx = block.find("Speak thy mind!")
if idx != -1:
sections.append(block[:idx])
print(f"Found {len(sections)} sections in Scene XXIII\n")
message_chars = []
for i, sec in enumerate(sections, 1):
all_counts: set[int] = set()
for line in sec.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("Juliet:"):
line = line[7:].strip()
if not line:
continue
all_counts |= extract_counts(line)
mask = sum(1 << k for k in all_counts)
message_chars.append(chr(mask))
print(f" Section {i:2d}: counts={sorted(all_counts)}, "
f"mask=0x{mask:02x} ({mask:3d}) = '{chr(mask)}'")
message = "".join(message_chars)
print(f"\nDecoded message: {message}")
leet = message.translate(LEET_MAP).replace(" ", "_")
print(f"Leetspeak: {leet}")
print(f"\nFlag: THEM?!CTF{{{leet}}}")
if __name__ == "__main__":
main()
Decoded message: bro might actually be shakespeare
Flag: THEM?!CTF{br0_m16h7_4c7u4lly_b3_5h4k35p34r3}
Challenge 7: Aviation History (OSINT, 100pts)
Author: smartfella. Description: Project BLACK SHIELD A-12 mission over North Vietnam. Flag format: THEM?!CTF{MISSION_Lastname_Location_Start_End}.
This challenge involved researching historical A-12 reconnaissance missions flown from a specific airbase over North Vietnam during a specific date range. The flag was assembled from the mission codename, the pilot’s last name, the operating location, and the start and end dates of the operation. Opencode + Deepseek V4 Flash was able to perform research for each of the components and piece them together to form the flag.
Snippet of the research:
🕵️ Research Results: A-12 BLACK SHIELD Mission — October 30, 1967
1️⃣ Mission Number: BX6734
- The 18th BLACK SHIELD mission (the famous one with 6 SAMs fired, aircraft hit)
- Confirmed from the CIA "Critique for OXCART Mission Number BX6734" and the CIA report "BLACK SHIELD Reconnaissance Missions, 16 August - 31 December 1967" (BYE-1296/68, Page 25)
- BX6732 was the earlier October 28 mission (1 SAM, no damage)
2️⃣ Pilot's Last Name: Sullivan
- Pilot: Dennis B. Sullivan (CIA civilian, former USAF)
- Awarded the CIA Intelligence Star for this mission
3️⃣ Fragment Location: Lower Right Wing Fillet Area
- Exact wording from CIA's "The Oxcart Story" (McIninch): "...a piece of metal had penetrated the lower right wing fillet area and lodged against the support structure of the wing tank."
- "Fillet area" = the curved fairing where the wing meets the fuselage on the underside
4️⃣ Second Pass Timing (ISO 8601): 04:45:00Z to 04:59:23Z
- Source: CIA document BYE-1296/68, Page 25:
"The second pass was over North Vietnam from approximately 0445:00Z to 0459:23Z."
- First pass: 0343:58Z to approximately 0353:50Z
- Takeoff from Kadena: 0219Z | Landing: 0603Z
THEM?!CTF{BX6734_Sullivan_Lower_Right_Wing_Fillet_Area_04:45:00Z_04:59:23Z}
Challenge 8: Zero Disguise (misc, 100pts)
Author: smartfella.
The challenge file contained a binary steganography payload using 0/00 tokens. The ciphertext was a string of whitespace-separated “0” and “00” tokens. Pairs of tokens encoded individual bits: a “0” prefix followed by a “00” run encoded a 1 bit, while a “00” prefix encoded a 0 bit. The resulting bitstream decoded to readable text.
cipher = r"""0 00 00 000 0 0000 00 0 0 0 00 000 0 000 00 0 0 0 00 0 0 000 00 000 0 0000 00 0 0 0 00 0 0 000 00 0 0 0000000 00 0 0 000 00 00 0 00 00 0000 0 000 00 00 0 0 00 0 0 000 00 00 0 0 00 00 0 00 00 000 0 0 00 0 0 00 00 0 0 0 00 0 0 00 00 0 0 00000000 00 0000 0 000 00 0 0 00000 00 0 0 000 00 00 0 00 00 00 0 0000 00 00 0 0 00 00 0 0 00 0 0 00000 00 0 0 00 00 0 0 00000 00 0 0 0 00 0000 0 00 00 00 0 000 00 0 0 00000 00 0 0 00 00 000 0 000 00 0 0 000 00 0 0 00 00 00 0 00 00 0 0 000 00 00 0 0 00 00 0 00 00 0 0 0 00 00 0 0 00 0 0 0000000 00 000 0 0 00 00 0 00 00 00 0 0000 00 00 0 00 00 00 0 00 00 0000 0 000 00 00 0 0 00 00 0 00 00 00 0 000 00 0 0 00000 00 0 0 00 00 000 0 00 00 0 0 0 00 0 0 0 00 00 0 0 00 0 0 00000000 00 00 0 00000 00 0 0 0 00 000 0 00 00 0 0 0 00 00 0 000 00 00 0 0 00 0 0 000 00 0 0 0 00 000 0 00 00 00 0 0000 00 00 0 0 00 00"""
tokens = cipher.split()
bits = ""
for i in range(0, len(tokens), 2):
prefix = tokens[i]
run = tokens[i + 1]
bit = "1" if prefix == "0" else "0"
bits += bit * len(run)
print("Bit length:", len(bits))
for n in (7, 8):
text = "".join(
chr(int(bits[i:i+n], 2))
for i in range(0, len(bits) - len(bits) % n, n)
)
print(f"\n{n}-bit decode:")
print(text)
THEM?!CTF{chuck_n0rr15_pwn3d_7h3_1nfr4_b3f0r3_1T_st4rt3d}
Conclusion
THEM?!CTF 2026 delivered challenges spanning five decades of computing. I went from decoding a 1970s CHIP-8 state machine to reverse engineering modern PE64 binaries, from digging through 432 git commits to parsing Shakespearean steganography. The GhidraAssistMCP + Opencode workflow performed well — GhidraAssistMCP’s tool access let me pull decompiled code, search for constants, rename variables, and trace cross-references without leaving the agent environment. Parallel subagent execution let Opencode explore multiple functions simultaneously.
The tricky parts came from real reverse engineering: finding state machine ciphers inside CHIP-8 ROMs, reconstructing VM opcodes with mutating register permutation, debugging pre-increment versus post-increment discrepancies in decompiler output. Those required human pattern recognition that no tool automates. Until the next challenge.