Reverse Engineering / Sea of Secrets CTF 2026
Pirate's Last Algorithm
blackthorn_vault.exe is a 32-bit MinGW console PE (SHA-256 27b3ae11c55316152af02e38629d2028ad1ff77bb20f0233ab5b31e1b1cb2198) that asks for a "Captain's authorization key". It still has…
Flag: HACKITUP{b14ckth0rn_v4u1t_c04st41_4str014b3_m4st3r}
blackthorn_vault.exe is a 32-bit MinGW console PE (SHA-256 27b3ae11c55316152af02e38629d2028ad1ff77bb20f0233ab5b31e1b1cb2198) that asks for a "Captain's authorization key". It still has symbols, which gives away the layout: g_astrolabe_program, g_rune_table, g_vault_ciphertext. The key is checked by a small bytecode VM, then a 32-bit hash, and the hash seeds an LCG that decrypts the flag.
Data locations
.rdata is at VMA 0x404000, file offset 0x1a00, so raw = 0x1a00 + (va - 0x404000).
| Symbol | VA | Content |
|---|---|---|
g_vault_ciphertext | 0x4042a0 | 51 bytes |
g_astrolabe_program | 0x4042e0 | VM code, 12 bytes per instruction (op, a, b as u32) |
g_rune_table | 0x4044a0 | 256-byte S-box |
Key format
main wants a 22-character string with B, T at positions 0 and 1 and - at 2, 7, 12 and 17: BT-XXXX-XXXX-XXXX-XXXX. The four groups are parsed as hex words w0..w3, then mixed before the VM sees them:
M[0] = dx = rol16(w0, 5) ^ 0xA55A;
M[1] = bx = (w1 + 0x1776) & 0xFFFF;
M[2] = si = (dx ^ w2) & 0xFFFF;
M[3] = m3 = (w3 - bx) & 0xFFFF;The VM
Eight 16-bit registers, 16 words of memory, hash initialised to 0x5A17CAFE. Opcodes 0 to 13 are NOP, MOVI, LOAD, STORE, ADD, SUB, XOR, XORR (reg ^= reg), ROL, SBOX, CMP, JNE, HASH (h = h*33 ^ R[a]), ADDR. Anything above 13 halts. The program is four independent checks, each loading one M[i], hashing it, and comparing a derived value:
00 LOAD R0,M0 01 HASH R0 02 XOR R0,0x1337 03 CMP R0,0x7C4B 04 JNE 32
05 LOAD R1,M1 06 HASH R1 07 ROL R1,3 08 LOAD R2,M0 09 XORR R1,R2
10 CMP R1,0x8E19 11 JNE 32
12 LOAD R2,M2 13 HASH R2 14 LOAD R3,M0 15 SBOX R3 16 XORR R2,R3
17 ADD R2,0x4242 18 CMP R2,0xBEEF 19 JNE 32
20 LOAD R3,M3 21 HASH R3 22 LOAD R1,M1 23 XORR R3,R1 24 LOAD R2,M2
25 ROL R2,4 26 ADDR R3,R2 27 CMP R3,0xCAFE 28 JNE 32
29 MOVI R0,1 30 STORE M8,R0 31 HALT
32 MOVI R0,0 33 STORE M8,R0 34 MOVI R0,0xDEAD 35 HASH R0 36 HALTAny failed compare jumps to 32, which stores 0 to M8.
Solving
Each constraint is directly invertible, in order:
dx = 0x7C4B ^ 0x1337 = 0x6F7C
bx = ror16(0x8E19 ^ dx, 3) = 0xBC2C
si = 0xBEEF - 0x4242 - rune[0x7C] = 0x7C28 (rune[0x7C] = 0x85)
m3 = (0xCAFE - rol16(si,4)) ^ bx = 0xB45BUndo the preprocessing:
w0 = ror16(dx ^ 0xA55A, 5) = 0x3651
w1 = bx - 0x1776 = 0xA4B6
w2 = dx ^ si = 0x1354
w3 = m3 + bx = 0x7087Key: BT-3651-A4B6-1354-7087.
Hash and flag
The four HASH steps give h = ((((0x5A17CAFE*33)^dx)*33^bx)*33^si)*33^m3 = 0xF2A1C0DD, which is also the constant main compares against afterwards, so the solved key passes the final check without further work. That hash seeds the LCG that decrypts the flag:
seed = 0xF2A1C0DD;
for (i = 0; i < 51; i++) {
seed = seed * 0x41C64E6D + 0x3039;
flag[i] = ct[i] ^ (seed >> 16 & 0xFF);
}Solver
data = open("blackthorn_vault.exe", "rb").read()
at = lambda va, n: data[0x1A00 + va - 0x404000:][:n]
rune, ct = at(0x4044A0, 256), at(0x4042A0, 51)
rol = lambda x, n: ((x << n) | (x >> (16 - n))) & 0xFFFF
ror = lambda x, n: ((x >> n) | (x << (16 - n))) & 0xFFFF
dx = 0x7C4B ^ 0x1337
bx = ror(0x8E19 ^ dx, 3)
si = (0xBEEF - 0x4242 - rune[dx & 0xFF]) & 0xFFFF
m3 = ((0xCAFE - rol(si, 4)) & 0xFFFF) ^ bx
w = [ror(dx ^ 0xA55A, 5), (bx - 0x1776) & 0xFFFF, dx ^ si, (m3 + bx) & 0xFFFF]
print("BT-" + "-".join(f"{x:04X}" for x in w))
h = 0x5A17CAFE
for v in (dx, bx, si, m3):
h = ((h * 33) ^ v) & 0xFFFFFFFF
assert h == 0xF2A1C0DD
out = bytearray()
for c in ct:
h = (h * 0x41C64E6D + 0x3039) & 0xFFFFFFFF
out.append(c ^ (h >> 16 & 0xFF))
print(out.decode())