Step 184. Assembly 3: Following a C Program Line by Line in gdb — Live Coverage of the Calling Convention
Level 3 — Pwn Track | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: you’ve finished Steps 182–183. You know registers and basic assembly instructions (mov, push, call, ret), and you can set breakpoints and view registers in gdb.
⚠️ 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: every program today is a three-screen-line C program you write yourself. Dissecting your own program with a debugger is safe. Still, the "reality of the calling convention" you learn today becomes the skeleton of attack techniques in later chapters.
In Step 65 you learned the agreement "arguments via rdi/rsi, results via rax," and in Step 183 you picked up gdb’s basic controls. Today you follow that agreement, live and moving, line by line. The moment a single call instruction executes — where rip jumps to, what lands at the stack top, what gets loaded into rax when the function ends — assembly reads differently once you’ve seen this flow with your own eyes. And these scenes become the picture you’ll recall every time you attack and defend the stack.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Read assembly source made with
gcc -Sside by side with gdb’sdisasoutput - Explain what one step of a call instruction does to rip and the stack
- Confirm the arguments loaded in rdi, rsi, rdx right after function entry with gdb
- Prove by comparison that the return address at the stack top is "the address of the instruction after call"
- Catch the moment a return value lands in rax while stepping with
ni, and come back withfinish
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 -no-pie (fixed-address debugging compile), gcc -S (generate assembly source), in gdb: disas (view assembly), ni (one machine instruction, skips function calls), si (one instruction, enters calls), finish (to end of function), x/2gx $rsp (view two 8-byte stack cells), info symbol address (an address’s name tag) |
| Concepts needed | Calling convention, return address, registers (rdi, rsi, rdx, rax, rip, rsp, rbp), the instruction pointer’s march |
2-1. The Calling Convention — A Delivery Agreement Between Functions
The calling convention is the agreement by which functions exchange values. The x86-64 Linux agreement goes like this: arguments ride in registers rdi, rsi, rdx, rcx, r8, r9 in order (from the seventh on, the stack), and the return value comes back in rax.
That agreement you learned as a table in Step 65 — today you verify it in a living execution. An agreement becomes yours only when confirmed with your eyes, not in a document.
2-2. call and ret — A Pair That Jumps the Milepost
call does two jobs at once. ① It pushes the "address to come back to" (= the address of the instruction right after call, i.e., the return address) onto the stack top, and ② it jumps rip to the function’s start address.
ret is the reverse. It pops the value at the stack top into rip. So the function returns precisely to the instruction after wherever call was made.
Today’s key sentence: the return address call leaves on the stack = the address of the instruction after call. Comparing this in gdb is the peak of today’s practice.
2-3. ni and si — Skipping Over and Stepping In
Both are "advance one instruction," but they part ways at a call. ni (next instruction) counts the call as one step and skips past the function’s end. si (step instruction) follows into the function.
Today you alternate between the two. Go fast with ni until just before the call, then take that one call step with si to see what happens.
2-4. -no-pie — Fixing Addresses for Easier Observation
A default-compiled binary’s code addresses change on every run (that mechanism is PIE — formally met in Step 187). In observation practice, addresses changing every time make comparison with the book’s numbers hard, so today we use -no-pie to pin code addresses in the 0x40... range.
It’s fixing conditions for measurement in a lab — the switch doesn’t mean an attack technique. Just remember the name; you’ll meet it again in Steps 186–187.
3. Follow Along
3-1. The Test Program — add, Taking Three Arguments
Input (follow.c)
#include <stdio.h>
int add(int a, int b, int c) {
int s = a + b + c;
return s;
}
int main(void) {
int x = 10;
int y = 20;
int z = 30;
int total = add(x, y, z);
printf("total = %d\n", total);
return 0;
}
Compile and generate assembly source
gcc -g -O0 -no-pie follow.c -o follow
gcc -S -O0 -no-pie follow.c -o follow.s
How to read it: -g is debug info, -O0 is optimization off (turn optimization on and add melts away inline, eliminating your observation target), -no-pie fixes addresses. gcc -S leaves the compile’s intermediate product — assembly source — as a file.
3-2. Two Assemblies — Comparing gcc -S and gdb disas
First, look at main’s core in the follow.s that gcc -S made (measured 2026-09-09; lines starting with .cfi are debugging notations and are omitted):
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $10, -16(%rbp)
movl $20, -12(%rbp)
movl $30, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %ecx
movl -16(%rbp), %eax
movl %ecx, %esi
movl %eax, %edi
call add
movl %eax, -4(%rbp)
...
The same function, in gdb:
gdb -q ./follow
(gdb) disas main
Dump of assembler code for function main:
0x000000000040115c <+0>: endbr64
0x0000000000401160 <+4>: push %rbp
0x0000000000401161 <+5>: mov %rsp,%rbp
0x0000000000401164 <+8>: sub $0x10,%rsp
0x0000000000401168 <+12>: movl $0xa,-0x10(%rbp)
0x000000000040116f <+19>: movl $0x14,-0xc(%rbp)
0x0000000000401176 <+26>: movl $0x1e,-0x8(%rbp)
0x000000000040117d <+33>: mov -0x8(%rbp),%edx
0x0000000000401180 <+36>: mov -0xc(%rbp),%ecx
0x0000000000401183 <+39>: mov -0x10(%rbp),%eax
0x0000000000401186 <+42>: mov %ecx,%esi
0x0000000000401188 <+44>: mov %eax,%edi
0x000000000040118a <+46>: call 0x401136 <add>
0x000000000040118f <+51>: mov %eax,-0x4(%rbp)
...
(Measured 2026-09-09. Compiled with -no-pie, so addresses are fixed in 0x40....)
How to read the output: the two assemblies are two notations of the same code. gcc -S shows AT&T notation in source-file grammar; gdb’s disas shows it with memory addresses and <+offset> attached. Overlay the middle portions and the story appears.
movl $10, -16(%rbp)— put 10 (0xa) at -16(%rbp) on the stack. The birth of local variable x.movl -8(%rbp), %edx/movl %ecx, %esi/movl %eax, %edi— pull out z, y, x and load them into rdx, rsi, rdi. Argument delivery preparation. Note that the third argument went to rdx.call 0x401136 <add>— the call at main+46. And the next instruction is main+51 (0x40118f). Remember this address. You’ll meet it again on the stack soon.mov %eax,-0x4(%rbp)— after returning, store eax (the return value) into total.
Prediction: the moment call executes, what value lands at the stack top? If you believe 2-2’s sentence, the answer is decided. Verified in 3-4.
3-3. Function Entry — Confirming the Argument Delivery
(gdb) b add
Breakpoint 1 at 0x401147: file follow.c, line 4.
(gdb) r
Starting program: .../follow
Breakpoint 1, add (a=10, b=20, c=30) at follow.c:4
4 int s = a + b + c;
(gdb) info registers rdi rsi rdx
rdi 0xa 10
rsi 0x14 20
rdx 0x1e 30
(Measured 2026-09-09.)
How to read the output: as agreed. The first argument 10 (0xa) arrived in rdi, the second 20 (0x14) in rsi, the third 30 (0x1e) in rdx. gdb shows decimal next to hex, so don’t worry if hex is still unfamiliar. The info one line of b add gave (a=10, b=20, c=30) matching the registers’ contents — this comparison is "verifying the convention."
3-4. The Secret at the Stack Top — Comparing the Return Address
From that very spot where you’re stopped in add, peer at the stack top.
(gdb) x/2gx $rsp
0x7fffffffe660: 0x00007fffffffe680 0x000000000040118f
(Measured 2026-09-09. Stack addresses starting with 0x7fff... differ per run.)
How to read the output: x/2gx $rsp is "two 8-byte (giant) cells starting where rsp points, in hex (x)." In the first cell (0x7fffffffe660) sits the thing just piled on — 0x40118f. Go back to 3-2’s disas. 0x40118f is main+51, the instruction right after call.
The return address call left on the stack = the address of the instruction after call. The moment a prediction gets confirmed as fact. Where rip will go when the function rets is written right now at the stack top. This spot becomes an attack target later.
3-5. Inside One Step of call — Stepping In with si
This time, put the call instruction itself under the microscope. Stop main by pointing at the address directly.
(gdb) b *0x40118a
Breakpoint 1 at 0x40118a: file follow.c, line 12.
(gdb) r
Starting program: .../follow
Breakpoint 1, 0x000000000040118a in main () at follow.c:12
12 int total = add(x, y, z);
(gdb) x/i $rip
=> 0x40118a <main+46>: call 0x401136 <add>
(gdb) si
add (a=0, b=0, c=0) at follow.c:3
3 int add(int a, int b, int c) {
(gdb) info registers rip rsp
rip 0x401136 0x401136 <add>
rsp 0x7fffffffe668 0x7fffffffe668
(gdb) x/1gx $rsp
0x7fffffffe668: 0x000000000040118f
(Measured 2026-09-09. The asterisk in b *0x40118a means "stop at a memory address, not a name." Match the address against your own disas result.)
How to read the output: x/i $rip is "show me the one instruction rip points at" — you confirmed the instruction about to execute is call. One si. With that single step, three things happened at once.
- rip jumped from main+46 to 0x401136 — add’s start address.
- rsp shrank by 8 (the stack grows toward lower addresses — a new 8-byte slot was born).
- At that new slot (the stack top) landed 0x40118f — the return address.
call = "push return address + jump rip." You just saw with your own eyes two motions compressed into one instruction. By the way, the add (a=0, b=0, c=0) gdb showed right after si is just gdb unable to read values before the arguments get copied to the stack — don’t mind it; the real arguments in registers were already confirmed in 3-3.
3-6. The Moment of Return — The Sum Loading into rax
Now, inside add, step line by line with ni and follow the addition’s progress. First, get add’s assembly map.
(gdb) disas add
Dump of assembler code for function add:
0x0000000000401136 <+0>: endbr64
0x000000000040113a <+4>: push %rbp
0x000000000040113b <+5>: mov %rsp,%rbp
0x000000000040113e <+8>: mov %edi,-0x14(%rbp)
0x0000000000401141 <+11>: mov %esi,-0x18(%rbp)
0x0000000000401144 <+14>: mov %edx,-0x1c(%rbp)
=> 0x0000000000401147 <+17>: mov -0x14(%rbp),%edx
0x000000000040114a <+20>: mov -0x18(%rbp),%eax
0x000000000040114d <+23>: add %eax,%edx
0x000000000040114f <+25>: mov -0x1c(%rbp),%eax
0x0000000000401152 <+28>: add %edx,%eax
0x0000000000401154 <+30>: mov %eax,-0x4(%rbp)
0x0000000000401157 <+33>: mov -0x4(%rbp),%eax
0x000000000040115a <+36>: pop %rbp
0x000000000040115b <+37>: ret
(Measured 2026-09-09. The => arrow is "the instruction currently stopped at.")
How to read the output: at +8~+14 the register arguments come down to local slots on the stack, at +17~+28 the adding happens, and at +33 the final result moves to rax. After stopping with b add, press ni seven times.
(gdb) ni (×7 — one instruction at a time)
...
(gdb) info registers rax
rax 0x3c 60
(gdb) finish
Run till exit from #0 add (a=10, b=20, c=30) at follow.c:6
0x000000000040118f in main () at follow.c:12
12 int total = add(x, y, z);
Value returned is $1 = 60
(Measured 2026-09-09.)
How to read the output: two things got verified. ① 0x3c in rax — 60 in hex. The result of 10 + 20 + 30 loaded into rax as agreed. ② The spot where finish stopped after running the function to its end is 0x40118f, line 12 of main — the very address written at the stack top in 3-4. ret jumped to the return address written on the stack, which is exactly why it came back there.
The entire round trip now connects in numbers. Arguments go via registers, the address to return to stays on the stack, the result comes back via rax, and the flow retraces the stack’s address — that’s today’s one-line summary.
Review: mentally convert 0x3c to decimal. 3×16 + 12 = 60. If stuck,
python3 -c "print(0x3c)"is your calculator.
4. Missions & Exercises
Mission — A Call-Tracing Report
Modify follow.c to add the function int sub3(int a, int b, int c) { return a - b - c; } and make main call sub3(x, y, z) after add(x, y, z). Then:
- Compile with
gcc -g -O0 -no-pie, and indisas mainwrite down the address of the instruction calling add and "the address of the instruction after it" - With gdb, record rdi, rsi, rdx right after entering add and sub3 respectively (hex and decimal together)
- Inside add, write down the stack-top value with
x/1gx $rspand compare it with the "address after call" from step 1 — do they match? - Do the same comparison inside sub3 (is the address after the instruction calling sub3 at sub3’s stack top?)
- In each function, record the return value with
info registers raxright afterfinishand check the math in decimal - Organize the observations into one drawing, split into two chunks: "the way there (arguments, return address) / the way back (rax, ret)"
Exercises
Exercise 1. State, in order, the two things that happen when one call instruction executes.
Exercise 2. In 3-4, right after entering add, 0x40118f sat at the stack top, and in disas main that address was the instruction right after call. Explain the relationship between the two in one sentence.
Exercise 3. In 3-6, finish stopped at 0x40118f. Why did it stop in the middle of main instead of at add’s end?
Exercise 4. How do ni and si behave differently when they hit a call? Which one must you use to observe a call’s internals?
5. Model Answers & Completion Criteria
Mission Model Answer
An example tracing report (2026-09-09, Ubuntu 24.04, gcc 13.3.0, with -g -O0 -no-pie):
[map] disas main: call add → 0x40118a <main+46>, next instruction 0x40118f <main+51>
call sub3 → (address varies with source; recorded for comparison below)
[add entry] rdi=0xa(10) rsi=0x14(20) rdx=0x1e(30)
[add stack] x/1gx $rsp → 0x40118f ← matches the address after call ✔
[add return] after finish, rax=0x3c(60) ← 10+20+30 ✔
[sub3 entry] rdi=0xa(10) rsi=0x14(20) rdx=0x1e(30)
[sub3 stack] x/1gx $rsp → matches the "next address" of the instruction calling sub3 ✔
[sub3 return] after finish, rax=0xffffffe2(-30) ← 10-20-30 ✔
(negatives display in two's complement — gdb shows decimal alongside)
How to verify: ① the "address after call = stack-top value" comparison must succeed twice, once per function. ② the return-value check must match the C code’s math. ③ the drawing must have all four arrows (argument passing, return-address push, rax return, ret jump). The address numbers themselves may differ per environment — matching relationships is the grading criterion.
Exercise Answers
Answer 1. ① It pushes the address to come back to (the address of the instruction right after call — the return address) onto the stack top, and ② it jumps rip to the called function’s start address. In 3-5, rsp shrank by 8, 0x40118f landed at the stack top, and rip changed to 0x401136 (add) — that’s the live footage.
Answer 2. The return address call leaves on the stack is always "the address of the instruction after call" — because that’s how the function can resume execution right after the call site when it rets. The stack top’s 0x40118f exactly matching main+51 (after call) is no coincidence; it’s the call instruction’s definition.
Answer 3. Because finish is a command that stops "right after the current function executes through ret and returns to its caller." When add rets, rip goes to the return address 0x40118f pulled from the stack, which corresponds to line 12 of main. In other words, finish’s stop position is itself evidence that "ret jumped to the return address."
Answer 4. ni counts a call as one step and skips past the function’s end; si follows into the function. To see what call does to the stack and rip (as in 3-5), you must use si.
Completion Criteria Checklist
- [ ] I know that
gcc -S‘s assembly source and gdb’sdisasoutput are two notations of the same code - [ ] I can explain call’s two motions (return-address push, rip jump)
- [ ] I confirmed the arguments in rdi, rsi, rdx right after function entry
- [ ] I proved by comparison that the stack-top return address matches the address of the instruction after call
- [ ] I followed one call step in with
siand observed the rip jump - [ ] I caught the moment a return value loaded into rax while stepping with
ni - [ ] I confirmed
finish‘s stop position equals the return address - [ ] Mission: I completed the call-tracing report (comparison records + drawing)
6. Common Pitfalls & Fixes
Wall 1. The book’s addresses differ from my screen’s
Symptom: the book’s call is at 0x40118a but your screen shows a different address.
Cause: even a tiny source difference (whitespace, variable names) changes code length and shifts addresses. Forgetting -no-pie sends addresses to the 0x55... range.
Fix: don’t memorize addresses — look at relationships. As long as the match "address of the instruction after call = stack-top value" is confirmed, it’s a success. Read addresses from your own environment’s disas output and compare with those numbers.
Wall 2. Variable names and line numbers don’t show up
Symptom: even stopping with b add, you only get this:
Breakpoint 1, 0x0000000000401136 in add ()
Cause: you compiled without -g.
Fix: recompile with gcc -g -O0 -no-pie. Optimization options (-O2, etc.) can also inline functions and erase stopping points, so double-check -O0 too.
Wall 3. I pressed ni but can’t get into add
Symptom: you pressed ni at the call and it went straight to the next line.
Cause: ni counts a call as "one step" and skips the function. That’s its design.
Fix: when you want to see a call’s internals, use si (3-5). Conversely, when you don’t want to fall inside a library function like printf, ni is the right answer. They’re tools you split by purpose.
Wall 4. The /2gx in the x command confuses me
Symptom: the part after the slash in x/2gx $rsp looks like a cipher.
Cause: the three characters each have a meaning. 2 = two cells, g = 8 bytes per cell (giant), x = hex.
Fix: experiment yourself with variations like x/1gx (one cell) and x/4wx (four 4-byte cells) and see how the output changes. Change it once and you’ll remember for life.
*Wall 5. "No symbol" error at b address
Symptom: you typed b *0x40118a and it can’t recognize the address.
Cause: either you dropped the asterisk, or you typed the book’s address without checking your own environment. b 0x40118a without the asterisk fails while hunting for a "function named 0x40118a."
Fix: first confirm your environment’s call address with disas main, then point at it in the form b *that-address.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Calling convention | Arguments in rdi, rsi, rdx order; return value in rax — the delivery agreement between functions |
| Return address | The "place to come back to" call leaves on the stack = the address of the instruction after call |
| call | Return-address push + rip jump, two motions compressed |
| ret | Pull the stack-top value and jump rip to it — returning to the caller |
ni / si |
A step that skips a function call / a step that follows into it |
-no-pie |
A compile option fixing code addresses (for observation practice) |
Today’s Commands
| Command | What it does |
|---|---|
gcc -g -O0 -no-pie |
Fixed-address debugging compile |
gcc -S source.c |
Generate assembly source (.s) |
disas name |
View a function’s assembly map |
b *address |
Set a stop point directly by memory address |
x/2gx $rsp |
View two 8-byte cells at the stack top |
x/i $rip |
View the one instruction about to execute |
finish |
Run to the function’s end and stop in the caller |
An Instinct More Important Than Commands
Today’s essence is not commands but one picture. Calling a function is "leaving a note with the address to return to on the stack and departing"; returning is "coming back by reading that note." Remember that this note — the return address — sits on the stack, right in the middle of the road where the program’s data passes.
In Step 62 we saw overflowing input overwriting neighboring variables. What if that input reached past the variables, all the way to this note? That imagination is exactly Steps 185–186’s content — and today, you’ve seen with your own eyes where that explosive’s fuse is located.
Once every box is checked, Step 184 is complete. Click the checkbox in the sidebar to save your progress.