Step 185. The Stack, Fully Understood — A Complete Map of What Piles Up on a Function Call

Step 185. The Stack, Fully Understood — A Complete Map of What Piles Up on a Function Call

Level 3 — Pwn Track | Difficulty ★★★★☆ | Estimated time: 4 hours

Prerequisites: you’ve finished Step 184. You’ve confirmed in gdb that call leaves a return address on the stack, and you can use disas and x/gx.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: a WSL Ubuntu terminal, gcc, gdb. The measured environment is Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64.
  • Caution: the gets appearing today is a function you must never use in real work. Today you learn with your body why even the compiler throws warnings. Deliberately writing vulnerable code is for the observation lab only.

In Step 184 we saw call leave a return address on the stack. But the return address isn’t the only thing on the stack. Local variables, the previous function’s reference point, and that return address — all of these pile up side by side in a fixed order. Today we complete that blueprint. This is the day you measure, as precisely as with a ruler, what happens when you write past twenty-four bytes and up to thirty-two into a char buf[16], and exactly how many bytes lie between the buffer and the return address. This one map is the whole of buffer overflow attack and defense.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Draw the order of what piles up on the stack on a function call (local variables → saved rbp → RET)
  • Read the rbp–rsp relationship and local variable positions (rbp-relative offsets) in gdb
  • Confirm the return address sits at $rbp+8 and cross-check the value’s identity
  • Calculate the distance from buf to RET (the padding length)
  • Observe the stack filling with a long input and point out which byte lands where

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C, WSL Ubuntu bash, gcc 13.3.0, gdb 15.1 (x86-64)
Today’s commands/options gcc -g -O0 -fno-stack-protector -no-pie (observation compile), in gdb: info registers rbp rsp, p/x $rbp - (long)buf (distance calculation), x/2gx $rbp (view around the reference point), info symbol address (find an address’s name), b *address (stop by address)
Concepts needed Stack frame, saved rbp (SFP), RET (return address), the stack’s growth direction, the danger of gets

2-1. The Stack Frame — One Layer Stacked per Function

Every time a function is called, one stack frame — that function’s workspace — piles onto the stack. Local variables and buffers live in the frame, and when the function ends, the whole layer is swept away. The real thing behind the "function’s temporary drawer" you met in Step 60.

The problem is that this layer holds not only the function’s data but also control information (the address to return to). Data and control live as neighbors, one cell apart.

2-2. The Stack’s Growth Direction — A Tower That Grows Downward

On x86-64, the stack grows from high addresses toward low addresses. When something piles on (push), rsp gets smaller; when something pops off, it gets bigger. Feeling "backward" at first is normal.

But the writing direction inside a buffer is the opposite. When you write buf[0], buf[1], … the address moves toward higher values. The crossing of these two directions — the stack grows downward while buffer writes spread upward — is why an overflow becomes an accident that "covers the upper part of the frame past the buffer."

2-3. The Frame Blueprint — Today’s Star Picture

One function’s frame looks like this, from high address (top) to low address (bottom).

high addr ┌──────────────────┐
          │  RET (return addr)│ ← rbp + 8 : the note call left
          ├──────────────────┤
          │  saved rbp (SFP) │ ← rbp     : previous function's reference point
          ├──────────────────┤
          │  locals/buffers  │ ← rbp - N : buf lives here
low addr  └──────────────────┘ ← rsp (the top)

saved rbp (SFP) is the "previous function’s rbp," stored by push %rbp when the function starts. The current rbp points at this value, and the return address sits one cell above it (rbp + 8). So knowing rbp alone lets you compute the position of everything in the frame.

When writing spreads upward from the buffer (overflow), it covers, in order: the local variable area → saved rbp → RET. Cover RET, and the moment the function rets, rip goes to the value you wrote. The distance calculation you learn today is tomorrow’s attack blueprint.

2-4. gets — An Input Function with No Upper Bound

gets(buf) writes input into buf with no length limit until you press Enter. It never even asks how many cells the buffer has. That’s why it was removed from the C11 standard, and why the linker shows a "dangerous" warning — though it still runs.

Today we deliberately use this removed function. To observe how boundary-less writing ravages the stack map.


3. Follow Along

3-1. The Test Program — A 16-Cell Buffer and gets

Input (stack.c)

#include <stdio.h>

void f(void) {
    char buf[16];
    printf("buf address: %p\n", (void *)buf);
    printf("enter a string: ");
    fflush(stdout);
    gets(buf);
    printf("received value: %s\n", buf);
}

int main(void) {
    f();
    printf("returned safely\n");
    return 0;
}

Compile

gcc -g -O0 -fno-stack-protector -no-pie stack.c -o stack
stack.c: In function ‘f’:
stack.c:8:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
/usr/bin/ld: /root/lab185/stack.c:8:(.text+0x57): warning: the `gets' function is dangerous and should not be used.

(Measured 2026-09-09.)

How to read the output: two warnings appear, but the binary is still built. Especially the second one — remember the scene of the linker itself saying "this function is dangerous, don’t use it." A function the tooling begs you this hard to avoid is rare. Today is the day we prove why. -fno-stack-protector turns off the overflow watchdog you met in Step 62 (for observation); -no-pie fixes addresses.

3-2. f’s Frame Structure — Reading with disas

gdb -q ./stack
(gdb) b f
Breakpoint 1 at 0x4011a2: file stack.c, line 5.
(gdb) r
Starting program: .../stack

Breakpoint 1, f () at stack.c:5
5	    printf("buf address: %p\n", (void *)buf);
(gdb) disas f
Dump of assembler code for function f:
   0x0000000000401196 <+0>:	endbr64
   0x000000000040119a <+4>:	push   %rbp
   0x000000000040119b <+5>:	mov    %rsp,%rbp
   0x000000000040119e <+8>:	sub    $0x10,%rsp
=> 0x00000000004011a2 <+12>:	lea    -0x10(%rbp),%rax
   ...
   0x00000000004011ec <+86>:	call   0x401090 <gets@plt>
   ...
   0x000000000040120d <+119>:	leave
   0x000000000040120e <+120>:	ret

(Measured 2026-09-09.)

How to read the output: the function’s first three instructions are the construction work that builds the frame.

  • push %rbp — store the previous function’s (main’s) rbp on the stack. This is saved rbp.
  • mov %rsp,%rbp — make the current rsp the new reference point (rbp). rbp is born here.
  • sub $0x10,%rsp — lower by 0x10 = 16 bytes. Room for buf[16].
  • lea -0x10(%rbp),%raxbuf’s address = rbp – 0x10. The assembly tells you directly.

3-3. Putting Numbers on the Map — rbp, rsp, and RET’s Identity

From the stopped state, take four more steps (ni ×4) and look after the frame construction finishes.

(gdb) info registers rbp rsp
rbp            0x7fffffffe670      0x7fffffffe670
rsp            0x7fffffffe660      0x7fffffffe660
(gdb) p/x $rbp - (long)buf
$1 = 0x10
(gdb) x/2gx $rbp
0x7fffffffe670:	0x00007fffffffe680	0x000000000040121c
(gdb) info symbol *(long*)($rbp+8)
main + 13 in section .text of /root/lab185/stack

(Measured 2026-09-09. 0x7fff... addresses differ per run. p/x is gdb’s built-in calculator command.)

How to read the output: every coordinate on the map is now filled in.

  • rbp = 0x7fffffffe670, rsp = 0x7fffffffe660 — the difference is 0x10 (16). The frame’s size.
  • buf = rbp – 0x10 = 0x7fffffffe660 — buf sits at the very bottom of the frame (same place as rsp).
  • The first cell of x/2gx $rbp, 0x7fffffffe680 — saved rbp, main’s reference point.
  • The second cell, 0x40121c — this is RET, the address f returns to when it ends. info symbol confirms the name: main + 13.

And in main’s disas, call f is at 0x401217 (main+8) and the next instruction at 0x40121c (main+13) (measured 2026-09-09). Step 184’s rule — "return address = the instruction after call" — is exact here too.

3-4. The Distance Calculation — Today’s Key Number

Now count the distance from the buffer to RET. Organized as a picture:

address           content
0x7fffffffe678    RET (main+13 = 0x40121c)   ← rbp + 8
0x7fffffffe670    saved rbp (0x7fffffffe680) ← rbp
0x7fffffffe660    buf start (16 bytes)        ← rbp - 0x10

From buf start (0x…660) to the RET cell (0x…678): 0x678 – 0x660 = 0x18 = 24 bytes.

16 bytes (buf) + 8 bytes (saved rbp) = 24. Up to 24 bytes of input covers only the frame’s data; starting from the 25th byte, it begins covering RET. This "24" is the padding length in attack design. The distance varies with compiler and options, so in the field you always measure it with gdb like this.

Prediction: if you feed in 32 A’s, buf (16) + saved rbp (8) + RET (8) will all be covered with 0x41. Will it? Verified in the next section.

3-5. The Filling Stack — Observing the 0x41 Flood

Set a stop right after gets finishes (address 0x4011f1, the instruction after call <gets@plt> in disas) and feed in thirty-two A’s.

python3 -c 'print("A"*32)' > input32.txt
gdb -q ./stack
(gdb) b *0x4011f1
Breakpoint 1 at 0x4011f1: file stack.c, line 9.
(gdb) r < input32.txt
...
Breakpoint 1, f () at stack.c:9
9	    printf("received value: %s\n", buf);
(gdb) x/6gx $rbp-0x10
0x7fffffffe660:	0x4141414141414141	0x4141414141414141
0x7fffffffe670:	0x4141414141414141	0x4141414141414141
0x7fffffffe680:	0x00007fffffffe700	0x00007ffff7c2a1ca
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x000000000040120e in f () at stack.c:10
10	}

(Measured 2026-09-09. Match the stop address against your own disas result.)

How to read the output: exactly as predicted. buf’s two cells (0x660, 0x668), the saved rbp cell (0x670), and the RET cell (0x678) — all four cells are 0x4141414141414141, eight ‘A’s each. And when we kept running with c, f died the moment it retted. Because the value ret pulled from the stack top was 0x4141414141414141, and no such address exists in this program.

Notice that the stop point is f+120 — the ret instruction itself. The weapon (thirty-two A’s), the fatal wound (the RET cell), and the point of death (ret) are all on one screen.

Why: what you just saw is the complete mechanism of a buffer overflow. "Fill 24 bytes from the buffer, and the next 8 bytes cover RET." If those 8 bytes were not 0x4141414141414141 but an address that exists — the program goes to that address instead of dying. That sentence is the whole of Step 186.


4. Missions & Exercises

Mission — Precision Survey of the Stack Map

Change stack.c’s buf to char buf[32], and make an f2 function that adds the local variable int token = 777; above buf (prototype: void f2(void), call f2 from main).

  1. Compile with gcc -g -O0 -fno-stack-protector -no-pie and record the sub $0x..,%rsp value and buf’s rbp offset from disas f2
  2. Stop in f2, record the addresses of rbp, rsp, and buf, and compute $rbp - buf
  3. Confirm the identities of saved rbp and RET with x/2gx $rbp and info symbol *(long*)($rbp+8)
  4. Calculate the distance from buf to RET (the padding length)
  5. Draw the stack map with high addresses on top, filling in the addresses and contents of the four cells: RET / saved rbp / token / buf
  6. Investigate whether token sits above or below buf, and why (declaration order? compiler’s choice?), and write it in one line

Exercises

Exercise 1. Explain what "the stack grows downward" means on x86-64 in terms of rsp’s change. Then why does an overflow cover the upper side (saved rbp, RET) past buf?

Exercise 2. In f’s frame, RET was at rbp + 8. Why exactly +8? What’s at the rbp slot (rbp + 0)?

Exercise 3. In 3-4’s calculation the padding length was 24. If buf were char buf[24], what would the padding be? (Assume the same structure.)

Exercise 4. In 3-5, the program died at the ret instruction. Explain why it "dies at ret, not at gets."


5. Model Answers & Completion Criteria

Mission Model Answer

An example survey record (2026-09-09, Ubuntu 24.04, gcc 13.3.0, with -g -O0 -fno-stack-protector -no-pie):

[disas f2]  sub $0x30,%rsp   (buf 32 + token 4 + alignment padding)
            buf = near lea -0x30(%rbp)

[after f2 entry]  rbp = 0x7fffffffe650, rsp = 0x7fffffffe620
                  buf = rbp - 0x30

[saved rbp]  first cell of x/2gx $rbp = previous (main) rbp value
[RET]        second cell of x/2gx $rbp = the address after main's call → confirmed as "main + N" via info symbol

[padding calc]  0x30 + 0x8 = 0x38 = 56 bytes
               (buf 32 + saved rbp 8 ... token's position depends on compiler layout)

[map] recorded in order from high address: RET(rbp+8) / saved rbp(rbp) / token / buf

How to verify: ① the basis of the padding calculation (buffer size + saved rbp 8) must be stated. ② the RET cell’s value must be confirmed as "main + N" via info symbol. ③ the map must be drawn with addresses increasing upward. Even if your numbers differ from the book’s example, correct calculation process and cross-checking method is the right answer.

Exercise Answers

Answer 1. When a push or sub happens, rsp gets smaller — space appears toward lower addresses, hence "grows downward." On the other hand, writes to buf proceed buf[0] → buf[1] → … toward higher addresses. Since saved rbp and RET sit at higher addresses than buf in the frame, boundary-less writing covers them in turn past buf.

Answer 2. Because the rbp + 0 slot holds saved rbp (the previous function’s rbp), and the return address left by the call that invoked the function piles one cell above it. On 64-bit, an address is 8 bytes, so rbp + 8 — skipping one saved-rbp cell (8) — is RET’s slot.

Answer 3. 24 (buf) + 8 (saved rbp) = 32 bytes. Note, however, that compilers often align frames to 16-byte units, so you must check with gdb to be exact. "Estimate from the structure, verify with gdb" is the standard approach.

Answer 4. Because gets merely writes bytes onto the stack, and "writing" to the wrong place is not itself an immediate accident. The accident happens the moment that value gets "read and used." The very moment ret pulls the covered return address, loads it into rip, and tries to jump — 0x4141414141414141 is not an executable address, so the segfault happens here. The time lag between covering and dying matters.

Completion Criteria Checklist

  • [ ] I can draw the stack frame layout (local variables → saved rbp → RET) from a blank page
  • [ ] I can explain that the stack grows toward low addresses while buffer writes spread toward high addresses
  • [ ] I can read buf’s position (rbp offset) from disas in gdb
  • [ ] I’ve confirmed the identity of the RET value at $rbp+8 all the way through info symbol
  • [ ] I calculated the padding length (buffer size + 8) and cross-checked it against gdb measurement
  • [ ] I observed the RET cell being covered with 0x41 by a 32-byte input
  • [ ] I can explain the danger of gets and the meaning of the compiler/linker warnings
  • [ ] Mission: I completed the stack map for the buf[32] + token version

6. Common Pitfalls & Fixes

Wall 1. A warning says gets is not declared

Symptom: warnings like this appear at compile time (measured 2026-09-09):

warning: implicit declaration of function ‘gets’; did you mean ‘fgets’?
warning: the `gets' function is dangerous and should not be used.

Cause: this is normal. gets lost its standard header (stdio.h) declaration in C11 but remains in the library, so the executable is built along with the warnings.
Fix: only if it’s an error, not a warning, and the binary wasn’t built should you hunt for a cause. In today’s practice, these warnings are themselves part of the lesson.

Wall 2. Address directions keep flipping in my head

Symptom: you keep getting confused about "is buf above or below RET?"
Cause: the stack picture’s up/down and addresses’ big/small are opposite, so the brain gets scrambled. Everyone goes through this at first.
Fix: think only in numbers. buf = 0x…660, RET = 0x…678. Bigger numbers are on top. And writes spread from small numbers to big numbers. Those two sentences end the direction war.

Wall 3. The padding length differs from my calculation

Symptom: buf[16] should mean 16 + 8 = 24, but when you overwrite, it reacts at a different length.
Cause: the compiler may insert padding into the frame for alignment or rearrange variable layout. If the canary (protection) is on, the structure itself differs.
Fix: this is why the field uses measurement, not estimation. Verify -fno-stack-protector → check sub $0x..,%rsp and buf’s offset with disas → confirm RET’s position with x/2gx $rbp. Those three steps capture the distance in any environment.

Wall 4. I can’t tell which of the two cells in x/2gx $rbp is which

Symptom: you can’t tell whether 0x00007fffffffe680 or 0x000000000040121c is RET.
Cause: both look "address-like," but their ranges differ.
Fix: stack addresses start with 0x7fff…, and code addresses (with -no-pie) start with 0x40…. The second cell starting with 0x40 is RET. To be sure, info symbol 0x40121c — if a function name like "main + 13" comes out, it’s a code address, i.e., RET.

*Wall 5. I set b address but it doesn’t stop

Symptom: you set b with the book’s address to stop right after gets, but it runs past.
Cause: the book’s address (0x4011f1) belongs to the measured environment. Even a slight source difference shifts addresses.
Fix: always run disas f in your own environment first, read the address of the line right after call <gets@plt>, and set b *that-address.


7. Summary

Today’s Concepts

Concept One-line explanation
Stack frame A workspace stacked per function — an apartment shared by data and control information
saved rbp (SFP) The previous function’s reference point — the resident of the rbp + 0 slot
RET (return address) The note of where to return — the resident of the rbp + 8 slot, the attack’s target
Stack growth direction Push makes rsp smaller — a tower growing downward
Padding The distance from buf to RET = buffer size + 8 (must be measured per environment)
gets A boundless input function — the tool of map ravaging, banned in real work

Today’s Commands

Command What it does
gcc -g -O0 -fno-stack-protector -no-pie Compile for frame observation
info registers rbp rsp The frame’s top and bottom coordinates
p/x $rbp - (long)buf Distance calculation inside gdb
x/2gx $rbp View the two cells: saved rbp and RET
info symbol address Find an address’s identity (function name)
x/6gx $rbp-0x10 See the whole frame at a glance

An Instinct More Important Than Commands

Your notes today should hold one map. buf, saved rbp, RET — three boxes and the number 24. This map looks simple, but every Pwn chapter from now on plays on top of this picture. Attackers and defenders read the same map. The attacker sees "fill 24 bytes and write an address," and the defender sees "where do I post a guard to protect that note?"

And don’t forget: this distance of 24 is not a law of nature but today’s measurement from your compiler. In the field, the order your hands move is always the same: read the structure with disas, measure with gdb, design with those numbers.


Once every box is checked, Step 185 is complete. Click the checkbox in the sidebar to save your progress.