Parsing ELF Binaries in C
Every program on Linux is an ELF file. You know this if you’ve ever seen \x7fELF in a hexdump. But if you’ve only ever used readelf or objdump, you’ve never actually looked at one yourself.
So let’s. We’re building a small C program that opens a binary, checks it’s real, reads the ELF header, and walks the section table. No libraries, no libelf. Just casting pointers to structs and letting the compiler do the work.
Code’s at github.com/0xWelsh/elf-explorer. This is part one: header and sections. Part 2 — where I build an actual loader that runs binaries — lives in github.com/0xWelsh/elf-loader.
What an ELF file actually looks like
It’s bytes on disk. That’s it.
+-------------------+| ELF header | <- always at offset 0| program headers | <- the kernel reads these| .text, .data ... | <- actual code and data| section headers | <- metadata (debuggers use this)| string tables |+-------------------+The ELF header tells you where both tables are via offsets. Both point into the middle where your code lives. We’re reading the first box and walking the last one.
mmap, not fread
Most tutorials use fread. I don’t want to manage buffers or read loops, so:
int fd = open(filepath, O_RDONLY);struct stat st;fstat(fd, &st);
elf->size = st.st_size;elf->map = mmap(NULL, elf->size, PROT_READ, MAP_PRIVATE, fd, 0);close(fd);mmap maps the whole file into memory. Now elf->map points at byte zero of the binary. You read from that pointer and you’re reading the file.
Why bother? No buffers. No read loops. The kernel pages in chunks lazily — if you only touch the header, the rest of a 40 MB binary never hits disk. And here’s the real trick: since the file is in memory and the structs define the layout, you can just cast pointers. That’s how we parse.
One thing that trips people up: after mmap, closing the fd is fine. The mapping holds its own reference to the file. Only munmap tears it down. And MAP_PRIVATE means even if you wrote to the mapped bytes, you’d only be modifying your own copy. The file on disk stays untouched.
Checking the magic
First 16 bytes of every ELF are e_ident — basically the file’s passport. Here’s a hexdump of /usr/bin/true on my Arch box:
7f 45 4c 46— the magic. Always\x7fELF. If it’s not this, it’s not an ELF.02— class. 2 means ELF64.01— endianness. Little-endian.01— version. Always 1. Nobody else exists.
Our validation:
if (memcmp(elf->ehdr->e_ident, ELFMAG, SELFMAG) != 0) { fprintf(stderr, "[-] Not a valid ELF binary\n");}if (elf->ehdr->e_ident[EI_CLASS] != ELFCLASS64) { fprintf(stderr, "[-] Only ELF64 supported\n");}Try it yourself: head -c 4 /usr/bin/ls | xxd. Then try it on a .txt file. The magic check kills it before anything else happens.
The overlay trick
Here’s the idea that makes all of this work.
Linux defines the ELF header as a struct in <elf.h>:
typedef struct { unsigned char e_ident[16]; uint16_t e_type; uint16_t e_machine; uint32_t e_version; uint64_t e_entry; // entry point uint64_t e_phoff; // program header table offset uint64_t e_shoff; // section header table offset uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; uint16_t e_phnum; uint16_t e_shentsize; uint16_t e_shnum; uint16_t e_shstrndx;} Elf64_Ehdr;Since the file starts at byte zero in memory, and the header is at byte zero of the file, the header is already in memory. Just cast:
elf->ehdr = (Elf64_Ehdr *)elf->map;No parsing. No deserialization. The struct’s memory layout is the file format. That was the design goal when the ELF spec was written. Casting the pointer is the parse.
Yeah, this assumes the host’s endianness and alignment match the file. On x86-64 reading x86-64 binaries, they do. A proper tool would check EI_DATA and handle big-endian files. But for now, the caveat is worth knowing, not fixing.
The fields that matter
The header has ~15 fields but you only need three to navigate the file:
e_entry— where execution startse_phoff— program header table offset (the kernel’s loading instructions)e_shoff— section header table offset (where we’re going today)
The hexdump’s second line starts:
0300 3e00 0100 0000 6025 0000 0000 000003 00 is e_type = ET_DYN — a position-independent executable. Modern distros compile everything this way for ASLR. 60 25 00 00... is e_entry = 0x2560.
Notice 03 00 not 00 03? Little-endian. Least significant byte first. Once you see it, hexdumps stop being confusing.
Walking the section header table
The section header table is an array of Elf64_Shdr structs. Each one is exactly 64 bytes (e_shentsize). Each describes one chunk of the file — name, type, permissions, size, where it lives.
Getting to the array is one pointer addition:
Elf64_Shdr *shdr_table = (Elf64_Shdr *)(elf->map + hdr->e_shoff);for (int i = 0; i < hdr->e_shnum; i++) { Elf64_Shdr *sh = &shdr_table[i]; // sh->sh_type, sh->sh_addr, sh->sh_offset ...}e_shoff is the offset. e_shnum is the count. Done.
Two fields cause confusion, so let’s get them straight now:
sh_offset— where the section sits in the filesh_addr— where it ends up in memory at runtime
Different coordinate systems. .text might live at file offset 0x1000 but load at address 0x401000. A crash at 0x40123a? Debuggers use sh_addr. Tools slicing up the file? They use sh_offset.
String tables — why names are numbers
This is the part that confuses everyone.
Look at Elf64_Shdr:
uint32_t sh_name; // <-- NOT a stringIt’s an integer. Where’s the actual text?
It’s in a separate blob called .shstrtab (section header string table). All the section names live packed together in one null-terminated mess:
.shstrtab: \0 . s y m t a b \0 . s t r t a b \0 . i n t e r p \0 . t e x t \0 ...offsets: 0 9 17 26sh_name is a byte offset into that blob. .interp doesn’t contain “.interp” — it contains the number 26 or whatever, meaning “read from byte 26 of .shstrtab until you hit null.”
Why not just store the string? Because one pool referenced by integers is simpler and deduplicates everything. Same idea as DNS label compression or string interning in compilers.
Finding the blob is a small chicken-and-egg problem. The header tells you which section is the string table via e_shstrndx:
const char *get_section_name(const elf_file_t *elf, uint32_t sh_name) { Elf64_Shdr *shdr_table = (Elf64_Shdr *)(elf->map + elf->ehdr->e_shoff); Elf64_Shdr *shstrtab_hdr = &shdr_table[elf->ehdr->e_shstrndx]; const char *shstrtab = (const char *)(elf->map + shstrtab_hdr->sh_offset);
if (sh_name >= shstrtab_hdr->sh_size) return "<invalid string offset>";
return shstrtab + sh_name;}Three lookups stacked. Section table → find .shstrtab’s header → use sh_offset to land on the blob → add the offset. Pointer arithmetic all the way down.
Flags — the WAX letters
When you see readelf -S print WAX next to a section, it’s decoding a bitmask:
char flags_str[16] = {0};int pos = 0;if (flags & SHF_WRITE) flags_str[pos++] = 'W';if (flags & SHF_ALLOC) flags_str[pos++] = 'A';if (flags & SHF_EXECINSTR) flags_str[pos++] = 'X';One AND per letter. .text is AX — executable but not writable (W^X). .data is WA — writable but not executable. The hardware enforces this per page at runtime. The flags are where the linker told the kernel what you wanted.
Checkpoint — prove it works
Run the tool on the same binary as readelf and diff the output:
Run readelf -h and readelf -S on the same binary and you’ll see the same numbers with names attached:
Same numbers as readelf -h and readelf -S. That’s the whole validation loop: build a thing, diff it against the tool that already exists, trust nothing until the columns match.
Feed it things that aren’t ELFs — a PNG, your shell history. Try stripped binaries and note which sections vanish (symbols go, sections stay).
Where this is going
We can look at binaries now. Next:
- Part 2 — program headers:
PT_LOADsegments and how the kernel turns this file into a running process. That code is live in elf-loader. - Part 3 — symbols:
.symtabvs.dynsym, and what function names actually are. - Part 4 — relocations: how PIE binaries fix their addresses at load time.
- Part 5: glue it into a mini-readelf.
One thing to try before part 2: take the section table and sort sections by sh_addr. They cluster into a few contiguous runs. Those runs have a name — segments — and they’re what the kernel uses to load the binary.
Next reads
View all →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.
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.
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.