← All writeups

CDDC2026

Inversion

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):

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):

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]

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

Files