01 / ELF Background

The Executable and Linkable Format (ELF) is the standard binary format used across Linux and most UNIX-like systems. Every ELF file begins with a 64-byte header that identifies the file type (executable, shared library, core dump), target architecture, and the entry point address. Two parallel views exist within every ELF: section headers, which are used by the linker at link time, and program headers, which describe segments the kernel loader maps into memory at runtime.

The .dynamic section is the engine of dynamic linking. It is an array of Elf64_Dyn tagged entries that tell the runtime linker (ld-linux.so) everything it needs: which shared objects are needed (DT_NEEDED), where the symbol table lives (DT_SYMTAB), and where relocation tables are (DT_RELA, DT_JMPREL). Understanding this section is a prerequisite to understanding relocations.

Two command-line tools are your primary windows into ELF internals. readelf (from GNU binutils) parses and displays ELF structures in human-readable form, while objdump combines disassembly with section-level metadata. Learning to cross-reference their outputs is a core reverse engineering skill.

$ readelf -d /bin/ls | grep NEEDED
 0x0000000000000001 (NEEDED)   Shared library: [libselinux.so.1]
 0x0000000000000001 (NEEDED)   Shared library: [libc.so.6]

02 / Relocation Types

A relocation entry is a directive left by the compiler/linker instructing the runtime linker: “at this virtual address, patch in the resolved address of this symbol.” On x86-64, relocations are described by Elf64_Rela structures containing an offset, an info field (which encodes the relocation type and symbol index), and an addend.

The most important relocation types for dynamic linking are: R_X86_64_JUMP_SLOT, used for PLT entries (function calls via the lazy binding mechanism); R_X86_64_GLOB_DAT, used for global data symbols that need absolute address patching; and R_X86_64_RELA, a generic relative relocation used for position-independent data references. Each type encodes different patching semantics — understanding which type applies where is critical when analyzing malware or stripped binaries.

$ readelf -r ./binary

Relocation section '.rela.plt' at offset 0x618 contains 3 entries:
  Offset          Info           Type              Sym. Value    Sym. Name + Addend
004018 00000001  R_X86_64_JUMP_SLOT  0000000000000000  puts@GLIBC_2.2.5 + 0
004020 00000002  R_X86_64_JUMP_SLOT  0000000000000000  printf@GLIBC_2.2.5 + 0
004028 00000003  R_X86_64_JUMP_SLOT  0000000000000000  exit@GLIBC_2.2.5 + 0

Relocation section '.rela.dyn' at offset 0x570 contains 4 entries:
  Offset          Info           Type              Sym. Value    Sym. Name + Addend
003fd8 00000006  R_X86_64_GLOB_DAT   0000000000000000  __libc_start_main@GLIBC_2.2.5 + 0

03 / The GOT Explained

The Global Offset Table (GOT) is a data section (.got and .got.plt) containing an array of 8-byte pointers on x86-64. Its fundamental purpose is to decouple position-independent code from absolute addresses — instead of hardcoding a symbol’s address, the code performs an indirect load through the GOT, which the dynamic linker fills in at load time or on first call.

Linux supports two binding modes. With lazy binding (the default, controlled by DT_FLAGS not having DF_BIND_NOW), GOT entries for PLT-routed functions are initially populated with the address of a PLT stub that triggers resolution on first call. After resolution, the GOT entry is overwritten with the real symbol address. Subsequent calls jump directly through the GOT, bypassing the resolver entirely. With eager binding (LD_BIND_NOW=1 or RTLD_NOW), all GOT entries are resolved before the program’s main() is reached.

The memory layout of the .got.plt section is highly structured: GOT[0] holds the address of the .dynamic section; GOT[1] holds a pointer to the link map (a linked list of all loaded libraries maintained by the dynamic linker); GOT[2] holds the address of _dl_runtime_resolve. Per-function entries begin at GOT[3]. Knowing this layout is essential for exploiting GOT overwrites in CTF challenges and real-world vulnerabilities.

04 / The PLT Explained

The Procedure Linkage Table (PLT) is a section of executable stubs, one per dynamically linked function. Its purpose is to enable lazy binding without the caller having to know whether the symbol has been resolved yet. Every PLT entry follows the same three-instruction pattern.

On the first call, the jmp targets the GOT entry, which initially points back into the PLT itself (past the first instruction). This falls through to a push of the relocation index, then an unconditional jump to the PLT prologue (PLT[0]), which pushes the link map pointer and jumps to _dl_runtime_resolve. On subsequent calls, the GOT entry now holds the resolved address, so the jmp goes directly to the target function — zero overhead after first use.

; PLT stub for printf — annotated
0x401020 <printf@plt>:
  jmp    QWORD PTR [rip+0x2fca]   ; indirect through GOT entry
  push   0x0                      ; relocation index for printf
  jmp    0x401000 <_init+0x20>    ; jump to _dl_runtime_resolve trampoline

0x401000 <PLT[0]>:
  push   QWORD PTR [rip+0x2fb2]   ; push link_map (GOT[1])
  jmp    QWORD PTR [rip+0x2fb4]   ; jump _dl_runtime_resolve (GOT[2])

05 / Dynamic Linker Walkthrough

_dl_runtime_resolve is the central resolver implemented in ld-linux.so. When invoked, the stack already has the link map pointer and the relocation index pushed by the PLT. The resolver uses these to look up the Elf64_Rela entry, extract the symbol name from the string table, walk the symbol tables of all loaded shared objects in link-map order, and ultimately write the resolved address back into the GOT entry before tail-calling the target function.

The LD_DEBUG environment variable is invaluable for observing this process without a debugger. Setting LD_DEBUG=bindings prints every symbol binding as it happens, showing which library satisfies each symbol and the address written to the GOT. Setting LD_DEBUG=all produces exhaustive output including relocation processing, symbol lookups, and library search paths.

$ LD_DEBUG=bindings ./binary 2>&1 | head -30
     12345:  binding file ./binary [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `printf'
     12345:  binding file ./binary [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `puts'
     12345:  binding file ./binary [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `exit'

06 / Tracing with GDB

GDB lets you single-step through the entire resolution path. The key insight is to break on the PLT stub before the first call, then use si (step instruction) to follow execution through the GOT into _dl_runtime_resolve and back out again. Below is an annotated session tracing printf resolution.

(gdb) break *0x401020
Breakpoint 1 at 0x401020

(gdb) run
Breakpoint 1, 0x0000000000401020 in printf@plt ()

(gdb) x/3i $pc
=> 0x401020 <printf@plt>:      jmp    QWORD PTR [rip+0x2fca]
   0x401026 <printf@plt+6>:    push   0x0
   0x40102b <printf@plt+11>:   jmp    0x401000

(gdb) x/gx 0x403ff0       # GOT entry for printf (before resolve)
0x403ff0:  0x0000000000401026  # points back into PLT — unresolved!

(gdb) continue           # let resolution happen

(gdb) x/gx 0x403ff0       # GOT entry for printf (after resolve)
0x403ff0:  0x00007ffff7a63e10  # now points to printf in libc!

(gdb) info proc mappings
0x00007ffff7a00000  0x00007ffff7bc5000  libc-2.35.so
  # confirms 0x7ffff7a63e10 falls within libc

(gdb) x/8gx 0x403fd8      # dump GOT[0..3]
0x403fd8:  0x0000000000403e20  0x00007ffff7ffe2e0
0x403fe8:  0x00007ffff7fe1160  0x00007ffff7a63e10

07 / Further Reading

// KEY TAKEAWAYS

PLT stubs enable lazy binding: functions are resolved only on first invocation. GOT entries are initially populated with PLT stub addresses and rewritten in-place once resolved. The entire mechanism is orchestrated by _dl_runtime_resolve inside ld-linux.so, using the relocation table and symbol tables from the dynamic section. Mastering this flow is prerequisite for GOT-overwrite exploitation and deep binary analysis.

// TOOLS USED
readelf objdump GDB ltrace LD_DEBUG
// PRACTICE QUESTIONS
  1. What is the difference between R_X86_64_JUMP_SLOT and R_X86_64_GLOB_DAT relocations, and when does the linker emit each type?
  2. How does setting LD_BIND_NOW=1 affect PLT behavior? Describe the GOT state before main() is entered in each binding mode.
  3. Can you trace the full resolution path for write(2) using GDB? What is the first instruction executed inside _dl_runtime_resolve, and how does it recover the relocation index from the stack?