What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

C · Systems · Pwn

Step 184. Assembly 3: Following a C Program Line by Line in gdb — Live Coverage of the Calling Convention

Step 184Estimated practice · 4 hours

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 -S side by side with gdb’s disas output
  • 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 with finish

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 = %dn", 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.

  1. rip jumped from main+46 to 0x401136 — add’s start address.
  2. rsp shrank by 8 (the stack grows toward lower addresses — a new 8-byte slot was born).
  3. 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:

  1. Compile with gcc -g -O0 -no-pie, and in disas main write down the address of the instruction calling add and "the address of the instruction after it"
  2. With gdb, record rdi, rsi, rdx right after entering add and sub3 respectively (hex and decimal together)
  3. Inside add, write down the stack-top value with x/1gx $rsp and compare it with the "address after call" from step 1 — do they match?
  4. Do the same comparison inside sub3 (is the address after the instruction calling sub3 at sub3’s stack top?)
  5. In each function, record the return value with info registers rax right after finish and check the math in decimal
  6. 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?


Answers & completion criteria · expand/collapse

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’s disas output 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 si and 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.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next