Reverse Engineering / Sea of Secrets CTF 2026
Project Black Pearl: The Cursed Engine of Elias Blackthorn
black_pearl.exe is a stripped PE32+ console binary (MinGW GCC 14.1.0), objdump -d -M intel is enough, no packing tricks. sea_chart_fragment.txt is flavor text that explicitly admits it…
Reverse Engineering, Hard, 300 pts
FLAG{black_pearl_c4pt_7b9d_2e4f_8a1c_6d30_5f81_e92a_b}
black_pearl.exe is a stripped PE32+ console binary (MinGW GCC 14.1.0), objdump -d -M intel is enough, no packing tricks. sea_chart_fragment.txt is flavor text that explicitly admits it plants wrong keys to punish naive guessing (d34d_m4n5_ch35..., c4pt_bl4ck_b0n3..., KEY=d34d_b33f_...) — none of them are real.
main reads argv[1], trims it, and compares the first 17 bytes against an XOR-0x5d obfuscated string at 0x14000a630:
1b 11 1c 1a 26 3f 31 3c 3e 36 02 2d 38 3c 2f 31 02 ^ 0x5d -> "FLAG{black_pearl_"Matching that exact prefix is the trap: it diverts into a dead buffer that can never pass. The real path needs the trimmed input to not match it and to be exactly 36 characters, split into three 12-byte tokens that chain through four stages: stage1(token1) -> key1, stage2(token2, key1) -> vmres, stage3(token3, vmres) -> fres, stage4(key1, vmres, fres) -> pass/fail.
Stage 1 (0x140001440) hashes token1 as three 4-byte blocks with 64-bit FNV-1a, each byte XORed with 0x5a first. The comparison uses the accumulator before the final multiply, which turns each 4-byte block into a 3-byte brute force plus one directly solved byte. Unique solution: token1 = "c4pt_7b9d_2e", giving a fixed key1 = 0x21cb4b798cfbc229 on the true branch (the other branch, matching a different pair of hash targets, is the "Meridian 1 & 2 verified" decoy that dead-ends).
Stage 2 (0x1400019b0) decrypts a 218-byte blob at 0x14000a740 using only key1 (code[i] = blob[i] ^ (i*0x73 mod 256) ^ key1_byte[i mod 8]) into a small bytecode program for a 6-register VM. The program runs token2 through an S-box mix and a rotate/add chain for the first 6 bytes, a different mix+rotate+xor chain for the last 6, then two mulmods (moduli 65521 and 65511) that collapse the result to ~32 bits of real entropy. That collapse is the reason token2 isn't unique: the VM is non-injective, so a 40-bit-ish hex token has many pre-images.
Stage 3 (0x140001bc0) reads token3 as three little-endian dwords and runs an 8-round Feistel keyed by key1-derived round constants XORed with the first 8 Blowfish pi constants (243f6a88 85a308d3 ... at 0x14000a960). It's trivially invertible, and since vmres only has ~32 bits of real freedom (from stage 2's collapse), all (x,y) pairs were brute-forced, the Feistel inverted, and the result filtered to printable [0-9a-z_]. Exactly one inverse had the decoy-matching _xxxx_xxxx_x shape: token3 = "_5f81_e92a_b".
Token2 was then recovered via meet-in-the-middle against the fixed vmres: the first 6 input bytes only affect r0, the last 6 only affect r1 (with r0 just XORing in r1's updates), and the per-byte update is invertible (7⁻¹ mod 256 = 183). That split turns a ~16^10 search into ~2^40 feasible work and yields 32 token2 values that are all equally valid against the VM — a genuine design flaw (stage 4, the final combiner, only consumes key1/vmres/fres, all identical across the 32 candidates, so nothing downstream can disambiguate them). Only one, token2 = "4f_8a1c_6d30", is the author's intended string; it was confirmed correct against the platform, not derivable from the binary alone.
Key layout (36 chars, _-separated 4-char groups): c4pt_7b9d_2e4f_8a1c_6d30_5f81_e92a_b.
Stage 4 (0x140001cc0, a Blowfish-like block cipher keyed off key1/vmres/fres against a 16-byte target at 0x14000aa30) was never fully reverse engineered — it didn't need to be, since it can't tell the 32 token2 candidates apart.
Note on how this was solved
This writeup's deep stages (VM opcode table, Feistel inversion, meet-in-the-middle token2 search) come from a solver who ran the real binary and its own interpreter/brute-force tools (vm.py, s3.c, mm.c) to iterate and cross-check. My own pass, done without executing the binary (blocked in this environment), independently reached the same architecture by static reading alone — confirmed the FLAG{black_pearl_ prefix as a proven dead branch via direct byte extraction, recovered the exact stage-1 FNV-1a algorithm and its five hash targets, decoded the key1-only-keyed bytecode program, and mapped the full four-stage pipeline — but stopped short of extracting the VM opcode semantics and Feistel/stage-4 internals needed to actually derive token2/token3 or verify the final flag. The flag above is the one confirmed correct against the live platform.