ratatouille

A 70-byte .recipe section holds the flag, and the nine anti-analysis environment checks are not a defence — they are the key material.

2026.09.19 DefCamp CTF 2026 Quals Reversing
FLAG DCTF{e132475cf5b732c2ba714f028a159d957ace5f790edb629b99387621d03bbc61}

Category: Reverse Engineering / Malware · Difficulty: Medium · Author: thek0der

Description: https://www.youtube.com/watch?v=NgsQ8mVkN8w (Ratatouille trailer — pure flavour text)

1. Handout

README.txt     562 B
ratatouille    5,088,824 B
               sha256 90bea036072598cd34e6f67b2b1ec9ef6f7b9a4220754f3b3ce8087ebc9ab1d5
               ELF 64-bit LSB pie executable, x86-64, dynamically linked,
               interpreter /lib64/ld-linux-x86-64.so.2, BuildID 7842a8b8..., stripped

Rust 1.98 (/home/thek0der/.cargo/registry/... paths survive in .rodata), statically pulling in tokio + hyper + rustls + aws-lc-rs — hence 5 MB for what is a ~300-line program.

The README sets the goal precisely:

It stays dormant on our analysis hosts, but incident-response telemetry says it briefly prepared a secret “signature dish” on the original machine. Reconstruct its activation profile and recover the volatile secret.

and warns the binary has live persistence/collection routines. Everything below is static analysis — the binary is never executed.

2. The one thing that matters: a non-standard section

$ readelf -S ratatouille
...
[14] .rodata       PROGBITS  00000000003e7000  003e7000  00000000000941b6
[15] .recipe       PROGBITS  000000000047b1b6  0047b1b6  0000000000000046   <──
[16] .eh_frame_hdr PROGBITS  000000000047b1fc  ...

.recipe, 0x46 = 70 bytes:

$ objcopy -O binary --only-section=.recipe ratatouille recipe.bin && xxd recipe.bin
00000000: 565c bcc4 fd91 c4b6 1c1a 2114 a9dc 9955  V\........!....U
00000010: 1276 bdd5 a28f 7941 788f 2c0b f054 86cf  .v....yAx.,..T..
00000020: fd6e 8986 24ce c530 c477 371c f1d6 3452  .n..$..0.w7...4R
00000030: 5ef2 a2e4 4ae8 4a6a 73a6 07f0 bde6 4d0b  ^...J.Jjs.....M.
00000040: 8344 42aa fd64                           .DB..d

70 == len("DCTF{" + 64 hex + "}"). That is the flag, encrypted.

3. Program flow

_start → main @ 0x1f050 → … → 0x1c46a
    call   0x125f0        ; build_activation_mask(&profile) -> u16
0x1c46f movzx eax, ax
0x1c472 cmp   eax, 0x1ff  ; ALL nine bits must be set
0x1c477 jne   0x1d488     ; -> "Workstation rejected. Recipe calibration incomplete."
0x1c485 lea   rsi, [rsp+0xb0]   ; &profile
0x1c48d call  0x1d6c0     ; decrypt_recipe(&profile) -> String
0x1c4a8 call  0x127e0     ; is_valid_flag(&str)
0x1c4af je    0x1d4a3     ; -> "Workstation rejected. Pantry integrity failure."
0x1c4c1 call  0x191880    ; print "Calibration accepted. Telemetry buffer armed."
0x1c4f4 call  0x1d650     ; ZEROIZE the decrypted flag

Note the last line: the flag is never printed. It is decrypted, format-checked, and wiped. That is the “volatile secret … briefly prepared” from the README — you can only get it statically.

0x127e0 is the format check, and it hands you the plaintext layout for free:

cmp rsi, 0x46                          ; len == 70
cmp dword [rdi], 'DCTF' / byte [rdi+4], '{'
cmp byte [rdi+0x45], '}'
loop rdi[5 .. 0x45]: must be [0-9a-fA-F]

4. The activation profile (0x125f0) — a 9-bit mask

Each bit is an inlined constant comparison against one field of the profile struct. Because the comparisons are hardcoded immediates in .text, reconstructing the profile needs no VM, no faked environment, no debugger — you just read them off:

bitmaskstruct fieldsourcerequired value
00x001+0x78/+0x80std::env::consts::OSlinux
10x002+0x88/+0x90std::env::consts::ARCHx86_64
20x004+0xa8 (u8)VM/sandbox detectionmust be false — (x-1)&4
30x008+0x08/+0x10$USERremy
40x010+0x20/+0x28gethostname()gusteau
50x020+0x38/+0x40$SHELL/bin/bash
60x040+0x50/+0x58$RATATOUILLE_STATIONauguste-gusteau
70x080+0x68/+0x70file /tmp/.ratatouille/menuconfit-byaldi\n
80x100+0x98, +0xa0/proc/meminfo, cpu countmem >= 0x200000 KiB (2 GiB) and cpus >= 2

The comparisons are done 8 bytes at a time with overlapping loads, e.g. bit 6:

cmp    qword [rdi+0x58], 0xf          ; len == 15
mov    r8, qword [rdi+0x50]
movabs r10, 0x2d65747375677561        ; 'auguste-'
xor    r10, qword [r8]
movabs r11, 0x756165747375672d        ; '-gusteau'
xor    r11, qword [r8+7]              ; overlapping tail load
or     r11, r10
sete   r8b
shl    r8d, 6

Supporting strings in .rodata confirm where each value comes from:

RATATOUILLE_STATION  /tmp/.ratatouille/menu  linux  x86_64  /proc/meminfo
/proc/self/status  /proc/self/task  /proc/cpuinfo  /sys  /dev/kvm  QEMU  qemu  vbox  bogomips

The anti-analysis machinery (QEMU/VirtualBox strings, /dev/kvm, bogomips, /proc/self/*) only feeds bit 2 and never has to be defeated.

5. Key derivation (0x1d6c0 → 0x12840)

The caller repacks seven of the profile fields into a fresh [&str; 7], in an order that is not the struct order — read the movups shuffle carefully, it is the whole ballgame:

0x1d6ca mov    rax, [rsi+0x68]          ; /tmp/.ratatouille/menu contents
0x1d6dd cmp    byte [rax+rdx-1], 0xa    ; if it ends with '\n' ...
0x1d6e2 cmove  rdi, rax
0x1d6f1 cmove  rcx, rdx                 ; ... use len-1 → TRAILING NEWLINE IS TRIMMED
0x1d6f5 movups xmm0, [rsi+0x78] → [rsp+0x08]   ; os
0x1d6f9 movups xmm1, [rsi+0x88] → [rsp+0x18]   ; arch
0x1d700 movups xmm2, [rsi+0x08] → [rsp+0x28]   ; user
0x1d704 movups xmm3, [rsi+0x20] → [rsp+0x38]   ; hostname
0x1d708 movups xmm4, [rsi+0x38] → [rsp+0x48]   ; shell
0x1d70c movups xmm5, [rsi+0x50] → [rsp+0x58]   ; $RATATOUILLE_STATION
0x1d72e mov    [rsp+0x68], rax / [rsp+0x70], rcx  ; menu, trimmed
0x1d738 lea    rdi, [rsp+0x8]
0x1d73d call   0x12840                  ; fnv1a_seed()

0x12840 is FNV-1a-64 (basis 0xcbf29ce484222325, prime 0x100000001b3), 8×-unrolled, over

s0 || 0xff || s1 || 0xff || s2 || 0xff || s3 || 0xff || s4 || 0xff || s5 || 0xff || s6 || 0xff

(the 0xff separator is emitted after every slice, including the last — see the repeated xor rcx,0xff ; imul rcx,rax), finished with

0x12dab movabs rax, 0x72617461746f7569   ; "iuotatar"
0x12db5 xor    rax, rcx
→ seed = 0x21ce941768b41192

6. Keystream (0x1d770) — xorshift64*

The decrypt loop is unrolled ×2; folded back up it is textbook xorshift64* with the top byte of the multiply taken as output:

0x1d770 mov rdi, r14 ; shr rdi, 0xc  ; xor rdi, r14   ; x ^= x >> 12
0x1d77a mov r8,  rdi ; shl r8,  0x19 ; xor r8,  rdi   ; x ^= x << 25
0x1d784 mov rdi, r8  ; shr rdi, 0x1b ; xor rdi, r8    ; x ^= x >> 27
0x1d791 mov r9,  rdi ; imul r9, 0x2545F4914F6CDD1D
0x1d798 shr r9, 0x38                                  ; k = (x * M) >> 56
0x1d79c xor r9b, [rsi + rcx - 1]                      ; plaintext = ct ^ k
...
0x1d7d8 cmp rcx, 0x47 ; jne                           ; 70 bytes

0x2545F4914F6CDD1D appears exactly once in the whole binary, which is a fast way to find this loop if you start from the constant instead of from the section.

7. Solve

M = (1 << 64) - 1
PRIME, BASIS = 0x100000001b3, 0xcbf29ce484222325

fields = [b"linux", b"x86_64", b"remy", b"gusteau", b"/bin/bash",
          b"auguste-gusteau", b"confit-byaldi"]          # note: '\n' trimmed

h = BASIS
for f in fields:
    for c in f:
        h = ((h ^ c) * PRIME) & M
    h = ((h ^ 0xff) * PRIME) & M                          # separator after EVERY field

seed = h ^ 0x72617461746f7569                             # 0x21ce941768b41192

s, out = seed, bytearray()
for c in open("recipe.bin", "rb").read():
    s ^= s >> 12; s &= M
    s ^= (s << 25) & M
    s ^= s >> 27
    out.append(c ^ (((s * 0x2545F4914F6CDD1D) & M) >> 56))

print(bytes(out))
seed = 0x21ce941768b41192
b'DCTF{e132475cf5b732c2ba714f028a159d957ace5f790edb629b99387621d03bbc61}'

The output is self-validating: 70 bytes coming out as DCTF{ + 64 lowercase hex + } cannot happen with a wrong seed, so no VM run is needed to confirm it.

8. Everything else in the binary (not needed for the flag)

For completeness, the “RAT” half that runs after a successful calibration:

  • banner RATatouille Kitchen Telemetry Service v2.7.0 / Calibrating workstation recipe...
  • copies itself to /dev/shm/ratatouille, re-execs under setsid (failed to move RATatouille, Failed to restart RATatouille service.)
  • installs a systemd user unit (WantedBy=multi-user.target) — failed to create user service, failed to start user service
  • browser credential theft — failed to backup browsers
  • exfil over rustls/hyper to https://brev.ro/f… with a JSON body containing "hostname":", "username":" (Failed to send fingerprint data.)

None of it is reachable on an analysis host, and none of it touches the flag.

9. Takeaways

  1. Diff the section headers before anything else. A non-standard PROGBITS section in a stripped Rust blob is the entire challenge; .recipe at exactly 70 bytes gave away both the payload and its plaintext length.
  2. Anti-VM code was a red herring. The environment checks exist to derive the key, not to stop you — every value is an immediate in .text.
  3. Grep for crypto magic constants (0x2545F4914F6CDD1D, 0xcbf29ce484222325, 0x100000001b3). It beat reading a 940k-line disassembly.
  4. Watch the newline trim. The cmove at 0x1d6e2 drops the trailing \n of the /tmp/.ratatouille/menu contents before hashing, even though the mask check requires it to be present. Hash confit-byaldi, not confit-byaldi\n.
#rust#malware#fnv1a#xorshift#static-analysis