Relocations: How PIE Binaries Fix Their Addresses
In Part 3 we saw symbols: names, addresses, types. We also saw SHN_UNDEF, symbols the binary references but doesn’t define. So how does that actually work at runtime? When you call printf, how does the CPU know where libc actually put it?
The answer is relocations. This is Part 4.
The problem: addresses that can’t be written down
A static ET_EXEC binary has every address fixed at link time. When the linker produces it, it decides: .text goes here, this call targets 0x401234. Done. The loader in part 2 just maps it at exactly those addresses.
A PIE binary (ET_DYN) is loaded at a random base address for ASLR. That means the linker can’t write final addresses for anything, because it doesn’t know where the binary will land until load time.
But machine code is already written. Instructions are in the file. So how do you call a function when you don’t know where anything is?
The linker leaves placeholders and the loader patches them after mapping. Each patch is a relocation.
The relocation entry
Each relocation is an Elf64_Rela, 24 bytes:
typedef struct { uint64_t r_offset; //where to patch (virtual address) uint64_t r_info; //type + symbol index, packed int64_t r_addend; //a constant to add (RELA flavor)} Elf64_Rela;r_offset: the address in the binary that needs patching. For the GOT, this is the GOT slot’s addressr_info: two values packed: high 32 bits are the symbol table index, low 32 bits are the relocation type.r_addend: a constant added to the final result. Only inRELAformat (x86-64). The olderRELformat (i386) stores the addend in the memory being patched.
Extracting the fields:
int sym_idx = ELF64_R_SYM(rela->r_info); // high 32 bitsint type = ELF64_R_TYPE(rela->r_info); // low 32 bitsThe three types that do all the work
x86-64 has ~20 relocation types but you’ll actually meet three:
R_X86_64_RELATIVE (type 8)
The most common. It says: “take the base address the binary was loaded at, add the addend, and store it at r_offset.” The value is relative to the load base because it’s position-independent.
*(uint64_t *)r_offset = base + r_addend;This one has no symbol, it’s pure base-relative arithmetic. The Sym. Name + Addend column shows just the addend (like 1250), no name. In your elf-loader binary:
000000004dd0 000000000008 R_X86_64_RELATIVE 1250000000004dd8 000000000008 R_X86_64_RELATIVE 1200Three entries in .rela.dyn. The loader reads each: read the addend, add the load base, write it into the GOT. This is how pointers inside the binary (like __dso_handle) get fixed up.
000000004fc0 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0000000004fd0 000e00000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0Sym value 0, offset 0x4fc0. The loader finds __libc_start_main in libc, gets its real address, writes it into the GOT slot at 0x4fc0.
R_X86_64_JUMP_SLOT (type 7)
The one that powers lazy binding. Functions imported from shared libraries use this. The GOT slot starts pointing at a stub in the PLT (Procedure Linkage Table); the first call jumps to the stub, which resolves the symbol, patches the GOT slot, and jumps to the real function. Subsequent calls go straight through the patched slot.
000000005000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0000000005008 000400000007 R_X86_64_JUMP_SLO 0000000000000000 strlen@GLIBC_2.2.5 + 0Notice all the JMP_SLOT entries live in .rela.plt, the PLT is where these functions get resolved.
Why the dynamic linker needs .dynsym
Every GLOB_DAT and JUMP_SLOT entry references a symbol by index into the dynamic symbol table, not the full .symtab. The dynamic linker only has access to .dynsym (Part 3: .symtab gets stripped from release binaries).
At load time, ld.so:
- Reads the dynamic symbol table and its string table (
.dynstr) - For each relocation in
rela.dynandrela.plt, resolves symbol -> real address in the loaded shared objects - Writes the resolved address into the GOT slot at
r_offset
That’s why .dynsym “can never be stripped”, it’s the address book the linker uses to patch the binary.
Writing a minimal relocation applier
For a static binary (the kind our Part 2 loader handles), there are no shared libraries, so only R_X86_64_RELATIVE matters. Our loader can handle it in a few lines:
void apply_relocations(const elf_file_t *elf, uintptr_t base) { // Find .rela.dyn Elf64_Shdr *rela = find_section_by_name(elf, ".rela.dyn"); if (!rela) return;
int count = rela->sh_size / sizeof(Elf64_Rela); Elf64_Rela *entries = (Elf64_Rela *)(elf->map + rela->sh_offset);
for (int i = 0; i < count; i++) { int type = ELF64_R_TYPE(entries[i].r_info); if (type == R_X86_64_RELATIVE) { uintptr_t *slot = (uintptr_t *)(base + entries[i].r_offset); *slot = base + entries[i].r_addend; } }}Map the binary, apply RELATIVE relocations, then call the entry point. That’s the missing piece between loading segments (Part 2) and running a real dynamically-linked binary.
Before vs after: seeing it live
Grab any PIE binary (compiled with -fPIE -pie, which is the default on modern distros) and inspect it:
$ readelf -r ./elf-loader
Relocation section '.rela.dyn' at offset 0x848 contains 10 entries: Offset Info Type Sym. Value Sym. Name + Addend000000004dd0 000000000008 R_X86_64_RELATIVE 1250000000004dd8 000000000008 R_X86_64_RELATIVE 1200...000000004fc0 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0...
Relocation section '.rela.plt' at offset 0x938 contains 19 entries: Offset Info Type Sym. Value Sym. Name + Addend000000005000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0000000005008 000400000007 R_X86_64_JUMP_SLO 0000000000000000 strlen@GLIBC_2.2.5 + 0000000005010 000500000007 R_X86_64_JUMP_SLO 0000000000000000 __stack_chk_fail@GLIBC_2.4 + 0...The offsets, 0x4dd0, 0x4fc0, 0x5000, are all within the GOT region. That’s where the patches land.
Now see what a RELATIVE relocation patches. If the binary were loaded at base address 0x7f0000000000, the slot at 0x4dd0 would get:
0x7f0000000000 + 0x1250 = 0x7f0000001250That’s where the real address lives. Same for every RELATIVE entry.
Try it
Create a tiny program, compile it as a PIE, and inspect its relocations:
cat > /tmp/reltest.c <<'EOF'#include <stdio.h>int global_var = 42;int main(void) { printf("hello: %d\n", global_var); return 0;}EOFgcc -o /tmp/reltest /tmp/reltest.creadelf -r /tmp/reltestYou’ll see:
R_X86_64_RELATIVEforglobal_varand the data pointersR_X86_64_JUMP_SLOTforprintfandputs(and pretty much every libc function)R_X86_64_GLOB_DATfor__gmon_start__and friends
Also compare with no PIE:
gcc -no-pie -o /tmp/reltest-nopie /tmp/reltest.creadelf -r /tmp/reltest-nopieMuch shorter list. Most relocations are gone because the addresses are now fixed, no randomization needed, nothing to patch. That’s the ET_EXEC vs ET_DYN difference from Part 1, now at the level of actual bytes
Where this is going
- Part 5, a mini-readelf: glue headers, sections, symbols, and relocations into one tool.
Each part built on the last. By part 5 you’ll have a complete picture of what’s inside an ELF binary, not because a tool told you, but because you built the tool.
Next reads
View all →9 Sept
Mini-Readelf: Gluing It All Together
The capstone. Four parts of pieces, headers, sections, symbols, relocations, joined into one tool that reads any ELF. The only new mechanic is the sh_link chain: offset into a table that holds offsets into a table that holds strings.
4 Sept
Symbol Tables: What Function Names Actually Are
Function names in a binary are just entries in a table. Two tables actually: .symtab and .dynsym. Here's what each is for, how the struct works, and how to resolve a name from an address.
3 Sept
Building a Custom ELF Loader from Scratch, in C
You type ./program and the kernel loads it. Here's how that works: we build a userspace ELF loader in ~300 lines of C that maps PT_LOAD segments, builds a stack by hand, and jumps to the entry point.
Get posts by email
One email when I publish, not a drip, not weekly. Sign up and I'll only write when there's something new.
You won't get mail just for signing up. Unsubscribe any time.