Modern Position Independent Executables (PIE) rely on the PLT (Procedure Linkage Table) and GOT (Global Offset Table) to dynamically resolve shared library functions like puts(). Under "lazy binding", the dynamic linker looks up the true memory address of puts on the fly and dynamically overwrites the GOT entry with that exact address.
But there is a glaring issue with this mechanism: if the dynamic linker can overwrite the GOT at runtime, so can an attacker.
The GOT overwrite vulnerability
For lazy binding to work, the memory section containing the PLT-specific GOT entries (the .got.plt section) must be mapped into memory with write permissions (RW-). The dynamic linker (ld-linux.so) needs to write the resolved function addresses there.
However, write permissions are a double-edged sword. If an attacker discovers an arbitrary memory write vulnerability in your program, perhaps a classic buffer overflow, a format string vulnerability, or a use-after-free bug, they can target the .got.plt section.
The exploit flow:
- The attacker exploits the memory corruption bug to overwrite the GOT entry for a commonly called function, such as printf or exit.
- Instead of pointing to the real printf in libc, the attacker overwrites the GOT entry with the memory address of the system() function, or a pointer to their own malicious shellcode.
- The next time the program attempts to log a message using printf(user_input), it actually jumps directly to system(user_input), granting the attacker a root shell.
A first-principles example: the format string bug
One of the most classic ways to achieve a GOT overwrite is via a Format String Vulnerability. Imagine a poorly written C program that logs user input like this:
char user_input[100];
gets(user_input);
// VULNERABLE: No format specifier (like "%s") is used!
printf(user_input);
exit(0);
Because the user controls the format string, they can input format specifiers like %x to leak memory addresses off the stack. More dangerously, they can use the %n specifier. In C, %n does not print anything. Instead, it writes the number of bytes printed so far into the memory address provided by the corresponding argument.
By carefully crafting a payload, an attacker can pass the exact memory address of exit@got (the GOT entry for the exit function), pad the output until exactly X bytes have been printed (where X is the memory address of system() or their shellcode), and use %n to write that X value directly into the exit@got address. When the program subsequently calls exit(0), the CPU looks up the GOT entry, finds the attacker's newly written address, and executes it. The program doesn't exit; it spawns a malicious shell.
RELRO: making the GOT read-only
RELRO (Relocation Read-Only) is the mitigation. There are two levels:
- Partial RELRO: makes the non-PLT GOT entries read-only, but leaves .got.plt writable for lazy binding. This is the default on most systems.
- Full RELRO (-Wl,-z,relro,-z,now): resolves all symbols at load time (no lazy binding) and makes the entire GOT read-only. The tradeoff is startup time: every symbol is resolved eagerly.
Full RELRO closes the GOT overwrite door entirely: there is no writable GOT to attack, because all relocations are resolved before the program starts and the section is made read-only.
RELRO is like a bank that keeps its vault (GOT) locked during business hours. Partial RELRO is a vault that opens when a teller (lazy binding) needs it. Full RELRO is a vault that opens once at opening time and stays locked all day. The attacker needs the vault open to slip in a forged check.
If the dynamic linker can overwrite the GOT at runtime, so can an attacker. RELRO is the mitigation that makes the GOT read-only after resolution.