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:

  • x starts at 0x7fffffffe27c
  • heap_string starts at 0x7fffffffe280
  • stack_string starts at 0x7fffffffe28e

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.