Stack Overflows and Return Addresses
Start with the program
Section titled “Start with the program”In the previous lesson, we followed a function call all the way back to its caller. With an ordinary input, the program below does exactly what we’d expect from that explanation.
There is something odd about it, though: it has a function named win that nothing in main or read_label calls. It only prints a message and exits, which is a fairly modest idea of winning, but having a function with no apparent way to reach it gives us something interesting to investigate. First, let’s run the program normally.
#include <stdio.h>#include <stdlib.h>#include <unistd.h>
void win(void){ puts("You changed the plan."); exit(0);}
void read_label(void){ char label[16]; read(STDIN_FILENO, label, 64);}
int main(void){ puts("Enter a label:"); read_label(); puts("Returned to main."); return 0;}Create a fresh working directory, save the program as stack-path.c, and build it in the Ubuntu terminal:
gcc -g -O0 -fno-omit-frame-pointer -fno-stack-protector -no-pie -z noexecstack stack-path.c -o stack-pathGCC may complain about the call to read. That warning is expected for this example, so keep it in mind as we look at the program. We can leave the details of the compiler flags for later in the course.
This walkthrough was checked with GCC 13.3.0 on Ubuntu 24.04 x86-64. Another compiler or different build options can change the instructions, offsets, and addresses, so use the course VM for the walkthrough.
Run it and enter a short label such as LIMES:
$ ./stack-pathEnter a label: LIMESReturned to main.So far, that agrees with the source: main called read_label, the function accepted the input, and execution returned to the line after the call. win is still sitting there unused.
In the source, STDIN_FILENO is the standard-input file descriptor, whose value is zero on this system. The read call therefore accepts up to 64 bytes from the same input stream you used at the prompt, despite label having room for only 16.
The ordinary program flow
Section titled “The ordinary program flow”Let’s put that ordinary run on a diagram. We can follow the calls and returns from the source, including the second message that tells us we’re back in main:
This simplified flow follows the calls in the supplied source. Runtime and library code are omitted. The executable contains win, but this path never calls it. On narrow screens, scroll the diagram horizontally to follow the whole path.
That leaves win off to one side. It exists in the program, but there’s no call to it on the path we just followed. Keep that distinction in mind: being present and being reached are two different things.
Follow the ordinary call
Section titled “Follow the ordinary call”The source tells us that read_label returns to main, but the CPU still needs the saved information we followed in the last lesson. Let’s look underneath that return and find the frame the call set up:
- Before the call:
mainreachescall read_labelat0x4011d3. call read_label: The CPU saves0x4011d8, movesrspdown eight bytes, and entersread_label.push rbp:read_labelmovesrspdown again and savesmain’s frame pointer.mov rbp,rsp:rbpmoves to the base of the new frame without changing memory.sub rsp,0x10:rspmoves down sixteen bytes, reserving two eight-byte slots forlabel.
These are the operations from Anatomy of a Stack Frame, now serving read_label rather than add. Follow the saved address, 0x4011d8, as the new frame takes shape:
Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.
Starting state in main
Before call
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Available stack slotcontents not shown
- Available stack slotcontents not shown
- Available stack slotcontents not shown
- Available stack slotcontents not shown
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x4011d3 · call read_labelrsp0x7fff00f0rbp0x7fff0100raxnot shown
rsp → 0x7fff00f0 in stack memory
rbp → 0x7fff0100 in stack memory
main is ready to call read_label. The return address has not been saved yet.
After call read_label
Save where main will continue
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
d8 11 40 00 00 00 00 00main: after call - Available stack slotcontents not shown
- Available stack slotcontents not shown
- Available stack slotcontents not shown
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x401197 · read_label entryrsp0x7fff00e8rbp0x7fff0100raxnot shown
rsp → 0x7fff00e8 in stack memory
rbp → 0x7fff0100 in stack memory
call moved rsp down eight bytes, stored 0x4011d8 at that address, and moved execution into read_label. rbp still belongs to main.
After push rbp
Save the caller’s pointer
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
d8 11 40 00 00 00 00 00main: after call - Saved main rbp
00 01 ff 7f 00 00 00 000x7fff0100 - Available stack slotcontents not shown
- Available stack slotcontents not shown
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x40119c · mov rbp, rsprsp0x7fff00e0rbp0x7fff0100raxnot shown
rsp → 0x7fff00e0 in stack memory
rbp → 0x7fff0100 in stack memory
push moved rsp down another eight bytes and stored main’s frame pointer there. rbp itself has not changed yet.
After mov rbp, rsp
Set the frame pointer
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
d8 11 40 00 00 00 00 00main: after call - Saved main rbp
00 01 ff 7f 00 00 00 000x7fff0100 - Available stack slotcontents not shown
- Available stack slotcontents not shown
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x40119f · sub rsp, 0x10rsp0x7fff00e0rbp0x7fff00e0raxnot shown
rsp → 0x7fff00e0 in stack memory
rbp → 0x7fff00e0 in stack memory
rbp now points to the saved frame-pointer slot at 0x7fff00e0. rsp and the stack contents stayed the same.
After sub rsp, 0x10
Reserve space for label
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
d8 11 40 00 00 00 00 00main: after call - Saved main rbp
00 01 ff 7f 00 00 00 000x7fff0100 - label[8–15]contents not shown
- label[0–7]contents not shown
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x4011a3 · lea rax, [rbp-0x10]rsp0x7fff00d0rbp0x7fff00e0raxnot shown
rsp → 0x7fff00d0 in stack memory
rbp → 0x7fff00e0 in stack memory
rsp moved down sixteen bytes, reserving two eight-byte slots for label. rbp and the stored values stayed the same.
Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. Code addresses are verified; stack addresses are illustrative.
The absolute stack addresses are illustrative because your process may put its stack somewhere else. The frame has:
- Two eight-byte slots for
label. - One holding the saved
rbp. - One holding
0x4011d8.
That last value is the address immediately after call, where ret will take us back into main. The spacing between these slots is what we’ll need next.
Find the return slot
Section titled “Find the return slot”The C source tells us that label holds sixteen bytes, but it does not show where the compiler placed it relative to the saved return address. objdump will show us the instructions in read_label:
objdump -d -Mintel ./stack-path | sed -n '/<read_label>:/,/^$/p'There’s more in the listing, but for now we’re interested in the instructions that establish the frame and later take it down:
40119b: push rbp40119c: mov rbp,rsp40119f: sub rsp,0x104011a3: lea rax,[rbp-0x10] ...4011b4: call 401070 <read@plt>4011ba: leave4011bb: retsub rsp,0x10reserves sixteen bytes forlabel.lea rax,[rbp-0x10]givesreadthe beginning of that space.- The source allows
readto store up to sixty-four bytes there.
Immediately above the sixteen bytes reserved for label are eight bytes for the saved rbp, followed by the saved return address. This layout belongs to the build in front of us, so count the slots instead of treating 24 as a standard offset.
The frame has four eight-byte slots:
Byte offsets from the start of label. Higher addresses at the top.
- Offsets 24–31
- Offsets 16–23rbp
- Offsets 8–15label[8–15]8 bytes
- Offsets 0–7rsplabel[0–7]8 bytes
Region heights are not to scale. Pointers show the established frame, before teardown.
The first sixteen bytes fill label, and the next eight replace the saved frame pointer. That puts the saved return address at offset 24, so bytes 24 through 31 occupy its slot.
Make the program fail
Section titled “Make the program fail”I use A for every byte in this first input because its byte value is 0x41, so eight of them appear as 0x4141414141414141 in the debugger and are easy to recognize if the input reaches the return slot.
Save this as probe.py:
from pathlib import Path
probe = b"A" * 32Path("probe.bin").write_bytes(probe)print(f"wrote {len(probe)} bytes")Generate the file and run the program with it:
python3 probe.py./stack-path < probe.binThe program prints its prompt and then crashes. On the course VM, the shell reports a segmentation fault and may also mention that a core was dumped.
Well, that isn’t the return we were expecting. The missing message tells us we didn’t get back to main, but a segmentation fault doesn’t explain which part went wrong. Let’s follow the write through the frame and compare that picture with what the debugger shows.
Each cell is one byte. Increase the write from 0 to 32 bytes and watch where it crosses the sixteen-byte label buffer.
Stack memory · read_label
Higher addresses ↑ Write moves upward ↑
Lower addresses ↓ Start at offset 0
All 8 written bytes fit inside label. Neither saved value has changed.
Within each row, offsets increase left to right. After its last cell, the write continues in the row above.
Byte values are hexadecimal: 41 is A, and ?? means an unwritten buffer byte whose original value is unknown. Written bytes are underlined; writes beyond the buffer also turn blue.
This shows changes to memory during the write. Overwriting the saved return address does not itself change the instruction pointer; the function has not returned yet.
About the saved values and this layout
These are saved values in memory, not live registers. The starting frame pointer is illustrative; the return address matches the recorded build. In this little-endian layout, the first byte overwritten changes the rightmost pair of hex digits.
This build places the saved frame pointer at offset 16 and the saved return address at offset 24. Byte 17 first crosses the buffer boundary; byte 25 first reaches the return slot; byte 32 completes its overwrite. Other builds can have different layouts. This read example adds no string terminator.
The write passes through three parts of the frame:
- The first sixteen bytes stay within
label. - The next eight replace the saved frame pointer.
- The last eight replace
0x4011d8with0x4141414141414141.
read copies across all four rows because they are adjacent in memory. Their different roles only matter when later instructions use them.
Stop at ret and inspect the eight bytes named by rsp:
gdb -q ./stack-path(gdb) break *0x4011bb(gdb) run < probe.bin(gdb) x/gx $rsp0x...: 0x4141414141414141rsp points at the eight A bytes from the end of the probe, which shows that the input reached the saved return slot and supplied the value ret is about to use.
Leave GDB with quit, confirming when it asks.
Compare the two returns
Section titled “Compare the two returns”The ordinary run and the crashing run both reach the same ret instruction at 0x4011bb; the value at rsp is what differs.
- Short input: the bytes at
rspare0x4011d8, soretcontinues inmain. - Thirty-two-byte probe: the bytes at
rspare0x4141414141414141. That is not a usable instruction address in this process, so the program faults instead of continuing inmain.
The ordinary return depends on the saved address remaining in the return slot. The probe shows that input can replace it, although a row of A bytes gives us a crash rather than a useful destination.
Look back at win. What value would the return slot need to contain for ret to take us there?
Choose a valid destination
Section titled “Choose a valid destination”The code for win is already in the executable, so use nm to find its address in the symbol table:
nm -n ./stack-path | grep ' win$'0000000000401176 T winThe first twenty-four bytes take us to the return slot. Replace the final eight A bytes with 0x401176 in the form the processor expects. Python’s pack("<Q", value) handles that conversion for us. Save this as payload.py:
from pathlib import Pathfrom struct import pack
DESTINATION = 0x401176payload = b"A" * 24 + pack("<Q", DESTINATION)
Path("input.bin").write_bytes(payload)print(f"wrote {len(payload)} bytes")Generate the input:
python3 payload.pywrote 32 bytesThe first twenty-four bytes are unchanged from the probe, while the final slot now contains the address of win instead of 0x4141414141414141.
Execution is still inside read_label, so changing the return slot has not changed rip yet. Follow leave and ret from that starting point:
Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.
Before leave
Input complete
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
76 11 40 00 00 00 00 00win - Saved main rbp
41 41 41 41 41 41 41 410x4141414141414141 - label[8–15]
41 41 41 41 41 41 41 41AAAAAAAA - label[0–7]
41 41 41 41 41 41 41 41AAAAAAAA
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x4011ba · leaversp0x7fff00d0rbp0x7fff00e0rax32
rsp → 0x7fff00d0 in stack memory
rbp → 0x7fff00e0 in stack memory
The input has already changed memory. rip is still inside read_label.
Inside leave: copy rbp into rsp
Move back to the saved frame pointer
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
76 11 40 00 00 00 00 00win - Saved main rbp
41 41 41 41 41 41 41 410x4141414141414141 - label[8–15]
41 41 41 41 41 41 41 41AAAAAAAA - label[0–7]
41 41 41 41 41 41 41 41AAAAAAAA
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x4011ba · leaversp0x7fff00e0rbp0x7fff00e0rax32
rsp → 0x7fff00e0 in stack memory
rbp → 0x7fff00e0 in stack memory
rsp now meets rbp at the saved frame-pointer slot. The label slots are outside the active stack, but their bytes remain. This is an explanatory view inside leave, not a separate instruction.
Finish leave: pop the saved value into rbp
Ready to return
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
76 11 40 00 00 00 00 00win - Saved main rbp
41 41 41 41 41 41 41 410x4141414141414141 - label[8–15]
41 41 41 41 41 41 41 41AAAAAAAA - label[0–7]
41 41 41 41 41 41 41 41AAAAAAAA
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x4011bb · retrsp0x7fff00e8rbp0x4141414141414141rax32
rsp → 0x7fff00e8 in stack memory
rbp → 0x4141414141414141, not a stack address in this frame
rbp now contains the overwritten saved value, so it no longer points into this frame. rsp advances eight bytes to the return slot; ret will read the destination there.
ret pops 0x401176 into rip
Execution reaches win
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main's frame pointercontents not shown
- main's stackcontents not shown
- main's stackcontents not shown
- Saved return address
76 11 40 00 00 00 00 00win - Saved main rbp
41 41 41 41 41 41 41 410x4141414141414141 - label[8–15]
41 41 41 41 41 41 41 41AAAAAAAA - label[0–7]
41 41 41 41 41 41 41 41AAAAAAAA
Lower addresses
Registers
Stack addresses and instruction locations are hexadecimal.
rip0x401176 · winrsp0x7fff00f0rbp0x4141414141414141rax32
rsp → 0x7fff00f0 in stack memory
rbp → 0x4141414141414141, not a stack address in this frame
The CPU’s next instruction comes from win. The old stack bytes remain visible here, but rsp has moved past them.
Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. Code addresses are verified; stack addresses are illustrative.
leave positions rsp at the changed return slot, just as it did during the ordinary run. ret then copies 0x401176 into rip, and the processor begins executing win. The return instruction is doing its usual job with a value supplied by our input.
Run the program with the new input:
./stack-path < input.binEnter a label:You changed the plan.There it is: win printed its message. We still haven’t added a call or branch to it in the source, but the program reached it anyway, using the value read by ret.
This is stack smashing
Section titled “This is stack smashing”The thirty-two-byte input crossed the end of label, passed through the saved frame pointer, and replaced the saved return address. When read_label finished, ret used the replacement and execution continued in win, which is an example of stack smashing: writing beyond a stack buffer far enough to overwrite other values in the frame.
A challenge that supplies a win function and asks you to redirect execution into it is often described as ret2win. That is a name for the challenge, while the overwrite used to solve it is stack smashing.
If your run differs, don’t start changing the padding at random. Every value in the final input came from an earlier observation. To recover, work forward and stop at the first one that doesn’t match:
- If either script does not report thirty-two bytes, regenerate its file before interpreting the result.
- If your
objdumpoffsets ornmaddress differ, restore the supplied source and exact build command. - If GDB still shows
0x4011d8atrsp, check that you started the program with the intended input file.
Change the destination and explain the result
Section titled “Change the destination and explain the result”The first twenty-four bytes determine which stack slot the input reaches, while the final eight determine the value ret uses. Change that value once more to check the explanation.
Then change only the destination. Replace DESTINATION in payload.py with 0x4011d8, the ordinary return address, regenerate input.bin, and predict the output before running it. The write still crosses the same four slots, but the final eight bytes now name the instruction where main normally continues.
Check the changed destination
The program should print Returned to main. instead of entering win. The write still crosses the same four slots, so the overflow is still present even though the return slot contains the ordinary continuation address again.
Restore DESTINATION = 0x401176 and regenerate the input when you’re done, so you’re back at the working example.
Knowledge check
Question 1 of 3
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
The same return instruction took part in each run, but it read a different saved value. We can now put the debugger’s view of those values alongside the output and explain the difference between the ordinary return, the crash, and the message from win.