Step 183. Assembly 2: gdb Basics — A Microscope for Running Programs

Step 183. Assembly 2: gdb Basics — A Microscope for Running Programs

Level 3 — Real-World CTF and Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 5 hours

Prerequisites: Step 182 (registers and stack frames). You can read gcc -S output and draw a stack frame.

⚠️ 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: WSL Ubuntu (gcc, gdb). The measured environment is Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64.
  • Caution: today’s practice is 100% safe. You’re merely observing a program you compiled with your own debugger. gdb extensions like gef/pwndbg require external downloads, so they’re not installed in this environment and are introduced as "Screen example" only.

gdb is a microscope that freezes a running program at a point of your choosing and lets you peer inside. Until yesterday we read compile outputs (.s files) as still images. Starting today you observe a living program — registers and the stack changing with every instruction. You’ll learn hands-on why Pwn attacks get designed in gdb by observing "where and how input piles up on the stack."


1. Learning Objectives

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

  • Explain what gcc -g -O0 means (-g: debug info, -O0: optimization off) and build a debugging binary
  • Set a breakpoint (b) and run the program up to that point
  • Know the difference between next/ni/si/finish and pick the right one
  • Observe registers, variables, and memory with info registers, print, and x/
  • Read "where am I now" from the => marker in disassemble output

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C + gdb, WSL Ubuntu bash (measured: gcc 13.3.0, gdb 15.1)
Today’s commands b location, run, next/ni/si/finish, print variable, info registers, x/10gx $rsp, disassemble
Concepts needed Breakpoints, instruction-level vs source-line stepping, debug info (-g), stack frames (Step 182)
Today’s artifact gdb command cards + a function-call observation note

2-1. How a Debugger Works — Freezing and Peering

A debugger does two things. ① Stop the program at a designated point (breakpoints), ② read registers, memory, and variables in that stopped state. The combination of the two "replays execution in slow motion."

If ordinary execution is "screening a film," debugging is "stepping frame by frame with a magnifier." And this microscope’s subject can be your own program, like today, or someone else’s sourceless binary (reversing).

2-2. The Stepping Family — next, ni, si, finish

"Advance one step" has four faces:

Command Unit When it meets a function call
next (n) One source line Skips over (executes the function whole)
step (s) One source line Steps in
ni One machine instruction Skips over
si One machine instruction Steps in
finish Runs to the end of the current function

The difference between ni and si exists only when they hit a call. On flat ground they’re identical. Today we confirm this difference by measurement.

2-3. Reading Memory — The x/ Command’s Grammar

x/ is "examine" — a command for reading memory directly. The grammar is x/count-size-format address:

x/s address     → read as a string (s = string)
x/4wx address   → 4 items, in words (4 bytes), as hex (w = word, x = hex)
x/10gx $rsp     → 10 items, in giant words (8 bytes), as hex — the standard for viewing a 64-bit stack

Variables go through print variable-name (shortened to p), and a variable’s address is &variable-name. x/s &name means "read as a string starting from name’s address."

2-4. gef and pwndbg — Screen Example

In real-world Pwn, gdb extensions like gef or pwndbg are standard. With every command, registers, stack, and disassembly auto-display on one screen (Screen example — not installed in this environment):

# Screen example — gef's stopped-screen layout
───────────────────────── registers ────
$rax: 0x11   $rdi: 0x11   $rsp: 0x7fffffffe640
───────────────────────── stack ────
0x7fffffffe640│+0x0000: 0x00007fffffffe670
───────────────────────── code:x86:64 ────
→ 0x555555555174 <square+11>  mov eax, DWORD PTR [rbp-0x14]

Installation is one line after downloading a script from the internet, but getting comfortable with plain gdb first is today’s goal — because what the extensions show is ultimately the output of the commands you learn today.


3. Follow Along

3-1. A Practice Program and a Debugging Compile

Input (test.c)

#include <stdio.h>

int square(int n) {
    int r = n * n;
    return r;
}

int main(void) {
    char name[] = "guardian";
    int age = 17;
    int sq = square(age);
    printf("%s: %d\n", name, sq);
    return 0;
}

Compile and test-run

gcc -g -O0 test.c -o test
./test
guardian: 289

(Measured 2026-09-09.)

How to read the options: -g is "load debug info — variable names, line numbers — into the binary," and -O0 is "optimization off." Without both, gdb doesn’t know variable names and the code order gets scrambled — you’ll see the actual symptoms in Wall 1.

3-2. Breakpoints and run — Freezing

gdb ./test
GNU gdb (Ubuntu 15.1-1ubuntu1~24.04.1) 15.1
...
Reading symbols from ./test...
(gdb)

Now you’re at gdb’s interactive prompt. Set a breakpoint on main and run:

(gdb) b main
Breakpoint 1 at 0x118e: file test.c, line 8.
(gdb) run
Breakpoint 1, main () at test.c:8
8	int main(void) {

(Measured 2026-09-09. The session outputs below were measured the same day; addresses differ per run.)

How to read it: b is a breakpoint — a "freeze here" marker. run starts the program. It froze at main’s entrance, and gdb shows "the line about to execute." Note that today’s sessions can also be run by collecting commands in a file with gdb -batch -x command-file ./test — convenient when repeating the same commands.

3-3. Observing Variables and Registers — Peering

(gdb) next
9	    char name[] = "guardian";
(gdb) next
10	    int age = 17;
(gdb) next
11	    int sq = square(age);
(gdb) print age
$1 = 17
(gdb) x/s &name
0x7fffffffe65f:	"guardian"
(gdb) info registers rip
rip            0x5555555551b6      0x5555555551b6 <main+52>

How to read it: three nexts passed two assignment statements, and the variables now hold values. $1 in print age is a result number gdb assigns. x/s &name shows the string as-is, and rip is the address of the instruction about to execute — the microscope’s crosshair. By the way, if you print age before enough nexts, you see a garbage value — the assignment hasn’t happened yet. That’s the meaning of "observation timing."

3-4. The ni/si Fork — At a call

Right now you’re at line 11, just before the square(age) call. From here, go instruction by instruction:

The case of skipping with ni

(gdb) ni        (mov ... — an instruction loading age into a register)
(gdb) ni        (mov edi, ... — the first argument into rdi)
(gdb) ni        (call square — skipped! 0x...51c0, still line 11)
(gdb) ni        (mov [rbp-...], eax — storing the result into sq)
12	    printf("%s: %d\n", name, sq);
(gdb) print sq
$2 = 289

square executed whole and you arrived at line 12. sq holds the result 289. One thing to note — even right after skipping the call, you’re still on line 11. Since "assign to sq" is the same source line’s remaining instruction, you need one more ni to cross to line 12 (measured 2026-09-09).

The case of stepping in with si (from the same spot again)

(gdb) si        (mov ...)
(gdb) si        (mov edi, ...)
(gdb) si        (call square — stepped in!)
square (n=0) at test.c:3
3	int square(int n) {

si met the call and came inside the function. It shows n=0 because this is the moment of entry — the argument hasn’t come down to the stack yet; the prologue hasn’t run (remember Step 182’s prologue).

3-5. Inside the Function — Registers and Stack, Measured

For observing inside a function, going straight there with b square is cleaner:

(gdb) b square
Breakpoint 2 at 0x1174: file test.c, line 4.
(gdb) run
Breakpoint 2, square (n=17) at test.c:4
4	    int r = n * n;
(gdb) print n
$1 = 17
(gdb) info registers rdi rip
rdi            0x11                17
rip            0x555555555174      0x555555555174 <square+11>

How to read it: b square breaks on the first source line past the prologue. That’s why n is already 17. The calling convention, measured — the first argument 17 sits in rdi (0x11). rip points at square’s 11-byte mark.

Look at the stack:

(gdb) x/10gx $rsp
0x7fffffffe640:	0x00007fffffffe670	0x00005555555551c0
0x7fffffffe650:	0x0000001100000000	0x67007ffff7fe5af0
0x7fffffffe660:	0x006e616964726175	0xafa9197956204100
0x7fffffffe670:	0x00007fffffffe710	0x00007ffff7c2a1ca
0x7fffffffe680:	0x00007fffffffe6c0	0x00007fffffffe798

How to read the output: each line is an address plus two 8-byte chunks. Find three spots. ① The first chunk at 0x…e640, 0x…e670 — the saved old rbp (what square’s prologue pushed). ② Next to it, 0x5555555551c0 — the return address (the instruction after call in main). ③ 0x006e616964726175 at 0x…e660 — read these bytes backwards (little-endian): 67 75 61 72 64 69 61 6e, i.e., "guardian." main’s local variable name, alive on the stack. The stack isn’t an alien number board — it’s the site where the structures you learned actually pile up.

3-6. disassemble and => — Where Am I Now

(gdb) disassemble square
Dump of assembler code for function square:
   0x0000555555555169 <+0>:	endbr64
   0x000055555555516d <+4>:	push   rbp
   0x000055555555516e <+5>:	mov    rbp,rsp
   0x0000555555555171 <+8>:	mov    DWORD PTR [rbp-0x14],edi
=> 0x0000555555555174 <+11>:	mov    eax,DWORD PTR [rbp-0x14]
   0x0000555555555177 <+14>:	imul   eax,eax
   0x000055555555517a <+17>:	mov    DWORD PTR [rbp-0x4],eax
   0x000055555555517d <+20>:	mov    eax,DWORD PTR [rbp-0x4]
   0x0000555555555180 <+23>:	pop    rbp
   0x0000555555555181 <+24>:	ret
End of assembler dump.

How to read the output: => is the current position — the very instruction rip points at. It matches 3-5, where rip was <square+11>. Every ni moves => down one cell. If you get lost, check your position with disassemble — the debugger’s "you are here" button.

3-7. finish — To the End of the Function

(gdb) finish
0x00005555555551c0 in main () at test.c:11
11	    int sq = square(age);
Value returned is $3 = 289
(gdb) next
12	    printf("%s: %d\n", name, sq);
(gdb) print sq
$4 = 289

How to read it: finish runs until the current function rets, and even reports the value it came back with. The return value 289 rode rax back to main, and after the assignment statement, sq holds 289 too. The calling convention’s last piece — "results via rax" — is confirmed in execution.


4. Missions & Exercises

Mission — A Function-Call Observation Note

  1. Compile the code below with gcc -g -O0:
int add(int a, int b, int c) {
    int s = a + b + c;
    return s;
}
int main(void) {
    int r = add(10, 20, 30);
    return r;
}
  1. Stop with b add, then confirm the three loaded arguments with info registers rdi rsi rdx
  2. Find the return address at the stack top with x/8gx $rsp, and compare it with the address of the instruction after call in disassemble main — do they match?
  3. After finish, confirm the return value (60)
  4. Organize steps ①–④ into an "observation note" — the command, the observed value, and the agreement it confirmed (which clause of the calling convention)

Exercises

Exercise 1. If you open a binary compiled without -g in gdb and print variable-name, what happens? Why?

Exercise 2. When do ni and si behave differently? What about on flat ground (ordinary instructions)?

Exercise 3. Break down what each letter in x/10gx $rsp (10, g, x) means.

Exercise 4. What does => in disassemble output point at, and which register does it match?


5. Model Answers & Completion Criteria

Mission Model Answer

An example observation note (values follow the same principle as the 2026-09-09 measured test.c session):

[Observation note: add(10,20,30)]
① b add → run → stopped. info registers rdi rsi rdx
   rdi=0xa(10), rsi=0x14(20), rdx=0x1e(30)
   → agreement confirmed: arguments ride in rdi, rsi, rdx order
② x/8gx $rsp → stack top = 0x5555...51xx
   disassemble main → matches the address of the instruction right after call add
   → agreement confirmed: call pushes the return address onto the stack
③ finish → "Value returned is $N = 60"
   → agreement confirmed: the return value travels via rax

How to verify: ① does the note pair observed values with "agreements confirmed"? ② in the return-address comparison, did you confirm the two addresses match with your own eyes (don’t assume they’re equal)? ③ did you not forget -O0 and -g?

Exercise Answers

Answer 1. You get the error No symbol "age" in current context. (measured 2026-09-09). Compiling without -g leaves no variable-name or line-number info in the binary, so gdb knows only addresses, not the name "age." -g is essential for a debugging compile.

Answer 2. They differ only when hitting a callni executes the function whole and skips over, while si steps into the function. On ordinary instructions like mov and add, both are "advance one machine instruction" — identical.

Answer 3. 10 is the count, g is giant word (8 bytes, one cell of the 64-bit stack), x is hex display. In short: "10 items of 8 bytes each, starting where rsp points, in hex."

Answer 4. It points at the next instruction to execute — the current position. It matches the address the rip register points at — in 3-5 and 3-6, both were <square+11>.

Completion Criteria Checklist

  • [ ] I can explain the roles of -g and -O0 and do a debugging compile
  • [ ] I froze a program at a desired function’s entrance with b and run
  • [ ] I can state the differences between next/step/ni/si/finish
  • [ ] I read variables with print and x/s, registers with info registers
  • [ ] I viewed the stack with x/10gx $rsp and found the return address and local variables
  • [ ] I confirmed the current position via disassemble‘s =>
  • [ ] Mission: I completed the observation note for the add function call

6. Common Pitfalls & Fixes

Wall 1. No symbol "age" in current context.

Symptom: print age throws this error (measured 2026-09-09).
Cause: two possibilities. ① you compiled without -g, so there’s no variable-name info. ② you haven’t entered that variable’s scope (function) yet.
Fix: recompile with gcc -g -O0, and check you’re stopped inside the function where the variable lives. Stopped in main but printing square’s n gives the same error.

Wall 2. b function-name won’t set and asks a question

Symptom: it asks Make breakpoint pending on future shared library load? (y or [n]) (measured 2026-09-09 — when breaking on a nonexistent function name).
Cause: no function by that name exists — a typo, or the function got inlined by optimization.
Fix: check the typo. If the name is right but it won’t break, recompile with -O0 — optimization absorbs small functions into their call sites, erasing "the function’s body."

Wall 3. I printed and got a weird value

Symptom: you clearly assigned 17, but you get 0 or garbage.
Cause: you observed before passing the assignment statement. The line next shows is not "the line just executed" but "the line about to execute" (measured 2026-09-09 — while standing on line 10, age was still 0).
Fix: go one more next past the assignment, then observe. "Visible line = not-yet-executed line" is the first rule of reading a gdb screen.

Wall 4. I pressed ni but the line doesn’t change

Symptom: ni keeps displaying the same source line.
Cause: one source line is several machine instructions. A line like char name[] = "guardian" translates into multiple character-copy instructions (measured 2026-09-09 — line 9 needed several nis).
Fix: that’s normal — you asked for instruction-level stepping. If you prefer source-line units, use next.

Wall 5. Every number in the stack dump is unfamiliar

Symptom: you recognize nothing in x/10gx $rsp.
Cause: values the OS used are mixed into the stack too. Recognizing everything isn’t the goal.
Fix: practice finding just three things — ① the saved rbp (an address-shaped value the prologue pushed), ② the return address (starts with 0x5555…, code region), ③ data you know (today, the "guardian" bytes). The rest is background.


7. Summary

Today’s Concepts

Concept One-line explanation
Debugger A tool that freezes a running program and shows its insides
Breakpoint A "freeze here" marker — set by function name, line number, or address
Debug info (-g) A compile option loading variable names and line numbers into the binary
next vs step Skip over a function or step into it (ni/si are their machine-level versions)
x/ command Direct memory reading — specify count, size, format
=> and rip The current-position marker in disassembly = where rip points
gef/pwndbg gdb extensions — gather today’s outputs onto one screen

Today’s Commands

Command What it does
gcc -g -O0 test.c -o test Debugging compile
b main / b *address Set a breakpoint
run Run up to the breakpoint
next / ni / si / finish Step — source line / machine (skip) / machine (step in) / to function end
print variable See a variable’s value
info registers See registers
x/10gx $rsp, x/s &variable Read memory and strings
disassemble function See assembly (=> = current position)

An Instinct More Important Than Commands

A debugger’s essence is not "memorizing commands" but asking questions. "What’s in rdi right now?" — info registers rdi. "What comes back when this function ends?" — finish. In today’s measurements we confirmed the calling convention’s three agreements (arguments, return address, return value) not in a book but in a living program.

This observational skill becomes an attack-design tool next. Observing a buffer overflow challenge in gdb means, just as "guardian" was found on the stack today, seeing which slot of the stack your input lands in. You’ve learned to use the microscope — what remains is training to find things worth looking at.


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