Flag: CDDC2026{mL04dw__Re4d5_0ld__VrEgs__nO7__p3Nd1nG}
Challenge
Three files:
| File | Size | What it is |
|---|---|---|
device |
75 640 B | Stripped Zig ELF64 — a custom VM/coprocessor emulator |
encryptor.fwbin |
1 985 B | Firmware (header magic CDDC + 4 sections) loaded by the VM |
flag.enc |
192 B | Encrypted flag (6 × 32-byte blocks → 6 × 8-byte plaintext = 48 bytes) |
Usage:
./device --firmware encryptor.fwbin -i input.bin -o output.bin
Input must be a multiple of 8 bytes. Each 8-byte block is expanded to 32 bytes of ciphertext.
Whitespace bytes (0x09, 0x0a..0x0d, 0x20) at the very start/end of the input stream are trimmed before block processing.
Recon
Static look at the firmware (encryptor.fwbin):
Header: magic 'CDDC', then 4 (file_offset, load_vaddr) pairs
0x30 → 0x13000 (128 B)
0xb0 → 0x13080 (32 B)
0xd0 → 0x130a0 (128 B)
0x150 → 0x13120 (rest — bit-packed program, 1 649 B)
Section 1 (128 B) decoded as bfloat16 is a clean 8×8 matrix of small integers ∈ {−2, −1, 0, 1, 2, 3}. Section 4 is the program; the first 32 dwords look like an immediate-pool / dispatch table; the rest is bit-packed instructions. Sections 2 and 3 look random.
Black-box probing of enc(input):
- Output is 32 bytes per 8-byte block.
- Bytes at positions {0, 5, 9, 13, 14, 20, 27, 29} of each block are invariant regardless of input → these are the high-byte (sign + exponent) of 8 little-endian float32s dominated by a per-block bias.
- State propagates across blocks (CBC-like): changing block 0 plaintext changes all later ciphertext.
- The function is not XOR-affine in the input bytes — coordinate-descent / SA bottoms out at ~8–22 bit Hamming distance and gets stuck.
Three prior attempts to reverse the bit-packed instruction format from static disassembly alone failed (the decoder uses non-aligned bit-field extraction across word boundaries — tedious to model).
Cracking it with GDB
Sub-1-minute install (kali WSL):
sudo apt install -y gdb
The host disassembly has exactly 8 mulss instructions, all clustered in one function near 0x100eff0. That's the MAC kernel — one mulss per column of an 8-column matrix-vector product. So the entire encryption uses one matrix-vector multiply in float arithmetic.
Trace 1 — find the matrix and the input encoding
Break on the MAC and dump everything reachable from r12 (the VM struct base):
break *0x100eff0
commands
silent
python
import gdb
r12 = int(gdb.parse_and_eval('$r12'))
rsi = int(gdb.parse_and_eval('$rsi'))
rcx = int(gdb.parse_and_eval('$rcx'))
inf = gdb.selected_inferior()
print(f'rsi={rsi} rcx_rel=0x{rcx-r12:x} '
f'mat@rcx={inf.read_memory(rcx,2).tobytes().hex()} '
f'bcast={inf.read_memory(r12+0x698+rsi,2).tobytes().hex()} '
f'acc={inf.read_memory(r12+0x5d8,32).tobytes().hex()}')
end
continue
end
Results for input "AAAAAAAA" (every byte 'A' = 0x41 = 65):
bcastat VM offset+0x698=82 42 82 42 …repeated → bf160x4282= 65.0. So each input byte is cast to a bf16 (an exact float for any byte 0–255) and placed into an 8-element bf16 input vectorx.- The MAC reads matrix entries from
*rcx, wherercxadvances by 16 bytes per outer iteration starting at VM offset+0x618. Row stride 16 B, col stride 2 B, 8 rows × 8 cols. (The agent that first probed this was off-by-2 — they reported the matrix at+0x61a.) - Dumping 128 B at
+0x618and decoding as bf16 yields the integer-valued 8×8 matrix:
M =
[+1 -1 +1 -1 0 -1 0 0]
[ 0 +1 +1 0 -1 -1 0 -1]
[ 0 0 +1 +1 -1 +1 0 0]
[+1 -2 +1 +1 -1 +1 -1 0]
[-1 +2 +1 +1 0 +1 +2 -1]
[ 0 +1 0 0 -2 -1 -2 -1]
[+1 -2 0 -2 +3 -1 +3 +1]
[ 0 -1 -1 +1 +1 0 -1 0]
- After 64 MAC ops (8 cols × 8 rows), the accumulators at
+0x5d8(eight float32s) hold:acc[j] = Σᵢ M[i][j] · float(input_byte[i])For "AAAAAAAA" withbcast = 65for alli,acc = [130, -130, 260, 65, -65, -65, 65, -130]— clean integers, matches a manual matrix-product.
Trace 2 — find what scrambles acc into the output bytes
The accumulator clearly isn't the output byte-for-byte. Searching the disassembly for FP arithmetic shows only those 8 mulss — there is no second matmul. So the scrambler must be integer/byte work.
Catch the write(3, buf, 32) syscall and dump the VM struct; the buffer at r12 + 0x2800 is the actual write source. Setting hardware watchpoints on that buffer revealed it gets filled via the optimized memcpy at 0x1012f50. Backtracking the source of that memcpy showed the bytes are staged on the stack — the construction itself is in the queue-dispatch loop near 0x100ba00, which reads bit-packed instructions and broadcasts/xors bytes.
Rather than fully reverse that dispatch, model it empirically. Per-output-float bit accounting on the difference out_AAAA XOR K_zero (where K_zero = enc(0×8)[:32]) showed:
acc[j] (4 bytes LE float32) → ROR by Rⱼ bits → XOR with K_zero[j*4 : j*4+4] → out[j*4 : j*4+4]
with rotation constants
R = [2, 26, 25, 24, 15, 1, 9, 28]
found by matching (out XOR K_zero) against each candidate ROR(uint32(acc[j]), k) for k = 0…31.
Verification:
def predict(P):
acc = [sum(M[i][j]*P[i] for i in range(8)) for j in range(8)]
out = bytearray(32)
for j in range(8):
bits = struct.unpack('<I', struct.pack('<f', acc[j]))[0]
rot = ror32(bits, R[j])
b = struct.pack('<I', rot)
for k in range(4):
out[j*4+k] = K_zero[j*4+k] ^ b[k]
return bytes(out)
20/20 random 8-byte inputs match device byte-for-byte. ✅
Block chaining
K_zero only works for block 0. For block N > 0, the per-block key K_N depends on the previous plaintexts. The cleanest way to recover K_N is to ask the encryptor: encrypt (P_0 || P_1 || … || P_{N-1} || 0×8) and take output block N.
K_N = enc(decrypted_prefix || 0×8)[N*32 : (N+1)*32]
Decryption
from fractions import Fraction
import struct, subprocess
M = [...] # the 8×8 matrix above
R = [2, 26, 25, 24, 15, 1, 9, 28]
def ror32(x, n): n &= 31; return ((x >> n) | (x << (32-n))) & 0xFFFFFFFF
def rol32(x, n): return ror32(x, (32-n) % 32)
def enc(buf):
open('/tmp/in.bin','wb').write(buf)
subprocess.run(['./device','--firmware','encryptor.fwbin',
'-i','/tmp/in.bin','-o','/tmp/out.bin'], check=True)
return open('/tmp/out.bin','rb').read()
# Exact 8x8 inverse over the rationals.
def mat_inverse(A): # ...standard Gauss–Jordan with Fraction
...
# acc[j] = Σᵢ M[i][j] * P[i] ⇒ acc = Mᵀ · P ⇒ P = (Mᵀ)⁻¹ · acc
MT = [[M[i][j] for i in range(8)] for j in range(8)]
MT_inv = mat_inverse(MT)
def decrypt_block(c32, k32):
acc = []
for j in range(8):
diff_u32 = struct.unpack('<I', bytes(c32[j*4+k] ^ k32[j*4+k] for k in range(4)))[0]
acc_u32 = rol32(diff_u32, R[j]) # undo the ROR
acc.append(struct.unpack('<f', struct.pack('<I', acc_u32))[0])
P = bytearray(8)
for i in range(8):
v = sum(MT_inv[i][j] * Fraction(acc[j]).limit_denominator(10**12) for j in range(8))
P[i] = max(0, min(255, int(round(float(v)))))
return bytes(P)
flag_enc = open('flag.enc','rb').read()
K0 = enc(b'\x00'*8)
plaintext = bytearray()
prev = b''
for n in range(6):
K_n = K0 if n == 0 else enc(prev + b'\x00'*8)[n*32:(n+1)*32]
P_n = decrypt_block(flag_enc[n*32:(n+1)*32], K_n)
plaintext += P_n
prev = bytes(plaintext)
print(plaintext.decode())
Output:
Block 0: P_0 = b'CDDC2026' ✓
Block 1: P_1 = b'{mL04dw_' ✓
Block 2: P_2 = b'_Re4d5_0' ✓
Block 3: P_3 = b'ld__VrEg' ✓
Block 4: P_4 = b's__nO7__' ✓
Block 5: P_5 = b'p3Nd1nG}' ✓
FLAG: CDDC2026{mL04dw__Re4d5_0ld__VrEgs__nO7__p3Nd1nG}
Why the challenge is called "inversion"
The encryption is a single affine map in float-bit space, hidden behind an integer-cast/bf16-quantise input encoding, a per-float byte-rotation, and a per-block XOR mask. The matrix M has small integer entries and is invertible over the rationals — and once you have it, computing M⁻¹ is the whole job. Everything else (the ROR, the XOR with K_N, the block chaining) is reversible by construction once you know the constants.
Lessons / things that didn't work
- Pure black-box hill climb / SA — stuck at ~8 Hamming bits because the rotated XOR mask doesn't expose a monotone gradient byte-wise.
- Static disassembly of the bit-packed instruction stream — three prior attempts all bogged down in modelling the obfuscated bit extraction in the decoder near
0x100b07c..0x100b270. Don't fight bit-packed VMs from static analysis; trace them. - Off-by-two on the matrix base address — early attempts read the matrix at
+0x61a(the col-1 base) instead of+0x618(the col-0 base / row start used byrcx). The resulting matrix looked "almost right" but produced the wrong signs and the wrong finalacc. The fix came from dumpingrcx - r12at the BP. - Assuming each byte of output corresponds bytewise to a fixed acc byte — the LUT model matched only 3 of the 32 output bytes (positions 12, 14, 15 — the right-rotated-by-8-bits float[3]). The right model is per-float ROR, with eight different rotation amounts.
Files
- Decryptor:
/tmp/decrypt.pyon the kali WSL filesystem (the snippet above) - GDB tracing scripts:
C:\Users\wooai\Downloads\trace*.gdb - Working dir:
C:\Users\wooai\Downloads\inversion_extracted\