Secondhand
Secondhand
Platform: Cyber Apocalypse CTF 2026 | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: mid_instruction_jump, computed_goto_dispatch, self_decoding_shellcode, ptrace_traceme_antidebug, tracerpid_check, fnv_hash_decoy, splitmix64_keystream, unicorn_emulation_oracle, invertible_bijection_cipher, modular_inverse_transform_inversion, rolling_key_stream_inversion
Summary
Task: not-stripped x86-64 crackme with three nested layers of misdirection (a rolling-key check, a 16-byte SIMD cipher, and a self-decoding shellcode path) plus ptrace anti-debug. Solution: identify the len-8 branch as the real path, decode the mmap'd shellcode transform X(pw), exploit a computed-goto that jumps mid-instruction into a movabs to satisfy a magic-constant compare, invert X with a modular inverse, then splitmix64-decrypt the flag.
Secondhand — Cyber Apocalypse CTF 2026
Description
This mechanism skipped inspection. It runs perfectly — every tolerance dead on, which is exactly the problem. Something got built into this one that was never meant to survive an inspection.
Runtime decoy strings printed by the binary:
- "This is a rough cut. What is playing is the version we agreed to air, not the version that happened. The honest take got cut. It is still in here, it is just not the one running."
- "Facilities, second floor printer... it only feeds clean if you start the page from the inner crease, not from the top."
- "That is the line we cut into the trailer. It tested well. It is not in the film." (failure message)
- "There is a camera in the room. Cutting to the version where everyone behaves."
A not-stripped x86-64 crackme reads an input line and validates it. The prose and the runtime strings are the load-bearing hints: the "rough cut" checks are decoys; the real logic is "the one running", reachable only by entering "from the inner crease, not from the top" — i.e. a mid-instruction entry point.
The binary layers three nested decoys, each mapped to one of the narrative hints:
| Hint | Trick |
|---|---|
| "the version we agreed to air, not the one running" | b_roll rolling-key check (decoy flag) |
| "the honest take got cut, still in here" | 16-byte SIMD cipher (decoy, non-printable preimage) |
| "the one running" / "feed from the inner crease, not the top" | len-8 path, mid-instruction computed-goto dispatch |
| "static read as noise" / "the rotor pass" | splitmix64 keystream that decrypts the real flag |
| |
Analysis
Recon
file: ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped, GCC Ubuntu 13.3.0, ~14 KB.- Two custom symbols:
b_roll(0x401370) andthreat_level_midnight(0x4014f0) — the latter is an The Office reference. - Strings hint: "editing log: rolling key looks random enough..." and "the rotor pass is what makes the static read as noise, leave it in".
- Anti-debug:
ptrace(PTRACE_TRACEME)plus/proc/self/statusTracerPidchecks (helpers at 0x401330 / 0x401470 / 0x401490). Running under Docker needs--cap-add=SYS_PTRACE.
main flow (0x401100)
- Reads a line via an
fgetswrapper (b_roll+0x70= 0x4013e0), strips the trailing\n, returns lengthL. - If
L == 8→ callsthreat_level_midnight+0x550(0x401a40) — the REAL path. - Else → ptrace check, then
b_roll(input)must return 0, thenthreat_level_midnight(input, L)requiringL == 16— a decoy path.
The branch on input length is the first tell: the "normal" 16-byte path is elaborate but is designed to be a dead end; the 8-byte path is the honest take.
Decoy #1 — b_roll (0x401370), the "rough cut"
Requires strlen(input) == 28. For i in 0..27:
si = 31*i(accumulates+0x1feach step)- rolling key
cxstarts at0x5aand+= input[i]each step al = rol((input[i] + si) & 0xff, 3) ^ (cx & 0xff), compared to bytes at 0x402040.
The rolling key depends only on previous bytes, so it inverts sequentially. The recovered string is a decoy flag HTB{secondhand_rough_cut_v3} — but in main, if b_roll returns 1 (i.e. the input matches) the program takes the FAIL branch. This is "the version we agreed to air, not the version that happened."
python read-only • copy alloweddef rorb(v, n): v &= 0xff; n &= 7; return ((v >> n) | (v << (8-n))) & 0xff target = bytes.fromhex("...") # 28 bytes at 0x402040 out = bytearray(); cx = 0x5a for i in range(28): al = rorb(target[i] ^ (cx & 0xff), 3) # undo rol(...,3) ^ key ch = (al - 31*i) & 0xff # undo + si out.append(ch); cx = (cx + ch) & 0xff # rolling key uses recovered byte # out == b"HTB{secondhand_rough_cut_v3}" -> DECOY
Decoy #2 — the 16-byte SIMD cipher (threat_level_midnight, 0x4014f0)
- Builds a 256-byte S-box (SIMD
paddbchain((i)*13-ish)then^ 0x63) and a splitmix64 key table (seed0x9E3779B97F4A7C15, mult0x100000001b3). - 10 rounds of: key-XOR, an S-box-driven permutation+substitution, and a final per-byte transform:
dl ^= (round ^ 0xa5); dl = rolb(dl, r14+idx); esi += dl(the rotate amount grows per round viar14). - Permutation
P = [0,5,10,15,4,9,14,3,8,13,2,7,12,1,6,11];perm_out[i] = SBOX[state[P[i]]]. - Then XOR the 16-byte state with the target at 0x402510 =
7ac4119e386df0528b2fd74019a663ecand require all-zero.
The cipher is a full-diffusion bijection — it IS invertible (verified: a Python re-implementation matched a Unicorn forward oracle on 200 random inputs). Inverting the target gives preimage cda50dba01b2cb5578ce21798832f03b, which is non-printable; feeding it triggers the "not in the film" failure line. So this whole 16-byte check is also a decoy. The tell is the same structural pattern as b_roll: main treats a match as failure.
The REAL path — len-8 branch (0x401a40) with a mid-instruction dispatch trick
The 8 input bytes are packed little-endian into pw (u64), then:
Step A — self-decoding shellcode (0x401c50). mmap RWX, decode a 38-byte blob at 0x40255f (rolb 3, xor 0x3c, xor a table at 0x402535), mprotect RX, execute with rdi = pw. Decoded shellcode:
asm read-only • copy allowedmov rax, rdi movabs r11, 0xa5a5a5a5deadbeef xor rax, r11 rol rax, 0x11 ; 17 movabs r11, 0x100000001b3 imul rax, r11 not rax ret
So X(pw) = ~(rol(pw ^ 0xa5a5a5a5deadbeef, 17) * 0x100000001b3) mod 2^64. This becomes r13.
Step B — the "hash" via computed-goto dispatch (0x401d90). The code computes:
r11 = (esi & 0x1f) ^ 0xe
target = 0x401dd0 + r11 ; push fake returns ; jmp target
There is a clean FNV-1a routine at 0x401dd0 that runs only when the offset is 0 (i.e. esi ≡ 14 mod 32). But this path is called with esi = 8, so the offset is (8 & 0x1f) ^ 0xe = 6, and control lands at 0x401dd6 — INSIDE a movabs. The bytes reinterpret as:
asm read-only • copy allowedmovabs r10, 0xb8491337c0debabe cmp rax, r10 ; rax == X(pw) jne <fnv fallback> ret ; returns rax == X(pw)
So for length-8 input the "hash" is: if X(pw) == 0xb8491337c0debabe, return X(pw) (which then equals Y), making the subsequent X == Y comparison in 0x401a40 succeed. This is exactly "the one running" / "feed from the inner crease, not from the top" — you must enter mid-instruction, not at the function top.
Solution
Invert X for the magic constant 0xb8491337c0debabe:
X(pw) = magic
~magic = rol(pw ^ K, 17) * M
pw = ror((~magic) * M^{-1} mod 2^64, 17) ^ K
with K = 0xa5a5a5a5deadbeef, M = 0x100000001b3.
python read-only • copy allowedMASK = (1 << 64) - 1 def rol(v, n): v &= MASK; n %= 64; return ((v << n) | (v >> (64 - n))) & MASK def ror(v, n): v &= MASK; n %= 64; return ((v >> n) | (v << (64 - n))) & MASK K = 0xa5a5a5a5deadbeef M = 0x100000001b3 magic = 0xb8491337c0debabe Minv = pow(M, -1, 1 << 64) pw = ror((~magic & MASK) * Minv & MASK, 17) ^ K # pw = 0x28382f8d1780680f -> LE bytes: 0f 68 80 17 8d 2f 38 28 (win8.bin)
On success (0x401ab0) the binary prints H4D5_UP_Y0U_M4D3_IT, then decrypts a 21-byte blob at 0x402520 with a splitmix64 keystream seeded by pw (constants 0x9E3779B97F4A7C15, 0xBF58476D1CE4E5B9, 0x94D049BB133111EB) XORed against the ciphertext — the "rotor pass" that "makes the static read as noise":
python read-only • copy allowedGOLD, C1, C2 = 0x9E3779B97F4A7C15, 0xBF58476D1CE4E5B9, 0x94D049BB133111EB def splitmix_next(state): state = (state + GOLD) & MASK; z = state z = ((z >> 30) ^ z) * C1 & MASK z = ((z >> 27) ^ z) * C2 & MASK return state, (z ^ (z >> 31)) & MASK enc = bytes.fromhex("55dd4eb0a2b16c88e3a2d4224a1b250eb720db0bfa") # 21 bytes @0x402520 ks, st = bytearray(), pw while len(ks) < len(enc): st, out = splitmix_next(st); ks += out.to_bytes(8, "little") flag = bytes(a ^ b for a, b in zip(enc, ks)) # HTB{REDACTED}
Verification
Ran the real ELF in Docker (--cap-add=SYS_PTRACE to satisfy the ptrace anti-debug):
bash read-only • copy allowedcat win8.bin | docker run --rm -i -v "$PWD":/w -w /w \ --platform linux/amd64 --cap-add=SYS_PTRACE ubuntu:22.04 ./secondhand # H4D5_UP_Y0U_M4D3_IT # HTB{REDACTED}
win8.bin = the 8-byte input 0f 68 80 17 8d 2f 38 28 (hex 0f6880178d2f3828).