$ / 2 min read/rev

nitro

The binary decrypts its own checker at runtime and reuses those decrypted bytes as the flag keystream. Solved statically — the binary never runs, so the anti-debug never fires.

section
CSAW26
event
ctf.csaw.io ↗
category
rev
status
● solved

Summary

The binary decrypts its own secret_check function at runtime and then uses those same decrypted bytes as the keystream for the flag — the code is its own key material. Solved entirely statically; the binary is never executed, so no anti-debug is ever engaged.

Chain

1. Self-modifying code

main() calls mprotect on its own .enccode section to make it RWX, then XOR-decrypts 0x15e bytes at secret_check with an 8-byte repeating key 1337c0debaadf00d (symbol smc_key.0), and calls the result.

Replicating that offline gives the decrypted function body.

2. Password recovery

The decrypted secret_check builds its expected password on the stack one byte at a time as mov BYTE PTR [rbp+disp8], imm8 — opcode c6 45 <disp8> <imm8>.

Regex the decrypted bytes for that pattern, sort by stack displacement, and take the longest run of consecutive slots holding printable values:

slots = sorted({d[0]: v[0] for d, v in re.findall(rb'\xc6\x45(.)(.)', dec, re.S)}.items())

→ n2o_boost

3. Flag keystream

The flag is not stored. It is XORed against a keystream indexed back into the decrypted code:

k_i = (dec[(7*i + 3) % 0x15e] + 5*i + 0x6b) mod 256
flag[i] = enc_flag[i] ^ k_i          # over 0x2f bytes

Note

Because the keystream is derived from the decrypted code, you cannot simply dump the flag at runtime without also reconstructing the SMC step — and reconstructing the SMC step makes running the binary unnecessary.

Solver: ~/ctf/work/nitro/solve.py

## challenge files

26 files · 115 KB
  • nitro
  • nitro.dec
  • nitro.dec_ghidra/
  • nitro.decomp.c
  • nitro_ghidra/
  • patch.py
  • secret.c
  • solve.py