$ cat writeup.md…
$ cat writeup.md…
GPNCTF 2026
Task: intro reverse engineering ELF that runs the input flag through a 5-stage cooking-themed byte transform pipeline and compares against a hardcoded TARGET. Solution: each stage is reversible (XOR 0xAA, nibble swap, buffer reverse, padding mask), so invert the pipeline in reverse order from TARGET to recover the flag.
I always feel like cooking is such a chore... You have to chop up all your ingredients, cook them for hours and then make the plating look half-decent. But not with this new machine I got! You just have to put in your recipe (weirdly, the interface calls it a flag...) and it will get cooked for you. It's so easy, even someone with no experience in
cookingreverse engineering can do it.
English summary: We are given a 64-bit ELF binary that reads a "recipe" (the flag), runs it through a series of cooking-themed transforms, and checks the result against a hardcoded target. The goal is to find the input flag that "cooks" into the expected output.
The handout autocooker.tar.gz contains a single ELF:
ELF 64-bit LSB executable, x86-64, dynamically linked, GNU/Linux 3.2.0, NOT stripped, 16616 bytes.checksec: Partial RELRO, No canary, NX enabled, No PIE (base 0x400000), not stripped.Because the binary is not stripped, every transform function keeps its descriptive name, which essentially documents the whole algorithm:
check_recipe_length, explain_current_food, salt, fry, trim, mix, taste, main
mainfgets into a 64-byte buffer RECIPE at 0x4040e0 (size 0x40).check_recipe_length: ensures RECIPE[TARGET_LENGTH] == 0 and RECIPE[TARGET_LENGTH-1] != 0 (length sanity check); otherwise it exits with "Your recipe is too complicated or too simple...".RECIPE into the 64-byte FOOD buffer at 0x404120.explain_current_food printing flavor text between stages), then taste compares FOOD to a hardcoded TARGET array and prints "Congratulations, you cooked a delicious plate of food!" on a full match.Each stage iterates i = 0..63 over the 64-byte FOOD buffer:
| Stage | Operation | Property |
|---|---|---|
salt | FOOD[i] ^= GRAIN_OF_SALT (XOR 0xAA) | self-inverse |
fry | FOOD[i] = ((FOOD[i] << 4) | (FOOD[i] >> 4)) & 0xFF (nibble swap) | self-inverse |
trim | for i in TARGET_LENGTH(61)..63: FOOD[i] &= 0x0F | only touches padding bytes 61-63 |
mix | reverse the entire 64-byte buffer (FOOD = FOOD[::-1]) | self-inverse |
taste | require FOOD[i] == TARGET[i] for all i | comparison / exit |
TARGET_LENGTH @ 0x404060 = 61GRAIN_OF_SALT @ 0x404064 = 0xAATARGET (64 bytes) @ 0x404080:0a0a0a0a 7ddfa94c 5f9dfc2c 9db9ec5f d9f9fcee 8fe92e5f 8dff5c5f 8d5ecc5f
3feee9fc 8f5ffe8f bc5ffd5c 3f5ffe1e 3cb95f6c fc99cc5f 3c1dceef 9e4eafde
Every stage is reversible:
salt is XOR — its own inverse.fry is a nibble swap — its own inverse.mix is a buffer reversal — its own inverse.trim only masks the 3 trailing padding bytes (indices 61-63). After mix reverses the buffer, those bytes land outside the 60-character flag, so trim is lossy but irrelevant to recovering the flag.So we invert the whole pipeline by applying the inverse operations in reverse order, starting from TARGET:
mix: reverse the TARGET arrayfry: nibble-swap each bytesalt: XOR each byte with 0xAA#!/usr/bin/env python3 target = [0x0a,0x0a,0x0a,0x0a,0x7d,0xdf,0xa9,0x4c,0x5f,0x9d,0xfc,0x2c,0x9d,0xb9,0xec,0x5f, 0xd9,0xf9,0xfc,0xee,0x8f,0xe9,0x2e,0x5f,0x8d,0xff,0x5c,0x5f,0x8d,0x5e,0xcc,0x5f, 0x3f,0xee,0xe9,0xfc,0x8f,0x5f,0xfe,0x8f,0xbc,0x5f,0xfd,0x5c,0x3f,0x5f,0xfe,0x1e, 0x3c,0xb9,0x5f,0x6c,0xfc,0x99,0xcc,0x5f,0x3c,0x1d,0xce,0xef,0x9e,0x4e,0xaf,0xde] GRAIN = 0xAA nibswap = lambda b: ((b << 4) | (b >> 4)) & 0xFF food = target[::-1] # undo mix (reverse) food = [nibswap(b) for b in food] # undo fry (nibble swap) food = [b ^ GRAIN for b in food] # undo salt (XOR 0xAA) print(bytes(food)) # b'GPNCTF{REDACTED}\n\n\n\n'
The first 60 bytes are the flag (7 for GPNCTF{ + 52 body + }). The trailing bytes are the fgets newline plus padding.
Feeding the recovered flag (plus a newline, padded to 64 bytes) through salt -> fry -> trim -> mix reproduces TARGET exactly, confirming correctness:
def forward(buf): buf = [b ^ GRAIN for b in buf] # salt buf = [nibswap(b) for b in buf] # fry for i in range(61, 64): # trim buf[i] &= 0x0F return buf[::-1] # mix flag = b"GPNCTF{REDACTED}\n" buf = list(flag) + [0] * (64 - len(flag)) assert forward(buf) == target print("verified")
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar