RATatouille
- Category: rev
“We recovered a half-finished telemetry implant from a Michelin inspector’s workstation. 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.”
A 5 MB stripped Rust binary carrying a full implant (reqwest, rustls, tokio, systemd user-service persistence, browser-profile collection). None of that is the challenge. The flag is in a custom ELF section, and the key to it is the machine the implant expected to be running on — so the work is recovering an environment, not an algorithm.
Solution:
1. The flag is a section, and it is exactly flag-sized
diec says Rust 1.98, and the section table has one entry that does not belong to any toolchain:
[14] .rodata PROGBITS 00000000003e7000 3e7000 0941b6
[15] .recipe PROGBITS 000000000047b1b6 47b1b6 000046
[16] .eh_frame_hdr PROGBITS 000000000047b1fc 47b1fc 009814
0x46 is 70 bytes. The stated flag format is DCTF{<64 lowercase hex>} — which is 5 + 64 + 1 = 70 bytes exactly. So .recipe is the flag, encrypted in place, and there is no need to guess what the plaintext looks like.
$ xxd -s 0x47b1b6 -l 32 ratatouille
0047b1b6: 565c bcc4 fd91 c4b6 1c1a 2114 a9dc 9955 V\........!....U
0047b1c6: 1276 bdd5 a28f 7941 788f 2c0b f054 86cf .v....yAx.,..T..
The whole binary references it once, at 0x1d768.
2. The activation profile is spelled out in the gate’s compare immediates
main (sub_12dc0, reached via lang_start from 0x1f050) builds a struct at rsp+0xb0, passes it to sub_125f0, and demands the return value be 0x1ff — nine bits, all set:
1c462: lea rdi,[rsp+0xb0]
1c46a: call 125f0 ; profile gate
1c46f: movzx eax,ax
1c472: cmp eax,0x1ff ; all 9 checks must pass
1c477: jne 1d488 ; -> "Workstation rejected. Recipe calibration incomplete."
1c48d: call 1d6c0 ; -> decrypt .recipe
That gate is the whole prize. It compares each profile field against inline immediates rather than against .rodata strings, which is why grepping the binary for the expected hostname or username finds nothing. Decompiled, bit 4 looks like this:
if *((a0 + 0x28) as *mut i64) != 7 { v5 = 0; }
else {
v5 = ((*((*((a0 + 0x20) as *mut *mut i32) as i64 + 3) as *mut i32) == 0x75616574
&& **((a0 + 0x20) as *mut *mut i32) == 0x74737567) as u16) << 4;
}
Length 7, bytes 0-3 0x74737567 = gust, bytes 3-6 0x75616574 = teau — the field is gusteau. Reading all nine the same way recovers the profile the implant was built for:
| bit | field | required value |
|---|---|---|
| 0 | std::env::consts::OS |
linux |
| 1 | std::env::consts::ARCH |
x86_64 |
| 2 | anti-VM probe | must come back clean |
| 3 | station | remy |
| 4 | user | gusteau |
| 5 | shell | /bin/bash |
| 6 | hostname | auguste-gusteau |
| 7 | menu file | confit-byaldi\n |
| 8 | RAM / CPUs | >= 0x200000 kB and >= 2 |
Bit 2 is the dormancy the prompt advertises — /sys/class/dmi/id/product_name and /proc/cpuinfo matched against vmware, virtualbox, kvm, qemu, xen, bochs, parallels, microsoft hyper-v. It gates execution but contributes nothing to the key.
3. How the pieces connect

Curated from kuna decompile-all --json rather than r2 -A — the profile collection and the entire anti-VM probe are inlined into main, so an automatic call graph draws them as nothing at all. The green path is the only one that prints a flag.
4. The seed is an FNV-1a-64 of seven of those fields
sub_1d6c0 marshals the profile into a compact array of seven &str before hashing, and reorders it while doing so — linux and x86_64 are moved to the front:
1d6ca: mov rax,[rsi+0x68] ; last String: ptr
1d6ce: mov rdx,[rsi+0x70] ; len
1d6dd: cmp BYTE PTR [rax+rdx-1],0xa ; trailing '\n'?
1d6e2: cmove rdi,rax
1d6f1: cmove rcx,rdx ; if so, use len-1
1d6f5: movups xmm0,[rsi+0x78] ; "linux" -> field 1
1d6f9: movups xmm1,[rsi+0x88] ; "x86_64" -> field 2
1d700: movups xmm2,[rsi+0x8] ; station -> field 3
1d73d: call 12840 ; FNV-1a-64
That cmove pair matters: the menu field is read from a file and arrives as confit-byaldi\n, but it is hashed without the newline. sub_12840 is a textbook FNV-1a-64 (basis 0xcbf29ce484222325, prime 0x100000001b3) with one twist — a ^0xff then multiply after every field, including the last, so the seven fields cannot be run together as one string:
12907: xor rcx,0xff
1290e: imul rcx,rax ; separator, repeated after each field
12da0: xor rcx,0xff
12da7: imul rcx,rax
12dab: movabs rax,0x72617461746f7569 ; "ratatoui"
12db5: xor rax,rcx ; seed
5. The keystream is xorshift64*
Back at the single .recipe xref, the loop is unrolled two bytes at a time but is plain xorshift64* with the standard 0x2545F4914F6CDD1D multiplier, taking the top byte of the product:
1d75e: movabs rdx,0x2545f4914f6cdd1d
1d768: lea rsi,[rip+0x45da47] ; 0x47b1b6 -> .recipe
1d770: mov rdi,r14
1d773: shr rdi,0xc
1d777: xor rdi,r14 ; x ^= x >> 12
1d77d: shl r8,0x19
1d781: xor r8,rdi ; x ^= x << 25
1d787: shr rdi,0x1b
1d78b: xor rdi,r8 ; x ^= x >> 27
1d794: imul r9,rdx
1d798: shr r9,0x38 ; (x * M) >> 56
1d79c: xor r9b,BYTE PTR [rcx+rsi-0x1]
1d7d8: cmp rcx,0x47 ; 70 bytes
6. Recover it
Seven fields, seven separators, the "ratatoui" mix, then 70 bytes of keystream:
h = 0xcbf29ce484222325
for f in [b"linux", b"x86_64", b"remy", b"gusteau",
b"/bin/bash", b"auguste-gusteau", b"confit-byaldi"]:
for b in f:
h = ((h ^ b) * 0x100000001b3) & M
h = ((h ^ 0xff) * 0x100000001b3) & M
seed = h ^ 0x72617461746f7569
$ ./solve.py
seed = 0x21ce941768b41192
flag = DCTF{e132475cf5b732c2ba714f028a159d957ace5f790edb629b99387621d03bbc61}
Full script: solve.py
7. The binary confirms its own answer
No need to build the fake workstation to check this. sub_127e0 — the call at 0x1c4a8 that decides between “Calibration accepted” and “Pantry integrity failure” — is a validator for the decrypted plaintext:
if ((a1 == 0x46 && (a0[1] as u8 == b'{' && *a0 == 0x46544344))
&& (*((a0 as i64 + 0x45) as *mut u8) == b'}')) {
// then: all 64 bytes at +5 must be [0-9a-fA-F]
Length 70, 0x46544344 = DCTF, { at offset 4, } at offset 0x45, 64 hex digits between. The recovered plaintext satisfies every one of those, and on failure sub_1d650 zeroes the buffer before the rejection message — the “volatile secret” is wiped rather than leaked. A wrong 64-bit seed producing 70 bytes that pass that check by chance is not a thing that happens.
Flag: DCTF{e132475cf5b732c2ba714f028a159d957ace5f790edb629b99387621d03bbc61}
Takeaways
- A non-standard ELF section name is worth more than any string dump.
.recipewas 70 bytes in a 5 MB binary, had exactly one cross-reference, and matched the stated flag length exactly — that alone reduced the challenge to “find the seed”. - When a check compares against inline immediates rather than
.rodatastrings, the expected values are invisible tostringsandgrep. Heregrep SHELLandgrep HOSTNAMEboth returned nothing while the binary was demanding/bin/bashandauguste-gusteau; the values were sitting incmpoperands. Read the comparison, not the data section. - Environment-keyed decryption doesn’t need the environment reproduced. The profile is only an input to a hash — deriving the seven strings statically is strictly easier than building a VM named
auguste-gusteauwith a user calledgusteau, and it sidesteps the anti-VM check entirely. - Look for the target’s own validator before building one.
sub_127e0made the solve self-verifying, which is worth more than any amount of “this looks right”.