ELF (Executable and Linkable Format) is the most common object and executable file format on Unix-like systems.
Most people don’t need to care about ELF until production bites:
lddlooks fine, but the binary still fails withnot found- you hit
undefined symbolafter a library upgrade - you get
wrong ELF classwhen a 64-bit binary tries to load a 32-bit library (or vice versa) - you’re handed a random binary and need to answer: static or dynamic? which loader? which segments?
Once you understand the ELF structure, the chain of compile -> link -> load -> run becomes debuggable.
A 3-minute triage path for dynamic linking issues
Rule of thumb: ldd is a simulation under the current environment, not a perfect guarantee of runtime behavior.
When you see “library not found / wrong version loaded”, walk this path:
- Confirm platform/arch:
file ./a.out
readelf -h ./a.out | head
- Check what the binary declares as dependencies (NEEDED):
readelf -d ./a.out | grep NEEDED
- Inspect runtime search hints (RPATH/RUNPATH):
readelf -d ./a.out | grep -E 'RPATH|RUNPATH'
- Ask the loader to explain itself:
LD_DEBUG=libs,files ./a.out 2>&1 | head -n 80
- Compare with system cache and env:
ldconfig -p | grep <libname>
echo $LD_LIBRARY_PATH
The rest of the post explains why these knobs exist and how sections/segments relate to what the loader does.
The two views of ELF
ELF contains two parallel descriptions of the same binary:
- Sections (linker view): used during compilation and static linking — code, data, symbols, relocations.
- Segments (program headers, loader view): what the kernel’s ELF loader maps into memory with permissions (R/W/X).
A simplified mapping:
Sections (linker) Segments (loader)
.text LOAD (R-X)
.data + .bss LOAD (RW-)
.symtab / .strtab / .rel.* not loaded (only for linkers/debuggers)
Common ELF types include REL (relocatable object), EXEC (executable), and DYN (shared object). Do not infer dynamic linking from this field alone: traditional non-PIE dynamically linked executables are commonly EXEC, while PIE executables and shared libraries are commonly DYN.
Inspect three places together:
readelf -hW ./app | grep 'Type:'
readelf -lW ./app | grep INTERP
readelf -dW ./app | grep NEEDED
PT_INTERPmeans the kernel starts the named program interpreter, such asld-linux.DT_NEEDEDlists shared objects requested at runtime.- A
DYNfile withoutPT_INTERPmay be a shared library or static PIE; use the Program Headers and Dynamic Section to finish the classification.
Relocatable file example: ELF Header + Sections
A relocatable object (.o) header excerpt from readelf -h:
ELF Header:
Class: ELF64
Data: 2's complement, little endian
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0
Start of section headers: 0x2c0 (bytes into file)
Number of section headers: 12
A readelf -S excerpt (key columns only):
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 1] .text PROGBITS 0000 0040 0038 00 AX 0 0 16
[ 2] .rela.text RELA 0000 0210 0018 18 6 1 8
[ 3] .data PROGBITS 0000 0080 0020 00 WA 0 0 8
[ 4] .bss NOBITS 0000 00a0 0010 00 WA 0 0 8
[ 5] .symtab SYMTAB 0000 0240 00f0 18 6 8 8
[ 6] .strtab STRTAB 0000 0330 0048 00 0 0 1
Notes:
- Addr: Load address (often 0 in relocatable files; fixed by the linker).
- Off/Size: File offset and size.
- Flg: Permissions (A=alloc, X=exec, W=write).
Object file layout (simplified)
0x0000 ELF Header
0x0040 .text
0x0080 .data
0x00a0 .bss (no file bytes)
0x0210 .rela.text
0x0240 .symtab
0x0330 .strtab
0x02c0 Section Header Table
Executable example: Program Headers and Segments
After linking, an executable includes Program Headers:
Program Headers:
Type Offset VirtAddr FileSiz MemSiz Flg Align
LOAD 0x0000 0x400000 0x0800 0x0800 R E 0x1000
LOAD 0x1000 0x601000 0x0200 0x0300 RW 0x1000
Explanation:
- First LOAD: Contains
.text, permissions R-X. - Second LOAD: Contains
.data + .bss, permissions RW-. MemSiz > FileSizusually means.bssoccupies memory only.
Use readelf -l to view Section to Segment mapping and see how Sections are merged into Segments.
Relocation: an x86-64 experiment that shows the patch
Create reader.c. It declares a variable but does not know its final address:
extern int shared_value;
int read_value(void) {
return shared_value + 1;
}
Create main.c with the definition and caller:
int shared_value = 41;
int read_value(void);
int main(void) {
return read_value() != 42;
}
Compile only reader.c, then inspect its relocation table and disassembly:
gcc -O0 -fno-pic -c reader.c -o reader.o
readelf -rW reader.o
objdump -dr reader.o
On a typical x86-64 GNU toolchain, the output contains an R_X86_64_PC32 relocation for shared_value. The RIP-relative displacement is still a placeholder, and objdump -dr prints the relocation immediately below the instruction that needs it.
Finish the link:
gcc -O0 -fno-pie -no-pie reader.o main.c -o reloc-demo
objdump -d reloc-demo | sed -n '/<read_value>/,+8p'
./reloc-demo
echo $?
The linker writes the displacement from the final symbol address; the expected exit code is 0. An assembler can usually resolve local branches within one Section. Cross-Section or external references may still need relocations even when the instruction uses relative addressing.
Shared libraries and PIC / GOT / PLT
Shared objects usually use PIC so code does not embed fixed absolute addresses:
- GOT (Global Offset Table) stores variable or function addresses.
- PLT (Procedure Linkage Table) provides stubs for external calls.
- Lazy binding resolves a function on first call;
-z noworLD_BIND_NOW=1requests startup-time binding.
Use puts to verify the path instead of memorizing compiler-specific addresses. Save this as lazy.c:
#include <stdio.h>
int main(void) {
puts("first call");
puts("second call");
return 0;
}
gcc -O0 -fno-pie -no-pie -Wl,-z,lazy lazy.c -o lazy-demo
readelf -dW lazy-demo | grep -E 'BIND_NOW|FLAGS'
readelf -rW lazy-demo | grep -E 'JUMP_SLOT|puts'
objdump -d -j .plt lazy-demo
LD_DEBUG=bindings ./lazy-demo 2>&1 | grep -E 'binding.*puts'
If readelf -dW does not print BIND_NOW, the executable permits lazy binding. LD_DEBUG=bindings shows the bindings that actually occur at runtime.
readelf -rW provides the GOT Slot for puts. Use that address in GDB to compare the first and second calls:
(gdb) break 'puts@plt'
(gdb) run
(gdb) disassemble /r 'puts@plt'
(gdb) x/gx <GOT_SLOT_ADDRESS>
(gdb) continue
(gdb) x/gx <GOT_SLOT_ADDRESS>
At the second breakpoint, the first call has completed and the GOT Slot is normally updated from the resolver path to the actual libc function. Exact addresses and PLT instructions vary with the architecture, linker, and CET/PLT configuration; do not treat copied addresses as evidence.
One more real-world gotcha: “I installed the library, why can’t it be found?”
Three common causes:
- The file exists but is not in the active search chain: inspect
RPATH/RUNPATH,LD_LIBRARY_PATH,ld.so.cache, and default directories. - Multiple files provide the same SONAME: the service environment may resolve a different file than your shell.
- ABI/arch mismatch: 64-bit vs 32-bit (
wrong ELF class).
Do not run ldd first on an untrusted binary. Use readelf -dW or objdump -p to inspect NEEDED statically. Run LD_DEBUG=libs,bindings inside an isolated environment when you need the actual search and binding trace.
Tool checklist
# ELF header, Sections, Segments
readelf -h ./a.out
readelf -S ./a.out
readelf -l ./a.out
# relocations, symbols, disassembly
readelf -rWs ./a.out
objdump -dr ./a.out
nm -C ./a.out
# size, dependencies, strings
size ./a.out
readelf -dW ./a.out
strings -a ./a.out | less