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.

Which authority governs which claim

RELRO is not a kernel mechanism. It is a three-party contract, and it matters which party you consult for which fact, because each of them has changed behaviour independently over the years:

PartyArtifact it ownsWhat it decidesAuthority consulted here
Linker (GNU ld, gold, lld)the PT_GNU_RELRO program header; section ordering and paddingwhich bytes are nominated for freezing, and where the page boundaries fallGNU ld 2.46.1 manual (binutils ld, “Options”) and the default linker script dumped with ld --verbose on the test machine
Dynamic loader (glibc ld.so)the mprotect(2) callwhen the freeze happens and exactly which pages it lands onglibc elf/dl-reloc.c, function _dl_protect_relro (glibc 2.41 source)
ABI (Linux/GNU extensions to the System V gABI)the constants PT_GNU_RELRO, DT_BIND_NOW, DF_BIND_NOW, DF_1_NOWthe on-disk encoding both sides agree on/usr/include/elf.h from glibc 2.43, cross-checked against the base gABI program-header chapter

The kernel’s only role is mprotect(2) itself — it has no notion of RELRO, no PT_GNU_RELRO handling in fs/binfmt_elf.c, and no policy knob for it. (Verified: PT_GNU_RELRO does not appear anywhere in Linux v6.12’s fs/binfmt_elf.c.) That is the opposite of the situation in PIE, where the randomization half genuinely is kernel code. If you find yourself looking for a kernel sysctl that turns RELRO on, there isn’t one and there cannot be one: the decision was baked into the binary at link time.

The measurement environment for this note

Every command output reproduced below was run by the author on one machine on 2026-09-11, and the numbers are that machine’s, not folklore:

ComponentVersion
DistributionFedora 44
Kernel7.1.13-200.fc44.x86_64, x86-64
Compilergcc (GCC) 16.2.1 20260819 (Red Hat 16.2.1-2)
Linker / readelfGNU ld / readelf 2.46.1-1.fc44
C library / loaderglibc 2.43-8.fc44
randomize_va_space2 (default)

The exact source file used throughout is a small C program (gotwrite.c, listed in full below) that walks its own _DYNAMIC array to find DT_PLTGOT, prints the /proc/self/maps line covering each GOT slot, and then — optionally — performs the write an attacker would perform. Three builds of it differ only in linker flags.

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.

The same story told as a timeline makes the ordering constraint visible, which is the part people get wrong. The mprotect cannot be moved earlier without the loader faulting on its own writes, and it cannot be moved later without leaving a window in which the constructors in .init_array run against a still-writable GOT:

sequenceDiagram
  autonumber
  participant K as Kernel<br/>(fs/binfmt_elf.c)
  participant L as ld.so<br/>(glibc)
  participant M as mprotect(2)
  participant P as Program

  K->>L: map PT_LOAD segments (RW page carries .got)<br/>transfer control to the interpreter
  L->>L: read PT_GNU_RELRO -> l_relro_addr, l_relro_size
  Note over L: _dl_relocate_object_no_relro()
  L->>L: apply .rela.dyn (RELATIVE, GLOB_DAT)<br/>— WRITES into the RELRO region
  alt -z now (full RELRO)
    L->>L: apply ALL .rela.plt JUMP_SLOTs eagerly<br/>— also writes the RELRO region
  else lazy (partial RELRO)
    L->>L: write only PLTGOT[1] and PLTGOT[2]<br/>(link_map, _dl_runtime_resolve)
  end
  Note over L,M: _dl_protect_relro()
  L->>M: mprotect(ALIGN_DOWN(start), ALIGN_DOWN(end)-…, PROT_READ)
  M-->>L: region now r--
  L->>P: run .init_array constructors, then main()
  alt full RELRO
    P--xP: any write to a GOT slot -> SIGSEGV
  else partial RELRO
    P->>P: first call to foo() -> _dl_runtime_resolve<br/>patches PLTGOT[n] on a still-writable page
  end

What it shows: the fixed ordering — map, relocate (writing the region), then and only then mprotect — and where the two RELRO levels diverge inside step 4. The insight to take: RELRO is not a property the kernel enforces at load; it is a self-imposed, one-shot permission downgrade that the loader performs on itself, at a single point in time, and everything about the mitigation’s strength follows from what has already been written by that moment.

Physically, RELRO is a page-boundary story. The linker’s job is to arrange the writable data so that everything freezable sits below a page boundary and everything that must stay writable sits above it:

flowchart LR
  subgraph BEFORE["Writable data, no RELRO (-z norelro)"]
    direction TB
    b1[".init_array<br/>.fini_array"]:::rw
    b2[".dynamic"]:::rw
    b3[".got"]:::rw
    b4[".got.plt<br/>(PLT slots)"]:::rw
    b5[".data / .bss"]:::rw
    b1 --- b2 --- b3 --- b4 --- b5
  end
  subgraph AFTER["Writable data, full RELRO (-z relro -z now)"]
    direction TB
    a1[".init_array<br/>.fini_array"]:::ro
    a2[".dynamic"]:::ro
    a3[".got<br/>incl. former .got.plt"]:::ro
    pb(("page<br/>boundary")):::pb
    a5[".data / .bss"]:::rw
    a1 --- a2 --- a3 --- pb --- a5
  end
  BEFORE ==>|"link with -z relro -z now,<br/>then ld.so mprotects"| AFTER
  classDef rw fill:#f9d5d3,stroke:#b23,color:#000
  classDef ro fill:#d3ecd5,stroke:#282,color:#000
  classDef pb fill:#eee,stroke:#666,color:#000,stroke-dasharray: 4 3

What it shows: the same writable data before and after hardening — red is rw-, green is r--. The insight to take: the mitigation is purely a question of where the page boundary falls relative to the pointer tables. Everything above the boundary (.data, .bss, heap, stack) is untouched by RELRO no matter how the binary is linked, which is why “RELRO protects my function pointers” is only true for the linker-managed ones.

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.

The program header is a GNU extension, not base ELF. It is worth being precise about the layering, because it explains why RELRO behaves differently across toolchains. The System V generic ABI’s program-header chapter defines exactly eight segment types — PT_NULL through PT_TLS — plus the operating-system-reserved range PT_LOOS (0x60000000) to PT_HIOS (0x6fffffff) (gABI ch. 5). PT_GNU_RELRO is not in that list; it is a GNU-allocated value inside the OS range. The local glibc 2.43 /usr/include/elf.h gives the exact numbers:

#define PT_LOOS       0x60000000  /* Start of OS-specific */
#define PT_GNU_RELRO  0x6474e552  /* Read-only after relocation */
...
#define DT_BIND_NOW   24          /* Process relocations of object */
#define DT_FLAGS      30          /* Flags for the object being loaded */
#define DT_FLAGS_1    0x6ffffffb  /* State flags, see DF_1_* below.  */
#define DF_BIND_NOW   0x00000008  /* No lazy binding for this object */
#define DF_1_NOW      0x00000001  /* Set RTLD_NOW for this object.  */

Reading these line by line: PT_GNU_RELRO is the segment that nominates a byte range for freezing. Eager binding is signalled three redundant ways for historical reasons — the standalone DT_BIND_NOW tag (oldest), the DF_BIND_NOW bit inside DT_FLAGS, and the DF_1_NOW bit inside DT_FLAGS_1 — which is why readelf -d on a -z now binary prints both a FLAGS BIND_NOW line and a FLAGS_1 Flags: NOW line for what is a single linker decision. A tool that checks only one of the three can misclassify a binary.

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). But the authoritative answer is not prose at all — it is the default linker script, which any GNU ld will print on demand with ld --verbose. On the test machine (binutils 2.46.1) the RELRO region is delimited by two builtin functions, DATA_SEGMENT_ALIGN and DATA_SEGMENT_RELRO_END, and everything the script places between them is what ends up inside PT_GNU_RELRO:

Order in scriptSectionWhy it is freezable
1.eh_frame, .sframe, .gnu_extab, .gcc_except_table, .exception_rangesonly the ONLY_IF_RW variants — unwind tables that needed relocating
2.tdata, .tbssthe TLS initialization image, copied per-thread but never written in place
3.preinit_arraypre-constructor function-pointer table; run once, never rewritten
4.init_arrayconstructor pointer table (C++ static ctors, __attribute__((constructor)))
5.fini_arraydestructor pointer table
6.ctors, .dtors, .jcrlegacy equivalents of 4/5 plus the Java class registry
7.data.rel.ro“read-only data that needed relocations” — most importantly C++ vtables in a PIC object
8.dynamicthe dynamic section; ld.so patches DT_DEBUG and (historically) d_ptr fields into it
9.gotthe non-PLT GOT — data symbols and, under -z now, function symbols too
—DATA_SEGMENT_RELRO_ENDthe cut line
10.got.pltlazily-patched PLT slots (absent entirely under -z now, see below)
11.data, .data1, .bssordinary mutable globals — never in RELRO

Table: the contents of the RELRO region, in the order GNU ld 2.46.1’s default script emits them. The insight to take: .data.rel.ro is the quiet heavyweight here — in C++ code it holds the vtables, so full RELRO is also a (weak, partial) vtable-hijacking mitigation, a fact that rarely appears in the “RELRO protects the GOT” summaries.

The cut line itself is the interesting part. The script does not simply end the region after .got; it says:

  .got            : { *(.got) *(.igot) }
  . = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 24 ? 24 : 0, .);
  .got.plt        : { *(.got.plt) *(.igot.plt) }

Read that middle line carefully: if .got.plt exists and is at least 24 bytes, the RELRO region is extended 24 bytes into .got.plt — exactly three 8-byte entries on a 64-bit target. Those three are the reserved PLT-GOT header: PLTGOT[0] holds the address of .dynamic, PLTGOT[1] the struct link_map * for this object, and PLTGOT[2] the address of _dl_runtime_resolve (see The Procedure Linkage Table). They are written once by the loader and never again, so they can be frozen — and freezing PLTGOT[2] in particular denies an attacker the single most valuable target in a lazily-bound binary, because overwriting the resolver pointer hijacks every not-yet-resolved call at once. The SIZEOF(...) >= 24 ? 24 : 0 guard exists because the whole expression must degrade gracefully when a link produces no PLT at all.

The mechanism by which that works is spelled out in the linker-script reference: with -z relro in effect, DATA_SEGMENT_RELRO_END(offset, exp) causes DATA_SEGMENT_ALIGN to be “padded so that exp + offset is aligned to the commonpagesize argument given to DATA_SEGMENT_ALIGN” (binutils, builtin functions). In plain terms: the linker inserts however much padding is needed earlier in the writable segment so that the RELRO end lands exactly 24 bytes short of a page boundary — leaving room for precisely the three reserved .got.plt entries to complete that page. This is a layout computation done backwards from the page grid, which is why hardened binaries often carry a few hundred bytes of otherwise pointless padding in the writable segment; the padding is the price of the alignment.

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). That is the right shape but not the exact code, and the difference matters. In glibc the entire runtime implementation is nine lines in elf/dl-reloc.c (glibc 2.41):

void
_dl_relocate_object (struct link_map *l, struct r_scope_elem *scope[],
		     int reloc_mode, int consider_profiling)
{
  if (l->l_relocated)
    return;
  _dl_relocate_object_no_relro (l, scope, reloc_mode, consider_profiling);
  _dl_protect_relro (l);                      /* <-- strictly after relocation */
}
 
void
_dl_protect_relro (struct link_map *l)
{
  if (l->l_relro_size == 0)
    return;
 
  ElfW(Addr) start = ALIGN_DOWN ((l->l_addr + l->l_relro_addr),
				 GLRO(dl_pagesize));
  ElfW(Addr) end   = ALIGN_DOWN ((l->l_addr + l->l_relro_addr
				  + l->l_relro_size),
				 GLRO(dl_pagesize));
  if (start != end
      && __mprotect ((void *) start, end - start, PROT_READ) < 0)
    {
      static const char errstring[] = N_("\
cannot apply additional memory protection after relocation");
      _dl_signal_error (errno, l->l_name, NULL, errstring);
    }
}

Symbol by symbol: l->l_addr is the object’s load bias (zero for a non-PIE executable, the random base for a PIE or a shared library — see Position-Independent Code and PIE); l->l_relro_addr and l->l_relro_size are p_vaddr and p_memsz copied straight out of the PT_GNU_RELRO header when the object was mapped; GLRO(dl_pagesize) is the runtime page size, read from the AT_PAGESZ auxiliary vector rather than compiled in.

The two ALIGN_DOWN calls are the whole story of RELRO’s sharp edges, and they are not symmetric in their consequences:

  • Rounding the start down means the loader freezes from the beginning of the page containing the RELRO region, not from l_relro_addr itself. Anything writable that happens to share that first page gets frozen too. This is why ld must pad rather than merely order sections: if it let ordinary .data share a page with .init_array, the loader would silently make that .data read-only and the program would crash on its first store.
  • Rounding the end down means a partial trailing page is not frozen. Bytes nominated by PT_GNU_RELRO but living above the last full page boundary stay writable. This is the mechanical reason the linker bends over backwards (via DATA_SEGMENT_RELRO_END) to make the region end exactly on a page boundary — a region that ends mid-page silently loses its tail.
  • if (start != end) is the degenerate case: a RELRO region smaller than one page, or one that does not span a full page, results in no mprotect call at all. The segment is present in readelf -l, the tool reports “RELRO”, and nothing whatsoever is protected.

The ordering constraint is absolute in both directions. Relocation writes the region, so the mprotect must come after; and the region contains .init_array, whose function pointers are about to be dereferenced, so the mprotect must come before constructors run. _dl_relocate_object enforces both by calling them back to back, per object, with l_relocated guarding against a second pass.

Where the mprotect actually landed — measured. Both roundings are directly observable. For the partial-RELRO build below, readelf -l reports the region as starting at 0x402df8 with size 0x208. Applying the formula: ALIGN_DOWN(0x402df8, 0x1000) = 0x402000 and ALIGN_DOWN(0x402df8 + 0x208, 0x1000) = ALIGN_DOWN(0x403000, 0x1000) = 0x403000, so the loader should call mprotect(0x402000, 0x1000, PROT_READ). And /proc/self/maps, printed by the process itself, shows exactly that one page flipped:

$ ./gw_partial
  _DYNAMIC               0x402e08  ->  00402000-00403000 r--p ... /gw_partial
  DT_PLTGOT[0]           0x402fe8  ->  00402000-00403000 r--p ... /gw_partial
  DT_PLTGOT[3] (1st slot) 0x403000 ->  00403000-00404000 rw-p ... /gw_partial

The page containing .dynamic, .got and the three reserved PLT-GOT entries is r--p; the very next page, which begins exactly at the first real JUMP_SLOT, is still rw-p. That boundary is not a coincidence — it is the DATA_SEGMENT_RELRO_END(24, .) computation and glibc’s ALIGN_DOWN agreeing to the byte.

That the three reserved entries really are what the script assumes is easy to check. A one-file program that walks _DYNAMIC for DT_PLTGOT and prints the first few slots, built -no-pie -z relro -z lazy, gives:

$ ./pltgot
&_DYNAMIC  = 0x402e08
PLTGOT     = 0x402fe8
PLTGOT[0]  = 0x402e08        <- identical to &_DYNAMIC
PLTGOT[1]  = 0x7f9d55ffb2e0  <- struct link_map *, inside ld.so's mapping
PLTGOT[2]  = 0x7f9d55fd4760  <- _dl_runtime_resolve, inside ld.so's mapping
PLTGOT[3]  = 0x7f9d55dd3990  <- first real JUMP_SLOT (already bound)

PLTGOT[0] matching &_DYNAMIC to the byte confirms the layout; PLTGOT[1] and PLTGOT[2] pointing into the loader’s own mapping confirms that those two are the loader’s lazy-binding scratch slots.

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).

The description above is one toolchain generation out of date

Both of those quotes describe .got.plt being moved into PT_GNU_RELRO. On binutils 2.46.1 that is no longer what happens on x86-64: under -z now the linker does not emit a .got.plt section at all. The PLT slots are allocated inside .got, and DT_PLTGOT points at .got rather than at a separate .got.plt. Measured on the same source, only the link flags differing:

$ readelf -SW gw_partial | grep -E ' \.got|\.got\.plt'
  [22] .got      PROGBITS  0000000000402fd8 001fd8 000010 08  WA  0  0  8
  [23] .got.plt  PROGBITS  0000000000402fe8 001fe8 000060 08  WA  0  0  8
 
$ readelf -SW gw_full | grep -E ' \.got|\.got\.plt'
  [22] .got      PROGBITS  0000000000403f90 002f90 000070 08  WA  0  0  8

0x70 is exactly 0x10 + 0x60 — the two sections fused, not one deleted. And the JUMP_SLOT relocations in the full-RELRO build target offsets inside that merged .got:

$ readelf -rW gw_full | grep JUMP_SLOT | head -3
0000000000403fa8  ... R_X86_64_JUMP_SLOT  __isoc23_sscanf@GLIBC_2.38 + 0
0000000000403fb0  ... R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5 + 0
0000000000403fb8  ... R_X86_64_JUMP_SLOT  fclose@GLIBC_2.2.5 + 0

The security outcome is identical — everything is inside GNU_RELRO either way — but the observable artifacts differ, and any audit script that greps for a .got.plt section to decide “is the PLT GOT protected?” will now answer the question wrongly on a modern full-RELRO binary: the section is absent because it was merged, not because something is missing. Check the GNU_RELRO extent against the JUMP_SLOT relocation offsets instead.

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.

sequenceDiagram
  autonumber
  actor A as Attacker
  participant V as Vulnerable code<br/>(controlled write)
  participant G as GOT slot for printf()
  participant C as CPU
  participant S as system() / gadget

  A->>V: input crafted so a write lands at &GOT[printf]
  alt no RELRO, or partial RELRO (slot is rw-)
    V->>G: store 0x...address_of_system
    G-->>V: write succeeds, silently
    Note over A,G: process looks completely healthy
    C->>G: later: call *(GOT[printf]) via the PLT
    G-->>C: returns attacker's address
    C->>S: control transferred — arbitrary code execution
  else full RELRO (slot is r--)
    V--xG: store faults: page is PROT_READ
    Note over V,G: kernel delivers SIGSEGV at the moment of the write
    C-->>A: process dies — primitive degraded to denial of service
  end

What it shows: the same arbitrary-write primitive against a writable versus a frozen GOT slot. The insight to take: full RELRO does not detect the attack or sanitize the pointer — it moves the failure from “much later, on the next call, as a jump to attacker code” to “right now, at the store, as a fault.” The severity reduction comes entirely from the fault happening at the write rather than at the use.

This is directly testable, and the result is unambiguous. The gotwrite.c program takes a GOT index on the command line and performs exactly the store an exploit would perform — got_plt[idx] = (void *)0xdeadbeef. Slot 5 is a real JUMP_SLOT (the entry for fclose in this build). Three binaries, identical source, differing only in -z flags:

$ ./gw_norelro 5 ; echo "exit=$?"
attempting write to DT_PLTGOT[5] at 0x402810 ...
WRITE SUCCEEDED -- slot is still writable (mitigation absent)
exit=0
 
$ ./gw_partial 5 ; echo "exit=$?"
attempting write to DT_PLTGOT[5] at 0x403010 ...
WRITE SUCCEEDED -- slot is still writable (mitigation absent)
exit=0
 
$ ./gw_full 5 ; echo "exit=$?"
attempting write to DT_PLTGOT[5] at 0x403fb8 ...
exit=139

Exit status 139 is 128 + 11, i.e. the shell’s encoding of death by SIGSEGV. Note what the middle case says: partial RELRO did not stop the write. The binary has a GNU_RELRO segment, readelf reports it, a naive checker calls it “hardened”, and the canonical GOT-overwrite attack succeeds against it unchanged. That is the single most important practical fact in this note, and it is the one most often stated backwards.

Note also which addresses those are. Under no RELRO the slot sits at 0x402810, comfortably inside the single writable page. Under partial RELRO the linker has pushed it up to 0x403010 — onto the page after the frozen one, deliberately, so that lazy binding can still write it. Under full RELRO it is at 0x403fb8, back inside the frozen region because there is no longer any lazy writer to accommodate. The linker moves the target around in response to the flags; the flags do not merely annotate a fixed layout.

Code and Specification

The probe program

Everything measured in this note comes from one file. It is worth reading because it shows how little machinery is needed to inspect your own GOT from inside a running process — no debugger, no ptrace, no root:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <link.h>
 
extern ElfW(Dyn) _DYNAMIC[];          /* the linker always defines this symbol */
 
static void show_page_perms(const char *what, void *addr) {
    unsigned long a = (unsigned long)addr;
    char line[512];
    FILE *f = fopen("/proc/self/maps", "r");
    while (fgets(line, sizeof line, f)) {
        unsigned long lo, hi;
        if (sscanf(line, "%lx-%lx", &lo, &hi) == 2 && a >= lo && a < hi) {
            line[strcspn(line, "\n")] = 0;
            printf("  %-22s %p  ->  %s\n", what, addr, line);
            break;
        }
    }
    fclose(f);
}
 
int main(int argc, char **argv) {
    void **got_plt = NULL;
    for (ElfW(Dyn) *d = _DYNAMIC; d->d_tag != DT_NULL; ++d)
        if (d->d_tag == DT_PLTGOT) got_plt = (void **)d->d_un.d_ptr;
 
    printf("DT_PLTGOT (.got.plt or .got) = %p\n", (void *)got_plt);
    show_page_perms("_DYNAMIC", _DYNAMIC);
    show_page_perms("DT_PLTGOT[0]", &got_plt[0]);
    show_page_perms("DT_PLTGOT[3] (1st slot)", &got_plt[3]);
    show_page_perms("DT_PLTGOT[5]", &got_plt[5]);
 
    if (argc > 1) {
        long idx = strtol(argv[1], NULL, 0);
        printf("attempting write to DT_PLTGOT[%ld] at %p ...\n", idx, (void *)&got_plt[idx]);
        fflush(stdout);
        got_plt[idx] = (void *)0xdeadbeef;      /* the "arbitrary write" */
        printf("WRITE SUCCEEDED -- slot is still writable (mitigation absent)\n");
    }
    return 0;
}

Line by line on the parts that matter:

  • extern ElfW(Dyn) _DYNAMIC[] — every dynamically linked ELF object defines _DYNAMIC as the address of its own .dynamic section. ElfW(x) is glibc’s <link.h> macro that expands to Elf64_x or Elf32_x for the host word size, so the same source is correct on both. This is the standard, portable way for an object to find its own dynamic section without parsing its own headers.
  • The for loop scans .dynamic for DT_PLTGOT, the tag whose value is the runtime address of the PLT’s GOT. Crucially this is self-describing: it points at .got.plt on a lazily-bound binary and at the merged .got on a -z now binary, so the probe follows the toolchain’s choice instead of assuming one.
  • show_page_perms re-reads /proc/self/maps — the kernel’s live view of the process’s own mappings — and prints the line whose [lo, hi) range contains the address. This reports the permissions as the kernel currently sees them, i.e. after ld.so has (or has not) called mprotect. It is the only honest way to answer “is my GOT writable right now”; the ELF headers cannot answer it.
  • got_plt[idx] = (void *)0xdeadbeef is the entire exploit primitive, reduced to one store. fflush(stdout) before it matters: if the store faults, unflushed output would be lost and the transcript would look like the program never ran.
  • Index 3 is the first non-reserved slot (indices 0–2 are the reserved header described earlier); index 5 is a real imported function in these builds.

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 — here is the real output from the test machine for a PIE + full-RELRO build (gcc -O2 -pie -fPIE -Wl,-z,relro,-z,now):

$ readelf -lW ./app_pie_full | grep GNU_RELRO            # presence of the segment
  GNU_RELRO  0x001d70 0x0000000000002d70 0x0000000000002d70 0x000290 0x000290 R  0x1
 
$ readelf -dW ./app_pie_full | grep -E 'BIND_NOW|FLAGS'  # eager-binding marker => Full
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE

Column by column on the GNU_RELRO line: 0x001d70 is the file offset, the two 0x...2d70 values are the virtual and physical addresses (unbiased, because this is a PIE — the real address is this plus the random load bias), 0x000290 is p_filesz then p_memsz, R is p_flags (read-only, which is what the segment is asking for, not what the pages are before relocation), and 0x1 is p_align — deliberately 1, because PT_GNU_RELRO is a description, not a mapping, so it imposes no alignment of its own.

The Flags: NOW PIE line pairs DF_1_NOW (0x00000001) with DF_1_PIE (0x08000000). The PIE bit only appears on a PIE; the identical build with -no-pie prints Flags: NOW alone —

$ readelf -dW ./app_full | grep FLAGS_1       # same source, -no-pie
 0x000000006ffffffb (FLAGS_1)            Flags: NOW

— which is the marker that lets modern tools tell a PIE apart from an ordinary shared object, both of which are ET_DYN. See Position-Independent Code and 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.

Doing the audit without checksec

checksec is not installed on many systems (it is not present on the Fedora 44 test machine), and it is worth knowing that its RELRO verdict is a two-line decision you can reproduce from readelf alone. The whole rule is: segment absent → No RELRO; segment present but no eager-binding marker → Partial; both → Full.

#!/bin/sh
# relrocheck.sh — compute the checksec-style RELRO verdict from readelf output alone.
for f in "$@"; do
  seg=$(readelf -lW "$f" 2>/dev/null | grep -c GNU_RELRO)                       # the segment
  now=$(readelf -dW "$f" 2>/dev/null | grep -Ec 'BIND_NOW|Flags:.*\bNOW\b')     # any of the 3 markers
  if   [ "$seg" -eq 0 ]; then verdict="No RELRO"
  elif [ "$now" -eq 0 ]; then verdict="Partial RELRO"
  else                        verdict="Full RELRO"
  fi
  printf '%-14s  GNU_RELRO=%s  BIND_NOW markers=%s  ->  %s\n' "$f" "$seg" "$now" "$verdict"
done

Line by line: readelf -lW lists program headers, so counting GNU_RELRO answers “is there a RELRO segment?”; readelf -dW lists the dynamic section, and the grep -E catches both the (FLAGS) BIND_NOW line and the (FLAGS_1) Flags: NOW line, because as shown above the linker sets more than one marker and different linkers set different subsets. -W (wide) is not optional — without it readelf truncates the columns and the greps become fragile. Run against the three builds plus a few system binaries:

$ ./relrocheck.sh app_norelro app_partial app_full /usr/bin/ls /usr/bin/gcc /usr/lib64/libc.so.6
app_norelro     GNU_RELRO=0  BIND_NOW markers=0  ->  No RELRO
app_partial     GNU_RELRO=1  BIND_NOW markers=0  ->  Partial RELRO
app_full        GNU_RELRO=1  BIND_NOW markers=2  ->  Full RELRO
/usr/bin/ls     GNU_RELRO=1  BIND_NOW markers=2  ->  Full RELRO
/usr/bin/gcc    GNU_RELRO=1  BIND_NOW markers=0  ->  Partial RELRO
/usr/lib64/libc.so.6  GNU_RELRO=1  BIND_NOW markers=2  ->  Full RELRO

markers=2 is the two redundant flags (DT_FLAGS/DF_BIND_NOW and DT_FLAGS_1/DF_1_NOW) discussed earlier. The /usr/bin/gcc line is not a typo and is discussed in Production Notes below.

What actually changed in the ELF, level by level

The three builds are the same 30-line C file; only -z flags differ. Side by side, from readelf -lW and readelf -SW on the test machine:

Observable-z norelro-z relro -z lazy-z relro -z now
GNU_RELRO program headerabsent0x402df8 + 0x2080x403d90 + 0x270
PT_LOAD segments334 (read-only reloc data split out)
.got0x4027d8, 0x100x402fd8, 0x100x403f90, 0x70
.got.plt0x4027e8, 0x600x402fe8, 0x60section does not exist
DT_PLTGOT points at.got.plt.got.plt.got (merged)
DT_FLAGS / DT_FLAGS_1absentabsentBIND_NOW / Flags: NOW
Sections inside RELRO—.init_array .fini_array .dynamic .got + 24 B of .got.plt.init_array .fini_array .dynamic .got (all of it)
Runtime perms of the page holding .dynamicrw-pr--pr--p
Runtime perms of the page holding the first JUMP_SLOTrw-prw-pr--p
Write to a JUMP_SLOT at runtimesucceedssucceedsSIGSEGV
Lazy binding still worksyesyesno (eager)
Link flags-Wl,-z,norelro-Wl,-z,relro-Wl,-z,relro,-z,now
Startup costnonenoneone symbol lookup per import (measured below)
Steady-state costnonenonenone (and no resolver trap on first call)

Table: the three RELRO levels as observed, not as described. The insight to take: the row that decides whether the mitigation works is “runtime perms of the page holding the first JUMP_SLOT”, and partial RELRO shares its value with no RELRO at all.

Two rows in that table are worth attributing correctly, because they are caused by -z now and not by -z relro. Linking the same source four ways isolates it:

$ for b in app_norelro app_partial app_full app_nownorelro; do
>   echo "$b: LOAD=$(readelf -lW $b|grep -c '^  LOAD') relro=$(readelf -lW $b|grep -c GNU_RELRO) gotplt=$(readelf -SW $b|grep -c '\.got\.plt')"
> done
app_norelro:    LOAD=3  relro=0  gotplt=1     # -z norelro
app_partial:    LOAD=3  relro=1  gotplt=1     # -z relro -z lazy
app_full:       LOAD=4  relro=1  gotplt=0     # -z relro -z now
app_nownorelro: LOAD=4  relro=0  gotplt=0     # -z norelro -z now

The fourth build is the diagnostic one. With -z now but without -z relro, the .got.plt merge and the extra PT_LOAD still happen, and there is still no GNU_RELRO segment — so eager binding is paid for and nothing at all is frozen. -z norelro -z now is a legal, silently-accepted, entirely pointless combination: it is the worst of both worlds and no tool warns about it. It appears in the wild when a build system hard-codes -z now for startup-determinism reasons and a separate one passes -z norelro to work around an unrelated problem.

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 — measured, not asserted. 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. “Slight” is doing a lot of work in that sentence, so it is worth bounding it from both ends with an actual experiment.

The cost scales with the number of imported functions that are never called, so the honest way to measure it is to build the worst case deliberately. A generated shared library exports 5,000 functions; a generated program declares all 5,000, but on the normal path calls exactly one. Linked twice, identically except for -z lazy versus -z now:

$ readelf -rW big_lazy | grep -c JUMP_SLOT
5000
$ readelf -dW big_now | grep -c BIND_NOW
1
 
$ bench() { t0=$(date +%s%N); for i in $(seq 2000); do ./$1 >/dev/null; done; \
>           t1=$(date +%s%N); echo "$1: $(( (t1-t0)/2000/1000 )) us/exec"; }
$ bench big_lazy ; bench big_now ; bench big_lazy ; bench big_now
big_lazy: 392 us/exec
big_now:  642 us/exec
big_lazy: 392 us/exec
big_now:  621 us/exec

Three repetitions of the pair agreed to within 4%. The eager-binding surcharge is ≈ 230–250 µs for 5,000 unused imports, i.e. roughly 45–50 ns per symbol resolved — which is the cost of one lookup-scope walk plus a hash-table probe, and is exactly what you would expect. In this contrived worst case it is ~60% of total process startup.

Now the realistic end. The same benchmark on a trivial C program with no JUMP_SLOT relocations at all, and on the PIE/non-PIE pair:

$ readelf -rW tiny_lazy | grep -c JUMP_SLOT
0
$ bench tiny_lazy ; bench tiny_now
tiny_lazy: 304 us/exec
tiny_now:  309 us/exec

Five microseconds on a ~305 µs fork+exec+ld.so path — below this benchmark’s run-to-run noise, which was itself ±9 µs across repetitions. So both bounds are real and both matter:

Binary shapeImports resolved eagerlyExtra startup under -z nowAs a share of startup
Trivial C program (glibc only, all lazy slots already elided)0~5 µs (within noise)< 2%
Synthetic worst case (5,000 imports, 1 called)5,000230–250 µs~60%
Per-symbol marginal cost, derived—≈ 45–50 ns each—

Table: eager-binding startup cost measured on the test machine, 2,000 exec()s per data point. The insight to take: the cost is proportional to imports-never-called, not to program size, and for an ordinary binary it is unmeasurable. The “full RELRO is slow to start” folklore is calibrated to the second row, which almost nothing real looks like.

For a program that calls only a fraction of what it imports and is short-lived, this is genuine overhead; for a long-running daemon it is a negligible one-time cost, and it removes a per-first-call resolver trap. It is steady-state free — there is no per-call penalty once binding is done either way.

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.

The page-size mismatch that silently disables RELRO entirely. This is the sharpest edge in the whole mechanism and it is stated plainly in the linker documentation: for -z relro, “Specifying common-page-size smaller than the system page size will render this protection ineffective” (binutils, ld options), repeated under DATA_SEGMENT_ALIGN as “note however that -z relro protection will not be effective if the system page size is larger than commonpagesize” (binutils, builtin functions).

The reason follows directly from the nine lines of _dl_protect_relro quoted earlier. The linker lays the region out to end on a common-page-size boundary — typically 4 KiB. If the running kernel’s page size is larger, say 64 KiB, then GLRO(dl_pagesize) is 64 KiB, and for a RELRO region of a few hundred bytes to a few kilobytes both ALIGN_DOWN calls land on the same 64 KiB boundary. start == end, the if (start != end) guard is false, and no mprotect call is made at all. The binary still has a GNU_RELRO program header. readelf -l still shows it. checksec and the script above still report “Full RELRO”. Nothing is protected.

This is not hypothetical: 64 KiB kernel pages are a supported and sometimes default configuration on aarch64 and ppc64le, both of which are primary architectures for enterprise distributions. A binary cross-linked with x86-defaults and run on a 64 KiB-page aarch64 kernel is the concrete failure case. The defence is to check permissions at runtime rather than trust the header — read /proc/<pid>/maps for the page containing a known GOT slot, as the gotwrite.c probe above does, instead of reading the ELF.

Uncertain

Verify: whether a current aarch64/ppc64le distribution actually ships binaries whose PT_GNU_RELRO is skipped under a 64 KiB-page kernel, or whether the toolchains for those targets set common-page-size to 64 KiB and avoid it. Reason: the failure mode above is derived — from the binutils documentation plus the verbatim _dl_protect_relro source — and could not be reproduced on the x86-64 test machine, where GNU ld 2.46.1 ignored -z common-page-size=512 and =256 and emitted an identical 4 KiB-aligned layout every time. To resolve: link a test binary on an aarch64 host with a 64 KiB-page kernel and read /proc/<pid>/maps for the GOT page, or instrument ld.so with LD_DEBUG / strace -e mprotect. uncertain

The region has silently under-covered before — this is a real, dated bug class. The end-of-region computation described above is not simple, and it has gone wrong in shipping binutils. PR ld/32690, fixed by H. J. Lu in February 2025, is the clearest example. lang_size_relro_segment_1 walks the RELRO sections from the top down, adjusting the “desired relro end” for each; on an x86-64 link with this layout —

section       vma       size    vma + size  alignment
.got          0x320268  0xd80    0x320fe8   8
.dynamic      0x31ffe8  0x280    0x320268   8
.data.rel.ro  0x2f3020  0x2cfc8  0x31ffe8   128
.fini_array   0x2f3008  0x8      0x2f3010   8
.init_array   0x2f3000  0x8      0x2f3008   8

— the walk drove the desired end from 0x321000 down through 0x320280, 0x320000, and finally 0x2f3000, which is below .fini_array’s own end. The resulting RELRO end was 0x2f3008, covering .init_array and nothing else: a 184 KiB .data.rel.ro (all the C++ vtables) plus .dynamic and the entire .got fell outside the segment (H. J. Lu, “ld: Avoid moving desired relro end below relro section”, binutils@sourceware.org, 2025-02-15). The fix “set[s] the desired relro end for the previous section … to the aligned end of the current section.”

The lesson generalizes past this one bug: a GNU_RELRO header being present says nothing about it being complete. The check that catches this class is comparing the segment’s [p_vaddr, p_vaddr + p_memsz) against the address ranges of .got, .dynamic, .data.rel.ro and .init_array from readelf -SW — which is more work than checksec does, and is the only way to notice a region that covers a tenth of what it should.

A RELRO region smaller than a page protects nothing, on any architecture. The same if (start != end) guard has a second, page-size-independent consequence. Any object whose entire RELRO region fits inside one page without crossing a boundary gets no mprotect. In practice the linker’s DATA_SEGMENT_RELRO_END alignment makes this rare for normal links, but it does occur for hand-written linker scripts, for -Ttext-style custom layouts, and for very small shared objects. strace -f -e trace=mprotect ./prog is the direct test: if there is no mprotect(..., PROT_READ) on the object’s writable range, RELRO did not happen regardless of what the headers claim.

Alternatives and When to Choose Them

Before the list: on a modern GNU toolchain the starting point is not “no RELRO”, it is partial RELRO. ld --help on binutils 2.46.1 states it outright —

$ ld --help | grep -iE 'relro|z now'
  -z now                      Mark object non-lazy runtime binding
  -z relro                    Create RELRO program header (default)
  -z norelro                  Don't create RELRO program header

— and linking a trivial object with a bare ld (no compiler driver, no distro specs, no LDFLAGS) confirms it: GNU_RELRO=1, BIND_NOW markers=0 → Partial RELRO. So does a plain gcc hello.c on the same machine, which produces a non-PIE ET_EXEC with partial RELRO. The upgrade you have to ask for is -z now; the downgrade you have to ask for is -z norelro. This matters when reading old advice: writeups from before roughly binutils 2.2x treat -z relro as something you add, and it is now something you would have to remove.

  • 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 — and note you must now explicitly ask for it, since -z relro is the linker default.
  • 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.

Where the flags actually come from, on three distributions

“Distribution policy” is vague until you can point at the file. All three of these were read directly, either off the test machine’s disk or from the project’s own wiki, on 2026-09-11:

DistributionMechanismVerbatimEffect
Fedora 44RPM macros in redhat-rpm-config-344-2.fc44, injected into every %build%build_ldflags begins -Wl,-z,relro …; %_hardening_ldflags is -Wl,-z,now %{_hardening_gcc_ldflags}; %_hardened_build 1full RELRO for anything built through RPM, nothing for anything else
Arch Linux/etc/makepkg.conf shipped by pacmanLDFLAGS="-Wl,-O1 -Wl,--sort-common -Wl,--as-needed -Wl,-z,relro -Wl,-z,now …", and notably CFLAGS="… -fno-plt …"full RELRO plus -fno-plt, which makes eager binding cheaper still
Debian / Ubuntudpkg-buildflags hardening featuresoptions named DEB_BUILD_HARDENING_RELRO (ld -z relro) and DEB_BUILD_HARDENING_BINDNOW (ld -z now) (Debian wiki, Hardening)RELRO everywhere; BIND_NOW listed in Ubuntu’s matrix as delivered by a “gcc patch (amd64, ppc64el, s390x), package list for others” across 22.04 LTS through 26.10 (Ubuntu wiki, Security/Features)

Table: the actual provenance of RELRO on three distributions. The insight to take: Fedora and Arch set it in the packaging layer, so a binary built outside rpmbuild/makepkg gets nothing; Ubuntu sets BIND_NOW via a patched GCC, so even a manual gcc invocation on Ubuntu inherits it — the same end state reached two structurally different ways, which is exactly why “the distro does it” is not a portable statement.

The Arch line contains the detail most worth stealing: pairing -z now with -fno-plt is strictly better than -z now alone. -fno-plt makes the compiler emit call *foo@GOTPCREL(%rip) instead of call foo@PLT, so the call goes straight through the (now frozen) GOT with no PLT stub in between — one fewer indirection and one fewer writable-looking code path. See The Procedure Linkage Table for why the stub exists at all.

Auditing a real fleet: every ELF in /usr/bin

Claims about distro hardening are checkable in one loop. Running the relrocheck.sh logic plus readelf -h over every ELF file in /usr/bin on the Fedora 44 test machine — 2,875 binaries — gives:

VerdictCountShareELF type
Full RELRO2,77496.5%all DYN (PIE)
Partial RELRO963.3%85 EXEC, 11 DYN
No RELRO at all50.2%all EXEC
— PIE (DYN) overall2,78596.9%—
— non-PIE (EXEC) overall903.1%—

Table: RELRO and PIE status of every ELF binary in /usr/bin on stock Fedora 44, measured 2026-09-11. The insight to take: “all Fedora binaries are full RELRO” is 96.5% true, and the 3.5% that are not is a structured, explainable set rather than random noise.

The exceptions are the interesting part, and they fall into exactly two classes.

The five binaries with no RELRO whatsoever are all Go binaries: /usr/bin/go, /usr/bin/gofmt, /usr/bin/tailscale, /usr/bin/tailscaled, /usr/bin/containerd-shim-runc-v2. Go’s own linker produces static ET_EXEC binaries with no PT_GNU_RELRO segment, and because the Go toolchain does not consume LDFLAGS the RPM macros never reach it. This is not a Fedora oversight so much as a whole language toolchain sitting outside the distribution’s hardening plumbing. It also illustrates the limit of the mitigation’s relevance: a static Go binary has no dynamic GOT to overwrite in the first place, so “no RELRO” is much less alarming here than the same verdict on a dynamically linked C program.

The 96 partial-RELRO binaries are dominated by packages that override the injected flags, and — pointedly — gcc itself is among them. Resolving them to their owning packages:

$ awk '$1!="FULL"{print $3}' usrbin_scan.txt | xargs rpm -qf --qf '%{NAME}\n' | sort | uniq -c | sort -rn | head
     11 wine-common
     11 gcc
      9 gcc-riscv64-linux-gnu
      6 wine-core
      5 containerd.io
      4 zip
      4 virtualbox-guest-additions
      4 gcc-c++
      4 deltarpm

So on a fully-patched Fedora 44 system, /usr/bin/gcc, /usr/bin/cc, /usr/bin/g++ and /usr/bin/cpp are Partial RELRO and non-PIE — shipped by the Fedora Project, built with Fedora’s own hardening macros nominally enabled. The usual causes are a build system that assigns rather than appends to LDFLAGS (discarding %build_ldflags wholesale), or an explicit %undefine _hardened_build for a package that broke under PIE. Either way the lesson for an auditor is the same: the distribution’s stated policy is an input to the build, not a property of the output. Verify the artifact, not the macro.

Uncertain

Verify: whether any mainstream distribution now ships the MPK-based “read-only GOT with lazy binding” scheme rather than full RELRO, and the current RELRO/PIE defaults on non-x86-64 architectures (aarch64, ppc64le, s390x, riscv64). Reason: the fleet scan above is a single x86-64 Fedora 44 machine, and the Ubuntu matrix explicitly qualifies BIND_NOW as covering “amd64, ppc64el, s390x” with a separate “package list for others” — implying at least some architectures are not covered by default. The Memory Protection Keys path’s deployment status remains undocumented in any source consulted here. To resolve: repeat the /usr/bin scan on an aarch64 and an s390x install of each distribution, and check pkey_mprotect usage in a current glibc’s elf/dl-runtime.c. 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