Controlling Execution
You own the return address, so now you pick where the program goes next.
What you will learn
- -Redirect a return to a chosen function inside the binary
- -Build a reliable overflow payload with pwntools
- -Pass an argument into the target function by loading rdi
Controlling the return address is worth nothing until you point it somewhere useful. The simplest useful target is a function already sitting in the binary that you were never meant to reach. CTF authors put one there and call it win. Real binaries have their own version: a call to system, an admin routine, a debug handler. The move is the same. Overwrite the saved return address with that function's address and let ret deliver you.
Say the binary has this:
void win() {
system("/bin/sh"); // the function you want to reach
}
You already found the offset last lesson: 72 bytes to the return address. Now you need the address of win. With PIE off it is fixed, so read it straight out of the binary:
$objdump -d ./vuln | grep '<win>:'$nm ./vuln | grep ' win'Build the payload with pwntools
You could echo raw bytes, but the address has null bytes and needs little-endian packing, so do it in Python. pwntools makes this clean and it is the tool every serious writeup uses.
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 72
payload = b'A' * offset
payload += p64(elf.symbols['win']) # p64 packs the address little-endian
p.sendline(payload)
p.interactive() # drop into the shell win() spawned
p64 turns 0x401156 into the byte string \x56\x11\x40\x00\x00\x00\x00\x00, which is how the address must sit in memory on x86-64. Run that and if win calls system("/bin/sh"), you land in an interactive . Type id and you are done.
When the target needs an argument
Half the time the useful function takes a . Maybe it is win(int magic) that only spawns a shell if magic == 0xdeadbeef, or a system call where you supply the command string. You cannot just jump there, because on x86-64 the first argument lives in rdi, not on the , and your overflow put your bytes on the stack.
The fix is a pop rdi ; ret gadget, a tiny two-instruction sequence already inside the binary. You put it on the stack, follow it with the value you want, and it pops that value into rdi before returning onward. This is the first link of return-oriented programming.
$ROPgadget --binary ./vuln | grep 'pop rdi'The stack you are building reads like a to-do list the ret instruction walks through: pop the argument into rdi, then return into your target. Layout it out as offset , then the gadget address, then the argument, then the function address. Each ret hands to the next entry. That is the mental model that carries you into full ROP later.
Redirecting execution is arbitrary code execution by another name. Practice it on your own compiled targets, pwnable.kr, PicoCTF, or a scoped engagement. Doing it to any process you do not own or have written permission to test is a criminal act, no exceptions.
Your target function is win(unsigned magic) and only spawns a shell when magic equals 0xdeadbeef. Your overflow controls the return address. What do you need to set that argument on x86-64?