Security Engineering · Binary Exploitation

How the PLT and GOT Turn a Library Call Into Writable Memory

Lazy binding keeps resolution out of process startup by routing calls through a stub that patches a table, which leaves a writable function pointer for every imported symbol.

A program that calls printf does not contain printf. It contains a jump to a stub, and the stub reads an address out of a table and jumps there. The indirection exists so that the address can be filled in after the program has started, once the library is mapped and its own dependencies are satisfied. That is the whole reason dynamic linking works without rewriting the text of the executable, and it is also the reason a large writable table of function pointers exists in every dynamically linked process.

The chain from a call site to the real function

On x86-64 a call to an imported function compiles to a direct call to a small stub in a section named .plt. Each stub is sixteen bytes and has the same shape:

0000000000001030 <printf@plt>:
    1030:  ff 25 e2 2f 00 00     jmp    QWORD PTR [rip+0x2fe2]   # 4018 <printf@got.plt>
    1036:  68 01 00 00 00        push   0x1
    103b:  e9 e0 ff ff ff        jmp    1020 <plt0>

The first instruction is an indirect jump through an entry in .got.plt. If that entry holds the resolved address, control goes straight to the function: one extra memory read and one extra jump, and the branch predictor learns the pattern because it is stable. If the entry holds the address of the stub’s own second instruction, the jump lands three bytes later and execution continues into the resolver path.

The push of an immediate index identifies which relocation to resolve. The jump to plt0 enters the common resolver stub, which pushes the link map — the per-object structure describing this shared object — and jumps to the dynamic linker’s resolver through another GOT entry. The resolver looks up the symbol, writes the result into the calling object’s GOT slot, and jumps to it. The next call through that stub takes the first path and never enters the resolver again.

On 32-bit x86 the same structure exists with absolute rather than PC-relative addressing: the stub jumps through a GOT entry, pushes the relocation index, and jumps to plt0, which pushes the link map and jumps through another GOT slot. The return address the caller’s call left on the stack stays there throughout, which is why the resolver can return to it once the slot is patched. The mechanism predates x86-64 and survived the move because the alternative is worse: resolving everything eagerly.

What the linker has to prepare before any of this runs

The executable does not know where the library will be mapped. Under address-space randomisation the load address differs on every run, so absolute addresses cannot be baked into the text. The linker instead emits relocations: records describing, for each place in the program that needs an address, which symbol supplies it and how the value should be combined.

Two relocation types matter here. R_X86_64_GLOB_DAT fills a GOT slot with the address of a symbol at load time; these are the entries for data objects and for functions resolved eagerly. R_X86_64_JUMP_SLOT fills a GOT slot that is jumped through, and these are the ones lazy binding leaves alone until first use. The dynamic linker processes GLOB_DAT entries before transferring control to the program, because a global variable may be read at any moment. It leaves JUMP_SLOT entries pointing at their stubs, because a function is only needed when called.

The table of relocations lives in .rela.plt, and the count of them is what .plt is sized against. The GOT itself is split: .got holds entries the loader fills eagerly and is normally read-only after relocation, while .got.plt holds the lazily resolved slots and must remain writable for the resolver to patch them. On a hardened binary those two sections are separated precisely so that the eager portion can be mapped read-only.

Why lazy binding was chosen and what it costs

The alternative is -Wl,-z,now, which resolves every JUMP_SLOT at load time. That costs startup latency proportional to the number of imported functions, and for a program that imports hundreds of symbols from a large library and calls a fraction of them, the work is wasted. Lazy binding spreads that cost across the calls that actually happen.

The costs it introduces are concrete. Every call through an unresolved slot enters the dynamic linker, which acquires a lock, walks the scope of loaded objects, and consults symbol tables that may need to be searched by hash and then by string comparison. A program that calls an imported function in a loop for the first time pays that once per symbol, which is negligible; a program that calls many distinct symbols once each pays it many times, which shows up as a startup-shaped cost later in execution.

Lazy binding also makes the process’s memory layout non-deterministic in a way that eager binding does not: whether a given GOT slot holds a library address or a stub address depends on whether that function has been called yet. Debuggers and profilers deal with this by forcing resolution, and LD_BIND_NOW in the environment does the same for the whole process without recompiling.

There is a correctness argument too. A missing symbol is reported at the first call under lazy binding and at load time under eager binding. A program that can start without a symbol it never calls is more available and harder to diagnose, which is why -z now is common in software that would rather fail immediately than partway through a request.

The security consequence of a writable function pointer table

Every lazily bound import leaves a slot that holds an address the program will jump to. If an attacker can write eight bytes to that slot, the next call to the corresponding function transfers control to a chosen address with the original arguments intact. This is why the GOT is the canonical target of an arbitrary write: it needs no stack manipulation, no knowledge of the stack layout, and it survives across function returns. The write itself is often supplied by a format-string conversion aimed at the low half of the slot.

The attack has two variants that differ in what the attacker needs. Writing to .got.plt requires only the address of the slot, which is at a fixed offset from the executable’s base and therefore reachable with a partial overwrite when the base is known or when the low bytes suffice. Writing to .got requires the section to be writable, which it is not on a binary linked with -z relro and full RELRO enabled, because the loader remaps it read-only after relocation.

-z relro with -z now is the combination that removes the target: full RELRO makes .got read-only after relocation, and immediate binding means there are no lazy slots left in .got.plt to patch. Partial RELRO, which is the default on many toolchains, protects .got but leaves .got.plt writable, which is exactly the table lazy binding needs. The distinction is worth checking per binary rather than assuming, because the default differs between distributions and between compiler drivers.

Partial overwrites and the shape of the write

A GOT entry is eight bytes and the address it holds is in the library’s mapping. Overwriting all eight bytes requires knowing the library base, which randomisation makes unavailable without a leak. Overwriting the low two bytes requires knowing only that the slot’s current value points into the same 64 KiB window as the desired target, which is true when both the current function and the target live in the same library and their offsets are close.

That is the practical form: a one- or two-byte write that redirects a call from one library function to another nearby one. The value of such a redirect depends on what the two functions do with the same argument registers. A redirect from a function that takes a format string to one that executes a command, or from a validation routine to a stub that returns success, needs no further setup. The GOT entry does not care what the caller intended.

Lazy binding widens this window in one specific way. Before a function has been called, its slot points into the executable’s own .plt, an address in the same mapping as the slot itself. An attacker who can write one byte to an unresolved slot can aim it at any stub in the same 256-byte region, which selects among a handful of imports. That is a weaker primitive than redirecting to an arbitrary library address, but it requires no leak at all, because both the source and the target are in the executable.

Where the indirection moved instead

Modern toolchains reduce the number of GOT-resolved calls by binding locally where possible. -fno-plt emits an indirect call through the GOT directly, removing the stub and the lazy path entirely, which trades a slightly larger GOT for one less jump per call and no writable resolver path. -fvisibility=hidden and -Bsymbolic let the linker resolve references within a shared object to local definitions, which removes the slot altogether for those symbols.

Position-independent executables change the arithmetic of finding the tables but not their existence: the GOT is still there, still indexed by the same relocations, and still the place where a resolved address lives. The defence that actually removes the primitive is making the pages read-only once resolution is complete, which is what full RELRO does, and that defence is defeated not by attacking the GOT but by finding a write primitive that targets the loader’s own structures before the remap happens.

References

  1. System V Application Binary Interface: AMD64 Architecture Processor Supplement — System V ABI
  2. ELF for the Arm 64-bit Architecture (AArch64) — Arm Limited
  3. Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification — TIS Committee