Reading Binaries by hand
RSS github
ELF PE Zig 0.16.0

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.

May 2026

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.

the number 0x12345678 in memory
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.

same number, big endian
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:

zig
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:

e_ident[0..4] — 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..16]
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:

zig — format detection
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.

FieldType (32 / 64)Description
e_ident[16]u8Magic, class, endianness, OS ABI
e_typeu16File type — ET_EXEC, ET_DYN, ET_REL, ET_CORE
e_machineu16Target ISA — 3=i386, 62=x86_64, 183=AArch64, 243=RISC-V
e_versionu32ELF version, always 1
e_entryu32 / u64Virtual address of the entry point
e_phoffu32 / u64Byte offset of the program header table
e_shoffu32 / u64Byte offset of the section header table
e_flagsu32Architecture-specific flags
e_ehsizeu16Size of this header in bytes
e_phentsizeu16Size of one program header entry
e_phnumu16Number of program header entries
e_shentsizeu16Size of one section header entry
e_shnumu16Number of section header entries
e_shstrndxu16Index 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

FieldELF32ELF64
e_entryu32u64
e_phoffu32u64
e_shoffu32u64
sh_flagsu32u64
sh_addru32u64
sh_offsetu32u64
p_offsetu32u64
p_vaddru32u64

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.

field order comparison
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:

NameTypeContents
.textPROGBITSExecutable machine code
.rodataPROGBITSString literals, constants
.dataPROGBITSInitialized global variables
.bssNOBITSUninitialized globals — zero-filled, takes no space in file
.symtabSYMTABSymbol table — function and variable names
.shstrtabSTRTABSection name strings
.eh_framePROGBITSException 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:

zig — ELF64 header struct
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,
};
zig — ELF64 section header struct
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,
};
zig — ELF64 program header struct
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:

zig — casting bytes to struct
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:

1
Read data[0..4] — check magic bytes \x7FELF
2
Read data[4] — class byte → ELF32 or ELF64
3
Cast data[0..sizeof(ElfHeader)] → ElfHeader struct
4
Read e_shstrndx → index into section header table
find that section header
its sh_offset + sh_size → the .shstrtab byte range
5
For each section (0..e_shnum):
offset = e_shoff + i × e_shentsize
cast to SectionHeader struct
name = .shstrtab[sh_name..] until null byte
6
For each segment (0..e_phnum):
offset = e_phoff + i × e_phentsize
cast to ProgramHeader struct
read p_type, p_vaddr, p_flags, p_filesz

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.

zig — reading a section name from .shstrtab
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:

DOS magic — MZ
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:

zig — finding the 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:

data[0..2] == "MZ"
DosHeader.e_lfanew → PE header file offset
"PE\0\0" (4-byte signature, must match)
PeFileHeader (20 bytes)
├─ machine, number_of_sections, characteristics
└─ size_of_optional_header ← needed to skip to sections
OptionalHeader (variable size)
└─ magic: 0x010B → PE32 | 0x020B → PE32+
Section headers (offset = pe_offset + 4 + sizeof(PeFileHeader) + size_of_optional_header)

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:

FieldPE32PE32+
magic0x010B0x020B
image_baseu32u64
size_of_stack_reserveu32u64
size_of_stack_commitu32u64
size_of_heap_reserveu32u64
size_of_heap_commitu32u64
base_of_datau32 (exists)— (removed)
address_of_entry_pointu32 RVAu32 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:

NameFlagsContents
.textR-XExecutable code
.rdataR--Read-only data, import table, export table
.dataRW-Initialized global variables
.bssRW-Uninitialized data
.rsrcR--Resources — icons, version info, manifests
.pdataR--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:

zig — DOS header
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
};
zig — PE file 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,
};
zig — section header
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:

zig — section flags
IMAGE_SCN_MEM_READ    = 0x40000000
IMAGE_SCN_MEM_WRITE   = 0x80000000
IMAGE_SCN_MEM_EXECUTE = 0x20000000
19 May 2026

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

Format-Hex .\limon -Offset 0x00 -Count 16
          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:

e_ident — full breakdown
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

PE

General