RELRO Relocation Read-Only

RELRO (Relocation Read-Only) is an ELF hardening technique that makes the parts of a program’s data that the dynamic linker writes during startup — most importantly the Global Offset Table (GOT)read-only after relocation finishes, so that a later memory-corruption bug cannot overwrite them. The linker marks the relevant sections into a dedicated program header, PT_GNU_RELRO, and after ld.so has applied all the relocations covering that region it calls mprotect(region, PROT_READ) to drop write permission. RELRO comes in two strengths: partial RELRO (-z relro) protects the non-lazy portion of the GOT and other init data but leaves .got.plt writable so lazy binding can still patch it; full RELRO (-z relro -z now) additionally forces eager binding so the entire GOT can be locked, at the cost of resolving every symbol at startup. The threat it closes is the classic GOT-overwrite attack, where an attacker with an arbitrary write redirects a function pointer in the GOT to attacker-controlled code (Red Hat). Like PIE, full RELRO is a distribution hardening default, not an upstream compiler default.

This note builds on The Global Offset Table, Lazy Binding, and Relocation Processing; it is the security-hardening capstone of the PLT/GOT section of Linux Userspace Runtime MOC.

Mental Model

The GOT is a table of pointers the dynamic linker fills in at load time and, under lazy binding, keeps writing to as the program runs. That makes it a perfect target: it is writable, it lives at a predictable offset, and its entries are function pointers the CPU will jump to. An attacker who can write one arbitrary word (a format-string bug, a heap overflow) can overwrite a GOT entry — say the slot for printf — with the address of system or a ROP gadget, and the next call to that function hijacks control. RELRO’s insight is that the GOT only needs to be writable during the brief relocation window at startup; once that is done, nothing legitimate writes it again (in the eager case), so the OS can simply take away write permission. The table becomes a read-only constant, and the arbitrary write hits a read-only page and faults instead of granting code execution.

flowchart TB
  START["ld.so maps segments"] --> RELOC["apply relocations:<br/>fill .got, .init_array, .dynamic ..."]
  RELOC --> DECIDE{binding mode}
  DECIDE -- "-z relro (partial)" --> P1["mprotect PT_GNU_RELRO region RO<br/>(.got locked, .got.plt STILL writable)"]
  DECIDE -- "-z relro -z now (full)" --> F1["resolve ALL JUMP_SLOTs eagerly"]
  F1 --> F2["mprotect region RO<br/>(.got AND .got.plt locked)"]
  P1 --> RUN["main() runs; lazy binding<br/>still patches .got.plt"]
  F2 --> RUN2["main() runs; whole GOT<br/>read-only — GOT overwrite faults"]

What it shows: relocation runs first, then the loader mprotects the PT_GNU_RELRO region read-only; partial RELRO leaves .got.plt writable so lazy binding survives, while full RELRO first resolves everything eagerly and then locks the entire GOT. The insight to take: read-only-ness and lazy binding are mutually exclusive for .got.plt — you can have a writable lazily-patched PLT-GOT, or a locked one, but not both, which is exactly why full RELRO implies -z now.

Mechanical Walk-through

The program header. RELRO is expressed by a segment, not a section: “GNU invented the PT_GNU_RELRO program header” (MaskRay GOT). At link time, with -z relro, the linker groups the sections that are (a) written only during relocation and (b) safe to freeze afterward — and covers them with a PT_GNU_RELRO program header. Because mprotect operates on whole pages, the linker also arranges the layout so the RELRO region is page-aligned and does not accidentally include data that must stay writable.

What is covered. Under partial RELRO, “the non-PLT part of the GOT section (.got from readelf output) is read only but .got.plt is still writeable” (Red Hat). More precisely the linker “places .got into PT_GNU_RELRO” (MaskRay GOT). The region typically also contains other init-time-only data: .init_array and .fini_array (constructor/destructor pointer tables), .dynamic (the dynamic section the loader reads), .data.rel.ro (relocated read-only data such as vtables of certain objects), and .preinit_array. Freezing these matters because they are all pointer tables an attacker would love to redirect — the constructor/destructor arrays especially.

The loader’s mprotect. The runtime half is in ld.so. “At runtime, after ld.so resolved relocations for an object, it calls mprotect(relro_start, relro_size, PROT_READ) to mark the .got region read-only” (MaskRay GOT). In glibc this is the _dl_protect_relro step, run per-object once that object’s relocations are complete. The ordering is essential: relocation writes the region, so the mprotect must come after the writes — otherwise the loader would fault on its own relocation.

Full RELRO and eager binding. Partial RELRO cannot lock .got.plt, because lazy binding needs to keep writing it (each first call patches a slot — see Lazy Binding). Full RELRO removes that obstacle by forcing eager binding: “With -z relro -z now, the linker additionally places .got.plt into PT_GNU_RELRO. At runtime, ld.so resolves .got.plt relocations eagerly and then calls mprotect. This scheme disables lazy binding PLT” (MaskRay GOT). So the two flags are a package: -z now (set via the DF_1_NOW dynamic flag, or equivalently LD_BIND_NOW=1 — see Lazy Binding) makes the loader resolve every R_*_JUMP_SLOT at startup, and only then is it safe to fold .got.plt into the read-only region. Red Hat: “the entire GOT (.got and .got.plt both) is marked as read-only” and this “Requires immediate function resolution at startup (no lazy binding)” (Red Hat).

A middle ground: read-only GOT with lazy binding. Fedora’s toolchain work added .got.plt isolation in binutils “to support a read-only GOT with lazy binding on systems which provide support for memory protection keys” (Fedora HardeningFlags28). The idea is to use Memory Protection Keys (MPK/pkey) so the lazy resolver can transiently unlock the page to patch a slot while it stays read-only to ordinary code — recovering some of lazy binding’s benefit without giving up the hardening. This is an advanced, hardware-dependent path; the mainstream default remains full RELRO with eager binding.

The GOT-Overwrite Attack RELRO Closes

The concrete threat model, from Red Hat’s walkthrough (Red Hat): a program has a controlled-write vulnerability (a format-string %n, an out-of-bounds array store, etc.). Without RELRO, “exploiting a controlled write vulnerability allows overwriting GOT entries,” so the attacker “overwrites the GOT such that printf now points to attacker-controlled address” — the next printf call jumps to system("/bin/sh") or a ROP chain. RELRO “ensures that the GOT cannot be overwritten in vulnerable ELF binaries” by resolving the functions up front “and then makes the GOT read-only.” With full RELRO, “the application crashes with SIGSEGV when an attempt is made to overwrite the GOT” — the exploit’s write hits a read-only page and the process dies instead of being hijacked. This converts a code-execution primitive into a mere denial-of-service, a large reduction in severity. Partial RELRO is materially weaker here: because .got.plt (the lazily-bound function pointers) stays writable, the classic GOT-overwrite of an imported function is not prevented — partial RELRO mainly reorders sections and protects .got/.init_array, but the lazy PLT slots remain a target.

Code and Specification

Producing each RELRO level and detecting it:

# Full RELRO: relro + eager binding, entire GOT locked
$ gcc -Wl,-z,relro,-z,now  -o app_full  app.c
# Partial RELRO: relro only, .got.plt stays writable
$ gcc -Wl,-z,relro         -o app_part  app.c
# No RELRO
$ gcc -Wl,-z,norelro       -o app_none  app.c

Red Hat’s exact enabling line is gcc -g -O0 -Wl,-z,relro,-z,now -o <binary> <source> (Red Hat). Detection with the two standard tools:

$ checksec --file=./app_full
   RELRO           : Full RELRO
$ checksec --file=./app_part
   RELRO           : Partial RELRO
 
$ readelf -l ./app_full | grep GNU_RELRO           # presence of the segment
  GNU_RELRO      0x0000000000002dd0 ...  0x230 0x230  R   0x1
$ readelf -d ./app_full | grep -E 'BIND_NOW|FLAGS'  # eager-binding marker => Full
  0x000000000000001e (FLAGS)   BIND_NOW
  0x000000006ffffffb (FLAGS_1) Flags: NOW PIE

Reading the last block: the presence of GNU_RELRO in the program headers means some RELRO; the BIND_NOW/DF_1_NOW dynamic flag distinguishes full (eager) from partial (lazy) RELRO. checksec collapses this into the “Full RELRO” vs “Partial RELRO” verdict by checking for both the segment and the flag.

Failure Modes and Common Misunderstandings

Partial RELRO does not stop GOT-function-pointer overwrites. The most common misconception is that “RELRO protects the GOT.” Only full RELRO protects the whole GOT; partial RELRO leaves .got.plt — the lazily-bound imported-function pointers, the juiciest target — writable. A binary reported as “Partial RELRO” by checksec is still exploitable via GOT overwrite of an imported function. If you care about the mitigation, you need full RELRO.

Full RELRO cannot coexist with lazy binding. This is not a limitation to work around but a definition: locking .got.plt and having the lazy resolver write it are contradictory. Attempting -z relro -z now “disables lazy binding PLT.” The only escape is the MPK-based read-only-with-lazy scheme above, which needs hardware support and toolchain buy-in.

Startup cost. Full RELRO “has a slight performance impact during application startup (as the linker has to populate the GOT entries before entering the main function)” (Red Hat) — this is just the eager-binding cost from Lazy Binding: every imported symbol is resolved up front instead of on demand. For a program that calls only a fraction of what it imports and is short-lived, this is pure overhead; for a long-running daemon it is a negligible one-time cost. It is steady-state free — there is no per-call penalty.

RELRO does not cover .data, the stack, or the heap. RELRO freezes only relocation/init data. Function pointers stored in ordinary writable globals, on the heap, or on the stack are not protected — those need other mitigations (stack canaries, CFI, pointer authentication). RELRO is one layer, aimed specifically at the linker-managed pointer tables.

Page granularity surprises. Because mprotect is page-granular, the linker must isolate RELRO data onto its own pages; a small object placed adjacent to the RELRO region can force padding, and a linker script that mislays sections can leave part of the intended region writable. readelf -l showing the exact GNU_RELRO extent is the way to confirm what is actually frozen.

Alternatives and When to Choose Them

  • No RELRO (-z norelro). Fastest startup, weakest posture; the GOT and init arrays stay writable for the process lifetime. Only defensible for throwaway or trusted-input tooling.
  • Partial RELRO (-z relro). Locks .got, .init_array, .dynamic, etc., but keeps lazy binding. A compromise that protects the constructor/destructor tables and non-PLT GOT while preserving lazy-binding startup latency — reasonable when startup latency dominates and the threat model does not include imported-function GOT overwrite.
  • Full RELRO (-z relro -z now). The security default. Locks the entire GOT; pairs naturally with -fno-plt (which already forces eager binding of those calls; see Lazy Binding) and with PIE to give ASLR + non-writable GOT. Choose for anything security-relevant.
  • Full RELRO + MPK read-only-with-lazy. Bleeding-edge; recovers lazy-binding latency on hardware with protection keys. Rare in practice.

Production Notes

The vault’s recurring finding applies directly here: Fedora ships full RELRO + -z now as distribution policy, not as an upstream default. Red Hat states plainly that “all ELF binaries shipped with Fedora version 23 and later are built with full RELRO support” (Red Hat), enabled through packaging flags (redhat-rpm-config build flags injecting -Wl,-z,relro,-z,now), and Fedora 28 extended the toolchain hardening further (Fedora HardeningFlags28). Upstream binutils, by contrast, does not force -z now, and whether a plain ld link gets partial or full RELRO depends on how the toolchain was configured. So the same caveat as PIE: do not state “Linux binaries have full RELRO” as a universal truth — it is true of distro-packaged binaries and false of many from-source or minimal-container builds unless the flags are passed explicitly. Debian, Ubuntu, Arch, and Gentoo Hardened make comparable choices, but the specific level and the arch coverage differ per distro and per release.

Uncertain

Verify: the exact current default RELRO level per distribution/architecture (2026) and whether any mainstream distro now defaults to the MPK-based “read-only GOT with lazy binding” rather than full RELRO. Reason: hardening defaults are per-distro/per-arch and evolve; my sources confirm Fedora ≥23 full RELRO and the F28 toolchain changes but not a complete 2026 matrix, and the MPK path’s deployment status is unclear. To resolve: inspect each distro’s current build-flags packaging and run checksec/readelf -d on stock binaries. uncertain

Operationally, checksec --file= (or hardening-check) is the fast audit; for a fleet, scanning readelf -d | grep BIND_NOW distinguishes full from partial across many binaries. When a hardened binary crashes with SIGSEGV on a write to an address inside the GNU_RELRO extent, that is very often the mitigation working — a bug (or exploit) tried to write a frozen pointer table. And when integrating a JIT or a plugin system that legitimately needs to patch function pointers, remember that those pointers must live outside the RELRO region (e.g. in ordinary .data), or the write will fault after _dl_protect_relro runs.

See Also