How a Process Lays Out Memory: Stack, Heap and ELF Segments
A defender's tour of a Linux process's address space: ELF segments, the stack and calling conventions, the heap, mmap, and why permissions and randomization matter.
Every memory-corruption bug is ultimately a write or read that lands somewhere it should not. To judge how serious that is, you need to know what lives at the destination: a return address, a function pointer, allocator metadata, a string nobody reads again, or an unmapped page that will crash the process harmlessly. This guide builds that mental model for a 64-bit Linux process. It is the foundation for the rest of the site, from stack buffer overflows to binary hardening flags.
Virtual memory in one paragraph
A process never sees physical RAM. It sees a private virtual address space, split into 4 KiB pages (larger pages exist but do not change the model). The kernel maps each page to physical memory, to a file, or to nothing at all, and attaches permissions to it: readable, writable, executable, or a combination. Touching an unmapped page, writing to a read-only page or executing a non-executable page raises a fault that the kernel delivers as SIGSEGV. That single fact is why so many memory-safety bugs show up as segmentation faults, and why page permissions are one of the most important mitigations we have.
From ELF file to running process
An executable on Linux is an ELF file. The linker groups the program's sections into segments, described by program headers, and the kernel and dynamic loader map those segments into memory. You can list them with readelf:
readelf -lW ./server | sed -n '/Program Headers/,/Section to Segment/p'
Type Offset VirtAddr ... Flg Align
PHDR 0x000040 0x0000000000000040 ... R 0x8
INTERP 0x000318 0x0000000000000318 ... R 0x1
LOAD 0x000000 0x0000000000000000 ... R 0x1000
LOAD 0x001000 0x0000000000001000 ... R E 0x1000
LOAD 0x002000 0x0000000000002000 ... R 0x1000
LOAD 0x002db8 0x0000000000003db8 ... RW 0x1000
DYNAMIC 0x002dc8 0x0000000000003dc8 ... RW 0x8
GNU_STACK 0x000000 0x0000000000000000 ... RW 0x10
GNU_RELRO 0x002db8 0x0000000000003db8 ... R 0x1
Three things in that output matter for security:
- Only one
LOADsegment is executable (R E), and it is not writable. Code that can be modified at runtime is code an attacker can modify. GNU_STACKisRW, notRWE, which tells the kernel to map the stack non-executable. This is the NX/DEP mitigation in its ELF form.GNU_RELROmarks a range that the loader makes read-only after relocations are applied, protecting the Global Offset Table. With full RELRO, function pointers used for calls into shared libraries can no longer be overwritten.
The virtual addresses start at zero because this binary is position-independent (PIE). The loader picks a random base at runtime, which is what makes ASLR effective for the main program and not only for libraries.
The sections you will meet most often
| Section | Typical permissions | Contents | Why defenders care |
|---|---|---|---|
.text | R-X | Machine code | Should never be writable |
.rodata | R-- | String literals, constant tables | Format strings belong here, not in user input |
.data | RW- | Initialized globals | Global function pointers are an attractive target |
.bss | RW- | Zero-initialized globals | Large static buffers often live here |
.got / .got.plt | RW- or R-- | Addresses of external functions and data | Read-only only with full RELRO |
.init_array / .fini_array | R-- after RELRO | Constructor and destructor pointers | Protected by RELRO |
The stack and calling conventions
Each thread has its own stack, a contiguous region that grows toward lower addresses. Every function call pushes a new frame: space for local variables, saved registers, and the address to return to.
On x86-64 Linux, the System V ABI passes the first six integer or pointer arguments in registers (rdi, rsi, rdx, rcx, r8, r9), and the return value comes back in rax. The call instruction pushes the return address; a typical prologue then saves the caller's frame pointer and reserves space for locals:
higher addresses
+--------------------------------+
| caller's frame |
+--------------------------------+
| return address | <- pushed by call
+--------------------------------+
| saved rbp | <- rbp points here
+--------------------------------+
| stack canary (if enabled) |
+--------------------------------+
| local arrays |
| local scalars |
+--------------------------------+ <- rsp
lower addresses
Here is the uncomfortable consequence. Local arrays are written from low addresses to high addresses, but the return address sits above them. A copy that runs past the end of a local array therefore walks toward saved control data. That geometry is the reason stack overflows were historically so dangerous, and the reason the stack canary sits exactly where it does.
Other architectures differ in details that matter during triage. AArch64 keeps the return address in the link register x30 and only spills it to the stack in non-leaf functions; with pointer authentication enabled, the spilled value is signed. Windows x64 passes four arguments in rcx, rdx, r8 and r9 and reserves a 32-byte shadow space for the callee. Knowing which ABI you are looking at avoids misreading a backtrace.
Frame pointers and backtraces
Compilers often omit the frame pointer at -O2 (-fomit-frame-pointer) and use rbp as a general-purpose register. Debuggers then rely on DWARF unwind information (.eh_frame) to reconstruct the call stack. Several distributions, including Fedora and Ubuntu, now build packages with -fno-omit-frame-pointer to make profiling and crash analysis more reliable. If your backtraces from production look truncated, missing unwind information or frame pointers is a likely cause, a topic we return to in reading crash reports.
The heap
Memory requested with malloc, calloc, realloc or new comes from the heap allocator. In glibc, small and medium requests are carved out of arenas that grow via brk (the main arena) or mmap (thread arenas), while requests above a threshold (128 KiB by default, adjusted dynamically) get their own mmap mapping.
The allocator stores metadata next to user data. Each glibc chunk begins with a size field, and freed chunks are linked into bins through pointers stored inside the freed memory itself:
in-use chunk free chunk (tcache)
+-------------------+ +-------------------+
| prev_size / data | | prev_size / data |
| size | A | M | P | | size | A | M | P |
+-------------------+ <- ptr +-------------------+
| user data ... | | next (mangled) |
| | | key |
+-------------------+ +-------------------+
This design is fast, but it means a heap overflow or a write through a dangling pointer can corrupt the allocator's own bookkeeping. Modern glibc responds with integrity checks that abort on inconsistent metadata and with safe-linking, which mangles free-list pointers. The details, and the defensive options such as hardened allocators, are covered in heap corruption and use-after-free.
The mmap region and shared libraries
Between the heap and the stack sits the region where the kernel places mmap mappings: shared libraries such as libc.so.6, the dynamic loader, large allocations, memory-mapped files and thread stacks. With ASLR enabled, its base is randomized at every execution.
You can inspect the live layout of any process you own:
cat /proc/self/maps
55d4c8a00000-55d4c8a01000 r--p 00000000 08:01 1837 /usr/bin/cat
55d4c8a01000-55d4c8a05000 r-xp 00001000 08:01 1837 /usr/bin/cat
...
55d4ca1f2000-55d4ca213000 rw-p 00000000 00:00 0 [heap]
7f3b1c600000-7f3b1c628000 r--p 00000000 08:01 4410 /usr/lib/x86_64-linux-gnu/libc.so.6
7f3b1c628000-7f3b1c7bd000 r-xp 00028000 08:01 4410 /usr/lib/x86_64-linux-gnu/libc.so.6
...
7ffd6f9e1000-7ffd6fa02000 rw-p 00000000 00:00 0 [stack]
7ffd6fbd4000-7ffd6fbd6000 r-xp 00000000 00:00 0 [vdso]
Run it twice and the addresses change: that is ASLR at work. Look for any mapping that is both writable and executable (rwxp). In a well-built modern program there should be none, apart from deliberate cases such as JIT compilers, which have their own hardening strategies.
How permissions and randomization combine
Memory layout is not only a teaching device. It is exactly what the main mitigations manipulate:
| Mitigation | What it changes in the layout | Bug consequence it limits |
|---|---|---|
| NX / DEP | Stack, heap and data pages are not executable | Injected bytes cannot run as code |
| ASLR | Stack, heap, mmap and vDSO bases change per run | Hard-coded addresses stop working |
| PIE | The executable's own base is randomized too | Code in the main binary cannot be located blindly |
| RELRO | GOT and init/fini arrays become read-only | Library call targets cannot be redirected |
| Stack canary | A secret value sits between locals and saved data | Linear stack overflows are detected at return |
| Guard pages | Unmapped pages around stacks | Stack exhaustion faults instead of overlapping memory |
You can check a binary's posture in seconds with checksec --file=./server or with readelf, as shown in binary hardening flags.
Key takeaways
- A process sees a virtual address space made of pages, each with its own read, write and execute permissions. Faults on bad accesses surface as
SIGSEGV. - ELF program headers decide what is executable.
GNU_STACKwithoutEmeans a non-executable stack, andGNU_RELROprotects relocation data. - On the stack, local arrays sit below saved control data, so linear overflows run toward the return address. That is why canaries and variable reordering exist.
- The heap keeps metadata inline with user data, so heap bugs can corrupt the allocator itself.
/proc/<pid>/maps,readelf -landcheckseclet you verify layout, permissions and randomization instead of assuming them.