The Procedure Linkage Table

The Procedure Linkage Table (PLT) is an array of tiny machine-code stubs that lets position-independent code call functions whose addresses are not known until the program is loaded. Because a shared library can be mapped at any virtual address, a call printf cannot embed printf’s address — that address only exists after the dynamic linker (ld.so) maps libc. So the compiler emits the call as call printf@plt, which targets a fixed-offset stub inside the program. The stub performs an indirect jump through a Global Offset Table (GOT) slot. On the first call that slot still points back into the PLT, which routes the call into the resolver _dl_runtime_resolve; the resolver finds the real printf, patches the GOT slot, and tail-calls it. Every subsequent call jumps straight through the now-patched GOT to printf at near-native cost. This indirection-plus-self-patching is lazy binding, and the PLT is the machinery that implements it on the x86-64 System V ABI (per the x86-64 psABI / Oracle Linker Guide; mechanics confirmed in MaskRay 2021).

Mental Model — A Call Forwarding Desk That Learns

Think of every external function as someone you can only reach through a forwarding desk. The first time you ask for printf, the desk doesn’t have the number — it phones the directory service (_dl_runtime_resolve), gets the number, writes it into its own address book (the GOT), and connects you. From then on the desk reads the number straight out of the book and connects you instantly; it never calls the directory again for printf. The PLT stub is the desk’s fixed phone extension (a stable address your code can hardcode), and the GOT slot is the one line in the address book it consults. The trick that makes the first call route to the resolver is that the GOT slot is pre-loaded to point back into the PLT stub itself, one instruction past the indirect jump — so the “address book entry” initially says “go ask the directory.”

flowchart TB
  CALL["main: call puts@plt"]
  subgraph PLTSEC[".plt.sec puts@plt (CET build)"]
    EB["endbr64<br/>(IBT landing pad)"]
    IJ["jmp *GOT[puts]"]
    EB --> IJ
  end
  subgraph PLT[".plt lazy entry for puts"]
    PUSH["push 0  (reloc index)"]
    JPLT0["jmp PLT0"]
    PUSH --> JPLT0
  end
  subgraph PLT0[".plt PLT0 header"]
    P8["push GOT[1]  (link_map)"]
    J16["jmp *GOT[2]  (_dl_runtime_resolve)"]
    P8 --> J16
  end
  GOT["GOT[puts] slot<br/>1st call: points to .plt lazy entry+? <br/>after: points to real puts"]
  RESOLVER["_dl_runtime_resolve<br/>→ _dl_fixup: look up puts,<br/>write addr into GOT[puts],<br/>tail-call puts"]
  LIBC["libc puts"]

  CALL --> EB
  IJ -->|reads| GOT
  GOT -.->|"1st call"| PUSH
  JPLT0 --> P8
  J16 --> RESOLVER
  RESOLVER -->|patches| GOT
  RESOLVER --> LIBC
  GOT -.->|"2nd+ call"| LIBC

The PLT call path on a modern Intel-CET x86-64 build. What it shows: the call lands on the .plt.sec stub (whose endbr64 is a Control-flow Enforcement Technology landing pad), which jumps indirectly through the GOT slot. On the first call that slot routes via the lazy .plt entry → PLT0 → _dl_runtime_resolve, which resolves puts, writes its address into the GOT slot, and calls it; later calls jump straight to puts. The insight: the PLT never changes — it is read-only shared text — yet behaviour changes between the first and later calls because the data it reads (the GOT slot) is what gets patched.

The Boundary: This Note Owns the Code, the GOT Note Owns the Table

Readers trip over this constantly, so it is worth fixing before the mechanics. The PLT is code. It lives in an AX (allocatable, executable) section, it is part of the read-only shared text image, and it is never modified at runtime — not by the linker, not by the resolver, not by anything. The GOT is data. It lives in a WA (writable, allocatable) section, it is private to each process, and it is the only thing that changes. Every sentence you will ever read of the form “the PLT is patched” is describing a GOT write. This note covers the stubs, the header entry, the resolver trampoline, and the control flow between them; the sibling note covers the slots those stubs read, the relocation types that fill them, and RELRO. Neither is complete without the other, and the split is where the line naturally falls: code on this side, data on that side.

The Reference Binary

Everything below is measured on one deliberately small program rather than paraphrased from the specification. libdemo.so exports greet() and bump(); main.c calls greet() twice — the minimum needed to see the first-call and second-call paths differ — plus bump() once and printf() once, so a symbol can be watched staying unresolved while its neighbour resolves.

gcc -shared -fPIC -O0 -o libdemo.so lib.c
gcc -O0 -fPIE -pie -Wl,-z,lazy,-z,norelro -L. -ldemo -Wl,-rpath,'$ORIGIN' -o app_lazy main.c
gcc -O0 -fPIE -pie -Wl,-z,relro,-z,now  -L. -ldemo -Wl,-rpath,'$ORIGIN' -o app_now  main.c
gcc -O0 -fPIE -pie -fcf-protection=full -Wl,-z,ibt,-z,shstk,-z,lazy,-z,norelro \
                                        -L. -ldemo -Wl,-rpath,'$ORIGIN' -o app_cet  main.c

Toolchain, for reproducibility: Fedora 44 / x86_64; gcc (GCC) 16.2.1 20260819 (Red Hat 16.2.1-2); GNU ld 2.46.1-1.fc44; GNU objdump/readelf 2.46.1-1.fc44; glibc 2.43; GNU gdb (Fedora Linux) 17.2-2.fc44. The specification quoted throughout is the x86-64 psABI at git commit e1ce0983 (2025-03-12, the current head of master — the document has no numbered releases, so a commit is the only honest pin).

The first call, traced instruction by instruction

This is the sequence the whole note exists to explain, taken from a real gdb session on app_lazy.

sequenceDiagram
    autonumber
    participant M as main<br/>(.text)
    participant P as greet@plt<br/>(.plt, read-only code)
    participant G as GOT[greet]<br/>(.got.plt, writable data)
    participant H as PLT0 header<br/>(.plt)
    participant T as _dl_runtime_resolve_xsavec<br/>(ld.so)
    participant F as _dl_fixup<br/>(ld.so, C)
    participant L as greet<br/>(libdemo.so)

    M->>P: call 0x...4340 (greet@plt)
    P->>G: jmp *0x23ba(%rip) — read the slot
    Note over G: slot holds 0x...4346<br/>= greet@plt + 6, NOT greet
    G-->>P: fall back into the stub at +6
    P->>P: push $0x1 (index into .rela.plt)
    P->>H: jmp PLT0
    H->>H: push GOT[1] (struct link_map *)
    H->>T: jmp *GOT[2]
    T->>T: save %rax %rcx %rdx %rsi %rdi %r8 %r9<br/>then xsavec the full extended state
    T->>F: call _dl_fixup(link_map, 1)
    F->>F: reloc = DT_JMPREL + 1*24<br/>_dl_lookup_symbol_x("greet", scope)
    F->>G: elf_machine_fixup_plt:<br/>*reloc_addr = 0x7ffff7fb6399
    F-->>T: return 0x7ffff7fb6399
    T->>T: xrstor + restore GPRs<br/>add $(LOCAL+16), %rsp — undo the PLT's 2 pushes
    T->>L: jmp *%r11
    L-->>M: ret (straight back to main, resolver is gone)

The complete first-call path, every step verified against gdb single-stepping and glibc 2.43 source. What it shows: eleven distinct transfers of control to accomplish one function call, of which steps 3–5 exist purely to smuggle a relocation index to the resolver, and step 13 is the one that makes all of it a one-time cost. The insight: the stack at step 8 is not a normal call frame — the PLT has pushed two words under the caller’s return address, which is why the trampoline has to add $(LOCAL_STORAGE_AREA + 16), %rsp before jumping, and why a backtrace taken mid-resolution looks strange.

Contrast with the second call to the same function, after the slot has been rewritten:

sequenceDiagram
    autonumber
    participant M as main<br/>(.text)
    participant P as greet@plt<br/>(.plt)
    participant G as GOT[greet]<br/>(.got.plt)
    participant L as greet<br/>(libdemo.so)

    M->>P: call 0x...4340 (greet@plt)
    P->>G: jmp *0x23ba(%rip) — read the slot
    Note over G: slot now holds 0x7ffff7fb6399<br/>= the real greet
    G-->>L: control lands directly in libdemo.so
    L-->>M: ret

The same call site on its second execution. What it shows: two instructions — one call, one indirect jmp — and the resolver is not involved at all. The insight: the PLT stub’s machine code is byte-for-byte identical between the two diagrams. Nothing about the code changed; the pointer it dereferences changed. That is the entire idea of the PLT, and it is why the stub can live in read-only, physically-shared text.

I confirmed both paths by single-stepping. Before the first call the slot points into the stub; afterwards it points into the library; and the second call reaches greet in exactly two instructions:

(gdb) break *0x0000555555554467          # the first `call greet@plt` in main
(gdb) info symbol *(void**)0x555555556700
greet@plt + 6 in section .plt of .../app_lazy
 
(gdb) stepi                              # into the stub
=> 0x555555554340 <greet@plt>: jmp *0x23ba(%rip)   # 0x555555556700 <greet@got.plt>
(gdb) stepi                              # the indirect jump lands 6 bytes later, in the same stub
=> 0x555555554346 <greet@plt+6>: push $0x1
 
(gdb) continue                           # let the resolver finish
AFTER first call, GOT[greet] = 0x00007ffff7fb6399
(gdb) info symbol *(void**)0x555555556700
greet in section .text of .../libdemo.so
 
(gdb) info symbol *(void**)0x5555555566f8   # printf's slot, same moment
printf@plt + 6 in section .plt of .../app_lazy      <- still unresolved
 
(gdb) break *0x0000555555554476           # the SECOND `call greet@plt`
(gdb) stepi
=> 0x555555554340 <greet@plt>: jmp *0x23ba(%rip)
(gdb) stepi
=> 0x7ffff7fb6399 <greet>: push %rbp      <- straight into libdemo.so, no resolver

Three separate claims fall out of that one transcript, and each is worth stating plainly. The initial slot value really is stub + 6 — the instruction after the indirect jump — so the “jump” on the first call is a jump to the next instruction, an elegant way to spend zero extra bytes on the not-yet-resolved case. Resolution really is per symbol: printf’s slot is untouched while greet’s is live, so a program that calls one of a library’s thousand functions pays for one lookup. And the second call really does take two instructions, landing on greet’s own push %rbp prologue with the resolver nowhere in the path.

Mechanical Walk-through

Why the PLT must exist

A position-independent executable or shared object is compiled so its .text contains no absolute addresses — only PC-relative references — so the kernel and ld.so can map it anywhere (the basis of Address Space Layout Randomization and of sharing one physical copy of libc across processes; see Position-Independent Code and PIE). A direct call printf would need printf’s absolute address baked into .text, which is impossible at compile time and would also make .text non-shareable. The classic solution is a layer of indirection. Function calls go through the PLT (code) which reads the GOT (writable, per-process data) for the actual address. The code stays read-only and shared; only the small GOT, which is private to each process, is written.

The classic (pre-CET) PLT layout

On the System V x86-64 ABI the linker builds two cooperating tables: a .plt section of 16-byte stubs and a .got.plt section of 8-byte pointer slots. The very first stub, PLT0 (the header), is special. The psABI publishes the intended layout as pseudo-assembly (psABI, Procedure Linkage Table):

  .PLT0: pushq    GOT+8(%rip)              # GOT[1]
         jmp      *GOT+16(%rip)            # GOT[2]
         nopl     0x0(%rax)
  .PLT1: jmp      *name1@GOTPCREL(%rip)    # 16 bytes from .PLT0
         pushq    $index1
         jmp      .PLT0
  .PLT2: jmp      *name2@GOTPCREL(%rip)    # 16 bytes from .PLT1
         pushq    $index2
         jmp      .PLT0

And here is what GNU ld 2.46.1 actually emitted for app_lazy, verbatim. It matches the specification instruction for instruction — this is one of the rare corners of systems programming where the document and the bytes agree exactly:

$ objdump -d -j .plt app_lazy
 
0000000000000320 <printf@plt-0x10>:            ; PLT0 — the resolver trampoline, one per object
 320: ff 35 c2 23 00 00   push  0x23c2(%rip)   ; push GOT[1]  (0x26e8 = .got.plt+8, the link_map)
 326: ff 25 c4 23 00 00   jmp  *0x23c4(%rip)   ; jmp *GOT[2]  (0x26f0 = .got.plt+16, the resolver)
 32c: 0f 1f 40 00         nopl  0x0(%rax)      ; pad to 16 bytes
 
0000000000000330 <printf@plt>:                 ; PLT1
 330: ff 25 c2 23 00 00   jmp  *0x23c2(%rip)   ; jmp *GOT[printf] (0x26f8)
 336: 68 00 00 00 00      push  $0x0           ; .rela.plt index 0
 33b: e9 e0 ff ff ff      jmp   320 <PLT0>
 
0000000000000340 <greet@plt>:                  ; PLT2
 340: ff 25 ba 23 00 00   jmp  *0x23ba(%rip)   ; jmp *GOT[greet] (0x2700)
 346: 68 01 00 00 00      push  $0x1           ; .rela.plt index 1
 34b: e9 d0 ff ff ff      jmp   320 <PLT0>
 
0000000000000350 <__cxa_finalize@plt>:         ; PLT3
 350: ff 25 b2 23 00 00   jmp  *0x23b2(%rip)
 356: 68 02 00 00 00      push  $0x2
 35b: e9 c0 ff ff ff      jmp   320 <PLT0>
 
0000000000000360 <bump@plt>:                   ; PLT4
 360: ff 25 aa 23 00 00   jmp  *0x23aa(%rip)
 366: 68 03 00 00 00      push  $0x3
 36b: e9 b0 ff ff ff      jmp   320 <PLT0>

Read the encodings and several design decisions become visible. Every stub is exactly 16 bytes and 16-byte aligned (.plt is 0x50 = 80 bytes for five entries), which is what lets the linker compute a stub’s address from its index with a shift. Every stub is self-similar apart from two immediates: the %rip-relative displacement naming its GOT slot, and the pushed index. The objdump label <printf@plt-0x10> for PLT0 is a symbolisation artefact — PLT0 has no symbol of its own, so objdump names it relative to the first one that does. And the disassembler’s comment column resolves each displacement for you: 0x23c2(%rip) at 0x330 means 0x336 + 0x23c2 = 0x26f8, printf’s slot.

Walking greet@plt symbol by symbol: the stub begins with an indirect jump, jmp *GOT[greet], that reads the 8-byte GOT slot belonging to greet and jumps to whatever address it holds. The whole lazy-binding trick is the initial content of that slot. The linker pre-fills it not with garbage but with the address of greet@plt + 6 — the instruction immediately after the jmp, i.e. the push $0x1. Reading the raw file bytes confirms it:

$ objdump -s -j .got.plt app_lazy
Contents of section .got.plt:
 26e0 b8240000 00000000 00000000 00000000  .$..............
 26f0 00000000 00000000 36030000 00000000  ........6.......
 2700 46030000 00000000 56030000 00000000  F.......V.......
 2710 66030000 00000000                    f.......
        ^^^^^^^^ little-endian: 0x336, 0x346, 0x356, 0x366

The four function slots at 0x26f8, 0x2700, 0x2708, 0x2710 hold 0x336, 0x346, 0x356, 0x366 — and the disassembly above shows those are precisely printf@plt + 6, greet@plt + 6, __cxa_finalize@plt + 6 and bump@plt + 6. (The values are link-time addresses; ld.so adds the load bias to each one at load time, which is why the running process showed 0x555555554346 rather than 0x346 — see The Global Offset Table for that step.)

So on the first call, jmp *GOT[greet] jumps to push $0x1 — the next instruction — which pushes the relocation index 1, the index of this function’s entry in the .rela.plt relocation table, and then jmp 320 falls into PLT0. PLT0 pushes a second word, GOT[1] (the per-object struct link_map * the dynamic linker filled in at load time), and does jmp *GOT[2], an indirect jump to the resolver whose address ld.so also wrote into GOT[2] at load time. (See The Global Offset Table for why GOT[0..2] are reserved and what happens to them under -z now.)

It is worth pausing on how little the stub costs. Three instructions, 16 bytes, no register clobbered, and the only state passed to the resolver is two words on the stack. The design squeezes the entire not-yet-resolved case into the dead space that the resolved case jumps over — an instruction-level equivalent of a null-object pattern.

What the resolver does

_dl_runtime_resolve is a small architecture-specific assembly trampoline in glibc (sysdeps/x86_64/dl-trampoline.S). It saves the argument registers (because we are in the middle of a real function call and %rdi, %rsi, etc. hold puts’s arguments), then calls the C function _dl_fixup with the two values the PLT pushed: the link_map and the relocation index. _dl_fixup (glibc source) locates the relocation by adding the index to the DT_JMPREL table base — reloc = JMPREL_table + reloc_offset(reloc_arg) — reads the symbol name (puts@GLIBC_2.2.5), runs the full symbol search across the lookup scope via _dl_lookup_symbol_x, computes the resolved address, and — crucially — writes that address back into the GOT slot via the macro elf_machine_fixup_plt (which on x86-64 simply stores value at reloc_addr). It returns the resolved address; the trampoline restores the saved registers and tail-jumps to puts so the call completes as if it had gone there directly.

After this, the GOT slot for puts no longer points to puts@plt+6 — it points to the real puts in libc. Every later call puts@plt runs jmp *GOT[puts] and lands directly in libc: one indirect jump, no resolver, no register save/restore. That is why lazy binding’s cost is paid once per symbol, on first use, and is essentially free thereafter.

The trampoline is expensive, and the reason is register state

“It saves the argument registers” is the usual summary and it badly understates the cost. The trampoline is entered in the middle of a real call, so it must preserve everything the callee is entitled to receive — and on x86-64 that includes the vector registers, because floating-point and SIMD arguments arrive in %xmm0–%xmm7. Saving those by hand is impossible in a generic trampoline, because the set of live extended state depends on the CPU. glibc’s answer is to use the hardware’s own state-save instruction. From sysdeps/x86_64/dl-trampoline.h:

_dl_runtime_resolve:
	_CET_ENDBR
	pushq %rbx                     ; realign the stack to STATE_SAVE_ALIGNMENT (64)
	mov %RSP_LP, %RBX_LP
	and $-STATE_SAVE_ALIGNMENT, %RSP_LP
	sub _rtld_local_ro+...+XSAVE_STATE_SIZE_OFFSET(%rip), %RSP_LP   ; runtime-sized!
	movq %rax, REGISTER_SAVE_RAX(%rsp)     ; 7 integer registers by hand:
	movq %rcx, REGISTER_SAVE_RCX(%rsp)     ;   rax rcx rdx rsi rdi r8 r9
	...
	movl $STATE_SAVE_MASK, %eax
	xsavec STATE_SAVE_OFFSET(%rsp)         ; and the ENTIRE extended state by hardware
	mov (LOCAL_STORAGE_AREA + 8)(%BASE), %RSI_LP   ; %rsi = reloc index  (PLT push #1)
	mov LOCAL_STORAGE_AREA(%BASE), %RDI_LP         ; %rdi = link_map     (PLT push #2)
	call _dl_fixup
	mov %RAX_LP, %R11_LP                   ; stash the resolved address
	xrstor STATE_SAVE_OFFSET(%rsp)         ; restore everything
	...
	add $(LOCAL_STORAGE_AREA + 16), %RSP_LP        ; "Adjust stack (PLT did 2 pushes)"
	jmp *%r11                              ; tail-jump; the call frame is now pristine

Three details in that listing repay attention. The save area’s size is read from a runtime variable, not assembled as a constant — XSAVE_STATE_SIZE_OFFSET(%rip) — because how much extended state exists depends on the CPU. STATE_SAVE_MASK names what is preserved, and sysdeps/x86/sysdep.h defines it as SSE, AVX, MPX bound registers, AVX-512 opmask (K), both halves of the ZMM state, and APX — a comment adds that “Bound and APX registers are mutually exclusive”, and AMX tile state is deliberately excluded from the resolver path and included only in xsave_state_full_size. And the add $(LOCAL_STORAGE_AREA + 16), %rsp carries the comment “Adjust stack(PLT did 2 pushes)” — the trampoline is explicitly cleaning up the two words the PLT stub pushed, which is the coupling between the two tables made concrete in one instruction.

glibc builds this file three times with different save instructions and picks one at startup based on CPU features (dl-trampoline.S #includes dl-trampoline.h under USE_FXSAVE, USE_XSAVE and USE_XSAVEC). So the symbol in GOT[2] is not literally _dl_runtime_resolve:

(gdb) info symbol *(void**)0x5555555566f0        # GOT[2] of app_lazy
_dl_runtime_resolve_xsavec in section .text of /lib64/ld-linux-x86-64.so.2

On this machine glibc selected the xsavec (“compacted”) variant. A write-up that tells you to breakpoint _dl_runtime_resolve will silently never fire; breakpoint the address in GOT[2] instead.

This is also the honest answer to “how expensive is lazy binding’s first call?” It is not “a few register moves plus a hash lookup”. It is a stack realignment, seven stores, a full xsavec of every enabled extended-state component, a versioned symbol search across the entire lookup scope (with THREAD_GSCOPE locking when the process is threaded), an xrstor, seven loads, and an indirect tail jump. Against that, the measured startup arithmetic on the reference program is modest because the program is tiny:

$ LD_DEBUG=statistics ./app_lazy 2>&1 | grep -E 'reloc|time'
  time needed for relocation: 1299 cycles (2.1%)
       number of relocations: 104
  time needed to load objects: 22236 cycles (36.0%)
   final number of relocations: 110
 
$ LD_DEBUG=statistics ./app_now 2>&1 | grep -E 'reloc|time'
  total startup time in dynamic loader: 42354 cycles
  time needed for relocation: 1392 cycles (3.2%)
       number of relocations: 108
  number of relative relocations: 6

Lazy processes 104 relocations before main and 110 in total; eager processes 108 before main and the same 110 in total. The gap is four relocations, because only four functions exist. Two lessons: relocation is a small fraction of startup here (2–3% against 36% for loading the objects at all), and the lazy/eager difference only becomes interesting when the symbol count is large and the used fraction is small — which is exactly Drepper’s O(R + nr) term with a large r (Drepper, How To Write Shared Libraries, §1.5). For a program that calls most of what it links against, lazy binding buys nothing and costs a permanently writable GOT.

The relocation index is literally a table index

The psABI is explicit that the pushed value is an index, not an address: “the program pushes a relocation index (index) on the stack. The relocation index is a 32-bit, non-negative index into the relocation table addressed by the DT_JMPREL dynamic section entry. The designated relocation entry will have type R_X86_64_JUMP_SLOT, and its offset will specify the global offset table entry used in the previous jmp instruction” (psABI). Lining the two dumps up shows the correspondence is exact:

StubPushed immediate.rela.plt entry at that indexr_offsetGOT slot the stub jumps through
printf@plt$0x0[0] R_X86_64_JUMP_SLOT printf@GLIBC_2.2.50x26f80x26f8 ✓
greet@plt$0x1[1] R_X86_64_JUMP_SLOT greet0x27000x2700 ✓
__cxa_finalize@plt$0x2[2] R_X86_64_JUMP_SLOT __cxa_finalize@GLIBC_2.2.50x27080x2708 ✓
bump@plt$0x3[3] R_X86_64_JUMP_SLOT bump0x27100x2710 ✓

The three-way correspondence between PLT stub, relocation index, and GOT slot, measured on app_lazy. What it shows: the immediate each stub pushes is the position of its own relocation in .rela.plt, and that relocation’s r_offset is the very slot the stub’s jmp dereferences. The insight: the PLT carries no symbol information at all — not a name, not a pointer, just a small integer. Every piece of identity lives in .rela.plt, which is why DT_JMPREL must be a dense array and why _dl_fixup can find its work with a multiply rather than a search.

The lookup in _dl_fixup (elf/dl-runtime.c) is exactly that multiply:

const PLTREL *const reloc
  = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset (pltgot, reloc_arg));
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
...
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);

Base plus scaled index, then a symbol-table index out of r_info, then an assertion that the type really is JUMP_SLOT. The JUMP_SLOT relocation type is precisely “this .got.plt entry should hold the address of this function”, and it is the only type _dl_fixup will accept. (See ELF Relocation Entries and Relocation Processing.)

One case breaks the neat “index order equals GOT order” pattern, and it is worth knowing because it confuses disassembler output. An IFUNC symbol — one whose implementation is chosen at runtime by a resolver function — also gets a PLT entry, but its relocation is R_X86_64_IRELATIVE, which has no symbol. Building a PIE with a hidden IFUNC gives:

$ readelf -rW ifexe
Relocation section '.rela.plt' contains 4 entries:
0000000000004008  R_X86_64_JUMP_SLOT   strlen@GLIBC_2.2.5 + 0
0000000000004010  R_X86_64_JUMP_SLOT   printf@GLIBC_2.2.5 + 0
0000000000004018  R_X86_64_JUMP_SLOT   __cxa_finalize@GLIBC_2.2.5 + 0
0000000000004000  R_X86_64_IRELATIVE                       + 1a70
 
$ objdump -d -j .plt ifexe | head -14
0000000000000370 <*ABS*+0x1a70@plt>:
 370: ff 25 8a 3c 00 00   jmp *0x3c8a(%rip)   # 4000
 376: 68 03 00 00 00      push $0x3           <- index 3, but GOT slot 0x4000 is FIRST

The IRELATIVE entry sits last in .rela.plt (index 3) while owning the first GOT slot (0x4000), and objdump names its stub *ABS*+0x1a70@plt because there is no symbol to print — 0x1a70 is the addend, which is the address of the resolver function to call. This stub’s resolver path is never taken, because glibc processes IRELATIVE eagerly even in the lazy branch of elf_machine_lazy_rel, calling the resolver at load time. So an IFUNC gets PLT machinery for uniformity while opting out of laziness entirely. glibc uses this heavily: its own libc.so.6 carries 46 IRELATIVE relocations against only 17 JUMP_SLOT, because every CPU-dispatched memcpy/strlen variant goes through one.

The CET-era split: .plt and .plt.sec

Modern x86 CPUs support Control-flow Enforcement Technology (CET), whose Indirect Branch Tracking (IBT) feature requires that the target of every indirect branch be an endbr64 instruction; landing anywhere else raises a control-protection fault. A call foo@plt from another translation unit may itself be an indirect call, so the stub must begin with endbr64 to be a legal landing pad. But the classic 16-byte stub has no room for endbr64 (4 bytes) on top of its three instructions, and — more subtly — you do not want the resolver path and the fast path to share one landing pad, since that would let an attacker steer an indirect branch into the resolver entry. The binutils/glibc solution is to split the PLT in two. Building the reference program with -fcf-protection=full -Wl,-z,ibt,-z,shstk:

$ readelf -nW app_cet | grep -i 'Properties'
  Properties: x86 feature: IBT, SHSTK, x86 ISA needed: x86-64-baseline, ...
 
$ objdump -d -j .plt app_cet          ; the LAZY path — no indirect jump left in it
 320: ff 35 f2 23 00 00   push  0x23f2(%rip)   ; PLT0, unchanged
 326: ff 25 f4 23 00 00   jmp  *0x23f4(%rip)
 32c: 0f 1f 40 00         nopl   0x0(%rax)
 330: f3 0f 1e fa         endbr64              ; printf's lazy stub
 334: 68 00 00 00 00      push   $0x0
 339: e9 e2 ff ff ff      jmp    320 <PLT0>
 33e: 66 90               xchg   %ax,%ax       ; pad to 16 bytes
 340: f3 0f 1e fa         endbr64              ; greet's lazy stub
 344: 68 01 00 00 00      push   $0x1
 349: e9 d2 ff ff ff      jmp    320 <PLT0>
 
$ objdump -d -j .plt.sec app_cet      ; the FAST path — these are the call targets
0000000000000370 <printf@plt>:
 370: f3 0f 1e fa         endbr64              ; IBT landing pad
 374: ff 25 ae 23 00 00   jmp  *0x23ae(%rip)   ; jmp *GOT[printf] (0x2728)
 37a: 66 0f 1f 44 00 00   nopw   0x0(%rax,%rax,1)
 
0000000000000380 <greet@plt>:
 380: f3 0f 1e fa         endbr64
 384: ff 25 a6 23 00 00   jmp  *0x23a6(%rip)   ; jmp *GOT[greet] (0x2730)
 38a: 66 0f 1f 44 00 00   nopw   0x0(%rax,%rax,1)

Note where the symbol names went: objdump labels the .plt.sec entries printf@plt and greet@plt, because those are now the call targets, while the .plt entries have become anonymous continuation stubs with no symbol at all. The indirect jump has moved out of .plt and into .plt.sec; the .plt entry is now purely the lazy path (endbr64; push index; jmp PLT0). Control flow therefore becomes: call greet@plt → .plt.sec endbr64 → jmp *GOT[greet] → (first call) the .plt lazy stub, whose own endbr64 makes that a legal indirect-branch target too → PLT0 → resolver. Two landing pads, two purposes, and the fast path never touches the lazy one after the first call.

Resolved: the bnd prefix is gone

An earlier revision of this note flagged uncertainty about bnd jmp (encoding prefix f2), which appears in MaskRay’s 2021 disassembly but not in the output above. This is now settled by measurement rather than inference: the bnd PLT variant was tied to Intel MPX, and binutils 2.46.1 has removed it outright — ld -z bndplt responds /usr/bin/ld.bfd: warning: -z bndplt ignored. The option is accepted and discarded, and -z bndplt no longer appears in the binutils 2.46 ld manual at all. A bnd prefix in a PLT stub now dates the binary to a pre-2.46 (in practice pre-MPX-removal) toolchain.

The design constraint behind all of this is stated plainly in the binutils list thread “x86-64: new CET-enabled PLT format proposal”, where Rui Ueyama (the author of mold) reports trying to fold the two stubs back into one and failing: “I needed to insert another endbr between jmp and call. jmp GOT[foo] can jump only to endbr if CET is enabled, so it can’t directly jump to the following call.” That is the whole problem in one sentence — under IBT every indirect branch target must be an endbr64, and the lazy path is reached by an indirect branch (the .plt.sec stub’s jmp *GOT[...] landing on the not-yet-resolved slot), so the lazy stub needs a landing pad of its own. Two stubs, two endbr64s, because one indirect jump feeds the other.

Uncertain

Verify: the additional claim that keeping the landing pads separate is itself a security benefit (preventing an attacker from steering an arbitrary indirect branch into the resolver entry). Reason: the mechanical necessity of two endbr64s is now established from the thread quoted above and from MaskRay’s size argument (“An endbr64 instruction takes 4 bytes and does not fit in the current 16-byte PLT entry scheme. They somehow decided to add a new section .plt.sec”), but the security framing is my own reading, and I found no message in the binutils archive stating it as a design goal. To resolve: read the original .plt.sec implementation patch and its cover letter — the archive at inbox.sourceware.org is searchable and reachable, so this is tractable; it was not exhausted here. uncertain

flowchart LR
  subgraph NOCET["Non-CET build — one table"]
    direction TB
    C1["call greet@plt"] --> S1["greet@plt (.plt)<br/>jmp *GOT[greet]<br/>push $1<br/>jmp PLT0"]
    S1 -->|"1st: slot = stub+6"| S1
    S1 -->|"after: slot = greet"| L1["greet in libdemo.so"]
  end
  subgraph CET["CET build — two tables"]
    direction TB
    C2["call greet@plt"] --> S2["greet@plt (.plt.sec)<br/>endbr64<br/>jmp *GOT[greet]"]
    S2 -->|"1st: slot = lazy stub"| S3["(.plt) lazy entry<br/>endbr64<br/>push $1<br/>jmp PLT0"]
    S3 --> P0["PLT0<br/>push GOT[1]<br/>jmp *GOT[2]"]
    P0 --> R["resolver"]
    R -->|writes slot| S2
    S2 -->|"after: slot = greet"| L2["greet in libdemo.so"]
  end

The same call, laid out for a non-CET and a CET build. What it shows: CET does not add a step to the fast path — both builds reach the library through one indirect jump off a GOT slot — it splits the slow path into its own stub so that each indirect branch has a legal endbr64 landing pad. The insight: the extra .plt.sec section costs 16 bytes per function of address space and nothing per call after the first, which is why the trade was acceptable enough to become a distribution-wide default.

.plt.sec is not a compiler default — it is a distribution default. The previous revision of this note asserted -fcf-protection=full was “the default for Fedora’s hardened builds”, which conflates two different things and is wrong as stated about the compiler. Measured:

$ gcc -Q --help=common | grep cf-protection
  -fcf-protection=[full|branch|return|none|check]      none

Plain gcc on Fedora 44 defaults to -fcf-protection=none, and a hand-built binary consequently has no .plt.sec and no endbr64 anywhere — I verified that all four non-CET reference builds lack the section entirely. What supplies it is Fedora’s RPM macro set: /usr/lib/rpm/redhat/macros defines %__cflags_arch_x86_64_common as -mtune=generic -fasynchronous-unwind-tables -fstack-clash-protection -fcf-protection -mtls-dialect=gnu2 .... The distinction has teeth — a survey of 3,932 ELF objects in /usr/bin and /usr/lib64 on this machine found .plt.sec in 93.6% of them, so nearly everything you install is CET-enabled while nothing you compile by hand is. If your own objdump output has no .plt.sec, that is expected, not a broken toolchain.

The Four PLT Entry Formats, Side by Side

Collecting the encodings in one place makes the family resemblance — and the one real difference — obvious. All figures are objdump output from the reference builds on GNU ld 2.46.1.

FormatWhereBytesInstructionsReached byLazy path?
PLT0 header.plt, entry 016push GOT[1] / jmp *GOT[2] / noplfall-through from any lazy stubit is the lazy path
Classic PLTn.plt, non-CET16jmp *GOT[n] / push $idx / jmp PLT0the call site directlyyes — the tail after the jump
CET fast stub.plt.sec16endbr64 / jmp *GOT[n] / nopwthe call site directlyno — it has no tail
CET lazy stub.plt, CET build16endbr64 / push $idx / jmp PLT0indirectly, via the GOT slotyes — this is all it is
.plt.got stub.plt.got8jmp *GOT[n] / int3 / int3 — no endbr64the call site directlyno — its slot is a GLOB_DAT in .got

The PLT entry formats. What it shows: every format is 16 bytes and every one either is an indirect jump through a GOT slot or is a two-word push into the resolver — there are only two jobs, distributed differently. The insight: CET does not change the work, it changes the partitioning. The classic stub does both jobs in one entry by putting the resolver path in the bytes the fast path jumps over; the CET layout cannot, because the jump target must be an endbr64, so it splits the jobs into two entries and pays 16 extra bytes per function.

The .plt.got row is measured rather than inferred; it is rare enough (1.5% of objects on this machine) that it is easy to describe wrongly. From /usr/bin/net, which is IBT-enabled:

$ objdump -d -j .plt.got /usr/bin/net
00000000000755d0 <sync_pw2keytabs@plt>:
   755d0: ff 25 f2 25 09 00   jmp *0x925f2(%rip)   # 107bc8
   755d6: cc                  int3
   755d7: cc                  int3
$ readelf -rW /usr/bin/net | grep 107bc8
0000000000107bc8  R_X86_64_GLOB_DAT  sync_pw2keytabs@SAMBA_4.24.6_PRIVATE_SAMBA + 0
$ readelf -SW /usr/bin/net | grep -E ' \.got | \.got\.plt'
  [29] .got.plt  PROGBITS  00000000001057f0 ...
  [31] .got      PROGBITS  0000000000107a78 ...

Three facts confirm the row. The entry is 8 bytes, not 16, padded with int3. Its target 0x107bc8 lies inside .got, not .got.plt, and carries a GLOB_DAT relocation — so it is bound eagerly and has no lazy tail to jump over, which is why 8 bytes suffice. And it has no endbr64 despite the binary declaring the IBT feature, because a .plt.got entry is only ever reached by a direct call from .text; nothing branches to it indirectly, so no landing pad is required. That last detail is a small confirmation of the landing-pad rule from the other direction.

The Life of One GOT Slot, as a State Machine

Everything above can be collapsed into the states a single function’s GOT slot passes through. Drawing it this way makes clear that “lazy” and “eager” are not two mechanisms but two paths through one, and that several transitions are writes performed by different actors at different times.

stateDiagram-v2
    [*] --> InFile: static linker writes<br/>link-time address of PLTn + 6
    InFile --> Biased: ld.so, lazy path<br/>*slot += load_bias<br/>(elf_machine_lazy_rel)
    InFile --> Bound: ld.so, eager path<br/>full symbol lookup<br/>(DT_BIND_NOW / -z now / LD_BIND_NOW)
    Biased --> Bound: FIRST call only<br/>_dl_fixup → _dl_lookup_symbol_x<br/>→ elf_machine_fixup_plt writes the slot
    Biased --> Biased: every call, if LD_BIND_NOT=1<br/>(resolved but never written)
    Bound --> Bound: every later call<br/>one indirect jmp, no resolver
    Bound --> Frozen: _dl_protect_relro<br/>mprotect(PROT_READ)<br/>full RELRO only
    Frozen --> [*]: any write now faults<br/>SIGSEGV
    Biased --> Writable: partial RELRO or none<br/>slot stays rw-p for<br/>the life of the process
    Writable --> [*]: GOT-overwrite primitive<br/>remains available

The states one R_X86_64_JUMP_SLOT GOT slot passes through, with the actor and mechanism on each transition. What it shows: three different writers touch the slot — the static linker (at link time), ld.so (at load), and _dl_fixup (on first call) — and only the full-RELRO path ever closes it. The insight: the InFile → Biased transition is the one most explanations omit, and it is why lazy binding still writes every slot at load time; and the Biased → Biased self-loop is exactly what LD_BIND_NOT=1 exposes, which is why that variable is the cleanest proof that the write is the cache.

Under -z now the PLT Does Not Disappear

A common assumption is that eager binding removes the PLT. It does not — the stubs are still emitted, still 16 bytes, still shaped for laziness — they simply never take their resolver path. Disassembling the -z now build gives byte-identical stubs to the lazy build, only relocated:

$ objdump -d -j .plt app_now
0000000000000360 <printf@plt-0x10>:    ; PLT0 still present
 360: ff 35 42 2c 00 00   push  0x2c42(%rip)
 366: ff 25 44 2c 00 00   jmp  *0x2c44(%rip)
0000000000000380 <greet@plt>:
 380: ff 25 3a 2c 00 00   jmp  *0x2c3a(%rip)   ; jmp *GOT[greet]
 386: 68 01 00 00 00      push  $0x1           ; dead code — never executed
 38b: e9 d0 ff ff ff      jmp   360 <PLT0>     ; dead code — never executed

The push/jmp PLT0 tail is unreachable in this binary, because ld.so has already written the real address into the slot. Confirming at main, before a single external call has run:

(gdb) info symbol *(void**)0x555555556fc0        # greet's slot in app_now
greet in section .text of .../libdemo.so
(gdb) info symbol *(void**)0x555555556fb8        # printf's slot
printf in section .text of /lib64/libc.so.6
(gdb) x/3gx 0x555555556fa0                       # GOT[0..2]
0x555555556fa0: 0x0000000000002d90  0x0000000000000000
0x555555556fb0: 0x0000000000000000

Everything is bound before main, and GOT[1]/GOT[2] are zero because glibc’s elf_machine_runtime_setup guards their installation on lazy — there is no resolver to reach, so no link_map pointer and no trampoline address are installed. Three independent things therefore have to be true simultaneously for the resolver path to run: the object must have a DT_JMPREL, binding must be lazy, and the slot must not yet have been overwritten. The -z now build fails the second, so the PLT degenerates into a plain indirect-jump thunk — which is exactly why its stubs are cheap enough to leave in place rather than rewrite.

Configuration.plt emitted?.plt.sec?.got.plt?Resolver path reachable?When symbols bind
-z lazy (GNU ld default)yesonly with CETyesyeson first call, per symbol
-z nowyesonly with CETno (merged into .got)no — stub tails are dead codeall at load
LD_BIND_NOW=1 at runtimeyes—yesno — slots pre-filledall at load
LD_BIND_NOT=1 at runtimeyes—yesyes, on every callre-resolved each time
-fno-pltshrinks to near-empty—shrinksn/a for affected symbolsat load (GLOB_DAT is eager)
-z mark-pltyes, plus DT_X86_64_PLT*—per -z now/-z lazyper binding modeper binding mode

The binding-mode matrix, every row verified on the reference program. What it shows: four different switches reach “eager binding”, but they differ in what else changes — only -z now merges .got.plt away and thereby enables full RELRO, while LD_BIND_NOW leaves the section layout untouched because it is a runtime decision made after the file was linked. The insight: LD_BIND_NOW=1 gives you eager binding but not a read-only GOT; the section merge that full RELRO depends on is a link-time decision that an environment variable cannot retrofit.

That last point deserves emphasis because it is a real operational trap. Setting LD_BIND_NOW=1 on a binary linked -z lazy makes all symbols resolve at startup, which helps debugging and determinism — but .got.plt still exists, still sits outside the PT_GNU_RELRO range, and is still writable for the life of the process. You get eager binding’s latency profile without eager binding’s security benefit.

LD_BIND_NOT: Proving the GOT Write Is the Cache

If the claim “the GOT write is what makes resolution happen once” is correct, then suppressing that single store should make every call re-resolve. glibc provides exactly that switch. In _dl_fixup, immediately before the store:

  /* Finally, fix up the plt itself.  */
  if (__glibc_unlikely (GLRO(dl_bind_not)))
    return value;                                /* resolved, but DON'T write the slot */
 
  return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value);

and elf_machine_fixup_plt on x86-64 is a one-liner — return *reloc_addr = value;. So LD_BIND_NOT=1, documented in ld.so(8) as “do not update the GOT (global offset table) and PLT (procedure linkage table) after resolving a function symbol”, removes precisely one store and nothing else. The reference program calls greet twice:

$ LD_DEBUG=bindings ./app_lazy 2>&1 | grep -c "symbol \`greet'"
1
$ LD_BIND_NOT=1 LD_DEBUG=bindings ./app_lazy 2>&1 | grep -c "symbol \`greet'"
2

One bind normally, two with the write suppressed — one per call. This is as close to a controlled experiment as this subject allows, and it settles the “does the PLT get patched?” question from the opposite direction: nothing about the PLT changed between the two runs, the binary is the same file, and yet the caching behaviour vanished. The cache was always the GOT slot.

LD_BIND_NOT is also the practical way to get a complete call trace. Because every call re-enters the resolver, LD_DEBUG=bindings prints a line per call rather than per symbol — an strace for intra-process dynamic calls, at a heavy but bounded cost.

-z mark-plt: Letting the Dynamic Linker Delete the Indirection

The psABI defines three optional dynamic tags that exist to let ld.so optimise the PLT away at load time: DT_X86_64_PLT (the table’s address), DT_X86_64_PLTSZ (its total size) and DT_X86_64_PLTENT (the per-entry size, “always a power of 2”). Their purpose is stated directly: “using DT_X86_64_PLT, DT_X86_64_PLTSZ and DT_X86_64_PLTENT together with the r_offset and r_addend fields of R_X86_64_JUMP_SLOT relocation, the dynamic linker may retrieve the indirect branch target address stored at the global offset table entry and rewrite the indirect branch in the procedure linkage table entry to a direct branch” (psABI).

Read that carefully, because it inverts this note’s central rule. Ordinarily the PLT is immutable and the GOT is mutable; under this scheme a dynamic linker is permitted to rewrite the PLT’s code, turning jmp *GOT[greet] into a direct jmp greet and eliminating the load entirely. The ABI hedges it with four preconditions — every entry must have the same layout, must begin with the indirect branch (optionally preceded by ENDBR64), must have its only entry point at byte zero, and must be uniformly sized and aligned — which is why the feature needs an explicit opt-in rather than being inferred.

Binutils exposes it as -z mark-plt, and its documentation names the compatibility cost: “Since this option stores a non-zero value in the r_addend field of R_X86_64_JUMP_SLOT relocations, the resulting executables and shared libraries are incompatible with dynamic linkers” that do not know about it (binutils 2.46 ld manual) — an old ld.so would add that addend and compute a wrong address. Measured on the reference program:

$ gcc -O0 -fPIE -pie -Wl,-z,mark-plt,-z,relro,-z,now ... -o app_markplt main.c
$ readelf -dW app_markplt | grep X86_64
 0x0000000070000000 (DT_X86_64_PLT)    0x360
 0x0000000070000001 (DT_X86_64_PLTSZ)  0x50
 0x0000000070000003 (DT_X86_64_PLTENT) 0x10
$ readelf -rW app_markplt | grep JUMP_SLOT
0000000000002fb8  R_X86_64_JUMP_SLOT  printf@GLIBC_2.2.5 + 370
0000000000002fc0  R_X86_64_JUMP_SLOT  greet + 380
0000000000002fc8  R_X86_64_JUMP_SLOT  __cxa_finalize@GLIBC_2.2.5 + 390
0000000000002fd0  R_X86_64_JUMP_SLOT  bump + 3a0

The addends 0x370, 0x380, 0x390, 0x3a0 are the addresses of the four PLT stubs, and DT_X86_64_PLT = 0x360 with PLTENT = 0x10 describes the table they live in — every ingredient the specification asks for.

glibc really does rewrite the code, and here is the switch

The feature is implemented, shipped, and off by default. glibc’s own release notes for 2.39 announce it: “A new tunable, glibc.cpu.plt_rewrite, can be used to enable PLT rewrite on x86-64. When enabled with non-lazy binding, the dynamic linker will rewrite indirect branches in PLT with direct branches” (glibc NEWS at tag glibc-2.39; the patch that added the note is on libc-alpha). Turning it on makes ld.so narrate the whole operation:

$ GLIBC_TUNABLES=glibc.cpu.plt_rewrite=1 LD_DEBUG=files,bindings ./selfplt 2>&1 | grep -i plt
changing PLT in '/lib64/libc.so.6' to writable
changing PLT in '/lib64/libc.so.6' to direct branch
changing 'realloc' PLT entry in '/lib64/libc.so.6' to direct branch
changing 'calloc'  PLT entry in '/lib64/libc.so.6' to direct branch
changing 'free'    PLT entry in '/lib64/libc.so.6' to direct branch
changing '__tls_get_addr' PLT entry in '/lib64/libc.so.6' to direct branch
   ... (17 entries in total)
changing PLT in '/lib64/libc.so.6' back to read-only
changing PLT in './selfplt' to writable
changing PLT in './selfplt' to direct branch
skipping 'dl_iterate_phdr' PLT entry in './selfplt'
skipping 'printf'          PLT entry in './selfplt'
skipping 'greet'           PLT entry in './selfplt'
changing PLT in './selfplt' back to read-only

So the dynamic linker mprotects the PLT to PROT_READ|PROT_WRITE, overwrites the machine code, and mprotects it back to PROT_READ|PROT_EXEC — the one and only circumstance in which the sentence “the PLT gets patched” is literally true. Note also that Fedora builds libc.so.6 itself with -z mark-plt, since its 17 internal JUMP_SLOT entries were all eligible.

Every entry in the executable was skipped, and the reason is instructive. x86_64_rewrite_plt in sysdeps/x86_64/dl-machine.h computes a displacement and range-checks it:

ElfW(Addr) branch_start = l_addr + reloc->r_addend;   /* the PLT entry, from the addend */
uint64_t disp = (uint64_t) value - branch_start - JMP32_INSN_SIZE;
if (((uint64_t) disp + (uint64_t) ((uint32_t) INT32_MIN)) <= (uint64_t) UINT32_MAX)
  {
    *(uint8_t  *) branch_start       = JMP32_INSN_OPCODE;   /* e9 */
    *(uint32_t *) (branch_start + 1) = disp;                /* rel32 */
  }
else if (GL(dl_x86_feature_control).plt_rewrite == plt_rewrite_jmpabs)
  { *(uint32_t *) branch_start = JMPABS_INSN_OPCODE; *(uint64_t *)(branch_start+3) = value; }
else
  { _dl_debug_printf ("skipping '%s' PLT entry in '%s'\n", ...); continue; }
...
for (; pad < plt_end; pad++) *(uint8_t *) pad = INT3_INSN_OPCODE;   /* fill with 0xcc */

A direct jmp rel32 reaches ±2 GiB. The executable is mapped near 0x5555_5555_4000 and its libraries near 0x7fff_f7xx_xxxx — tens of terabytes apart under ASLR — so no direct branch can span the gap, and glibc skips those entries rather than emitting a wrong one. Only the ±2 GiB variant, JMPABS (an APX instruction, plt_rewrite=2), can handle arbitrary 64-bit targets, and this CPU has no APX. That is why libc.so.6 — whose PLT calls land inside itself — was rewritten while the executable’s calls into libraries were not. I confirmed the outcome by having the binary dump its own PLT bytes through DT_X86_64_PLT: with the tunable on or off, greet@plt still reads ff 25 32 2c 00 00 68 01 ..., an unchanged indirect jump.

Two footnotes worth carrying. The leftover bytes of a rewritten entry are filled with INT3 (0xcc), so an attacker who lands mid-entry traps rather than executing stale push/jmp code. And getting this arithmetic right was not free: BZ #31218 records a real displacement-overflow bug on x32 where an unsigned 32-bit subtraction produced a PLT entry reading e9 cb 2f 44 08 jmp 0x401030 — a branch to nowhere — which is exactly the failure mode the range check now prevents.

The Same Machinery on AArch64

The PLT is a generic System V concept, and comparing architectures separates the idea from the x86 encoding. glibc’s AArch64 port performs the identical dance: elf_machine_runtime_setup writes got[1] = (ElfW(Addr)) l and got[2] = (ElfW(Addr)) &_dl_runtime_resolve, and elf_machine_lazy_rel initialises each R_AARCH64_JUMP_SLOT slot with the same *reloc_addr += l_addr bias add. The reserved-entry contract, the two-push convention and the lazy initialisation are architecture-neutral.

The interesting divergence is a class of symbol AArch64 refuses to bind lazily:

if (__glibc_unlikely (sym->st_other & STO_AARCH64_VARIANT_PCS))
  {
    /* Avoid lazy resolution of variant PCS symbols.  */
    ...
    elf_machine_rela (map, scope, reloc, sym, version, reloc_addr, skip_ifunc);
    return;
  }

STO_AARCH64_VARIANT_PCS marks functions using a variant procedure call standard — principally Scalable Vector Extension routines, which preserve far more vector state than the base standard allows. The generic resolver trampoline cannot save that state, so rather than grow the trampoline the ABI marks such symbols and glibc binds them eagerly (ARM ELF ABI; glibc sysdeps/aarch64/dl-machine.h).

That is the general law hiding behind the x86-64 xsavec listing earlier: lazy binding is only available for functions whose calling convention the trampoline knows how to save and restore. x86-64 buys universality by saving everything the hardware reports, at a cost that grows with each new register file Intel adds. AArch64 buys cheapness by marking the exceptions and excluding them. Both are answers to the same constraint, and knowing the constraint explains why _dl_runtime_resolve keeps getting slower on x86 with each ISA extension.

How to see it yourself

# --- build the variants; note -fcf-protection is NOT a gcc default, you must ask ---
gcc -O0 -no-pie -fno-pie -fcf-protection=none -Wl,-z,lazy,-z,norelro hello.c -o h_classic
gcc -O0 -fPIE  -pie      -fcf-protection=full -Wl,-z,ibt,-z,shstk   hello.c -o h_cet
gcc -O0 -fPIE  -pie      -Wl,-z,relro,-z,now                        hello.c -o h_now
 
# --- static: the tables and their relationship ---
objdump -d -j .plt     h_cet    # PLT0 header + the lazy stubs (endbr64; push idx; jmp PLT0)
objdump -d -j .plt.sec h_cet    # the endbr64 fast-path stubs that `call` actually targets
objdump -s -j .got.plt h_classic  # initial slot values == link-time address of PLTn + 6
readelf -rW h_cet               # .rela.plt order == the immediates the stubs push
readelf -dW h_cet | grep -E 'PLTGOT|JMPREL|PLTRELSZ|FLAGS|X86_64'
objdump -d h_cet | grep -E 'call.*@plt'   # the call sites in .text
 
# --- runtime: where the interesting behaviour lives ---
LD_DEBUG=bindings ./h_classic 2>&1        # one line per symbol, at the moment it binds
LD_DEBUG=reloc    ./h_classic 2>&1 | grep 'relocation processing'   # " (lazy)" marks the mode
LD_DEBUG=statistics ./h_classic 2>&1 | grep -E 'reloc|cycles'
LD_BIND_NOW=1 ./h_classic                 # eager at runtime, without relinking
LD_BIND_NOT=1 LD_DEBUG=bindings ./h_classic 2>&1   # resolve on EVERY call; GOT never written
GLIBC_TUNABLES=glibc.cpu.plt_rewrite=1 LD_DEBUG=files ./h_now   # watch ld.so rewrite PLT code
 
# --- step the stub itself; this is the only way to really see it ---
gdb -q ./h_classic
(gdb) break main
(gdb) run
(gdb) disassemble main                    # find a `call <fn>@plt`
(gdb) x/1gx &<fn>@got.plt                 # BEFORE: points at <fn>@plt + 6
(gdb) stepi ; stepi                       # into the stub, then into its own push
(gdb) finish
(gdb) x/1gx &<fn>@got.plt                 # AFTER: points into the library

Two of those are worth calling out because they are what separate a real understanding from a recited one. LD_DEBUG=reloc appends the literal string " (lazy)" per object — straight from _dl_relocate_object_no_relro — so it tells you the binding mode per object, and you will find that libc.so.6 never carries the marker because glibc ships itself -z now regardless of how your program is linked. And the gdb sequence is the only way to watch the GOT slot change; static tools show you the initial value, which is the one thing that is guaranteed to be wrong by the time any code has run.

Failure Modes and Common Misunderstandings

“The PLT gets patched.” No — the GOT gets patched; the PLT is read-only .text. The whole point is that the code stays immutable and shareable while only the per-process GOT data changes. Confusing the two is the single most common PLT misconception.

Symbol interposition surprises. Because the first call runs a full symbol search, a function can be interposed: an LD_PRELOADed library, or even an earlier object in the lookup scope, can define malloc and the PLT will bind to that, not libc’s. This is how LD_PRELOAD hooks work (see Symbol Interposition and LD_PRELOAD). It also means the PLT call is not free of policy: the binding depends on the entire lookup scope, not just “the obvious library.”

-fno-plt removes the stub. With gcc -fno-plt, the compiler does not emit call printf@plt; it emits an indirect call straight through the GOT, skipping the stub. Measured on the reference program:

$ gcc -O0 -fPIE -pie -fno-plt ... -o app_noplt main.c
$ objdump -d app_noplt | grep call
 437: ff 15 93 22 00 00   call *0x2293(%rip)   # 26d0 <greet>
 447: ff 15 83 22 00 00   call *0x2283(%rip)   # 26d0 <greet>
 44d: ff 15 9d 22 00 00   call *0x229d(%rip)   # 26f0 <bump>
 46c: ff 15 56 22 00 00   call *0x2256(%rip)   # 26c8 <printf@GLIBC_2.2.5>
 
$ readelf -rW app_noplt | grep -E 'greet|bump|printf'
00000000000026c8  R_X86_64_GLOB_DAT  printf@GLIBC_2.2.5 + 0
00000000000026d0  R_X86_64_GLOB_DAT  greet + 0
00000000000026f0  R_X86_64_GLOB_DAT  bump + 0
 
$ readelf -SW app_noplt | grep '\.plt '
  [ 3] .plt  PROGBITS  0000000000000320 000320 000020 10  AX  0   0 16

The relocations changed class — JUMP_SLOT became GLOB_DAT, moving the slots from .got.plt into .got — and .plt shrank from 0x50 to 0x20 (PLT0 plus a single leftover entry for __cxa_finalize, which the CRT startup code still calls through a stub). Because GLOB_DAT has no lazy path, this forces eager binding for every affected symbol, which is the real trade: you save two jumps per call and lose laziness entirely. MaskRay notes it is a win when the target is genuinely a preemptible external symbol but a pessimization for non-preemptible ones — you pay for indirection you did not need — and that linkers can partially undo the damage by rewriting the six-byte indirect call to addr32 call foo.

Stale GOT after a crash. If a process is in the middle of _dl_runtime_resolve and is examined in a core dump, the GOT slot may still point into the PLT — making the backtrace look like it goes through the resolver. This is normal, not corruption. The stack is genuinely unusual at that moment, too: the PLT has pushed two words under the caller’s return address, so an unwinder without correct CFI for the trampoline will produce nonsense. binutils tracks this as a real concern — there is a patch on the list titled “x86: Correct unwind information for the second PLT” — and MaskRay observes that linker-synthesised PLT entries “may not have unwind tables information”.

“.plt.got is the PLT’s GOT.” It is not, and the near-collision of names catches good writers out: LWN’s own “A look at dynamic linking” describes the PLT’s function-pointer table as being “separated from the program’s normal GOT in its own section called .plt.got”, when the section it means is .got.plt. .plt.got is something else entirely — a stubless PLT variant whose entries read a .got slot rather than a .got.plt one, present in about 1.5% of the ELF objects on this machine. This is precisely why code that analyses binaries should key off DT_PLTGOT and DT_JMPREL, which are ABI-mandated, rather than off section names, which are convention.

“Eager binding means no PLT.” Shown above: the stubs are still there, byte-identical, with unreachable push/jmp PLT0 tails. Do not conclude from the presence of .plt that a binary binds lazily — check DT_FLAGS for BIND_NOW or DT_FLAGS_1 for NOW.

Alternatives and When to Choose Them

The PLT exists to make lazy dynamic binding cheap. If you do not want laziness — for hardening or determinism — you bind eagerly with -z now (or LD_BIND_NOW), which lets full RELRO make the GOT read-only after relocation and closes the GOT-overwrite attack surface; the PLT stubs then exist but are run only as plain indirect-jump trampolines. If you want to avoid the PLT stub’s two extra jumps entirely, -fno-plt inlines the GOT indirection at each call site. And statically-linked binaries (see Static and Dynamic Linking in Go for the language-runtime view, and Static vs Dynamic Linking) have no PLT for internal calls at all — every call is a direct call to a known offset, because there is nothing to resolve at load time. Go in particular emits almost entirely static binaries and its own direct calls, sidestepping the PLT for Go-to-Go calls (the linker is link).

Production Notes

On a 2026 Fedora 44 system (gcc 16.2.1, GNU ld 2.46.1, glibc 2.43), distribution packages are built with -fcf-protection and full RELRO, so they carry .plt + .plt.sec with endbr64 landing pads and no .got.plt. I surveyed 3,932 ELF objects in /usr/bin and /usr/lib64: 93.6% have a .plt.sec and only 7.0% still have a .got.plt. /usr/bin/ls is representative — PIE, FLAGS BIND_NOW, FLAGS_1 NOW PIE, .plt.sec present, .got.plt absent. So on a stock 2026 distribution the lazy PLT path is largely a teaching artifact; the binaries you actually run resolve eagerly at startup and hand the GOT to RELRO before main.

Do not read that as “lazy binding is obsolete”. It remains GNU ld’s documented default and it is what you get when you build software yourself, so it is still the shape of most in-house and third-party binaries. It also still wins where it was always meant to: a program linking a library with thousands of exported functions and calling a handful of them defers a symbol search per unused function, and Drepper’s O(R + nr) analysis says that term scales with both the symbol count and the number of participating objects. What has changed is the judgement call. The resolver is far more expensive than it was when lazy binding was designed — every ISA extension Intel ships adds state that xsavec must preserve, as the STATE_SAVE_MASK listing above shows, and the trampoline’s save area is now sized from a runtime variable — while the security argument for eager binding has strengthened.

The XZ Utils backdoor (CVE-2024-3094): the PLT/GOT path in a real incident

The most consequential recent supply-chain attack used exactly the machinery this note describes, and it is worth tracing because it illustrates a boundary that is easy to state wrongly. The liblzma backdoor did not exploit lazy binding. It exploited IFUNC resolution, which runs during relocation — before RELRO has been applied to anything.

The backdoor supplied its own indirect-function resolvers, crc32_resolve() and crc64_resolve(), for crc32() and crc64() — plausible, since IFUNC’s legitimate purpose is picking a CPU-optimised implementation. As LWN’s analysis puts it: “When the dynamic linker finalizes the locations of those functions, it calls the backdoor’s resolver functions. At this point, dynamic linking is still in progress, so many of the linker’s internal data structures have not yet been made read-only. This would let the backdoor manipulate libraries that had already been loaded by overwriting entries in the procedure linkage table (PLT) or global offset table (GOT)” (LWN, How the XZ backdoor works). Because its ultimate target — OpenSSL’s RSA_public_decrypt — might not be mapped yet, the backdoor registered an audit hook, which ld.so invokes on every symbol binding, and waited for RSA_public_decrypt@got.plt to be resolved before overwriting that GOT entry in sshd’s own table.

Three lessons land directly on this note’s material. R_X86_64_IRELATIVE is the one relocation type that executes attacker-controllable code during relocation — everything else in the table merely stores a value, which is why the IFUNC branch of elf_machine_lazy_rel is eager regardless of binding mode: there is no safe way to defer running an arbitrary function. Full RELRO protects the GOT after relocation, not during it, so the window the backdoor used exists in hardened binaries too; RELRO is not a defence against a malicious object already inside the process. And the target was reached through the lazy-binding notification path, the la_symbind audit interface _dl_fixup consults, which is why that code exists in _dl_fixup at all. The PLT and GOT were the attack’s instrument rather than its vulnerability — the vulnerability was that a hostile library was linked in — but understanding the instrument is what let analysts reconstruct the attack within days.

See Also