Skip to content

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.

Published on 7 min read

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.

Simplified virtual address space of a 64-bit Linux process: text, rodata, data and bss at the bottom, then the heap growing up, mmap region with shared libraries, and the stack at the top growing down

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 LOAD segment is executable (R E), and it is not writable. Code that can be modified at runtime is code an attacker can modify.
  • GNU_STACK is RW, not RWE, which tells the kernel to map the stack non-executable. This is the NX/DEP mitigation in its ELF form.
  • GNU_RELRO marks 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

SectionTypical permissionsContentsWhy defenders care
.textR-XMachine codeShould never be writable
.rodataR--String literals, constant tablesFormat strings belong here, not in user input
.dataRW-Initialized globalsGlobal function pointers are an attractive target
.bssRW-Zero-initialized globalsLarge static buffers often live here
.got / .got.pltRW- or R--Addresses of external functions and dataRead-only only with full RELRO
.init_array / .fini_arrayR-- after RELROConstructor and destructor pointersProtected 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:

MitigationWhat it changes in the layoutBug consequence it limits
NX / DEPStack, heap and data pages are not executableInjected bytes cannot run as code
ASLRStack, heap, mmap and vDSO bases change per runHard-coded addresses stop working
PIEThe executable's own base is randomized tooCode in the main binary cannot be located blindly
RELROGOT and init/fini arrays become read-onlyLibrary call targets cannot be redirected
Stack canaryA secret value sits between locals and saved dataLinear stack overflows are detected at return
Guard pagesUnmapped pages around stacksStack 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_STACK without E means a non-executable stack, and GNU_RELRO protects 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 -l and checksec let you verify layout, permissions and randomization instead of assuming them.

Related guides

0x1000 · Vulnerability Classes

Heap Overflows, Use-After-Free and Double Free

How heap memory bugs corrupt allocator metadata and object state, why use-after-free is so dangerous, and the allocator hardening, sanitizers and ownership rules that stop them.