A Practical Tour of the Stack with gdb
Understanding the stack often comes down to seeing it with your own eyes. Using gdb to inspect memory directly can make abstract ideas like stack frames, pointers, and buffer overflows tangible. Here is a hands-on walkthrough of examining a C program's stack, from variable declarations to a classic buffer overflow.
The Test Program
We start with a deliberately unsafe C program that reads two strings from stdin. One string is allocated on the heap (via malloc), and the other is a fixed-size buffer on the stack.
#include <stdio.h>
#include <stdlib.h>
int main() {
char stack_string[10] = "stack";
int x = 10;
char *heap_string;
heap_string = malloc(50);
printf("Enter a string for the stack: ");
gets(stack_string);
printf("Enter a string for the heap: ");
gets(heap_string);
printf("Stack string is: %s\n", stack_string);
printf("Heap string is: %s\n", heap_string);
printf("x is: %d\n", x);
}
The program uses the infamous gets function, which is banned in production code but perfect for observing stack behavior when things go wrong.
Compiling and Starting
Compile the program with debugging symbols to make inspection easier:
gcc -g -O0 test.c -o test
The -g flag adds debugging information that gdb can use to locate variables. -O0 disables optimizations, ensuring variables like x remain in memory and aren't optimized away into registers.
Launch gdb on the compiled binary:
$ gdb ./test
Set a breakpoint on main and run:
(gdb) b main
Breakpoint 1 at 0x1171: file test.c, line 4.
(gdb) run
Starting program: /home/bork/work/homepage/test
Breakpoint 1, main () at test.c:4
4 int main() {
Where the Variables Live
The program declares three items: an integer x, a heap-allocated string heap_string, and a stack-based string stack_string. Each has its own memory address, which gdb can display:
(gdb) p &x
$3 = (int *) 0x7fffffffe27c
(gdb) p &heap_string
$2 = (char **) 0x7fffffffe280
(gdb) p &stack_string
$4 = (char (*)[10]) 0x7fffffffe28e
Those three addresses fall within the stack region for the current function. To understand exactly where, we need to know where the stack frame begins.
The Stack Pointer
x86 architecture has a register, ESP, known as the stack pointer. It holds the address where the current function's stack region begins. gdb exposes it as $sp. The value changes whenever a function is called or returns.
At the start of main, the stack pointer is:
(gdb) p $sp $7 = (void *) 0x7fffffffe270
The stack frame begins at 0x7fffffffe270. Using gdb's memory-dump command, we can examine the first 160 bytes (40 words) after that point to locate the program's data by address:
(gdb) x/40x $sp 0x7fffffffe270: 0x00000000 0x00000000 0x55555250 0x00005555 0x7fffffffe280: 0x00000000 0x00000000 0x55555070 0x00005555 0x7fffffffe290: 0xffffe390 0x00007fff 0x00000000 0x00000000 0x7fffffffe2a0: 0x00000000 0x00000000 0xf7df4b25 0x00007fff 0x7fffffffe2b0: 0xffffe398 0x00007fff 0xf7fca000 0x00000001 0x7fffffffe2c0: 0x55555169 0x00005555 0xffffe6f9 0x00007fff 0x7fffffffe2d0: 0x55555250 0x00005555 0x3cae816d 0x8acc2837 0x7fffffffe2e0: 0x55555070 0x00005555 0x00000000 0x00000000 0x7fffffffe2f0: 0x00000000 0x00000000 0x00000000 0x00000000 0x7fffffffe300: 0xf9ce816d 0x7533d7c8 0xa91a816d 0x7533c789
After accounting for byte ordering, the layout becomes clear:
xstarts at0x7fffffffe27cheap_stringstarts at0x7fffffffe280stack_stringstarts at0x7fffffffe28e
A notable detail: x contains the value 0x5555, not 10. That's because x = 10 is an assignment that hasn't executed yet at the very first instruction of main. The memory shown is its pre-initialization state.
Stepping Past Initialization
To see the variables after assignment, set another breakpoint at line 10, where x has been set to 10:
(gdb) b test.c:10
Breakpoint 2 at 0x5555555551a9: file test.c, line 11.
(gdb) continue
Continuing.
Breakpoint 2, main () at test.c:11
11 printf("Enter a string for the stack: ");
Dump the same memory region again:
(gdb) x/80x $sp 0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x73 0x74 0x7fffffffe290: 0x61 0x63 0x6b 0x00 0x00 0x00 0x00 0x00 0x7fffffffe298: 0x00 0x80 0xf7 0x8a 0x8a 0xbb 0x58 0xb6 0x7fffffffe2a0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe2a8: 0x25 0x4b 0xdf 0xf7 0xff 0x7f 0x00 0x00 0x7fffffffe2b0: 0x98 0xe3 0xff 0xff 0xff 0x7f 0x00 0x00 0x7fffffffe2b8: 0x00 0xa0 0xfc 0xf7 0x01 0x00 0x00 0x00
This time you can see the initialized values alongside a few interesting representations worth examining.
Strings vs. Pointers on the Stack
The string "stack" occupies five bytes on the stack: 0x73 (s), 0x74 (t), 0x61 (a), 0x63 (c), and 0x6b (k). We can print those bytes directly:
(gdb) x/10x stack_string
0x7fffffffe28e: 0x73 0x74 0x61 0x63 0x6b 0x00 0x00 0x00
0x7fffffffe296: 0x00 0x00
Or ask gdb to interpret the location as a string:
(gdb) x/1s stack_string
0x7fffffffe28e: "stack"
stack_string is stored inline — its contents reside on the stack. heap_string is entirely different type of data. The variable itself is just a pointer, a memory address. Its value (not the string contents) lives on the stack:
0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00
Because x86 is little-endian, those bytes are read in reverse order to reconstruct the address 0x5555555592a0. gdb can also display the pointer value directly:
(gdb) p heap_string
$6 = 0x5555555592a0 ""
The actual string content for heap_string lives at that heap address, far away from the stack.
The integer x is also stored in little-endian order: bytes 0x0a 0x00 0x00 0x00 reverse to 0x0000000a, which is decimal 10.
Input Time: Anatomy of a Buffer Overflow
Set a third breakpoint after the input and continue. The program will read two strings:
printf("Enter a string for the stack: ");
gets(stack_string);
printf("Enter a string for the heap: ");
gets(heap_string);
(gdb) b test.c:16
Breakpoint 3 at 0x555555555205: file test.c, line 16.
(gdb) continue
Continuing.
In this run, the supplied stack string was 123456789012 and the heap string was bananas. Check the stack string's memory:
(gdb) x/1s stack_string
0x7fffffffe28e: "123456789012"
Everything appears normal, because the string displays correctly. But look at the raw bytes on the stack — the discontinuity there makes something clear:
0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x31 0x32 0x7fffffffe290: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30 0x7fffffffe298: 0x31 0x32 0x00 0x8a 0x8a 0xbb 0x58 0xb6 0x7fffffffe2a0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe2a8: 0x25 0x4b 0xdf 0xf7 0xff 0x7f 0x00 0x00 0x7fffffffe2b0: 0x98 0xe3 0xff 0xff 0xff 0x7f 0x00 0x00 0x7fffffffe2b8: 0x00 0xa0 0xfc 0xf7 0x01 0x00 0x00 0x00
stack_string was declared to hold only 10 bytes. Writing 13 bytes into it (including the terminating null character) overflows the buffer and overwrites adjacent memory. That is a classic buffer overflow. A careful byte dump shows the data spilling into the memory past the variable's boundaries.
The program survives our overflow, but not always gracefully. When the flow returns, the shell output indicates a serious problem:
./test Enter a string for the stack: 01234567891324143 Enter a string for the heap: adsf Stack string is: 01234567891324143 Heap string is: adsf x is: 10 *** stack smashing detected ***: terminated fish: Job 1, './test' terminated by signal SIGABRT (Abort)
That message reflects a mitigation called stack protection (specifically, the stack smashing protector). The compiler inserts a random "canary" value at the boundary of the stack frame. Before the function returns, the compiled code checks that the canary hasn't been overwritten — which is precisely what our long input has done. When the value is corrupted, the program aborts with *** stack smashing detected ***.
The Heap String After Input
heap_string on the stack is unchanged after reading "bananas": the variable still holds the same pointer value from before:
(gdb) x/40x $sp 0x7fffffffe270: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7fffffffe278: 0x50 0x52 0x55 0x55 0x0a 0x00 0x00 0x00 0x7fffffffe280: 0xa0 0x92 0x55 0x55 0x55 0x55 0x00 0x00 0x7fffffffe288: 0x70 0x50 0x55 0x55 0x55 0x55 0x31 0x32 0x7fffffffe290: 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x30
What changed is what lives at that address. Examining the pointed-to memory region reveals the content:
(gdb) x/10x 0x5555555592a0
0x5555555592a0: 0x62 0x61 0x6e 0x61 0x6e 0x61 0x73 0x00
0x5555555592a8: 0x00 0x00
The bytes 0x62 0x61 0x6E ... are the ASCII codes for "bananas." That array sits on the heap, not on the stack.
Distinguishing Stack Versus Heap
Each running process exposes a memory map file, /proc/$PID/maps, which shows all memory allocations the kernel granted. Reviewing it makes the separation clear:
$ cat /proc/24963/maps
... lots of stuff omitted ...
555555559000-55555557a000 rw-p 00000000 00:00 0 [heap]
... lots of stuff omitted ...
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
Notice the pattern: heap addresses in this system begin with 0x5555, while stack addresses start with 0x7fffff. That prefix distinction makes it easy to tell where a value lives just from its address.
Exercises to Push Further
Playing with gdb like this is the best way to make the stack intuitive. If you'd like more investigation threads, try these:
- Add a second function to the program and set a breakpoint at its start. Watch whether the stack pointer moves to a lower or higher address from
main. - Write a function that returns a pointer to a local (stack-allocated) string. Inspect what remains at that memory location once the function returns.
- Deliberately cause a stack overflow. Use gdb to catch the moment and observe the error at that exact return time.
- Compile a Rust equivalent program and use the same technique to inspect its stack layout.
- Work through the buffer overflow exercises in the nightmare course. The goal for each exercise is to overwrite enough stack to make the binary print a "flag" string without reading solution materials in the repository.
Should you prefer lldb as an alternative debugger, most steps here translate directly. The exploration is the same, though the string-print syntax differs — p/s in lldb in place of gdb's p/1s.



