ReversingHidden Investigations CTF 2026
Cold Circuit
A stripped x86-64 binary validates a flag through 20 rounds of add-key, S-box, and permutation. Static analysis in Python extracts the tables and inverts the cipher without ever executing the binary.
What this one teaches
- Recovering cipher tables from a stripped ELF by noting that .rodata's sh_offset equals sh_addr, so file offsets and virtual addresses coincide.
- Recognising and ignoring anti-analysis: opaque predicates (n²mod4≠2) and a ptrace check that corrupts a round key under a debugger.
- Inverting a substitution-permutation cipher by running the rounds backward — undo permutation, undo S-box, undo add-key.
Task description
A small standalone checker,
check, decides whether a flag is right. That’s the whole interface.
Challenge
A single ~14 KB binary (check) that validates a flag passed via argv[1] or stdin.
ELF 64-bit LSB PIE executable, x86-64, dynamically linked, stripped
No objdump/radare2/WSL available on the host. Static analysis was done entirely in Python using pyelftools for sections/symbols and capstone for disassembly. The binary was never executed — it was only read as data.
Conveniently .rodata has sh_offset == sh_addr == 0x2000, so virtual addresses equal file offsets for all data tables.
Approach
Flag format check — parse_flag @ 0x12d6
The input is first stripped of trailing \r/\n, then validated:
- Length must be exactly 39.
- Must start with
hiCTF{(strncmp, 6 bytes, literal @0x2290). - Must end with
}. - The 32 characters in between (indices 6..37) are read as 16 hex byte-pairs (
hexval@0x1283accepts0-9,a-f,A-F) and decoded into a 16-bytestatebuffer.
So the real secret is the 16-byte value encoded as 32 hex chars.
Anti-analysis
Three checks sit between parsing and the cipher. All are inert on a normal static/clean run:
- Opaque predicate A @
0x1229: returns(n^2 mod 4) != 2. Since a square is always0or1 mod 4, this is always true. Taken onargc + state[0]; failure would exit0x62. - Opaque predicate B @
0x125c: returns((n^2 + 1) & 3) == 0. Sincen^2+1 mod 4 ∈ {1,2}, this is always false. Atruehere would trap to “Incorrect.” (exit0x63). - ptrace anti-debug @
0x1120:ptrace(PTRACE_TRACEME, ...). If a debugger is already attached the call returns-1witherrno == EPERM, which sets a flag that XORskey[0][0]with 1 (@0x15fb), corrupting the first round key so the answer comes out wrong. Under normal execution / static solving this flag is 0 and the key table is used verbatim.
The cipher
After parsing, 320 bytes (20 rounds × 16) of round keys are rep movsq’d from 0x2140 onto the stack. The 16-byte state then goes through 20 rounds, each consisting of:
- AddRoundKey —
state[j] = (state[j] + key[r][j]) & 0xff - S-box substitution —
state[i] = sbox[state[i]] - Permutation —
new[perm[m]] = state[m], thenstate = new
Table locations (file offset == vaddr):
| Table | Offset | Size |
|---|---|---|
| S-box | 0x2020 |
256 (bijection) |
| Permutation | 0x2120 |
16 |
| Round keys | 0x2140 |
320 (20×16) |
| Target ciphertext | 0x2280 |
16 |
Permutation table @ 0x2120:
[10, 8, 4, 14, 6, 7, 3, 0, 9, 5, 1, 13, 15, 11, 12, 2]
Target ciphertext @ 0x2280:
97 67 14 88 0a 50 0d 16 0a 21 f6 95 0e ef 34 81
Acceptance test @ 0x174c: diff = OR over i of (state[i] ^ target[i]). If diff == 0 (state equals target) it prints “Access granted.” and returns 0; otherwise “Incorrect.”.
Inversion
The cipher is fully invertible. Starting from the target bytes, run the rounds backwards (r = 19 .. 0), inverting each step in reverse order:
import sys
path = sys.argv[1]
with open(path, 'rb') as f:
data = f.read()
sbox = list(data[0x2020:0x2020+256])
perm = list(data[0x2120:0x2120+16])
keys = data[0x2140:0x2140+320]
target = list(data[0x2280:0x2280+16])
inv_sbox = [0] * 256
for i, v in enumerate(sbox):
inv_sbox[v] = i
key = [list(keys[r*16:r*16+16]) for r in range(20)]
state = list(target)
for r in range(19, -1, -1):
# undo permutation: forward new[perm[m]] = old[m] => old[m] = new[perm[m]]
state = [state[perm[m]] for m in range(16)]
# undo sbox
state = [inv_sbox[b] for b in state]
# undo addkey
state = [(state[j] - key[r][j]) & 0xff for j in range(16)]
body = bytes(state)
flag = "hiCTF{" + body.hex() + "}"
print("FLAG:", flag)
# forward verification
st = list(body)
for r in range(20):
st = [(st[j] + key[r][j]) & 0xff for j in range(16)]
st = [sbox[b] for b in st]
new = [0] * 16
for m in range(16):
new[perm[m]] = st[m]
st = new
print("forward matches target:", st == target)
This yields the 16-byte plaintext:
6e 30 5f 6a 74 61 67 5f 6e 65 65 64 65 64 21 21
Easter egg vs. the validated flag
The recovered 16 bytes are printable ASCII:
6e305f6a7461675f6e65656465642121 -> "n0_jtag_needed!!"
a nod to the “Cold Circuit” / JTAG theme. However, the checker validates the hex encoding of those bytes inside the braces — parse_flag hex-decodes the body, it does not compare ASCII. The flag that returns Access granted. / goodFlag is therefore the hex form.
Lessons
- When a binary’s
.rodataoffset equals its virtual address, you can read tables directly from the file without loading. - The ptrace anti-debug silently corrupts a round key rather than exiting, so dynamic analysis under a debugger would produce a wrong answer with no visible error.
The flagReveal
hiCTF{6e305f6a7461675f6e65656465642121}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.