.text:00421330; =============== S U B R O U T I N E ===============
.text:00421330
.text:00421330

How a call to puts reaches libc (PLT and GOT)

.text:00421330; author: Can Ozdemir date: 2025-04-19 size: 0xF60 tags: elf, linux, dynamic-linking, tutorial
.text:00421330
.text:00421330sub_421330procnear
.text:00421331

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.

.text:00421447

The program

.text:00421455
#include <stdio.h>
int main(void)
{
    puts("hello");
    puts("again");
    return 0;
}
.text:004214AE

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.)

.text:0042158D

main calls a stub, not puts

.text:004215AB
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:  ret
.text:004216BA

Both calls go to puts@plt at 0x401030, inside the binary's own .plt section.

.text:0042170C

The PLT stub

.text:0042171B
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)
.text:0042180A

The stub doesn't jump to puts directly. It jumps to whatever address is stored in the GOT slot at 0x404000.

.text:0042187D

The GOT slot, before the first call

.text:004218A3

.got.plt is a table of 8-byte addresses in a writable data segment. Dumping it from the file:

.text:00421902
objdump -s -j .got.plt hello
.text:0042191E
00403fe8  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.@.....|
.text:004219BB

The slot at 0x404000 contains 36 10 40 00 …, little-endian 0x401036: the address of the push 0x0 inside the stub itself.

.text:00421A41

The relocation table confirms which symbol that slot belongs to:

.text:00421A81
readelf -r hello
  Offset          Type                Sym. Name
  000000404000    R_X86_64_JUMP_SLO   puts@GLIBC_2.2.5
.text:00421AF8

Lazy binding, step by step

.text:00421B15

First call:

.text:00421B24
  1. main calls puts@plt.
  2. The stub jumps through the GOT slot, which still points to the next instruction in the stub, push 0x0.
  3. It pushes the relocation index (0 = puts) and jumps to PLT0, which calls the dynamic linker's resolver (_dl_runtime_resolve).
  4. The resolver looks up puts in libc, writes its real address into the GOT slot, and jumps to puts.
.text:00421C9C

Every later call: the stub's first jmp now goes straight to puts in libc. One indirect jump, no resolver.

.text:00421D0D

What modern binaries do

.text:00421D27
  • 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 .plt and .plt.sec, with endbr64 instructions. Same idea, different layout.
  • -fno-plt: the call site does call QWORD PTR [rip+…] through the GOT directly, skipping the stub.
.text:00421F35

Check a binary with readelf -d hello | grep -E 'BIND_NOW|FLAGS' and readelf -l hello | grep RELRO.

.text:00421F9B

Why this matters when reversing

.text:00421FBD
  • Tools label stubs as puts@plt or _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@plt catches every call, and watching the GOT slot change on the first call is the best way to see all of this happen for real: try gdb ./hello, break main, run, x/gx 0x404000, next twice, and x/gx 0x404000 again.
.text:004221FB
.text:004221FBretn
.text:004221FBsub_421330endp
.text:004221FB; © 2026 Can Ozdemir. All rights reserved. terms
.text:004221FB
.text:004221FC; -------- cross references --------
Output
Loading ozdc.us… 34 functions, 3 data segments.
Type help in the command line below and press Enter.