Reverse Engineering / Sea of Secrets CTF 2026

Pirate's Last Algorithm

01 Nov 2026 · 4 min read

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).

SymbolVAContent
g_vault_ciphertext0x4042a051 bytes
g_astrolabe_program0x4042e0VM code, 12 bytes per instruction (op, a, b as u32)
g_rune_table0x4044a0256-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:

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

text
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 HALT

Any failed compare jumps to 32, which stores 0 to M8.

Solving

Each constraint is directly invertible, in order:

text
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  = 0xB45B

Undo the preprocessing:

text
w0 = ror16(dx ^ 0xA55A, 5) = 0x3651
w1 = bx - 0x1776           = 0xA4B6
w2 = dx ^ si               = 0x1354
w3 = m3 + bx               = 0x7087

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

c
seed = 0xF2A1C0DD;
for (i = 0; i < 51; i++) {
    seed = seed * 0x41C64E6D + 0x3039;
    flag[i] = ct[i] ^ (seed >> 16 & 0xFF);
}

Solver

python
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())