Every time I open a dynamically linked ELF in a disassembler, the first thing the decompiler hands me is a wall of puts@plt, _GLOBAL_OFFSET_TABLE_+0x18, .plt.sec, .got, .got.plt — a little vocabulary of structures that exist entirely to answer one question: when my program calls puts, how does the call instruction know where puts is, given that nobody knew that address when the compiler ran? This post is me pinning that whole mechanism down end to end: why the indirection exists, exactly how a lazy bind unfolds the first time you call a libc function, what full RELRO changes, and all the surrounding characters — .plt.got, .gnu.hash, static and static-PIE binaries, the link_map chain you can walk out of GOT[1], and the two places this machinery shows up in offensive/defensive work: dynamic import resolution in malware, and the GOT-overwrite primitive.
Everything here is x86-64 Linux, System V AMD64 ABI, glibc. I built and ran every binary on Debian 13.7 — gcc 14.2, binutils 2.44, glibc 2.41 — and all the objdump/readelf/gdb output below is real output from that box. Where I link glibc source, I point at the matching glibc-2.41 tag, so the line numbers I cite are exactly what you’ll see if you check that release out. If you reproduce this on a different toolchain the mechanics are identical; only concrete addresses and counts like RELACOUNT will shift.
Why any of this is needed
Start from the thing we’re trying to avoid. You write puts("hi"). puts lives in libc.so.6, a separate file that will be mapped somewhere in your address space — and on any modern system, somewhere different every run, because the loader randomizes where shared objects land (ASLR). The compiler emitting your .o, and even the static linker emitting your executable, cannot possibly know the runtime address of puts. So the call can’t be call 0x7ffff7c87cc0 — that address doesn’t exist yet, and won’t survive to the next execution.
There are only two ways out of this. Either you patch the real address into every call site once you know it, or you route every call through one writable pointer that you patch exactly once. The first option is what the second one exists to avoid, and it’s worth being precise about why it’s unacceptable, because “it would be slow” undersells it.
Why you can’t just patch the .text
Say the linker left call 0x0 placeholders in .text and you fixed them up at load time with the real puts address. Three things break at once:
- It dirties executable code pages.
.textis mappedr-xp— read, no-write, execute, and the trailingp. Thatpisn’t a permission bit; it’s the mapping’s sharing mode in/proc/[pid]/maps:p= private (copy on write),s= shared (the two correspond tommap(2)‘sMAP_PRIVATE/MAP_SHARED). Those bits come from two places. The static linker puts.textin aPT_LOADsegment it stampsR E— i.e.p_flags = PF_R | PF_X, which you can see in theFlgcolumn ofreadelf -l. The kernel’s ELF loader then honors it:load_elf_binary()walks eachPT_LOADand translates its flags to page protections throughmake_prot()(if (p_flags & PF_X) prot |= PROT_EXEC;, and the same forPF_R/PF_W), then maps every file-backedPT_LOADwithMAP_PRIVATEby default — and that default is the trailingp. So the linker choosesr-xand the kernel adds thep;.textends up a private, file-backed mapping — its pages are shared read-only at the physical-page level with every other process running the same binary, right up until someone writes one. The moment the loader writes a resolved address into a code page, the kernel does copy-on-write: that page is now a private, dirty, anonymous page for your process alone. Do that across a real program’s thousands of call sites and you’ve converted almost all of its shared, demand-pageable, reclaimable code into per-process dirty memory. The whole point ofr-xpfile-backed text — sharing one physical copy of libc’s code across every process, evicting it under pressure for free because it’s clean — evaporates. - It requires writable code. To patch
.textyou must map it writable, at least transiently.W^X(write xor execute) is a baseline mitigation precisely to stop a program (or an attacker) from having memory that is both writable and executable. Load-time text patching is in direct tension with it. (That this still happens in the wild — under the name text relocations — is the subject of the paragraph just below.) - The relocations multiply. Each distinct call site to
putsis a separate fixup. Callputsfrom forty places and that’s forty addresses to patch. Route all forty through one pointer and it’s one.
The writable-code option hasn’t actually vanished, though — it survives as a legacy fallback called a text relocation, and it’s the exception that proves the rule. It turns up when a shared object is built from non-position-independent code: the compiler bakes an absolute address straight into an instruction, so the only way to fix it up at load time is to write into .text. You can manufacture one and watch the toolchain recoil — -mcmodel=large is the key, forcing the compiler to address g with a 64-bit absolute (movabs); the small-model default would instead emit a 32-bit absolute, which can’t encode a shared object’s ASLR’d 64-bit load address, so the linker rejects that outright rather than producing a text relocation at all:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | $ cat tr.c /* tr.c — non-PIC code that bakes an absolute address into .text */ long g = 42; long *get_addr(void) { return &g; } $ gcc -shared -fno-pic -mcmodel=large -o libtr.so tr.c /usr/bin/ld: /tmp/ccrKyB1i.o: warning: relocation against `g' in read-only section `.text' /usr/bin/ld: warning: creating DT_TEXTREL in a shared object $ readelf -d libtr.so | grep TEXTREL 0x0000000000000016 (TEXTREL) 0x0 0x000000000000001e (FLAGS) TEXTREL $ objdump -d -M intel libtr.so 00000000000010f9 <get_addr>: 10f9: 55 push rbp 10fa: 48 89 e5 mov rbp,rsp 10fd: 48 b8 00 00 00 00 00 movabs rax,0x0 1104: 00 00 00 1107: 5d pop rbp 1108: c3 ret $ readelf -r libtr.so | grep "g\ " 0000000010ff 000500000001 R_X86_64_64 0000000000004008 g + 0 |
The linker permits this but flags it — it marks .dynamic as needing text relocations — setting both the legacy DT_TEXTREL tag and the DF_TEXTREL bit in DT_FLAGS (the two lines readelf -d prints above) — and warns loudly (the stricter 32-bit form it refuses outright: “relocation R_X86_64_32 against symbol g can not be used when making a shared object; recompile with -fPIC”). At runtime it’s the dynamic loader, not the kernel, that copes: the kernel only ever mapped the segment r-x (the binfmt_elf path above). glibc’s _dl_relocate_object (elf/dl-reloc.c:257) checks l->l_info[DT_TEXTREL], and for every PT_LOAD segment lacking PF_W it __mprotects the page-aligned range to prot | PROT_WRITE (L281) — the source comment there is candid about it: /* Bletch. We must make read-only segments writable long enough to relocate them. */ — applies the relocations into the now-writable code, then __mprotects it back to r-x (L326) and flushes the instruction cache (CLEAR_CACHE, L333) because executable bytes changed under the CPU. For the width of that window the page is writable and executable — the exact W^X violation from the point above — and once patched it’s a per-process dirty COW page, which forfeits the sharing from the first point. Two mitigations broken at once, bought by nothing but having shipped non-PIC code. That’s why it’s a defect, not a design: the fix is always -fPIC (and -z text promotes the DT_TEXTREL to a hard link error), and -fPIC is exactly what routes the address through the GOT instead — the machinery the rest of this post is about.
So the design is the obvious one once you see the constraints: keep .text read-only, position-independent, and clean, and push all the “addresses I won’t know until runtime” into one dedicated writable table of pointers. That table is the Global Offset Table, the .got. Code refers to external things indirectly through a GOT slot (call QWORD PTR [rip+puts@GOTPCREL] in spirit), the loader writes the real address into the slot, and .text never changes.
PIE makes this non-negotiable
Even ignoring shared libraries, PIE (position-independent executable — the default gcc output for years) forces the same machinery on the main program’s own data. You can confirm the default locally — a plain compile yields an ET_DYN, and gcc reports it was configured that way:
1 2 3 4 | $ gcc -o t t.c && readelf -h t | grep Type Type: DYN (Position-Independent Executable file) # a PIE; -no-pie would give EXEC $ gcc -v 2>&1 | tr ' ' '\n' | grep enable-default-pie --enable-default-pie |
A PIE is an ET_DYN object: the kernel can map it at any base, and does (ASLR again). The split is explicit in the kernel’s load_elf_binary(): an ET_EXEC is mapped MAP_FIXED_NOREPLACE at the p_vaddr the linker baked into the file — no bias, so its address is fixed — whereas an ET_DYN with a PT_INTERP (an ordinary PIE) gets load_bias = ELF_ET_DYN_BASE plus a fresh per-exec random offset, load_bias += arch_mmap_rnd(), gated on the PF_RANDOMIZE flag; a static-PIE ET_DYN (no interpreter) instead takes load_bias = 0 and rides the architecture’s already-randomized mmap base. (ELF_ET_DYN_BASE on x86-64 is DEFAULT_MAP_WINDOW / 3 * 2, i.e. two-thirds up the address window; fs/binfmt_elf.c, Linux 6.12.) So the program can’t hardcode the absolute address of even its own global variables or functions into instructions. Two mechanisms cooperate:
- For code-relative references (a function calling another function in the same module, or loading the address of a local global), the compiler uses RIP-relative addressing:
lea rax,[rip+0xec0]. The displacement is fixed at link time because the distance between two things in the same module is constant regardless of load base. No runtime fixup needed. You can see it inmainbelow — the string pointer is computedlea rax,[rip+0xec0], not loaded from a table. - For anything whose absolute address must appear in memory (a pointer in
.datathat points into the module, the GOT slots themselves,.init_arrayentries), the module carriesR_X86_64_RELATIVErelocations: “at this offset, add the load base.” The loader walks them at startup and rebases each — in glibc the rebase is literally*reloc_addr = l_addr + reloc->r_addend(elf_machine_rela_relative), driven from_dl_relocate_objectthrough theDT_RELACOUNTfast-path loop inelf_dynamic_do_Relathat blasts through the leading run ofRELATIVEentries. These are cheap, symbol-less, and they’re the entire relocation budget of a well-behaved PIE that calls nothing external — and their count is exactly theRELACOUNTdynamic tagreadelf -dprints:
1 2 3 4 5 6 | $ gcc -O0 -fno-stack-protector -fcf-protection=none -o demo_partial demo.c -Wl,-z,relro,-z,lazy # partial RELRO; -fcf-protection=none pins the classic PLT layout (see the .plt.sec note below) $ readelf -d demo_partial | grep RELACOUNT 0x000000006ffffff9 (RELACOUNT) 3 # the demo PIE: a handful of RELATIVE relocs $ gcc -O0 -fno-stack-protector -static-pie -o demo_staticpie demo.c # same source, built static-PIE $ readelf -d demo_staticpie | grep RELACOUNT 0x000000006ffffff9 (RELACOUNT) 1099 # static-PIE has to rebase its entire self |
Here’s main from a trivial PIE that calls puts twice. One flag needs a word up front: I build every binary in this post with -fcf-protection=none. Some toolchains enable Intel CET by default (Ubuntu’s gcc, for one), and CET splits the PLT into two sections, which muddies every disassembly below — so pinning the flag off gives the classic, single-.plt layout no matter where you build. I deal with the CET split on its own terms in the .plt.sec section near the end.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | $ cat demo.c #include <stdio.h> int main(void) { puts("first call"); puts("second call"); return 0; } $ gcc -O0 -fno-stack-protector -fcf-protection=none -o demo_partial demo.c -Wl,-z,relro,-z,lazy $ objdump -d -M intel demo_partial 0000000000001139 <main>: 1139: 55 push rbp 113a: 48 89 e5 mov rbp,rsp 113d: 48 8d 05 c0 0e 00 00 lea rax,[rip+0xec0] # 2004 <_IO_stdin_used+0x4> 1144: 48 89 c7 mov rdi,rax 1147: e8 e4 fe ff ff call 1030 <puts@plt> 114c: 48 8d 05 bc 0e 00 00 lea rax,[rip+0xebc] # 200f <_IO_stdin_used+0xf> 1153: 48 89 c7 mov rdi,rax 1156: e8 d5 fe ff ff call 1030 <puts@plt> 115b: b8 00 00 00 00 mov eax,0x0 1160: 5d pop rbp 1161: c3 ret |
This single demo.c is the source for every demo_* binary in this post — only the compile and link flags differ. I’ll show the exact gcc line next to each binary where it first comes up, rather than dumping them all here. (The -Wl,-z,lazy above forces lazy binding; several current toolchains default to -z now, which would resolve everything up front and skip the path I want to show — more on that in the RELRO section.)
The string loads RIP-relative (no fixup), but puts — an external symbol — goes call 1030 <puts@plt>. Not call <puts>; call into the Procedure Linkage Table. That stub is where the whole dance happens.
The cast of sections
Before the dance, the four sections involved, and the one sentence each that matters:
| Section | Perms | What it holds |
|---|---|---|
.plt | r-xp | Code stubs. Entry 0 (PLT-0) is the trampoline into the resolver; the rest are per-symbol lazy stubs. |
.got.plt | rw-p* | The writable pointer table for functions. GOT[0..2] reserved; GOT[3..] are the per-function slots lazy binding patches. |
.got | r-xp after RELRO | The pointer table for everything else — data symbols, and functions whose address is taken (resolved eagerly to one canonical pointer via GLOB_DAT, not a lazy stub). |
.plt.sec / .plt.got | r-xp | Secondary code stubs (CET and GOT-dispatch cases). More below. |
* .got.plt‘s writability is the whole RELRO question — hold that thought.
Lazy binding, one instruction at a time (partial RELRO)
The promise of lazy binding: don’t resolve puts until the first time it’s actually called, so a program that imports 200 functions and calls 5 pays for 5 lookups, not 200. The cost of the promise is one layer of indirection and a clever self-modifying trampoline.
The classic three-instruction stub
1 2 3 4 5 6 7 8 9 10 | $ objdump -d -M intel -j .plt demo_partial 0000000000001020 <puts@plt-0x10>: # <-- PLT-0, the resolver trampoline 1020: ff 35 ca 2f 00 00 push QWORD PTR [rip+0x2fca] # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8> 1026: ff 25 cc 2f 00 00 jmp QWORD PTR [rip+0x2fcc] # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10> 102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 0000000000001030 <puts@plt>: 1030: ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] # 4000 <puts@GLIBC_2.2.5> <-- indirect jmp through GOT slot 1036: 68 00 00 00 00 push 0x0 <-- relocation index for puts (0) 103b: e9 e0 ff ff ff jmp 1020 <_init+0x20> <-- back to PLT-0 (= 0x1020) |
And the GOT it points into, straight out of the file:
1 2 3 4 | $ objdump -s -j .got.plt demo_partial Contents of section .got.plt: 3fe8 e03d0000 00000000 00000000 00000000 .=.............. 3ff8 00000000 00000000 36100000 00000000 ........6....... |
GOT[0] reads 0x3de0 in that dump — which is .dynamic‘s vaddr, and where nm puts the _DYNAMIC symbol:
1 2 3 4 | $ readelf -S ./demo_partial | grep "\.dynamic" [22] .dynamic DYNAMIC 0000000000003de0 00002de0 $ nm ./demo_partial | grep _DYNAMIC 0000000000003de0 d _DYNAMIC |
So GOT[0] holds the link-time (pre-relocation) address of _DYNAMIC — this object’s own .dynamic array. That’s its ABI-reserved role: the x86-64 psABI — the processor-specific ABI, i.e. the System V AMD64 ABI named at the top — fixes GOT[0] as the address of .dynamic. The reason is a chicken-and-egg: .dynamic is the table that lists an object’s relocations — it’s where DT_RELA, DT_JMPREL, and the rest point — so you have to read .dynamic before you can apply a single relocation, which means locating .dynamic can’t itself depend on one. GOT[0] breaks the cycle: the static linker bakes .dynamic‘s link-time address into that slot as a plain value in the image, readable with no relocation at all. The object that actually needs this is the dynamic linker relocating itself — the kernel maps ld.so and jumps straight to its entry point with nothing resolved and no second loader to hand it its own .dynamic, so it bootstraps from this reserved slot. Unlike GOT[1]/GOT[2] below, nothing rewrites GOT[0] at load time: I read it back as the raw 0x3de0, not base + 0x3de0. In today’s glibc the slot is more heirloom than hot path — the x86-64 loader recovers _DYNAMIC via __ehdr_start in elf_machine_dynamic rather than reading GOT[0] — but the linker still fills the slot per the ABI. GOT[1] and GOT[2] are zero in the file and get filled at load time. GOT[3] is the slot for puts, and here’s the first clever part: it’s initialized to 0x1036 — the address of the instruction right after the jmp in puts‘s own stub.
Now trace the very first call 1030 <puts@plt>:
jmp QWORD PTR [0x4000]— jump throughputs‘s GOT slot. It contains0x1036, so this jumps to0x1036… which is the next instruction. The indirect jump is, on the first call, a no-op that lands one line down. That’s the “first jump to the line after, not resolved yet” — the GOT slot is deliberately pre-loaded to bounce control right back into the stub.push 0x0— push the relocation index forputs. This is0becauseputsis the first (only) entry in.rela.plt. Confirmed:
1 2 3 4 5 | $ readelf -r demo_partial ... Relocation section '.rela.plt' at offset 0x610 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend 000000004000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0 |
- The reloc’s offset is
0x4000— exactlyputs‘s GOT slot — and it’s index 0 in the table. That index is the resolver’s handle on “which symbol am I being asked about.” jmp 1020— jump toPLT-0.PLT-0:push [GOT+8]thenjmp [GOT+0x10]. Arithmetic I checked: with_GLOBAL_OFFSET_TABLE_=0x3fe8, thepush QWORD PTR [rip+0x2fca]at0x1020targets0x3ff0=GOT[1], and thejmp QWORD PTR [rip+0x2fcc]at0x1026targets0x3ff8=GOT[2]. So PLT-0 pushesGOT[1]and tail-jumps throughGOT[2]. That base,0x3fe8, is just.got.plt‘s start — the address_GLOBAL_OFFSET_TABLE_names:
1 2 | $ readelf -S ./demo_partial | grep "\.got\.plt" [24] .got.plt PROGBITS 0000000000003fe8 00002fe8 |
What are GOT[1] and GOT[2]? They’re set once, at load time, by the loader’s elf_machine_runtime_setup (sysdeps/x86_64/dl-machine.h:70):
GOT[1]←struct link_map *for this module — the loader’s descriptor for the object whose symbol we’re resolving. (got[1]is assigned at line 94.)GOT[2]←GLRO(dl_x86_64_runtime_resolve)— the resolver trampoline (lines 125–126,*(got + 2) = GLRO(dl_x86_64_runtime_resolve)). ThatGLROfield holds whichever ISA-specific variant the CPU selected at startup —_dl_runtime_resolve_fxsave/_xsave/_xsavec, all built from the one_dl_runtime_resolvetrampoline — so the slot is filled from that pointer, not a fixed&_dl_runtime_resolve.
So PLT-0 effectively calls _dl_runtime_resolve(link_map *this_module, reloc_index) — the reloc index was pushed by the per-symbol stub, the link_map by PLT-0. I walked the whole chain live in pwndbg, on the same demo_partial binary as the disassembly above. Break at main, then follow it end to end ($rebase(off) adds pwndbg’s detected PIE base, here 0x555555554000):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | pwndbg> b main Breakpoint 1 at 0x113d pwndbg> r Breakpoint 1, 0x000055555555513d in main () # image base, and where the GOT falls: GOT[0..2] on a read-only page, the puts slot on the writable one. pwndbg> vmmap demo_partial LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA Start End Perm Size Offset File (set vmmap-prefer-relpaths on) ► 0x555555554000 0x555555555000 r--p 1000 0 demo_partial ► 0x555555555000 0x555555556000 r-xp 1000 1000 demo_partial ► 0x555555556000 0x555555557000 r--p 1000 2000 demo_partial ► 0x555555557000 0x555555558000 r--p 1000 2000 demo_partial ► 0x555555558000 0x555555559000 rw-p 1000 3000 demo_partial 0x7ffff7db4000 0x7ffff7db7000 rw-p 3000 0 [anon_7ffff7db4] # all three reserved slots — GOT[0] = link-time &_DYNAMIC (0x3de0, the raw value from the .got.plt dump above), GOT[1] = link_map, GOT[2] = resolver (telescope chains GOT[1] on through the link_map): pwndbg> telescope $rebase(0x3fe8) 3 00:0000│ 0x555555557fe8 (_GLOBAL_OFFSET_TABLE_) ◂— 0x3de0 01:0008│ 0x555555557ff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7ffe5f0 —▸ 0x555555554000 ◂— 0x10102464c457f 02:0010│ 0x555555557ff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fda490 (_dl_runtime_resolve_xsavec) ◂— push rbx # the puts slot BEFORE the first call — still pointing back at its own lazy stub: pwndbg> telescope $rebase(0x4000) 1 00:0000│ 0x555555558000 (puts@got[plt]) —▸ 0x555555555036 (puts@plt+6) ◂— push 0 /* 'h' */ # break in the resolver on that first call and read its backtrace: pwndbg> b _dl_fixup Breakpoint 2 at 0x7ffff7fd80d0: file ./elf/dl-runtime.c, line 47. pwndbg> c Breakpoint 2, _dl_fixup (l=0x7ffff7ffe5f0, reloc_arg=0x0) at ./elf/dl-runtime.c:47 pwndbg> bt #0 _dl_fixup (l=0x7ffff7ffe5f0, reloc_arg=0x0) at ./elf/dl-runtime.c:47 #1 0x00007ffff7fda50a in _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:98 #2 0x000055555555514c in main () # (#3–#5 — __libc_start_call_main → __libc_start_main → _start — trimmed) # let the resolve finish, then re-read the slot — now it jumps straight to libc's puts: pwndbg> d pwndbg> finish Run till exit from #0 _dl_fixup (l=0x7ffff7ffe5f0, reloc_arg=0x0) at ./elf/dl-runtime.c:47 _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:99 pwndbg> got [0x555555558000] puts@GLIBC_2.2.5 -> 0x7ffff7e372c0 (puts) ◂— push r14 |
Everything lines up. The resolver’s first argument, l = 0x7ffff7ffe5f0, is byte-identical to GOT[1], and reloc_arg = 0 is the index the per-symbol stub pushed — PLT-0 and the stub handed _dl_fixup exactly the two things it needs, nothing more. Before the call the puts slot points at puts@plt+6 (its own push 0; jmp PLT-0 lazy stub); after, it points straight at libc’s puts (0x7ffff7e372c0), so the second puts("second call") skips the entire detour and jumps there directly. Now map those addresses onto the vmmap above. The three reserved slots sit at the end of the read-only page: GOT[0]/GOT[1]/GOT[2] are at …557fe8/…557ff0/…557ff8 — the telescope above printed all three right there, at …557fe8/…557ff0/…557ff8 — all inside the r--p range …557000–558000. The puts slot is the very next eight bytes, at …558000, which is exactly where the next mapping — the rw-p page …558000–559000 — begins. So .got.plt straddles the boundary: its three reserved entries land on the read-only page, and the JUMP_SLOT array (first entry puts) starts on the writable one. Under partial RELRO the linker drops the page boundary right there — reserved slots frozen, the lazy function pointers left writable — the layout we’ll come back to when full RELRO freezes the lot.
GOT[1] points into ld.so’s own data — it’s the link_map for this object, not code; telescope even chases it one hop further, to the struct’s l_addr field (0x555555554000, the load base) and the \x7fELF header sitting there (telescope prints the first eight bytes as 0x10102464c457f). GOT[2] is _dl_runtime_resolve_xsavec — xsavec, not plain, because this CPU advertises xsavec (grep xsavec /proc/cpuinfo). That variant choice matters for the next point.
What _dl_runtime_resolve has to be careful about
_dl_runtime_resolve runs in the middle of your function call, after you’ve set up arguments for puts but before puts runs. RDI already holds the string pointer. The resolver is about to run a big pile of C code (symbol lookup touches hash tables, string compares, the works) that will clobber every caller-saved register. If it returns to puts having trashed RDI, puts prints garbage or segfaults.
So the trampoline’s first job is to preserve the entire argument-passing register state across the resolve. The asm (sysdeps/x86_64/dl-trampoline.h) saves RAX, RCX, RDX, RSI, RDI, R8, R9 by hand (glibc’s source is AT&T; rendered in Intel here to match the rest of the post):
1 2 3 4 5 6 7 8 9 | mov [rsp+REGISTER_SAVE_RAX], rax mov [rsp+REGISTER_SAVE_RCX], rcx mov [rsp+REGISTER_SAVE_RDX], rdx mov [rsp+REGISTER_SAVE_RSI], rsi mov [rsp+REGISTER_SAVE_RDI], rdi mov [rsp+REGISTER_SAVE_R8], r8 mov [rsp+REGISTER_SAVE_R9], r9 ... fxsave [rsp+STATE_SAVE_OFFSET] ; (or xsave/xsavec, chosen per-CPU) |
The integer regs cover the six SysV argument registers plus RAX (the vararg FP-count register). The fxsave/xsave/xsavec saves the vector state — because the arguments could include floats/vectors in XMM/YMM/ZMM, and the resolver’s own code (memcpy-family, string ops) will use them. This is why there are per-CPU resolver variants: the loader picks _dl_runtime_resolve_fxsave / _xsave / _xsavec to match the widest vector state the CPU has, so it saves exactly what must be saved and no more. The whole thing is a giant “I am about to stomp everything; put it all back exactly” bracket around the resolve.
The resolve itself
With the registers parked, the trampoline calls _dl_fixup(struct link_map *l, reloc_arg) (elf/dl-runtime.c:41) — l is the link_map PLT-0 pushed out of GOT[1], and reloc_arg is the index the per-symbol stub pushed (0, for puts). Its whole job is to turn that bare index into puts‘s real address and cache it, and every step is a table lookup hanging off l. I’ll describe the logic rather than paste it; the source reads cleanly if you want the exact code.
Finding the relocation. reloc_arg isn’t a pointer, it’s an ordinal. _dl_fixup takes the object’s .rela.plt base from the link_map‘s DT_JMPREL entry (the .dynamic-section tag that points at .rela.plt; glibc caches every such tag in l->l_info[]) and indexes in by that ordinal (:54), landing on one Elf64_Rela. That single entry is the whole handle: its r_offset is the .got.plt slot to patch (0x4000 here), and its r_info packs the relocation type (R_X86_64_JUMP_SLOT, asserted at :63) together with a symbol-table index.
Linking it to a symbol. The symbol index out of r_info selects an Elf64_Sym in this object’s .dynsym (:56). That symbol’s st_name is a byte offset into .dynstr, and following it gives the name the caller actually wants: "puts". So the chain is ordinal → Elf64_Rela → symbol index → Elf64_Sym → "puts", each hop a fixed-stride array read off a pointer the link_map already holds.
Finding where puts lives. A name still isn’t an address — puts is defined in some other object, and which one depends on the search scope. That scope is an ordered list of loaded objects the loader assembled at startup: the executable first, then every library reachable through DT_NEEDED, laid out in a breadth-first walk of the dependency graph (an object’s direct dependencies come before their dependencies), with repeats collapsed to the first time each object appears. That’s l->l_searchlist, and elf/dl-deps.c is where the loader builds it — the comment there calls it exactly that, a “breadth-first search of the dependency tree.”
_dl_lookup_symbol_x (:95) hands the name to do_lookup_x (elf/dl-lookup.c:329), which scans that list front to back and, in each object, probes its symbol table for a defining entry named "puts". That per-object probe is the .gnu.hash lookup the next section is about.
“Defining” is the load-bearing word there. A .dynsym can carry two kinds of entry for the same name: a definition, whose section index st_shndx names a real section so the symbol actually has an address in that object; or an undefined reference (st_shndx == SHN_UNDEF, value 0), which only records that the object uses the name and needs it from someone else. do_lookup_x wants the former and skips the latter — its check_match helper (dl-lookup.c:73) rejects any SHN_UNDEF candidate outright. This isn’t academic: the executable’s own .dynsym contains a puts entry, but only as an undefined reference — it calls puts without defining it — while the real definition lives in libc.so.6:
1 2 3 4 | $ readelf --dyn-syms demo_partial | grep puts 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 # UND = a reference, no address $ readelf --dyn-syms /lib/x86_64-linux-gnu/libc.so.6 | grep ' puts@@' 236: 00000000000802c0 514 FUNC WEAK DEFAULT 15 puts@@GLIBC_2.2.5 # Ndx 15 = defined in a real section |
So the scan walks right past the executable (its puts is just a reference) and keeps going until it hits the real definition in libc.so.6. The first object in the list that holds a definition wins: the scan stops there and returns that Elf64_Sym together with the link_map of the object that owns it.
That “first match in scope order wins” rule is the whole basis of symbol interposition. The executable sits at the front of the list, so a function it defines shadows libc’s of the same name; and LD_PRELOAD objects are spliced in right after the executable, ahead of the ordinary dependencies. That ordering is explicit in the loader: _dl_map_object_deps seeds the search list with the main object first (dl-deps.c:157), adds the preloads immediately after (:160), and only then expands DT_NEEDED dependencies breadth-first. You can watch both the order and its effect. Here’s a tiny interposer that defines its own puts:
1 2 3 4 5 6 7 8 9 10 11 12 13 | // preload_puts.c #include <unistd.h> #include <string.h> /* interpose puts(): if LD_PRELOAD puts this ahead of libc, THIS runs instead. We call write(2) directly rather than any stdio function so we don't recurse back into our own puts. */ int puts(const char *s) { const char marker[] = "[preload puts] "; write(1, marker, sizeof marker - 1); write(1, s, strlen(s)); write(1, "\n", 1); return 0; } |
Build it as a shared object, then run the program with it preloaded. LD_DEBUG=scopes dumps the search scope the loader assembled — and note every object shares the same global list, scope 0 (the leading NNN: is just the process id ld.so prints on each line):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | $ gcc -shared -fPIC -o preload_puts.so preload_puts.c $ LD_DEBUG=scopes LD_PRELOAD=./preload_puts.so ./demo_partial 185: 185: Initial object scopes 185: object=./demo_partial [0] 185: scope 0: ./demo_partial ./preload_puts.so /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2 185: 185: object=linux-vdso.so.1 [0] 185: scope 0: ./demo_partial ./preload_puts.so /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2 185: scope 1: linux-vdso.so.1 185: 185: object=./preload_puts.so [0] 185: scope 0: ./demo_partial ./preload_puts.so /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2 185: 185: object=/lib/x86_64-linux-gnu/libc.so.6 [0] 185: scope 0: ./demo_partial ./preload_puts.so /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2 185: 185: object=/lib64/ld-linux-x86-64.so.2 [0] 185: no scope $ LD_PRELOAD=./preload_puts.so ./demo_partial [preload puts] first call # our puts ran, not libc's [preload puts] second call |
Every object lists the same global scope 0, and in it ./preload_puts.so sits right after ./demo_partial and ahead of libc.so.6 — so a lookup of puts finds the preload’s definition first and stops. (ld-linux itself shows “no scope”: the loader resolves its own symbols internally and isn’t part of this search.) That’s exactly how a preloaded malloc transparently stands in for libc’s across the whole process. One more constraint visible in the relocation itself: the reference is puts@GLIBC_2.2.5, so a candidate has to match the requested symbol version too, not just the name — the loader won’t bind to a puts carrying the wrong version tag.
One oddity in that dump is worth a sentence: linux-vdso.so.1 is the only object with a second scope (scope 1: linux-vdso.so.1). That falls out of how _dl_new_object hands out scopes — every object loaded after the main program gets the global list as scope 0, and a second local scope only when that local scope differs from the global one (dl-object.c:168). For a DT_NEEDED dependency or an LD_PRELOAD object the loader chain resolves up to the executable, whose searchlist is the global list, so local and global coincide and no second entry is added. The vDSO, though, is created with a NULL loader (setup-vdso.h:32), which makes it its own loader; its local scope is then its own one-element searchlist (setup-vdso.h:75), distinct from the global list — hence the extra scope 1. The kernel injects the vDSO out of band, in nobody’s DT_NEEDED and not a member of the global list, so glibc gives it this private scope to resolve its own symbols (__vdso_clock_gettime and friends) directly — exactly what the comment there says it’s for.
Computing the address. Now it’s arithmetic. The defining symbol’s st_value is puts‘s offset inside libc’s image, and libc’s link_map carries that image’s load base in l_addr. SYMBOL_ADDRESS adds the two (:110) — l_addr + st_value — yielding puts‘s real runtime address: the 0x7ffff7e372c0 the got command printed above.
Writing it back. Finally elf_machine_fixup_plt (sysdeps/x86_64/dl-machine.h:218) stores that address into the slot r_offset named — its body is literally return *reloc_addr = value; — and returns it. The trampoline restores every saved register (so RDI is the string pointer again, as if nothing had happened) and tail-jumps to that address. puts runs.
So the first call pays for a full symbol lookup and rewrites its own GOT slot as a side effect — exactly the flip we watched in pwndbg above, from the lazy stub to 0x7ffff7e372c0. Every later call puts@plt is then just jmp [slot] straight into libc: one indirection, no resolver, forever after. That is the whole bargain of lazy binding — each imported function resolves itself once, on first use, and the PLT stub decays into a one-instruction trampoline.
Full RELRO: resolve everything up front, then freeze it
RELRO (“relocation read-only”) is the mitigation sitting on top of all this. Two levels.
Partial RELRO (-z relro, no -z now) protects the parts of the GOT that are done being written after startup — GOT[0..2], the .got (GLOB_DAT) section, .init_array, .dynamic — by mprotecting them read-only, while leaving .got.plt‘s lazy function slots writable so lazy binding can keep working. Which sections those are isn’t a guess. They all sit inside the one writable LOAD segment — the RW one based at 0x3dd0 — but the linker also emits a PT_GNU_RELRO header covering a sub-range of it. That header is never mapped into memory (only PT_LOAD segments are); it’s a note to the dynamic linker that says once startup relocations are done, mprotect this range back to read-only. readelf shows the range and which sections fall inside it:
1 2 3 4 5 6 7 8 9 | $ gcc -O0 -fno-stack-protector -fcf-protection=none -o demo_partial demo.c -Wl,-z,relro,-z,lazy # partial RELRO, classic layout $ readelf -lW demo_partial Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align ... GNU_RELRO 0x002dd0 0x0000000000003dd0 0x0000000000003dd0 0x000230 0x000230 R 0x1 Section to Segment mapping: Segment Sections... 13 .init_array .fini_array .dynamic .got |
The GNU_RELRO segment (number 13 in that listing) covers [0x3dd0, 0x3dd0 + 0x230) = [0x3dd0, 0x4000) and contains exactly .init_array, .fini_array, .dynamic, and .got — and nothing that stays writable, like .data or the JUMP_SLOT half of .got.plt.
That raises the obvious question — if that range lives in a segment the kernel maps RW, how does it ever become read-only? The loader does it to itself. The segment is mapped writable precisely because the loader has to write into it during startup: applying the R_X86_64_RELATIVE relocations to .got, rebasing embedded pointers, filling .init_array. Only after every relocation is applied does _dl_relocate_object call _dl_protect_relro, which rounds the PT_GNU_RELRO range down to page boundaries and mprotects it PROT_READ — here, the single page from base +0x3000 to base +0x4000. So the RW in the program header is only the initial permission; the loader lowers it to r-- at runtime, once nothing is left that still needs to write there. (It’s the same trick as the DT_TEXTREL case earlier, run the other direction: there the loader temporarily adds write permission to patch code, here it permanently removes it after relocating.) That page granularity is also why the linker ends the RELRO range exactly on the 0x4000 boundary and starts the writable JUMP_SLOTs right after it — mprotect can’t freeze half a page.
And it’s really enforced, not just advertised. Here’s a program that writes to its own .dynamic, reached through the linker-provided _DYNAMIC symbol:
1 2 3 4 5 6 7 8 9 10 11 12 | // relro_write.c #include <stdio.h> extern char _DYNAMIC[]; // the .dynamic array (ElfW(Dyn)[]), always provided by the linker int main(void) { volatile unsigned long *p = (unsigned long *)_DYNAMIC; printf(".dynamic (_DYNAMIC) @ %p, first word = 0x%lx\n", (void *)p, p[0]); printf("attempting to overwrite .dynamic[0] ...\n"); fflush(stdout); p[0] = 0x4141414141414141UL; // under RELRO this page is read-only -> SIGSEGV printf("WRITE SUCCEEDED -> .dynamic is writable (no RELRO)\n"); return 0; } |
Built with partial RELRO the write dies with SIGSEGV; built with RELRO off the identical write goes through:
1 2 3 4 5 6 7 8 9 10 11 | $ gcc -O0 -fno-stack-protector relro_write.c -Wl,-z,relro,-z,lazy -o relro_partial # partial RELRO $ gcc -O0 -fno-stack-protector relro_write.c -Wl,-z,norelro -o relro_norelro # RELRO off $ ./relro_norelro .dynamic (_DYNAMIC) @ 0x559a1d912188, first word = 0x1 attempting to overwrite .dynamic[0] ... WRITE SUCCEEDED -> .dynamic is writable (no RELRO) # exits 0 $ ./relro_partial .dynamic (_DYNAMIC) @ 0x56233ec4ede0, first word = 0x1 attempting to overwrite .dynamic[0] ... Segmentation fault (core dumped) # exits 139 = 128 + SIGSEGV |
That write happens to target .dynamic, but .init_array and .got sit in the very same PT_GNU_RELRO span, so the loader flips the whole [0x3dd0, 0x4000) range read-only in one mprotect — they are all frozen together.
Now the seam with the writable half. puts‘s JUMP_SLOT is at 0x4000, the first byte past that range. At runtime the loader page-aligns and mprotects, and pwndbg’s vmmap shows it:
1 2 | ► 0x555555557000 0x555555558000 r--p 1000 2000 demo_partial <- GOT[0..2] + .got live here: READ-ONLY ► 0x555555558000 0x555555559000 rw-p 1000 3000 demo_partial <- puts' JUMP_SLOT (0x...8000) + .data: WRITABLE |
So even under partial RELRO, GOT[1]/GOT[2] (the link_map* and resolver pointer, at ...7ff0/...7ff8) are already read-only — they’re set once at load time, before RELRO is applied, and never touched again. Only the lazy function pointers, which start on the next page at ...8000, stay writable.
Full RELRO (-z relro -z now) says: give up lazy binding entirely, resolve every function at startup, and then there’s nothing left that needs to be writable — so freeze the function GOT too. Concretely:
DT_BIND_NOW / DF_BIND_NOW is set. readelf -d shows a (FLAGS) BIND_NOW entry and the NOW bit in FLAGS_1, side by side with the partial binary that has neither:
1 2 3 4 5 6 7 | $ gcc -O0 -fno-stack-protector -fcf-protection=none -o demo_full demo.c -Wl,-z,relro,-z,now # full RELRO: add -z now $ readelf -d demo_full | grep -Ei 'bind|flags' 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE $ readelf -d demo_partial | grep -Ei 'bind|flags' 0x000000006ffffffb (FLAGS_1) Flags: PIE |
The loader resolves all of .rela.plt up front. During relocation processing, a set DT_BIND_NOW makes _dl_relocate_object force lazy = 0 (dl-reloc.c:248), and that one flag has two effects. elf_machine_runtime_setup skips installing the lazy trampoline — its GOT[1]/GOT[2] setup is gated on lazy (dl-machine.h:78) — and the relocation loop sends every JUMP_SLOT down the full path instead of the cheap lazy one (do-rel.h:80: the else to that if (lazy) runs elf_machine_rela, i.e. the same _dl_fixup/elf_machine_fixup_plt resolution, just eagerly over the whole table). By the time main runs, every function slot already holds its final libc address. I confirmed it — puts‘s slot is resolved before main:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | # full RELRO: break at main and read the GOT *before* any libc call runs (pwndbg) pwndbg> b main Breakpoint 1 at 0x113d pwndbg> r Breakpoint 1, 0x000055555555513d in main () # the default `got` view hides read-only entries — and under full RELRO *every* slot is read-only: pwndbg> got Filtering out read-only entries (display them with -r or --show-readonly) State of the GOT of /home/pugachev/work/demo_full: GOT protection: Full RELRO | Found 0 GOT entries passing the filter # force them all to show — puts is already resolved to its libc address, at main, before any call: pwndbg> got -r State of the GOT of /home/pugachev/work/demo_full: GOT protection: Full RELRO | Found 6 GOT entries passing the filter [0x555555557fd0] puts@GLIBC_2.2.5 -> 0x7ffff7e372c0 (puts) ◂— push r14 [0x555555557fd8] __libc_start_main@GLIBC_2.34 -> 0x7ffff7de0ce0 (__libc_start_main) ◂— push r15 [0x555555557fe0] _ITM_deregisterTMCloneTable -> 0 [0x555555557fe8] __gmon_start__ -> 0 [0x555555557ff0] _ITM_registerTMCloneTable -> 0 [0x555555557ff8] __cxa_finalize@GLIBC_2.2.5 -> 0x7ffff7df8b90 (__cxa_finalize) ◂— push r15 |
Two things to read off that. First, puts‘s slot already holds 0x7ffff7e372c0 — its real libc address — right at the main breakpoint, before a single puts has run: no lazy stub anywhere, no first-call penalty, because DT_BIND_NOW resolved the whole .rela.plt during startup. Second, plain got comes back with zero entries — pwndbg hides read-only slots by default, and under full RELRO every GOT entry is read-only, so you need got -r to see them at all. pwndbg says it on the protection line: GOT protection: Full RELRO.
.got.plt is merged into .got and the whole thing goes read-only. With -z relro, GNU ld 2.44 stops emitting a separate .got.plt section at all and merges the function slots into .got:
1 2 3 4 5 6 7 | $ readelf -S demo_full | grep -E '\.got' [13] .plt.got PROGBITS 0000000000001040 00001040 [23] .got PROGBITS 0000000000003fb8 00002fb8 $ readelf -S demo_partial | grep -E '\.got' [13] .plt.got PROGBITS 0000000000001040 00001040 [23] .got PROGBITS 0000000000003fc0 00002fc0 [24] .got.plt PROGBITS 0000000000003fe8 00002fe8 |
That grep catches three different sections, so read it carefully. .plt.got ([13]) is identical in both and has nothing to do with RELRO — it’s a separate stub table for GLOB_DAT-dispatched functions like __cxa_finalize, and it gets its own section further down. .got ([23]) is present in both too. The one that actually changes is .got.plt: demo_partial keeps it as a separate, writable section at 0x3fe8, while demo_full has no .got.plt line at all — its function slots were folded into .got. The size confirms the fold (add -W to see the size column that -S‘s two-line layout hides from grep): demo_full‘s .got is 0x48 bytes, exactly demo_partial‘s .got (0x28) plus its .got.plt (0x20).
The reserved GOT[1]/GOT[2] slots are still physically there in the merged .got (zeroed, and PLT-0 still references them) — they’re just never populated or used, because nothing ever calls the resolver. The lazy PLT-0 and per-symbol stubs are still emitted by the linker too; they’re dead code.
PLT-0 is still in the binary, still wired to the reserved slots:
1 2 3 4 5 6 7 8 9 | $ objdump -d -M intel -j .plt demo_full 0000000000001020 <puts@plt-0x10>: 1020: ff 35 9a 2f 00 00 push QWORD PTR [rip+0x2f9a] # 3fc0 <_GLOBAL_OFFSET_TABLE_+0x8> (= GOT[1]) 1026: ff 25 9c 2f 00 00 jmp QWORD PTR [rip+0x2f9c] # 3fc8 <_GLOBAL_OFFSET_TABLE_+0x10> (= GOT[2]) 102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 0000000000001030 <puts@plt>: # per-symbol lazy stub, still emitted... 1030: ff 25 9a 2f 00 00 jmp QWORD PTR [rip+0x2f9a] # 3fd0 <puts@GLIBC_2.2.5> (its JUMP_SLOT) 1036: 68 00 00 00 00 push 0x0 # ...pushes reloc index 0... 103b: e9 e0 ff ff ff jmp 1020 <_init+0x20> # ...jmps to PLT-0 — none of it runs |
But nothing ever fills those two slots, for two independent reasons. First, no relocation even targets them. readelf -r lists every fixup the loader will apply, and inside the merged .got — the range [0x3fb8, 0x4000) — every one lands on 0x3fd0 or higher:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $ readelf -r demo_full Relocation section '.rela.dyn' at offset 0x550 contains 8 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000003db8 000000000008 R_X86_64_RELATIVE 1130 000000003dc0 000000000008 R_X86_64_RELATIVE 10f0 000000004008 000000000008 R_X86_64_RELATIVE 4008 000000003fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0 000000003fe0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0 000000003fe8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0 000000003ff0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0 000000003ff8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0 Relocation section '.rela.plt' at offset 0x610 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend 000000003fd0 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0 |
The reserved trio — GOT[0]/GOT[1]/GOT[2] at 0x3fb8/0x3fc0/0x3fc8 — is in neither section. The lowest .got offset touched is 0x3fd0 (puts‘s JUMP_SLOT), and the GLOB_DATs fill from 0x3fd8 up; the three R_X86_64_RELATIVE entries (0x3db8, 0x3dc0, 0x4008) sit outside .got entirely. Second, DT_BIND_NOW makes the loader skip elf_machine_runtime_setup‘s lazy setup — the very code that would otherwise write the link_map and resolver pointer into GOT[1]/GOT[2]. Nothing relocates them and nothing resolves them, so they’re still zero at main, straight out of memory:
1 2 3 | pwndbg> x/3gx $rebase(0x3fb8) # .got's link-time base (readelf -S above); $rebase adds the PIE load address 0x555555557fb8: 0x0000000000003dc8 0x0000000000000000 0x555555557fc8: 0x0000000000000000 |
GOT[0] still holds the raw link-time 0x3dc8 (&_DYNAMIC) — never even rebased — and GOT[1]/GOT[2] are flat zero, while the function slots just past them are all resolved (the got -r dump above). So on this toolchain the reserved trio is present, referenced only by dead PLT-0 code, and completely inert — not stripped out. (Other linkers/configs — lld, or -z now combined with relocation packing — can be more aggressive and actually drop the reserved slots and PLT-0. This is a linker-behavior detail, not an ABI guarantee, so check your binary rather than assuming.)
It’s worth pausing on the thing that makes this genuinely confusing: run the same readelf -r on demo_partial and the relocation set is identical in shape — three R_X86_64_RELATIVE, five R_X86_64_GLOB_DAT, and one R_X86_64_JUMP_SLOT for puts. The RELRO level isn’t recorded in the relocation table at all. The only numeric tell readelf -r gives you is where that JUMP_SLOT lands: 0x3fd0 here, down inside the merged .got, versus 0x4000 under partial RELRO — the first byte of the next page, out in the separate, still-writable .got.plt. Same relocation, resolved the same way; what moved is the slot’s address, and with it which side of the page boundary it sits on. (The unambiguous signal is one section over, in readelf -d: the BIND_NOW/NOW flags from the comparison above. The relocation table tells you what gets fixed up; DT_BIND_NOW and the layout tell you when and where.)
The merged .got ends exactly at a page boundary, and PT_GNU_RELRO covers it, so the entire function table ends up r--p:
1 | ► 0x555555557000 0x555555558000 r--p 1000 2000 demo_full <- the whole merged GOT, puts slot included: READ-ONLY |
Calls still go through .plt (or .plt.sec). This is worth stressing because people assume full RELRO changes the call sequence. It doesn’t. main still does call puts@plt; the stub still does jmp [got slot]; the slot just happens to already hold the final address and to be read-only. The indirection layer is identical — only the slot’s writability and when it was filled changed.
The security logic is clean: the GOT is the single most useful thing to overwrite in memory (every indirect call reads it), so full RELRO’s deal is “pay the startup cost of resolving everything, and in exchange the attacker loses the writable function-pointer table.” Which is exactly the primitive the last section is about.
The mprotect itself is _dl_protect_relro (elf/dl-reloc.c:351): it takes the PT_GNU_RELRO segment, aligns start and end down to page size (lines 356–362), and __mprotect(start, len, PROT_READ). Page-aligning down on both ends is why the linker has to arrange the layout so the boundary between “protect” and “leave writable” lands on a page edge — which, in the partial case above, is exactly the 0x4000 seam between the reserved slots and the JUMP_SLOTs.
.got vs .got.plt: the eager half
The split between .got and .got.plt is the one people trip on — and with lazy binding and full RELRO now behind us, it’s easy to see straight. Both are “the GOT”; what separates them is when each entry gets its address, and that timing is exactly what the relocation type encodes.
.got.plt holds the lazily bound entries: function slots that get fixed up on their first call, carrying R_X86_64_JUMP_SLOT relocations in .rela.plt. .got holds the eagerly bound ones: everything resolved up front at load time — data symbols, plus any address-taken function — carrying R_X86_64_GLOB_DAT relocations in .rela.dyn.
Why two sections at all? So the linker can lock one of them down. The eager entries are all written once, at startup, and never touched again, so .got can be made read-only afterward. The lazy entries can’t: lazy binding keeps writing resolved addresses into .got.plt as the program runs, so it has to stay writable. RELRO is the knob that decides how much of this gets frozen — which the lazy-binding and full-RELRO sections above are all about.
The word doing the work there is eager, and it’s worth seeing why a function would ever need it. “Binding” just means resolving a symbol reference to a concrete address. A function you only ever call is happy being bound lazily: the call goes through the PLT, and the first call resolves its .got.plt slot (the lazy-binding section above walked through exactly this). But the moment the program takes the function’s address — stores &func in a pointer, compares it, hands it to something as a callback — that address becomes a value read as data, and it has to be the one true runtime address immediately, before any lazy machinery could run. You can’t hand out the address of a lazy PLT stub as the function’s identity, because then &func taken in the executable wouldn’t equal &func taken inside a shared library, and function-pointer comparisons across objects would silently break. So the linker binds such a function eagerly, at load time, into a single .got slot via a GLOB_DAT relocation, and every address-of reads that one canonical slot.
A consequence worth internalizing flips what you might expect: once a function’s address is taken through the GOT, that one eager slot ends up serving the call too. Take &puts in code and the compiler loads it from a GOT slot (puts@GOTPCREL); the linker fills that slot eagerly with a GLOB_DAT relocation, then satisfies the call from the same slot through a tiny .plt.got stub instead of handing puts a lazy .plt entry. So an address-taken-via-GOT function carries no JUMP_SLOT at all — the eager slot is the whole story. Here’s addr.c, taking &puts two ways and also calling it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | $ cat addr.c #include <stdio.h> void *p = (void *)&puts; /* address stored in .data -> R_X86_64_64 */ int main(void){ void *volatile q = (void *)&puts; /* address loaded via the GOT -> GLOB_DAT */ puts("hi"); /* called too */ return (p != 0) && (q != 0); } $ gcc -O0 -fno-stack-protector -fcf-protection=none -o addr addr.c $ readelf -r addr ... Relocation section '.rela.dyn' at offset 0x550 contains 10 entries: Offset Info Type Sym. Value Sym. Name + Addend ... 000000003fc8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 puts@GLIBC_2.2.5 + 0 # eager: &puts in a GOT slot 000000004010 000600000001 R_X86_64_64 0000000000000000 puts@GLIBC_2.2.5 + 0 # eager: &puts baked into p's .data ... # no '.rela.plt' section exists — puts has no lazy JUMP_SLOT |
Two eager relocations for puts, both in .rela.dyn: the GLOB_DAT at 0x3fc8 is the GOT slot holding puts’s one canonical address (what q = &puts reads), and the R_X86_64_64 at 0x4010 is that same address copied straight into p‘s .data. The tell is what’s missing — no .rela.plt, so no lazy JUMP_SLOT for puts.
The call is eager too. main emits an ordinary-looking call 1030 <puts@plt>, but 0x1030 isn’t in .plt — it’s a .plt.got stub that jumps straight through that same GLOB_DAT slot:
1 2 3 4 5 6 7 8 9 | $ objdump -d -M intel addr ... 1131: 48 8b 05 90 2e 00 00 mov rax,QWORD PTR [rip+0x2e90] # 3fc8 <puts@GLIBC_2.2.5> <- q = &puts reads the slot ... 1146: e8 e5 fe ff ff call 1030 <puts@plt> <- the call, into .plt.got $ objdump -d -M intel -j .plt.got addr 0000000000001030 <puts@plt>: 1030: ff 25 92 2f 00 00 jmp QWORD PTR [rip+0x2f92] # 3fc8 <puts@GLIBC_2.2.5> (= the GLOB_DAT slot) 1036: 66 90 xchg ax,ax |
So the address-of and the call both read that one GLOB_DAT slot — taking the address forced the whole thing eager, and the call just rides along. Contrast demo_partial from the lazy-binding section earlier, where puts is only called: it kept a lazy JUMP_SLOT in .rela.plt and an ordinary .plt stub, precisely because its address is never taken.
One naming distinction so the relocation types don’t trip you: GLOB_DAT and the plain R_X86_64_64 differ only in where the eager address lands. GLOB_DAT writes it into a GOT slot, reached indirectly — and a call can ride that slot, which is what the .plt.got stub above does. The global void *p = &puts; instead bakes the address straight into p‘s own .data, a plain R_X86_64_64. Both are eager and both live in .rela.dyn; only the GOT-slot flavor is GLOB_DAT, and only it lands in .got.
.plt.sec and .plt.got: the two extra stub tables
Every binary above was built with -fcf-protection=none, which is why they all showed the classic single-.plt layout. Turn CET on and the single .plt splits into two PLT-ish sections. The catch — which cost me some confusion, and maybe you too — is that the split is the linker’s doing, and it only shows up when the final binary is marked IBT-enabled, something your -fcf-protection compile flag alone may not decide (more in a moment). There’s also a third section you’ll meet either way. They confused me until I saw what each is for, so here I build a dedicated CET binary to show the split.
.plt.sec — the CET split
Intel CET (Control-flow Enforcement Technology) is a hardware defense against control-flow hijacking (ROP/JOP). Its forward-edge half, IBT (Indirect Branch Tracking), makes the CPU check every indirect call/jmp and raise a control-protection fault (#CP) unless the instruction it lands on is endbr64. That endbr64 is a 4-byte marker (f3 0f 1e fa) meaning “an indirect branch may legally arrive here” — it lives in the old multi-byte-NOP space, so on a CPU without CET it simply executes as a no-op and the same binary runs everywhere. You opt in with -fcf-protection; Wikipedia’s Indirect branch tracking has the full mechanism.
That’s precisely what breaks the classic stub: its indirect-jump target (0x1036, mid-stub) isn’t an endbr64, so that first jump through the GOT would fault under IBT. The fix is to split the PLT — work the linker does once the output is marked IBT-enabled. I force that marking here with -z ibt (why, just below); build the same source with CET and the two extra sections appear:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | $ gcc -O0 -fno-stack-protector -fcf-protection=branch -o demo_cet demo.c -Wl,-z,relro,-z,lazy,-z,ibt $ objdump -d -M intel -j .plt -j .plt.sec demo_cet Disassembly of section .plt: 0000000000001020 <.plt>: # PLT-0, unchanged 1020: ff 35 ca 2f 00 00 push QWORD PTR [rip+0x2fca] # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8> (= GOT[1]) 1026: ff 25 cc 2f 00 00 jmp QWORD PTR [rip+0x2fcc] # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10> (= GOT[2]) 102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0] 1030: f3 0f 1e fa endbr64 # per-symbol LAZY stub — endbr64 so the first jmp can land 1034: 68 00 00 00 00 push 0x0 1039: e9 e2 ff ff ff jmp 1020 <_init+0x20> Disassembly of section .plt.sec: 0000000000001050 <puts@plt>: # the stub main actually calls 1050: f3 0f 1e fa endbr64 1054: ff 25 a6 2f 00 00 jmp QWORD PTR [rip+0x2fa6] # 4000 <puts@GLIBC_2.2.5> $ readelf -n demo_cet | grep -i feature Properties: x86 feature: IBT |
That forced -z ibt is the real lesson here. .plt.sec is the linker’s doing, and ld emits it only when the whole link is IBT-enabled — and the output’s IBT bit is the logical AND of every input’s .note.gnu.property: your object and the C-runtime startup files (Scrt1.o, crti.o, …) the distro ships. -fcf-protection=branch marks only your code, so if the distro’s CRT isn’t CET-built the AND clears IBT and you drop back to a classic single .plt with no .plt.sec — exactly what happens on this Debian box (check yours with readelf -n $(gcc -print-file-name=Scrt1.o); no IBT in its properties means no split). Ubuntu’s and Fedora’s startup objects are CET-marked, so there the plain -fcf-protection=branch suffices. -z ibt forces the output IBT bit — and the CET PLT — regardless.
main now does call 1050 <puts@plt> into .plt.sec. .plt.sec is purely endbr64; jmp [GOT] — the IBT-legal fast path. The old .plt keeps PLT-0 and the lazy stubs, and the GOT slot is pre-initialized to point at the .plt lazy stub’s endbr64 at 0x1030 (not mid-instruction as in the classic layout — it has to land on an endbr64). So: .plt.sec is the call target, .plt is the resolver path, and the first call bounces .plt.sec → GOT → .plt lazy stub → PLT-0 → resolver, after which the slot points straight at libc and .plt.sec is a clean one-hop. Same mechanism, one more section.
.plt.got — stubs for GOT-dispatched (eager) functions
This is the same stub you saw with addr.c above: a call routed through a .got GLOB_DAT slot instead of a .got.plt JUMP_SLOT. There the dispatched symbol was puts, whose address you deliberately took. But .plt.got turns up in every ordinary binary without you asking — for the weakly-referenced __cxa_finalize the C runtime pulls in. Here it is in demo_partial:
1 2 3 4 5 6 | $ objdump -d -M intel -j .plt.got demo_partial 0000000000001040 <__cxa_finalize@plt>: 1040: jmp QWORD PTR [rip+0x2f9a] # 3fe0 <__cxa_finalize@GLIBC_2.2.5> $ readelf -r demo_partial # .rela.dyn — note GLOB_DAT, not JUMP_SLOT, and it's in .rela.dyn 0000...3fe0 ...000006 R_X86_64_GLOB_DAT __cxa_finalize@GLIBC_2.2.5 + 0 |
__cxa_finalize lands here for the same reason, minus any & in your code: the C runtime references it through a weak null-check, which takes its address. Same payoff — a single GLOB_DAT slot in .got, resolved eagerly, with a .plt.got stub so calls ride it. Rule of thumb: a .plt.got entry ⇔ a GLOB_DAT reloc in .rela.dyn; a .plt/.plt.sec entry ⇔ a JUMP_SLOT in .rela.plt.
.gnu.hash vs .hash: finding puts in libc fast
Step 2 of the resolve — “search libc for a definition of "puts"” — would be a linear scan of libc’s ~3000+ exported symbols if it were naive. It isn’t; that’s what the hash sections are for. There are two formats, and modern binaries carry only the newer one.
.hash (SysV, DT_HASH) is the original: a classic chained hash table over the SysV ELF hash of the name. .gnu.hash (DT_GNU_HASH), from ~2006, is what GNU ld emits by default now — --hash-style=gnu is the default; you only get a .hash with --hash-style=sysv or both:
1 2 3 4 5 6 7 8 | $ gcc demo.c -o demo $ readelf -d demo | grep -i hash 0x000000006ffffef5 (GNU_HASH) 0x3b0 # gnu only, by default $ gcc demo.c -o demo -Wl,--hash-style=both $ readelf -d demo | grep -i hash 0x0000000000000004 (HASH) 0x3b0 # both present 0x000000006ffffef5 (GNU_HASH) 0x3e0 |
The job here is narrow and lopsided. Given the name "puts", a library must either hand back that symbol’s index in its own .dynsym or answer not here — and not here is the answer it gives most of the time, because the loader asks this same question of every object in the search scope and only one of them actually defines puts. So the format is built to make the miss cheap above all else. A plain hash table can’t do that: even a miss may land in a bucket and walk its chain, comparing names as it goes. .gnu.hash puts a gate in front of the chains — a small Bloom filter — that turns almost every miss into a single memory read, and only on a maybe falls through to the real table.
It is four parts laid out back to back: a 16-byte header, the Bloom filter, a bucket array, and a chain array. The header is four 32-bit words at the very start of the section, so you can read it straight out of the file. First find where .gnu.hash lives:
1 2 | $ readelf -SW /lib/x86_64-linux-gnu/libc.so.6 | grep "gnu\.hash" [ 4] .gnu.hash GNU_HASH 0000000000004528 004528 0050e8 00 A 5 0 8 |
The Off column gives the section’s file offset — 0x4528 here. Dump the four words there; hexdump‘s 1/4 format reads them as 4-byte little-endian units, so each prints as its integer value directly (no manual byte-swapping):
1 2 3 4 5 | $ hexdump -s 0x4528 -n16 -e '1/4 "0x%08x\n"' /lib/x86_64-linux-gnu/libc.so.6 0x000003f1 0x00000015 0x00000200 0x0000000f |
Those four words are the whole header:
1 2 3 4 | 0x000003f1 = 1009 nbuckets number of hash buckets (glibc: l_nbuckets) 0x00000015 = 21 symndx first hashed .dynsym index (glibc: symbias) 0x00000200 = 512 maskwords Bloom filter size, 64-bit words (glibc: bitmask_nwords) 0x0000000f = 15 bloom_shift shift for the second Bloom bit (glibc: l_gnu_shift) |
glibc has no struct for this header either. Its own setup routine _dl_setup_hash reads the same four words positionally — map->l_nbuckets = *hash32++; and so on down the list — then caches them (and pointers to the bitmask, buckets, and chains that follow) as the l_nbuckets/l_gnu_* fields on the link_map, which is what do_lookup_x reads on every lookup. The on-disk layout is defined by the ELF gABI, not a C type — so stepping through the words by hand, as above, is exactly what the loader does too.
symndx— the.dynsymindex of the first hashed symbol (21here). Everything before it — the null entry, undefined imports, local symbols — is never in the table, so the search skips that whole prefix outright.- Bloom filter —
maskwords64-bit words answering is this name maybe here? without touching the buckets at all. From the name’s hashh, the loader reads one word —(h / 64) mod maskwords— and tests two bits in it:h mod 64and(h >> bloom_shift) mod 64. If either bit is 0, the name is definitely absent — reject the whole library, no chain walk. If both are set, the name might be here (or it’s a false positive), and only then does the loader go to the buckets. One word read, two bit tests (do_lookup_x,dl-lookup.c:402) — and because misses dominate, this is the path taken almost every time. (A Bloom filter is just that: a bit-array that can say definitely no or probably yes, never a false no.) - Buckets + chains — the real lookup, entered only once the Bloom filter passes.
bucket[h mod nbuckets]gives the.dynsymindex of the first candidate. The parallel chain array stores each symbol’s hash, with bit 0 stolen as a last-in-this-bucket marker. The loader walks forward from that index comparing the stored hash againsth(bit 0 ignored) and runs a realstrcmponly when the full 32-bit hashes match — so a near-collision costs a hash compare, not a string compare, and a genuine hit is usually a singlestrcmp(the bucket index and chain walk,dl-lookup.c:409).
The hash function — glibc’s _dl_new_hash, in dl-new-hash.h — is a djb2 variant: h = 5381; for each byte c: h = h*33 + c (i.e. h = (h << 5) + h + c), 32-bit. (glibc unrolls the byte loop for speed; the plain form is spelled out in that file’s header comment.) Here’s the whole lookup on a toy library, with hashes small enough to check by hand:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | A tiny library exporting three functions. Say _dl_new_hash gives these (tiny numbers so the arithmetic stays visible — real hashes are 32-bit): symbol hash h bucket = h mod 4 bloom bits = h mod 8, (h>>2) mod 8 read 9 1 1, 2 write 22 2 6, 5 close 14 2 6, 3 Header: nbuckets = 4, symndx = 1, one 8-bit Bloom word, bloom_shift = 2. (real glibc: an array of 64-bit words, the word picked by (h/64) mod maskwords) Bloom word = bits {1,2,3,5,6} set = 0b0110_1110 Tables the linker writes (symbols grouped by bucket, so each chain runs forward): bucket[0]=0 bucket[1]=1 bucket[2]=2 bucket[3]=0 (0 = empty) dynsym[1]=read chain[1]=9 bit0=1 (last in bucket 1) dynsym[2]=write chain[2]=22 bit0=0 (more follow in bucket 2) dynsym[3]=close chain[3]=15 bit0=1 (last in bucket 2) close: true hash 14, stored 15 — bit0 is the stop flag, not the hash Look up "close" (h = 14): bloom -> word bits 6 and 3 are both set -> PASS bucket -> bucket[14 mod 4 = 2] = 2 -> start at dynsym[2] = write chain -> chain[2]=22 vs 14: (22^14)>>1 != 0 -> no match; bit0=0, keep walking -> chain[3]=15 vs 14: (15^14)>>1 == 0 -> hashes match; strcmp("close") ok -> FOUND bit0=1, stop Look up "fork" (h = 8, not exported): bloom -> bit (8 mod 8)=0 is clear in the word -> DEFINITELY ABSENT reject the whole library, no bucket or chain walk |
The bit-0 subtlety is the one that trips people up: the chain stores (hash & ~1) | last_in_bucket, so a stored entry can differ from the symbol’s real hash in its lowest bit and still match, because the compare shifts bit 0 out of both sides ((stored ^ h) >> 1 == 0) before testing. The nonexistent symbol never reaches a bucket — the Bloom filter alone rejects it. The loader’s side of all this is do_lookup_x (elf/dl-lookup.c:329); the Bloom test is at lines 402–407, where hashbit1 = new_hash & 63 and hashbit2 = (new_hash >> l_gnu_shift) & 63 pick the two bits exactly as the toy does above — & 63 rather than & 7 only because real Bloom words are 64 bits wide, not the toy’s 8.
Why this matters for RE: when you see a stripped binary computing a djb2-ish rolling hash over a string and comparing against a constant, you’re very likely looking at either a .gnu.hash reimplementation or — far more interestingly — malware doing its own symbol resolution. Which is the next section.
The same mechanics everywhere else
-static: no dynamic linking, but not “no PLT/GOT”
A fully static binary (gcc -static) has no dynamic linking machinery — and I want to be precise, because “no dynamic artifacts at all” is the common phrasing and it’s not quite true:
1 2 3 4 5 6 7 8 | $ gcc -O0 -fno-stack-protector -fcf-protection=none -static -o demo_static demo.c $ readelf -l demo_static | grep -E 'INTERP|DYNAMIC' # (nothing) $ readelf -d demo_static There is no dynamic section in this file. $ objdump -d -M intel demo_static | grep ' <_IO_puts>' 401733: e8 68 30 00 00 call 4047a0 <_IO_puts> # direct call, no PLT indirection 401742: e8 59 30 00 00 call 4047a0 <_IO_puts> 00000000004047a0 <_IO_puts>: |
No PT_INTERP (no ld.so runs), no PT_DYNAMIC, no .dynsym/.dynstr, no .rela.dyn symbol relocations. Each puts call site is an ordinary e8 near call with a fixed rel32 displacement straight to _IO_puts — no PLT stub, no GOT load. The target address is known at link time because everything’s in one ET_EXEC image mapped at the fixed base 0x400000.
But the static binary still has .plt, .got, .got.plt, and .rela.plt:
1 2 3 4 5 6 7 | $ readelf -S demo_static | grep -E '\.plt|\.got' [ 3] .rela.plt RELA 00000000004002f0 000002f0 [ 5] .plt PROGBITS 0000000000401018 00001018 [18] .got PROGBITS 00000000004a3f48 000a2f48 [19] .got.plt PROGBITS 00000000004a3fe8 000a2fe8 $ readelf -r demo_static | grep -oE 'R_X86_64_[A-Z_]+' | sort | uniq -c 22 R_X86_64_IRELATIV # readelf clips the type name to fit the column |
Those 22 relocations are all R_X86_64_IRELATIVE — the IFUNC mechanism. Functions like memcpy, memcmp, strcmp, memset ship several implementations (SSE2, AVX2, AVX-512, …) plus a resolver: a tiny selector function the binary carries, pointed at by the IRELATIVE reloc (which holds an address, not a symbol name). It runs once at startup, checks what the CPU supports (the AT_HWCAP bits / CPUID), and returns a pointer to the implementation to use. glibc’s x86-64 memcmp is a concrete one: its resolver is IFUNC_SELECTOR in sysdeps/x86_64/multiarch/ifunc-memcmp.h — a dozen lines that test the AVX-512 / AVX2 / MOVBE / BMI2 feature bits and return __memcmp_evex_movbe, __memcmp_avx2_movbe, or (fallback) __memcmp_sse2, registered as the symbol’s resolver by libc_ifunc_redirected (…, IFUNC_SELECTOR ()) in memcmp.c. That choice can’t be made until runtime, so even in a static binary the startup code calls each resolver and writes its returned address into the matching .got.plt slot; calls to those functions then route through a .plt stub exactly like dynamic symbols do.
You can watch one slot get filled. Grab memcmp‘s resolver address from nm, then grep the reloc table for it — the .rela.plt entry carries that address as its addend, and names the .got.plt slot it fixes up:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | $ nm demo_static | grep -w memcmp 00000000004389f0 i memcmp # memcmp's resolver sits at 0x4389f0 $ readelf -r demo_static | grep 4389f0 0000004a4058 000000000025 R_X86_64_IRELATIV 4389f0 # slot 0x4a4058 <- resolver 0x4389f0 $ pwndbg ./demo_static pwndbg> starti Program stopped. pwndbg> x/gx 0x4a4058 # the .got.plt slot for memcmp, before startup relocs 0x4a4058: 0x0000000000401076 # -> its .plt stub, unresolved pwndbg> info symbol 0x401076 No symbol matches 0x401076. pwndbg> b main Breakpoint 1 at 0x401729 pwndbg> r Breakpoint 1, 0x0000000000401729 in main () pwndbg> x/gx 0x4a4058 0x4a4058: 0x0000000000439bc0 # -> the resolver's return value pwndbg> info symbol 0x439bc0 __memcmp_avx2_movbe in section .text of /home/pugachev/work/demo_static |
At the first instruction the slot still points at its .plt stub (no symbol — it’s raw stub code); by the time main runs, the startup code has called the resolver at 0x4389f0 and overwritten the slot with 0x439bc0 — __memcmp_avx2_movbe, the AVX2+MOVBE variant this CPU selected. The reloc names the resolver; the slot ends up holding the resolver’s answer.
So the accurate statement is: static binaries have no dynamic linking (no external symbol resolution, no interpreter), but they retain PLT/GOT purely for IFUNC self-relocation. The relocation type tells you which world you’re in — JUMP_SLOT/GLOB_DAT (dynamic) vs IRELATIVE (static IFUNC).
-static-pie: static, but rebased for ASLR
-static-pie is the interesting hybrid: statically linked (no interpreter, no DSO dependencies) and position-independent (ET_DYN, so ASLR applies to the main image, which a plain -static ET_EXEC doesn’t get). That combination requires the binary to relocate itself, because there’s no ld.so to do it:
1 2 3 4 5 6 7 8 9 | $ gcc -O0 -fno-stack-protector -static-pie -o demo_staticpie demo.c $ readelf -h demo_staticpie | grep Type Type: DYN (Position-Independent Executable file) $ readelf -l demo_staticpie | grep -E 'INTERP|DYNAMIC' DYNAMIC 0x00000000000abda8 0x00000000000acda8 0x00000000000acda8 $ readelf -d demo_staticpie | grep NEEDED # (nothing — no shared-lib deps) $ readelf -r demo_staticpie | grep -oE 'R_X86_64_[A-Z_]+' | sort | uniq -c 22 R_X86_64_IRELATIV # the usual IFUNCs 1099 R_X86_64_RELATIVE # self-rebasing relocations for ASLR |
So -static-pie does carry a PT_DYNAMIC and a pile of R_X86_64_RELATIVE relocations, but zero DT_NEEDED entries and no interpreter. At startup a self-contained routine compiled into the binary — glibc’s _dl_relocate_static_pie — computes its own run-time load address (elf_machine_load_address (), stashed as l_addr) and runs ELF_DYNAMIC_RELOCATE over its own link_map, walking those 1099 RELATIVE relocs and adding the load base to each — rebasing every absolute pointer the binary embedded — before the IFUNC resolvers run. The payoff is ASLR on the main executable’s own code and data, which a classic -static ET_EXEC at fixed 0x400000 can’t have. Compare the headers: static is Type: EXEC (fixed address), static-pie is Type: DYN (randomized).
GOT[1] is a free handle on every loaded module
Here’s a trick that falls straight out of the layout. The general move — walk the loader’s in-memory module list to find libraries and resolve symbols with no imports and no /proc — genuinely is a staple of shellcode and malware; the textbook case is Windows PEB walking, and its Linux analogue is walking the dynamic linker’s link_map chain (most portably via the _r_debug struct that DT_DEBUG points at). GOT[1] is just an unusually cheap door into that chain on a lazily-bound ELF: it holds a struct link_map * — the loader’s descriptor for your module. And link_map is a doubly linked list node: the loader keeps every loaded object (the executable, every DSO, the vDSO, ld.so itself) on one list, each node carrying the object’s load base (l_addr) and name (l_name). So from GOT[1] — a fixed slot in your own writable GOT, reachable with one RIP-relative load, no syscalls — you can reach the entire map of what’s loaded and where, without touching /proc/self/maps.
The relevant link_map offsets on x86-64 (LP64, 8 bytes each, no padding): l_addr at +0x00, l_name at +0x08, l_ld at +0x10, l_next at +0x18, l_prev at +0x20. These five aren’t an accident of this build — they’re the public, ABI-stable head of struct link_map, which glibc’s own header marks as “part of the protocol with the debugger … the same format used in SVR4”. (The deeper fields — l_scope, l_info, l_mach — are the genuinely internal, version-dependent ones; the walk below needs none of them.) The executable’s own node is the head of the list, so you can read GOT[1] straight out of .got.plt and walk l_next forward:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | $ readelf -SW ./demo_partial | grep "got\.plt" [24] .got.plt PROGBITS 0000000000003fe8 002fe8 000020 08 WA 0 0 8 $ pwndbg ./demo_partial pwndbg> b main Breakpoint 1 at 0x113d pwndbg> r Breakpoint 1, 0x000055555555513d in main () pwndbg> telescope $rebase(0x3fe8) 3 00:0000│ 0x555555557fe8 (_GLOBAL_OFFSET_TABLE_) ◂— 0x3de0 01:0008│ 0x555555557ff0 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7ffe5f0 —▸ 0x555555554000 ◂— 0x10102464c457f 02:0010│ 0x555555557ff8 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fda490 (_dl_runtime_resolve_xsavec) ◂— push rbx pwndbg> set $lm = (struct link_map*)0x7ffff7ffe5f0 pwndbg> while $lm > printf "Base: 0x%016lx, Name: %s, Next: 0x%016lx\n", $lm->l_addr, $lm->l_name, $lm->l_next > set $lm = $lm->l_next >end Base: 0x0000555555554000, Name: , Next: 0x00007ffff7fbf000 Base: 0x00007ffff7fc5000, Name: linux-vdso.so.1, Next: 0x00007ffff7fbf7f0 Base: 0x00007ffff7db7000, Name: /lib/x86_64-linux-gnu/libc.so.6, Next: 0x00007ffff7ffe0a0 Base: 0x00007ffff7fc7000, Name: /lib64/ld-linux-x86-64.so.2, Next: 0x0000000000000000 pwndbg> linkmap Node Objfile Load Bias Dynamic Segment 0x7ffff7ffe5f0 <Unknown, likely /home/pugachev/work/demo_partial> 0x555555554000 0x555555557de0 0x7ffff7fbf000 linux-vdso.so.1 0x7ffff7fc5000 0x7ffff7fc5438 0x7ffff7fbf7f0 /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7db7000 0x7ffff7f9b940 0x7ffff7ffe0a0 /lib64/ld-linux-x86-64.so.2 0x7ffff7fc7000 0x7ffff7ffce58 |
That telescope is the lazy-binding story in one frame: GOT[1] is the link_map pointer (it points at 0x7ffff7ffe5f0, whose first word is l_addr = 0x555555554000, the PIE base) and GOT[2] is _dl_runtime_resolve_xsavec — the ISA-specific trampoline from the lazy section. Cast GOT[1] to struct link_map *, follow l_next, and you enumerate every module and its base — exe (empty name), vDSO, libc, ld.so — which pwndbg’s built-in linkmap then confirms. Cross-check against /proc/<pid>/maps: libc’s base is 0x7ffff7db7000, matching the l_addr from the walk exactly. A complete, self-contained “what’s mapped and where” with no file access — which is precisely why it’s attractive to code that wants to stay off /proc.
This hinges on lazy binding, not just GOT readability. glibc stores your link_map into GOT[1] from elf_machine_runtime_setup, but only on the lazy path — the assignment sits inside if (l->l_info[DT_JMPREL] && lazy) (dl-machine.h:94). So on an ordinary lazily-bound binary (partial or no RELRO) GOT[1] is populated and the walk works. Under full RELRO — which implies -z now, so lazy == 0 — that whole block is skipped: the reserved GOT[1] slot is still present in the read-only .got, but the loader never writes the link_map into it, so it just reads back as zero. The handle isn’t merely harder to reach; it was never filled in. No matter for real malware, though: the technique below needs no GOT of its own — it finds ld.so and walks its structures from scratch. The GOT[1] read is just the civilized shortcut.
How malware mimics the loader: dynamic import resolution
Packed and evasive samples routinely refuse to carry a normal import table, because a plaintext .rela.plt / .dynsym full of connect, ptrace, dlopen, socket is a gift to static triage — readelf -d and a glance at the imports tells an analyst what the sample does before it runs. So the sample resolves its own imports at runtime, reimplementing exactly the loader mechanics above, and this is pure diagnostic territory: the mechanics are the detection surface.
The canonical shape, on Linux x86-64:
- Find a loaded module’s base without imports. Either read
GOT[1]for alink_map(as above) and walk tolibc, or — the more common, import-table-free version — get any libc code pointer (a return address on the stack, orAT_BASE/link_mapvia the auxv) and scan backwards page-by-page for libc’s ELF header (\x7fELFat a page boundary). From the ELF header, parsePT_DYNAMIC→DT_SYMTAB/DT_STRTAB/DT_GNU_HASHto locate libc’s own export table. - Resolve names by hash, not string. Instead of storing
"dlopen"(greppable, YARA-able), the sample stores the hash0x...and reimplementsdl_new_hash(or a custom rolling hash — ROR13, djb2, CRC32, FNV) to match exports against it. This is the same bucket/chain walk the loader does, just open-coded. The constant-folded hash values and the hash loop itself become the signature. - Call through the resolved addresses, often caching them in a private table — the sample’s own hand-rolled GOT.
Defender telemetry — how this looks from the outside, which is the point of analyzing it:
- Static: a near-empty or absent dynamic import table on a binary that clearly does network/process/file work is itself the anomaly. The presence of a hash-compare loop over a small constant (
imul/shl+addrolling hash against an immediate, in a loop that reads a string table) is strongly indicative. YARA-able artifacts: the specific hash constants for high-value APIs (precompute_dl_new_hash("ptrace"),("dlopen"),("execve"),("socket")and hunt the immediates), and the hash-function prologue (mov eax, 5381/0x1505for djb2;ror/rol $13for the Windows-style ROR13 that shows up in cross-platform kits). - Dynamic: the sample reads
libc‘s ELF header and.dynsym/.gnu.hashfrom its own process — reads of libc’s mapped image from code that isn’tld.so, especially a backward page-scan for\x7fELF, are unusual. A userspace tracer or an eBPF uprobe on the resolution primitive catches the behavior that static packing hides. If the sample latermmapsRWXand writes resolved stubs, thatPROT_EXEC|PROT_WRITEmapping is a loud signal on aW^Xsystem. - The giveaway invariant: legitimate code resolves symbols via
ld.so(dlopen/dlsym), which leavesld.soframes on the stack and touches the loader’slink_map/_dl_debugstate. Hand-rolled resolution bypasses all of that — so aconnect()whose caller chain never passed throughld.soor the PLT, reached via an address that was computed rather than linked, is the behavioral tell. That’s the detection thesis: the imports are hidden from static view but the act of resolving them is observable at runtime.
None of this is exotic once you’ve internalized the loader: the malware is just being its own _dl_fixup, and every shortcut it takes to avoid the normal path is a place to instrument.
The GOT-overwrite primitive, and what full RELRO does to it
The flip side of “the GOT is a table of function pointers the CPU will jump through” is “if an attacker can write the GOT, they choose where those jumps go.” This is one of the oldest control-flow-hijack primitives, and it’s the cleanest motivating example for why RELRO exists — so, mechanics and mitigation, defensively framed.
The primitive, in the abstract. Suppose a bug gives an arbitrary (or sufficiently targeted) write — a format-string %n, an out-of-bounds array store, a heap primitive that lands on the right page. Under lazy binding (partial or no RELRO), a function’s .got.plt slot is writable and holds the address the next call func@plt will jump to. Overwrite that slot with the address of something you’d rather run, and the program’s next call to func transfers control there instead. The appeal is that it’s a function pointer the program will dereference as part of normal operation, so you don’t need to also hijack a return address or find a call gadget — you repoint an indirection the program already performs. Classic targets are slots for functions called after the write with attacker-influenced arguments.
Why the GOT specifically: it’s a large, enumerable, fixed-per-module table of code pointers, all of them live, many writable under the default-of-a-few-years-ago partial RELRO. Compared to hunting for a function pointer in the heap, the GOT is right there at a known offset.
What full RELRO does. Everything in this post’s RELRO section is the mitigation, stated as an attacker constraint:
- The function slots are resolved at load time and then
mprotectedPROT_READ. I showeddemo_full‘s merged.gotsitting on anr--ppage withputsalready resolved beforemain. A write to that slot now faults —SIGSEGVon the store, not a hijacked call. The arbitrary-write primitive still exists, but the GOT is no longer a valid target; the attacker has to find some other writable code pointer (a C++ vtable, a heap function pointer, libc hooks like__malloc_hook/__free_hookon targets old enough to still have them — glibc stopped calling them in 2.34 — or a saved return address). Full RELRO doesn’t fix the bug; it removes the single most convenient target it could reach. - Partial RELRO is a half-measure against this specific primitive: it freezes
GOT[0..2]and.gotbut leaves the lazy.got.pltfunction slots writable (thatrw-ppage at...8000in my trace). So under partial RELRO the function-pointer GOT is still overwritable — which is exactly why “is this binary full RELRO?” is one of the first questions in both exploitation and triage.
Quick way to read a target’s posture: checksec — the first thing most people run on an unknown binary. Its RELRO verdict is exactly the distinction this whole section turns on. It just reads the two facts we’ve been checking by hand — is there a GNU_RELRO segment, and is DT_BIND_NOW set? — and collapses them to No / Partial / Full. The check is a few lines of shell: readelf -l … | grep GNU_RELRO separates No from the rest, then readelf -d … | grep BIND_NOW splits Partial from Full:
1 2 3 4 5 6 7 | $ checksec --file=./demo_partial RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 36 Symbols No 0 0 ./demo_partial $ checksec --file=./demo_full RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 36 Symbols No 0 0 ./demo_full |
That RELRO column is the ladder from this post:
- No RELRO — no
GNU_RELROsegment; the entire GOT is writable (and theGOT[1]link-map trick is live). - Partial RELRO —
GNU_RELROpresent but noBIND_NOW;.gotandGOT[0..2]are frozen, yet the lazy.got.pltfunction slots stay writable (thatrw-ppage in my trace). The half-measure. - Full RELRO —
GNU_RELRO+BIND_NOW; everything is resolved up front and the whole GOT ismprotected read-only. (checksec also reports Full when a binary has no.got.pltat all — there’s nothing lazy left to protect.)
That’s the same readelf -l / readelf -d pair I compared on demo_full vs demo_partial earlier — checksec just labels the result. Confirm it at runtime with the vmmap permission check from before: r--p over the GOT page is the thing full RELRO buys you, and the thing a GOT overwrite needs not to be there.
Step back and the whole second half of this post is one fact seen four ways. The fact: the GOT is a writable table of function pointers that the loader fills in at runtime. Everything else is a different relationship to that one table:
- Lazy binding relies on it. The slot has to be writable, because the loader patches in the real address on the first call.
- Malware imitates it. A packed sample resolves its own imports — being its own loader — to keep that table (and its revealing import names) off the radar.
- A GOT overwrite abuses it. An attacker with a write repoints a slot the program will jump through, turning the loader’s convenience into a control-flow hijack.
- Full RELRO locks it down. Resolve everything up front, then
mprotectthe table read-only so there’s nothing writable left to abuse.
Same table — relied on, imitated, abused, frozen. Once you see it as one mechanism, each of those stops being a separate topic to memorize.
The short version
- Indirection through the GOT exists so
.textstays read-only, clean, shareable, and position-independent; PIE and shared libraries both make it mandatory, and hardcoding addresses into code would dirty shared pages and demand writable code. - Lazy bind:
call func@plt→ stubjmp [GOT slot]→ (first time) bounce into the stub’spush index; jmp PLT-0→PLT-0pushesGOT[1](link_map*), jumpsGOT[2](_dl_runtime_resolve) → trampoline saves all arg/vector registers,_dl_fixuplooks the name up via.gnu.hashin.dynsym/.dynstr, computesbase + st_value, writes the slot, restores, jumps to the real function. Every later call is a direct one-hop through the now-patched slot. - Full RELRO (
DT_BIND_NOW): resolve all of.rela.pltat startup, merge.got.pltinto.got,mprotectthe whole thing read-only. Calls still go through the PLT; the slots are just pre-filled and frozen. (GNU ld 2.44 keeps the reserved slots and PLT-0 around as dead weight rather than omitting them — verify your linker.) .plt.secis the CET/IBT call-target split;.plt.gotis forGLOB_DAT-dispatched (address-taken) functions..gnu.hash(Bloom filter + buckets + djb2-variant hash) replaced SysV.hashand is the default.-statichas no dynamic linking but keeps PLT/GOT for IFUNC (IRELATIVE);-static-pieaddsR_X86_64_RELATIVEself-relocation so it can be ASLR’d despite having no interpreter.GOT[1]is alink_map*→ the whole loaded-module list with bases, no/procneeded (partial/no RELRO). Malware reimplements_dl_fixupto hide imports (resolve by hash); the hash loop and constants are the detection surface. GOT overwrite abuses the writable.got.plt; full RELRO is the mitigation that takes the target away.
Happy hacking!