Series: Binary Exploitation | Difficulty: Beginner | Read time: ~25 min
Prerequisites: Part 0 — Lab Setup & Toolchain
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.
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:
./vuln).so files like libc.so.6.o files before linkingEvery 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 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:
| Offset | Bytes | Meaning |
|---|---|---|
| 0x00–0x03 | 7f 45 4c 46 | Magic: \x7f + "ELF" |
| 0x04 | 02 | Class: ELFCLASS64 (64-bit binary) |
| 0x05 | 01 | Data encoding: ELFDATA2LSB (little-endian) |
| 0x06 | 01 | ELF version: 1 (current) |
| 0x07 | 00 | OS/ABI: ELFOSABI_NONE (Linux) |
| 0x08–0x0f | 00 00 00 00 00 00 00 00 | Padding (unused) |
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 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:
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)
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
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:
| Feature | Section Headers | Program Headers (Segments) |
|---|---|---|
| Used by | Linker (ld) | Kernel / loader |
| View | Fine-grained (many small sections) | Coarse-grained (few large segments) |
| Command | readelf -S | readelf -l |
| Purpose | Compilation & linking | Loading into memory |
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.
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:
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
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.
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.
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.
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:
.plt (Procedure Linkage Table) — small stub functions that redirect to the actual library functions.got.plt (Global Offset Table) — stores the resolved addresses of library functionsProgram 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:
| Segment | Flags | Contents |
|---|---|---|
| LOAD #1 | R (read-only) | ELF header, program headers, .note, .gnu.property |
| LOAD #2 | R E (read + execute) | .init, .plt, .text, .fini (code) |
| LOAD #3 | R (read-only) | .rodata, .eh_frame, .eh_frame_hdr |
| LOAD #4 | RW (read + write) | .data, .bss, .got, .dynamic (mutable data) |
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.
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.
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.
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.
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 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).
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)
Answer these questions to make sure you understand ELF structure:
What are the first 4 bytes (magic number) of every ELF file?
Which command shows the program headers (segments) of an ELF file?
The .text section contains:
What does the "RW" flag on a LOAD segment mean?
When you run ./vuln, which function does the kernel actually jump to first?
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.