reading binaries by hand:
How ELF and PE files work
I’ve been exploring Zig for low-level development and decided to experiment with writing a simple kernel. Somewhere along the way, that turned into building a binary inspector to better understand the executables I was working with.
This is everything I've learned along the way, and will keep updating as I go — how executables are structured, how to detect their format, how to parse headers, and what the structs actually look like inside.
Endianness
Before you can read a number out of a binary, you need to know which end of it came first. That's endianness.
Little Endian
The least significant byte is stored first. x86 and x86_64 are little endian, so if you're on a modern PC, this is what you'll see almost everywhere.
Address: 0x00 0x01 0x02 0x03 Value: 78 56 34 12 ← least significant byte first
Big Endian
Most significant byte first. Network protocols use this — it's sometimes called "network byte order". You'll also see it on SPARC or older MIPS, but rarely on systems you're probably targeting.
Address: 0x00 0x01 0x02 0x03 Value: 12 34 56 78 ← most significant byte first
In practice
ELF stores the endianness in e_ident[5]: 1 = little, 2 = big. The bytes themselves don't change — endianness only affects how you interpret them when assembling a multi-byte value.
In Zig you just pass it as a parameter:
const val = std.mem.readInt(u32, data[offset..][0..4], .little);
What is ELF?
ELF stands for Executable and Linkable Format. It's been the standard binary format on Linux, replacing the older .out format. It's used for executables, shared libraries (.so files), object files (.o), and kernel images.
The format was defined in the System V ABI specification and has remained remarkably stable. The same spec still describes the binaries built today.
ELF has two views of the same file:
Linking view
On kernel design tries we will go deep into this part (linker.ld)...
Used by the linker when building the binary. The file is divided into named sections: .text (code), .rodata (read-only data), .bss (zeroed memory), etc.
Execution view
Used by the OS loader at runtime. The file is divided into segments — contiguous regions that get mapped into memory with specific permissions (read, write, execute). Dont mix with linux file permissions. These are memory permissions, not Linux file permissions. They define how the mapped memory region can be accessed at runtime.
Both views coexist in the same file. The ELF header points to both the section header table and the program header table.
ELF magic bytes
Every ELF file starts with a 16-byte identification block called e_ident. The first four bytes are always the same — the magic number:
Offset Hex ASCII Field ──────────────────────────────────────── 0x00 7F DEL EI_MAG0 0x01 45 E EI_MAG1 0x02 4C L EI_MAG2 0x03 46 F EI_MAG3
The remaining bytes in e_ident describe the file further:
e_ident[4] EI_CLASS 1 = ELF32, 2 = ELF64 e_ident[5] EI_DATA 1 = little endian, 2 = big endian e_ident[6] EI_VERSION always 1 (current) e_ident[7] EI_OSABI 0 = System V (most common) e_ident[8..] padding zeroes
In Zig, detecting an ELF file is two lines:
const ELFMAG = [4]u8{ 0x7F, 'E', 'L', 'F' }; if (std.mem.eql(u8, data[0..4], &ELFMAG)) { // ELF file const class = data[4]; // 1 = ELF32, 2 = ELF64 }
The ELF Header
After the 16-byte e_ident, the ELF header continues with fields that describe the binary's overall structure. These fields tell you what kind of file it is, what architecture it targets, where the code starts, and where to find the section and program header tables.
| Field | Type (32 / 64) | Description |
|---|---|---|
e_ident | [16]u8 | Magic, class, endianness, OS ABI |
e_type | u16 | File type — ET_EXEC, ET_DYN, ET_REL, ET_CORE |
e_machine | u16 | Target ISA — 3=i386, 62=x86_64, 183=AArch64, 243=RISC-V |
e_version | u32 | ELF version, always 1 |
e_entry | u32 / u64 | Virtual address of the entry point |
e_phoff | u32 / u64 | Byte offset of the program header table |
e_shoff | u32 / u64 | Byte offset of the section header table |
e_flags | u32 | Architecture-specific flags |
e_ehsize | u16 | Size of this header in bytes |
e_phentsize | u16 | Size of one program header entry |
e_phnum | u16 | Number of program header entries |
e_shentsize | u16 | Size of one section header entry |
e_shnum | u16 | Number of section header entries |
e_shstrndx | u16 | Index of the section name string table |
The most important fields for parsing: e_phoff + e_phnum to find segments, e_shoff + e_shnum to find sections, and e_shstrndx to resolve section names.
ELF32 vs ELF64
The two variants are almost identical. The differences come down to field sizes and one layout quirk in the program header.
Field size differences
| Field | ELF32 | ELF64 |
|---|---|---|
e_entry | u32 | u64 |
e_phoff | u32 | u64 |
e_shoff | u32 | u64 |
sh_flags | u32 | u64 |
sh_addr | u32 | u64 |
sh_offset | u32 | u64 |
p_offset | u32 | u64 |
p_vaddr | u32 | u64 |
The p_flags footgun
In Elf32ProgramHeader, p_flags is the last field. In Elf64ProgramHeader, it was moved to second. This was done in the spec to improve natural alignment — placing it before the 64-bit fields avoids implicit padding.
ELF32: p_type, p_offset, p_vaddr, p_paddr,
p_filesz, p_memsz, p_flags, p_align
↑ last
ELF64: p_type, p_flags, p_offset, p_vaddr,
p_paddr, p_filesz, p_memsz, p_align
↑ second — moved for alignment
Copy-pasting the 32-bit struct for 64-bit without moving p_flags gives you silently wrong segment permissions — the kind of bug that's invisible until you try to execute a segment and the kernel says no.
Sections vs Segments
ELF has two overlapping ways to describe the same bytes in the file. Understanding the difference is necessary if you're building a bootloader or kernel loader.
Sections
Used at link time. Named regions with semantic meaning: .text is code, .rodata is read-only data, .bss is zero-initialized memory, .eh_frame is exception handling data. Section names come from the .shstrtab string table at index e_shstrndx.
Segments
Used at load time. A segment describes a contiguous region to map into the process address space with specific permissions — R (read), W (write), X (execute). One segment typically contains multiple sections. A PT_LOAD segment is what the OS actually maps.
Common section types:
| Name | Type | Contents |
|---|---|---|
.text | PROGBITS | Executable machine code |
.rodata | PROGBITS | String literals, constants |
.data | PROGBITS | Initialized global variables |
.bss | NOBITS | Uninitialized globals — zero-filled, takes no space in file |
.symtab | SYMTAB | Symbol table — function and variable names |
.shstrtab | STRTAB | Section name strings |
.eh_frame | PROGBITS | Exception handling / stack unwinding data |
Structs in Zig
Zig's extern struct lays fields out in memory exactly as declared, with no padding — identical to a C struct with __attribute__((packed)). This lets you cast raw bytes directly to a struct pointer, which is exactly how parsing works.
These structs come directly from the Linux kernel's include/uapi/linux/elf.h:
pub const Elf64Header = extern struct { e_ident: [16]u8, // Magic + class + data + version e_type: u16, e_machine: u16, e_version: u32, e_entry: u64, // Extends to u64 in ELF64 e_phoff: u64, e_shoff: u64, e_flags: u32, e_ehsize: u16, e_phentsize: u16, e_phnum: u16, e_shentsize: u16, e_shnum: u16, e_shstrndx: u16, };
pub const Elf64SectionHeader = extern struct { sh_name: u32, // index into .shstrtab sh_type: u32, // SHT_PROGBITS, SHT_NOBITS, SHT_STRTAB... sh_flags: u64, sh_addr: u64, // virtual address sh_offset: u64, // file offset sh_size: u64, sh_link: u32, sh_info: u32, sh_addralign: u64, sh_entsize: u64, };
pub const Elf64ProgramHeader = extern struct { p_type: u32, p_flags: u32, // ← second in ELF64 (was last in ELF32) p_offset: u64, p_vaddr: u64, p_paddr: u64, p_filesz: u64, p_memsz: u64, p_align: u64, };
Parsing is then a direct cast — no manual field-by-field reading:
const hdr = std.mem.bytesAsValue(
Elf64Header,
data[0..@sizeOf(Elf64Header)],
);
std.debug.print("entry: 0x{X}\n", .{hdr.e_entry});
How ELF parsing works
Parsing an ELF file in order:
The section name is an offset into .shstrtab — a flat byte array of null-terminated strings. sh_name tells you where in that array to start reading. Iterate forward until you hit a null byte.
var name_end = sh.sh_name; while (name_end < strtab.len and strtab[name_end] != 0) : (name_end += 1) {} const name = strtab[sh.sh_name..name_end];
What is PE?
PE stands for Portable Executable. It's been the Windows binary format since Windows NT 3.1, replacing the older NE (New Executable) and MZ formats — though PE still carries a DOS stub header at the front for historical compatibility.
PE is used for .exe files (executables), .dll files (dynamic libraries), and .sys files (kernel drivers). It evolved from the COFF object file format and was extended by Microsoft for both 32-bit (PE32) and 64-bit (PE32+) systems.
PE magic bytes
Every PE file starts with a DOS stub — a tiny real-mode program that prints "This program cannot be run in DOS mode" if executed on DOS. The first two bytes are the DOS magic:
data[0x00] = 4D 'M' data[0x01] = 5A 'Z'
Info : MZ is initials of Mark Zbikowski, who designed the format
At offset 0x3C inside the DOS header, e_lfanew holds a 32-bit file offset pointing to the real PE header:
const dos = std.mem.bytesAsValue(DosHeader, data[0..@sizeOf(DosHeader)]); const pe_offset: usize = dos.e_lfanew; // verify PE signature const sig = std.mem.readInt(u32, data[pe_offset..][0..4], .little); // sig should equal 0x4550 — "PE\0\0"
This part some weird let is clerify more:)
MZ is only saying ı am an executable file and my format is DOS Okay but where is other data ? first of all read MZ
then parse DosHeader and read e_lfanew area. e_lfanew is offset of PE\0\0. Going this offset and here searching for PE\0\0 and understanding after this part Portable Executable format. Check the PE Parse flow part for schema
PE parse flow
PE has more layers than ELF — you go through the DOS header before you even reach the real structure:
The section header offset is computed, not stored directly — you get there by skipping past the file header and the optional header, size varies between PE32 and PE32+.
PE32 vs PE32+
The optional header magic tells you which variant you're dealing with. The differences are in field sizes and one removed field:
| Field | PE32 | PE32+ |
|---|---|---|
magic | 0x010B | 0x020B |
image_base | u32 | u64 |
size_of_stack_reserve | u32 | u64 |
size_of_stack_commit | u32 | u64 |
size_of_heap_reserve | u32 | u64 |
size_of_heap_commit | u32 | u64 |
base_of_data | u32 (exists) | — (removed) |
address_of_entry_point | u32 RVA | u32 RVA |
address_of_entry_point stays 32-bit in PE32+ even though the binary is 64-bit — it's a relative virtual address, and the Windows loader adds the image base to get the actual address. The base_of_data field was simply dropped when PE32+ was designed; the extra u64 fields that replaced the u32s take up that space.
Common sections in Windows binaries:
| Name | Flags | Contents |
|---|---|---|
.text | R-X | Executable code |
.rdata | R-- | Read-only data, import table, export table |
.data | RW- | Initialized global variables |
.bss | RW- | Uninitialized data |
.rsrc | R-- | Resources — icons, version info, manifests |
.pdata | R-- | Exception handling data (x64) |
Quick note — .rodata and .rdata or .data and .pdata arent a typo:) ELF uses .rodata, PE uses .rdata. etc. same thing, different name
Structs in Zig
PE structs come from Microsoft's PE/COFF specification and winnt.h. The field names and sizes are identical:
pub const DosHeader = extern struct { e_magic: u16, // 0x5A4D — "MZ" e_cblp: u16, // ... (14 more fields, mostly legacy) e_lfarlc: u16, e_ovno: u16, e_res: [4]u16, e_oemid: u16, e_oeminfo: u16, e_res2: [10]u16, e_lfanew: u32, // ← offset to PE header };
pub const PeFileHeader = extern struct { machine: u16, // 0x8664=x86_64, 0x014C=i386... number_of_sections: u16, time_date_stamp: u32, // unix timestamp of link time pointer_to_symbol_table: u32, number_of_symbols: u32, size_of_optional_header: u16, // ← use this to find section headers characteristics: u16, };
pub const PeSectionHeader = extern struct { name: [8]u8, // null-padded, NOT null-terminated if 8 chars virtual_size: u32, // size in memory virtual_address: u32, // RVA — relative virtual address size_of_raw_data: u32, // size on disk pointer_to_raw_data: u32, // file offset pointer_to_relocations: u32, pointer_to_linenumbers: u32, number_of_relocations: u16, number_of_linenumbers: u16, characteristics: u32, // R/W/X flags };
Section characteristics flags for R/W/X:
IMAGE_SCN_MEM_READ = 0x40000000 IMAGE_SCN_MEM_WRITE = 0x80000000 IMAGE_SCN_MEM_EXECUTE = 0x20000000
Now Practice Time
Already ı know struct of elf and which filed will come what ı guess :) On my test ı am on windows pc and use Format-Hex pwsh command to check binary for now.
Test binary also limon. you can download binary from here and github link ı am putting references part.
Firstly ı want to read ELF byte of file e_indent field holding this data
Offset Bytes Ascii
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
------ ----------------------------------------------- -----
0000000000000000 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 ▪ELF············
Yeee result as ı expected :)
Here 0x00 - 0x03 elf part, 0x04 is EI_CLASS here return 02 mean 64bit 0x05 is EI_DATA responsible to hold little endian big endian so return vallue is 01 mean Little Endian
0x06 is EI_VERSION current_version field 01 mean current
on paragragp looked weird so here a table:
Offset Hex Field Value ──────────────────────────────────────────────── 0x00 7F EI_MAG0 DEL 0x01 45 EI_MAG1 'E' 0x02 4C EI_MAG2 'L' 0x03 46 EI_MAG3 'F' 0x04 02 EI_CLASS 2 = 64-bit 0x05 01 EI_DATA 1 = Little Endian 0x06 01 EI_VERSION 1 = current 0x07 00 EI_OSABI 0 = System V 0x08 00 EI_ABIVERSION 0 0x09 00 padding 0x0A 00 padding 0x0B 00 padding 0x0C 00 padding 0x0D 00 padding 0x0E 00 padding 0x0F 00 padding
Fo now ı will check another fields and continue to Biber. LAter ı will update here also Maybe next times ı will continue from Biber also..
Go to PART 2 :)
References
Everything above can be verified against these primary sources.
When in doubt, go to the spec — not a blog post.
It is only what ı understand can be wrong !
ELF
- System V ABI — official ELF specification
- linux/elf.h — struct definitions in the Linux kernel
- x86-64 System V ABI
- OSDev Wiki — ELF