Neven Marić
Hidden Investigations CTF 2026

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.

  • reversing
  • elf
  • substitution-permutation-network
  • anti-debug
  • ptrace
  • pyelftools
  • capstone
  • static-analysis

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:

  1. Length must be exactly 39.
  2. Must start with hiCTF{ (strncmp, 6 bytes, literal @ 0x2290).
  3. Must end with }.
  4. The 32 characters in between (indices 6..37) are read as 16 hex byte-pairs (hexval @ 0x1283 accepts 0-9, a-f, A-F) and decoded into a 16-byte state buffer.

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 always 0 or 1 mod 4, this is always true. Taken on argc + state[0]; failure would exit 0x62.
  • Opaque predicate B @ 0x125c: returns ((n^2 + 1) & 3) == 0. Since n^2+1 mod 4 ∈ {1,2}, this is always false. A true here would trap to “Incorrect.” (exit 0x63).
  • ptrace anti-debug @ 0x1120: ptrace(PTRACE_TRACEME, ...). If a debugger is already attached the call returns -1 with errno == EPERM, which sets a flag that XORs key[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:

  1. AddRoundKey — state[j] = (state[j] + key[r][j]) & 0xff
  2. S-box substitution — state[i] = sbox[state[i]]
  3. Permutation — new[perm[m]] = state[m], then state = 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 .rodata offset 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 flagRevealhiCTF{6e305f6a7461675f6e65656465642121}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.