How a call to puts reaches libc (PLT and GOT)
A dynamically linked program doesn't know where puts will be in memory: libc is loaded at a random address each run. So how does call puts work? Through two small tables, the PLT and the GOT. Understanding them explains a lot of what you see in disassembly listings.
The program
#include <stdio.h>
int main(void)
{
puts("hello");
puts("again");
return 0;
}Built with gcc -O1 -no-pie -Wl,-z,lazy hello.c -o hello. (-no-pie gives fixed addresses that are easier to follow; -z lazy asks for lazy binding, which many distributions no longer use by default. More on that below.)
main calls a stub, not puts
401126: sub rsp, 0x8
40112a: lea rdi, [rip+0xed3] ; 0x402004: "hello"
401131: call 401030 <puts@plt>
401136: lea rdi, [rip+0xecd] ; 0x40200a: "again"
40113d: call 401030 <puts@plt>
401142: mov eax, 0x0
401147: add rsp, 0x8
40114b: retBoth calls go to puts@plt at 0x401030, inside the binary's own .plt section.
The PLT stub
401030 <puts@plt>:
401030: jmp QWORD PTR [rip+0x2fca] ; jump to the address stored at 0x404000
401036: push 0x0 ; relocation index of puts
40103b: jmp 401020 ; common resolver stub (PLT0)The stub doesn't jump to puts directly. It jumps to whatever address is stored in the GOT slot at 0x404000.
The GOT slot, before the first call
.got.plt is a table of 8-byte addresses in a writable data segment. Dumping it from the file:
objdump -s -j .got.plt hello00403fe8 08 3e 40 00 00 00 00 00 00 00 00 00 00 00 00 00 |.>@.............|
00403ff8 00 00 00 00 00 00 00 00 36 10 40 00 00 00 00 00 |........6.@.....|The slot at 0x404000 contains 36 10 40 00 …, little-endian 0x401036: the address of the push 0x0 inside the stub itself.
The relocation table confirms which symbol that slot belongs to:
readelf -r hello
Offset Type Sym. Name
000000404000 R_X86_64_JUMP_SLO puts@GLIBC_2.2.5Lazy binding, step by step
First call:
maincallsputs@plt.- The stub jumps through the GOT slot, which still points to the next instruction in the stub,
push 0x0. - It pushes the relocation index (0 = puts) and jumps to PLT0, which calls the dynamic linker's resolver (
_dl_runtime_resolve). - The resolver looks up
putsin libc, writes its real address into the GOT slot, and jumps toputs.
Every later call: the stub's first jmp now goes straight to puts in libc. One indirect jump, no resolver.
What modern binaries do
- Full RELRO / BIND_NOW (
-z now): the dynamic linker resolves every slot at startup and then makes the GOT read-only. No lazy binding; the PLT still exists but the slots are already filled. Many distributions make this the default as a hardening measure. .plt.sec: with Intel CET enabled you'll see the stubs split across.pltand.plt.sec, withendbr64instructions. Same idea, different layout.-fno-plt: the call site doescall QWORD PTR [rip+…]through the GOT directly, skipping the stub.
Check a binary with readelf -d hello | grep -E 'BIND_NOW|FLAGS' and readelf -l hello | grep RELRO.
Why this matters when reversing
- Tools label stubs as
puts@pltor_puts, which is why imported function names show up in a stripped binary: they come from the dynamic symbol table, which stripping can't remove. - A list of imports (
readelf --dyn-syms, or the Imports window in IDA/Ghidra) is a quick summary of what a program can do. - In a debugger, a breakpoint on
puts@pltcatches every call, and watching the GOT slot change on the first call is the best way to see all of this happen for real: trygdb ./hello,break main,run,x/gx 0x404000,nexttwice, andx/gx 0x404000again.