$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: a stripped x86-64 ELF that refuses to run without a GPU, an exact hostname, and a data.bin blob, hiding an encrypted payload. Solution: reimplement the embedded SPIR-V XXTEA keystream to recover the inner ELF, then invert its sbox + AVX2 reversal + per-byte rotation + Salsa20 transform using memory dumped from a qemu-user gdbstub.
The binary is dead on arrival. Or is it?
Files: challenge (stripped x86-64 ELF, Rust toolchain, Vulkan imports) and data.bin (1028 bytes).
English summary: the provided binary is a loader, not the real challenge. Run it on a normal
machine and it exits immediately — it "dies". The actual flag checker is an encrypted payload
embedded inside challenge, resurrected only under very specific conditions. The goal is to
recover that inner payload, reverse its flag checker, and invert the check to obtain the flag
(format Securinets{...}).
"Revenant" = one who returns from the dead. Combined with "dead on arrival. Or is it?", the title tells you the outer binary is a resurrection mechanism: it decrypts and executes a hidden second stage. This framed the whole approach — the outer binary is a puzzle to be unwrapped, not a checker to be solved directly.
Two planted flags exist and both are rejected by the real checker:
.note.securinets (offset 0x56a0) contains a literal Securinets{...} decoy.data.bin[0x200:...] contains another literal decoy flag. Only data.bin[0:512] is ever read
by the program, so this decoy is never even used.The inner ELF also embeds prompt-injection-style honeypot text (fake "notes" and a "register here" URL). Treated strictly as untrusted data and ignored.
Static analysis with objdump shows challenge opens /proc/self/exe, reads its own
.stab and .note.gnu-build-id sections.
.stab bytes are XORed with 0x5a to yield the ASCII string blackkad. The program calls
uname() and requires nodename[:8] == "blackkad"; otherwise it takes the "dead on arrival"
exit path.data.bin[0:512] and computes a splitmix64-flavoured hash over data.bin[0:256].
The hash is executed as a 42-byte JIT shellcode blob decrypted into an RWX page; the loop is
rax = ror(rax,1); rax ^= byte; rax += 0xbf58476d1ce4e5b9.SHA256(hostname8 ‖ (shellhash ^ buildid[4:12]) ‖ data.bin[0xa0:0xb0]),
computed with an inlined SHA-256 (standard K constants).So the "death" has three causes stacked together: no GPU/Vulkan, wrong hostname, and a
key derived from a hash of data.bin. All three are reproducible offline once the shader is
understood.
The .rodata section embeds a SPIR-V compute module — recognizable by the GLSL.std.450
opcode string, the main entry point, the gl_GlobalInvocationID builtin, and the buffer
member names key, iv0, iv1, n_blocks, PtBuf, CtBuf.
Carve the module by locating the SPIR-V magic 03 02 23 07, then run spirv-val / spirv-dis
on it. The disassembly shows an XXTEA-style keystream XOR over 64-bit blocks:
v0 = iv0 + idx; v1 = iv1; s = 0;
for 32 rounds:
v0 += ((((v1 << 4) ^ (v1 >> 5)) + v1) ^ (s + key[s & 3]));
s += 0x9E3779B9;
v1 += ((((v0 << 4) ^ (v0 >> 5)) + v0) ^ (s + key[(s >> 11) & 3]));
pt[2*idx] = ct[2*idx] ^ v0;
pt[2*idx+1] = ct[2*idx+1] ^ v1;
The ciphertext is the file's appended tail: a footer u64 = 0xc4990 gives the length, and the
ciphertext occupies challenge[0x5ec8 : 0xca858].
Precedence trap: SPIR-V computes v += (A ^ B), not (v + A) ^ B. Getting this wrong
produces garbage — this was the single most common failure mode when reimplementing.
Reimplementing the key derivation and the XXTEA keystream in Python decrypts the tail into a
blob starting with \x7fELF — a 805264-byte static ELF (inner_elf). The outer binary checks
exactly this: if the decrypted blob begins with \x7fELF, it writes it to a memfd_create
descriptor and execves it. That is the revenant — the payload returning from the dead.
inner_elf's main prints flag: , reads up to 0x100 bytes, and calls a verify function whose
pointer is installed by an .init_array constructor.
Anti-debug. The constructor reads /proc/self/status for TracerPid, scans for gdb,
radare2, ltrace, strace, and valgrind, and sets a "dead" byte. If that byte is non-zero,
main prints still dead. and exits. There is also an rdtsc timing check around the final
memcmp. To run the checker locally, patch the anti-debug routine to xor eax, eax; ret and
clear the "dead" byte in .data.
Verification pipeline (requires input length 0x4c = 76, consistent with
Securinets{<64 hex>}):
out[i] = sbox[input[i]], where sbox is a 256-byte table built from .rodata constants.vperm2i128 + vpshufb (masks 0f0e0d... and
03020100 80...). Reimplementing the lane-crossing semantics shows the shuffle is simply a
full 76-byte reversal.final[j] = rotl8(out[P(j)], (j % 7) + 1), where P is the reversal permutation and the
rotation count is (j % 7) + 1.final ^= keystream — Salsa20/20 with a key from .rodata and a zero nonce/counter.memcmp(result, expected@0x4c3d20, 76).#!/usr/bin/env python3 import struct, hashlib CH = open('challenge','rb').read() DATA = open('data.bin','rb').read() BUILDID = bytes.fromhex('<32 hex digits of .note.gnu-build-id>') FOOT, CTLEN = 0x5ec8, 0xc4990 CT = CH[FOOT:FOOT+CTLEN] M = 0xFFFFFFFF def shellhash(buf): rax = 0 r8 = 0xbf58476d1ce4e5b9 for i in range(0x100): rax = ((rax >> 1) | (rax << 63)) & 0xFFFFFFFFFFFFFFFF # ror 1 rax ^= buf[i] rax = (rax + r8) & 0xFFFFFFFFFFFFFFFF return rax def key_derive(hostname8): sh = shellhash(DATA[0:256]).to_bytes(8, 'little') X = bytes(a ^ b for a, b in zip(sh, BUILDID[4:12])) msg = hostname8 + X + DATA[0xa0:0xb0] return hashlib.sha256(msg).digest() def keystream_block(key, iv0, iv1, idx): v0, v1, s = (iv0 + idx) & M, iv1 & M, 0 for _ in range(32): A = ((((v1 << 4) & M) ^ (v1 >> 5)) + v1) & M v0 = (v0 + (A ^ ((s + key[s & 3]) & M))) & M # NOTE: v += (A ^ B) s = (s + 0x9E3779B9) & M A2 = ((((v0 << 4) & M) ^ (v0 >> 5)) + v0) & M v1 = (v1 + (A2 ^ ((s + key[(s >> 11) & 3]) & M))) & M return v0, v1 dg = key_derive(b'blackkad') key = list(struct.unpack('<4I', dg[0:16])) iv0, iv1 = struct.unpack('<2I', dg[16:24]) out = bytearray(len(CT)) for idx in range(len(CT) // 8): v0, v1 = keystream_block(key, iv0, iv1, idx) a = struct.unpack_from('<I', CT, idx*8)[0] b = struct.unpack_from('<I', CT, idx*8+4)[0] struct.pack_into('<I', out, idx*8, a ^ v0) struct.pack_into('<I', out, idx*8+4, b ^ v1) assert bytes(out[:4]) == b'\x7fELF' open('inner_elf', 'wb').write(bytes(out))
Unicorn has no AVX2 and ptrace is unavailable under qemu-user, so the reliable path is to
run the patched checker under the qemu gdbstub and attach gdb:
qemu-x86_64-static -g 1234 ./inner_revived # in one shell gdb -q -ex 'target remote :1234' ./inner_revived
Set breakpoints just before and just after the keystream XOR loop, feed a known input, and dump:
final — the transform output for that known input,B = final ^ keystream,expected — the memcmp target at 0x4c3d20,sbox — the 256-byte table.Then keystream = B ^ final and target = expected ^ keystream.
The whole pipeline is byte-wise invertible:
def rotl8(x, n): return ((x << n) | (x >> (8 - n))) & 0xff def rotr8(x, n): return ((x >> n) | (x << (8 - n))) & 0xff # P = the 76-byte reversal permutation recovered from the AVX2 shuffle # target = expected ^ keystream (both dumped from the gdbstub) sinv = [0] * 256 for i, v in enumerate(SBOX): sinv[v] = i flagbytes = [0] * 76 for j in range(76): v = rotr8(target[j], (j % 7) + 1) flagbytes[P[j]] = sinv[v] print(bytes(flagbytes)) # Securinets{REDACTED}
The recovered 76-byte string, fed to the patched checker, prints [+] correct!, confirming the
full chain. Its format is Securinets{<64 lowercase hex>}.
Use this approach when:
vkCreateInstance / vkCreateComputePipelines / vkCmdDispatch and
.rodata contains GLSL.std.450 or gl_GlobalInvocationID — the real crypto is an
embedded SPIR-V compute shader, not x86 code.uname() hostname comparison and a
string XOR-decoded out of an unusual section such as .stab.u64 equals the blob length — a classic
loader-plus-ciphertext layout; look for a memfd_create + execve pair that runs the
decrypted output.vperm2i128 / vpshufb with constant masks: reimplement the lane
semantics, don't guess — the shuffle often collapses to a simple permutation (here, a full
reversal).TracerPid / gdb / strace / valgrind string scans plus an rdtsc delta check mark an
anti-debug routine: patch it to xor eax, eax; ret and clear the "dead" flag byte.ptrace is blocked under qemu-user: use qemu-x86_64-static -g <port> and attach gdb to the
gdbstub to dump intermediates (keystream, expected, sbox) directly from memory.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar