Part 1 — Inside an ELF Binary

Series: Binary Exploitation | Difficulty: Beginner | Read time: ~25 min

Prerequisites: Part 0 — Lab Setup & Toolchain

Why This Matters

Before you can exploit a binary, you have to understand how it's built. Every compiled program on Linux is an ELF (Executable and Linkable Format) file — a carefully structured blob of machine code, data, and metadata that the kernel knows exactly how to load.

In this article, we'll crack open a compiled binary and examine every byte. You'll learn what the ELF header looks like, how sections map to memory, and why the Program Counter points where it does.

We're going to read hex for fun. Let's go.

What is ELF?

ELF is the standard binary format on Linux (and most Unix systems). When you run gcc -o vuln vuln.c, the compiler and linker produce an ELF file. This same format is used for:

Every ELF file starts with the same 16-byte header. This is how the kernel and ld.so (the dynamic linker) identify the file and decide how to handle it.

The ELF Magic Bytes

The first 4 bytes of every ELF file are a magic number that identifies it as an ELF:

$ file ./vuln
./vuln: ELF 64-bit LSB pie executable, x86-64, ...

$ xxd ./vuln | head -1
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000

Let's break those first 16 bytes down:

OffsetBytesMeaning
0x00–0x037f 45 4c 46Magic: \x7f + "ELF"
0x0402Class: ELFCLASS64 (64-bit binary)
0x0501Data encoding: ELFDATA2LSB (little-endian)
0x0601ELF version: 1 (current)
0x0700OS/ABI: ELFOSABI_NONE (Linux)
0x08–0x0f00 00 00 00 00 00 00 00Padding (unused)

Why the magic bytes matter for exploitation

Some exploits overwrite the ELF header in memory. If you corrupt the magic bytes (7f 45 4c 46), the kernel will refuse to load the binary. You'll also see these bytes referenced when tools like readelf and objdump parse an ELF file — they check magic bytes first to validate the file.

The Full ELF Header

The ELF header is a fixed-size structure that describes the entire file. Let's look at it with readelf:

$ readelf -h ./vuln
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little-endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1050
  Start of program headers:          64 (bytes into file)
  Start of section headers:          13568 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program header entries:    56 (bytes)
  Number of program headers:         11
  Size of section header entries:    64 (bytes)
  Number of section headers:         31
  Section header string table index: 30

The key fields for exploitation:

Type: DYN vs EXEC

If you compiled with -no-pie, you'll see Type: EXEC (fixed address). With PIE enabled (the default), you'll see Type: DYN. This tells you whether the binary's code is loaded at a fixed address or randomized each run.

# With PIE (default) — randomizable
$ gcc -o vuln vuln.c
$ readelf -h vuln | grep Type
  Type:    DYN (Position-Independent Executable)

# Without PIE — fixed address
$ gcc -no-pie -o vuln_nopie vuln.c
$ readelf -h vuln_nopie | grep Type
  Type:    EXEC (Executable file)

Entry Point Address

The entry point is the address where the kernel starts executing your code. It's not main() — it's _start, a libc function that sets up the environment and eventually calls your main(). We'll revisit this in a later article when we trace execution in GDB.

$ readelf -h ./vuln | grep "Entry point"
  Entry point address:  0x1050

Section Headers vs Program Headers

This is one of the most confusing parts of ELF for beginners. There are two different ways to describe the same binary, and they serve different purposes:

FeatureSection HeadersProgram Headers (Segments)
Used byLinker (ld)Kernel / loader
ViewFine-grained (many small sections)Coarse-grained (few large segments)
Commandreadelf -Sreadelf -l
PurposeCompilation & linkingLoading into memory

The mental model

Think of section headers as a "blueprint" (detailed instructions for the linker) and program headers as a "shipping manifest" (tells the kernel what chunks of data to load into memory). Both describe the same binary, just at different levels of detail.

Sections — The Linker's View

When you run readelf -S, you see every section in the binary. Here's what a typical output looks like:

$ readelf -S ./vuln
There are 31 section headers, starting at offset 0x3500:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
  [ 1] .interp           PROGBITS         0000000000000318  00000318
  [ 2] .note.gnu.property NOTE           0000000000000340  00000340
  [ 3] .note.gnu.build-id NOTE           0000000000000364  00000364
  [ 4] .hash             HASH             00000000000003a0  000003a0
  [ 5] .gnu.hash         GNU_HASH         00000000000003c8  000003c8
  [ 6] .dynsym           DYNSYM           0000000000000428  00000428
  [ 7] .dynstr           STRTAB           0000000000000580  00000580
  [ 8] .gnu.version      VERSYM           000000000000060c  0000060c
  [ 9] .gnu.version_r    VERNEED          0000000000000630  00000630
  [10] .rela.dyn         RELA             0000000000000668  00000668
  [11] .rela.plt         RELA             00000000000006e0  000006e0
  [12] .init             PROGBITS         0000000000001000  00001000
  [13] .plt              PROGBITS         0000000000001020  00001020
  [14] .text             PROGBITS         0000000000001050  00001050
  [15] .fini             PROGBITS         00000000000012a4  000012a4
  [16] .rodata           PROGBITS         0000000000002000  00002000
  [17] .eh_frame_hdr     PROGBITS         00000000000024b4  000024b4
  [18] .eh_frame         PROGBITS         0000000000002600  00002600
  [19] .init_array       INIT_ARRAY       0000000000003db8  00001db8
  [20] .fini_array       FINI_ARRAY       0000000000003dc0  00001dc0
  [21] .dynamic          DYNAMIC          0000000000003dc8  00001dc8
  [22] .got              PROGBITS         0000000000003f70  00001f70
  [23] .got.plt          PROGBITS         0000000000003f80  00001f80
  [24] .data             PROGBITS         0000000000004000  00002000
  [25] .bss              NOBITS           0000000000004010  00002010
  [26] .comment          PROGBITS         0000000000000000  00002010
  [27] .symtab           SYMTAB           0000000000000000  00002018
  [28] .strtab           STRTAB           0000000000000000  00002188
  [29] .shstrtab         STRTAB           0000000000000000  00002290
  [30] .symtab           NULL             0000000000000000  00000000

You don't need to memorize all of these. Here are the ones that matter for exploitation:

.text — Your Code

The .text section contains all the machine code — the actual compiled instructions your CPU executes. When you disassemble a binary with objdump, you're reading this section.

$ objdump -d -M intel ./vuln | head -20
0000000000001050 <_start>:
    1050:   31 ed                   xor    ebp,ebp
    1052:   49 89 d1                mov    r9,rdx
    1055:   5e                      pop    rsi
    1056:   48 89 e2                mov    rdx,rsp
    1059:   48 83 e4 f0             and    rsp,0xfffffffffffffff0
    105d:   50                      push   rax
    105e:   54                      push   rsp
    105f:   49 c7 c5 e0 12 00 00   mov    rbp,0x12e0
    1066:   48 c7 c5 d0 12 00 00   mov    rbp,0x12d0
    106d:   e8 be 00 00 00          call   1130 <__libc_start_main@plt>
    1072:   f4                      hlt

.data — Initialized Global Variables

Global variables that you initialize with a value (like int counter = 42;) live here. These values are stored directly in the binary and loaded into writable memory.

.bss — Uninitialized Global Variables

Global variables without an explicit value (like int counter;) go here. The .bss section doesn't take up space in the file — the kernel just allocates zeroed memory at load time. That's why readelf shows NOBITS for this section.

.rodata — Read-Only Data

String literals and constant data live here. When you write printf("Hello, world!\n");, that string is stored in .rodata. This section is mapped read-only — writing to it causes a segfault.

.plt and .got.plt — The Dynamic Linking Tables

These two sections work together to call functions from shared libraries (like printf from libc). We'll cover them in depth in a later article on dynamic linking, but for now know that:

Program Headers — The Kernel's View

Program headers tell the kernel how to load the binary into memory. Each program header describes one segment.

$ readelf -l ./vuln

Elf file type is DYN (Position-Independent Executable)
Entry point 0x1050
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr
                 FileSiz  MemSiz   Flags  Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040
                 0x0002d8 0x0002d8  R      0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318
                 0x00001c 0x00001c  R      0x1
  LOAD           0x000000 0x0000000000000000 0x0000000000000000
                 0x0006c0 0x0006c0  R      0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000
                 0x0002a4 0x0002a4  R E    0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000
                 0x000a0c 0x000a0c  R      0x1000
  LOAD           0x002db8 0x0000000000003db8 0x0000000000003db8
                 0x000258 0x000260  RW     0x1000
  DYNAMIC        0x002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x0001a0 0x0001a0  RW     0x8
  NOTE           0x000340 0x0000000000000340 0x0000000000000340
                 0x000024 0x000024  R      0x4
  NOTE           0x000364 0x0000000000000364 0x0000000000000364
                 0x000020 0x000020  R      0x4
  GNU_PROPERTY   0x000340 0x0000000000000340 0x0000000000000340
                 0x000024 0x000024  R      0x4
  GNU_RELRO      0x002db8 0x0000000000003db8 0x0000000000003db8
                 0x000248 0x000248  R      0x1

The most important entries are the LOAD segments:

SegmentFlagsContents
LOAD #1R (read-only)ELF header, program headers, .note, .gnu.property
LOAD #2R E (read + execute).init, .plt, .text, .fini (code)
LOAD #3R (read-only).rodata, .eh_frame, .eh_frame_hdr
LOAD #4RW (read + write).data, .bss, .got, .dynamic (mutable data)

Flags: R W E

The flags tell you what you can do with each memory segment:

A segment that is R E (read + execute) but not writable is typical for code. A segment that is RW (read + write) but not executable is typical for data. This is the basic idea behind NX (No-Execute) — one of the most important security mitigations we'll exploit in later parts.

How Sections Map to Segments

Sections get grouped into segments for loading. Here's the mapping for our example:

Segment LOAD #2 (R E)  ──>  .init  +  .plt  +  .text  +  .fini
    ┌──────────────────────────────────────────────────────────┐
    │  0x1000 ─ 0x12a3     Code goes here (read + execute)    │
    └──────────────────────────────────────────────────────────┘

Segment LOAD #4 (RW)  ──>  .data  +  .bss  +  .got  +  .dynamic
    ┌──────────────────────────────────────────────────────────┐
    │  0x3db8 ─ 0x401f     Data goes here (read + write)      │
    └──────────────────────────────────────────────────────────┘

Multiple sections share the same segment because the kernel loads memory in page-sized chunks (typically 4096 bytes). It's more efficient to pack related sections together than to create a separate segment for each one.

The NX bit

Notice that the code segment (R E) is not writable, and the data segment (RW) is not executable. This is enforced by the CPU's memory protection hardware. When we compile with -z execstack, we're telling the linker to make the stack executable too — adding an E flag to a writable segment. This is the vulnerability that makes shellcode attacks possible.

The Dynamic Linker and PLT/GOT

Most programs don't include every function they use. Standard library functions like printf, scanf, and gets live in libc.so, a shared library loaded at runtime. The dynamic linker (ld-linux-x86-64.so.2) handles this process.

$ readelf -d ./vuln | head -15
Dynamic section at offset 0x3dc8 contains 26 entries:
  Tag        Type                 Name/Value
  NEEDED     Library              [libc.so.6]
  INIT       0x1000
  FINI       0x12a4
  PLTGOT     0x3f80
  PLTRELSZ   24
  PLTREL     RELA
  JMPREL     0x6e0
  RELA       0x668
  RELASZ     120
  SYMTAB     0x428
  SYMENT     24
  STRTAB     0x580
  STRSZ      268
  GNU_HASH   0x3c8

When your program calls printf, it actually calls a tiny stub in the .plt section. On the first call, this stub jumps through the .got.plt entry (which initially points back into the PLT stub). The dynamic linker intercepts this, resolves the real address of printf in libc, writes it to .got.plt, and redirects execution. On subsequent calls, the PLT stub jumps directly to the real function.

Entry Point and _start

The entry point address from the ELF header (e.g., 0x1050) points to _start, not main(). Here's what actually happens when you run a program:

Kernel loads ELF into memory
        │
        ▼
    _start()
        │
        ▼
  __libc_start_main()
        │
        ▼
      main()  <── your code starts here
        │
        ▼
      exit()

_start is a minimal function provided by the C runtime. It sets up the stack, parses command-line arguments, and calls __libc_start_main(), which does more initialization and eventually calls your main() function.

$ objdump -d -M intel ./vuln | grep -A 20 '<_start>'
0000000000001050 <_start>:
    1050:   31 ed                   xor    ebp,ebp
    1052:   49 89 d1                mov    r9,rdx
    1055:   5e                      pop    rsi
    1056:   48 89 e2                mov    rdx,rsp
    1059:   48 83 e4 f0             and    rsp,0xfffffffffffffff0
    105d:   50                      push   rax
    105e:   54                      push   rsp
    105f:   49 c7 c5 e0 12 00 00   mov    rbp,0x12e0
    1066:   48 c7 c5 d0 12 00 00   mov    rbp,0x12d0
    106d:   e8 be 00 00 00          call   1130 <__libc_start_main@plt>
    1072:   f4                      hlt

When you use GDB's break *0x1050 or break _start, you're setting a breakpoint here — at the very beginning of your program's execution, before main() is even called.

The Symbol Table

The symbol table lists all named functions and variables in the binary. This is what tools like nm and objdump use to show you human-readable names instead of raw addresses.

$ nm ./vuln | grep " T "
0000000000001050 T _start
0000000000001080 T __libc_csu_init
00000000000010f0 T __libc_csu_fini
0000000000001100 T _fini
0000000000001000 T _init
0000000000001130 T __libc_start_main@plt
0000000000001140 T printf@plt

The capital letters tell you the symbol type: T means defined in the text (code) section, U means undefined (imported from a shared library), D means initialized data, B means uninitialized data (.bss).

Quick Reference Card

Pin this somewhere. You'll need it.

=== INSPECT ELF HEADER ===
readelf -h ./vuln

=== INSPECT SECTIONS ===
readelf -S ./vuln
objdump -d -M intel ./vuln

=== INSPECT SEGMENTS ===
readelf -l ./vuln

=== SYMBOL TABLE ===
nm ./vuln
nm ./vuln | grep " T "         # only defined code symbols
nm ./vuln | grep " U "         # only undefined (imported) symbols

=== DYNAMIC LINKING INFO ===
readelf -d ./vuln

=== KEY SECTIONS ===
.text       Code (instructions)
.data       Initialized globals
.bss        Uninitialized globals
.rodata     Read-only data (strings, constants)
.plt        PLT stubs (jump to library functions)
.got.plt    GOT entries (resolved library addresses)

Quiz — Test Your Understanding

Answer these questions to make sure you understand ELF structure:

Question 1

What are the first 4 bytes (magic number) of every ELF file?

Question 2

Which command shows the program headers (segments) of an ELF file?

Question 3

The .text section contains:

Question 4

What does the "RW" flag on a LOAD segment mean?

Question 5

When you run ./vuln, which function does the kernel actually jump to first?

What's Next

Now you know how ELF binaries are structured. In Part 2 — x86-64 Assembly & The Stack, we'll learn the assembly instructions that live inside .text, how the stack works, and why the return address is the most important 8 bytes in memory.

Let's read some assembly.

← Part 0 - Lab Setup Part 2 - Assembly & The Stack →