Binary Formats & Tools — Reading ELF and PE Like a Map
What you'll learn
By the end of this lesson you will be able to:
- Read the structure of an ELF file by hand and with
readelf: the header, the sections (the linker's view) and the segments (the loader's view) - Explain what symbols, imports, exports and relocations tell you about a program, using
nm,readelfandobjdump - Find your way through a Windows PE file: the MZ header, the PE signature, the COFF and optional headers, the section table, and convert an RVA into a file offset
- Pick the right tool for each question, from
fileup to a disassembler and decompiler such as Ghidra
A book is more than its pages 📚
Pick up a library book. Before chapter one there is a cover with the title, then a table of contents that says where every chapter starts. At the back you find an index of names and a bibliography of the other books it relies on. The binder may also have sewn the pages into a few thick bundles that are easier to handle than loose sheets.
An executable is built the same way. The header is the cover. The section table is the table of contents. The symbol table is the index, the imports are the bibliography, and the segments are the sewn bundles that the loader actually carries into memory. In the first lesson you read the cover; in this one you read the rest of the book.
As in every lesson of this track, the files here are a tiny program compiled for the lesson or byte dumps written by hand. Apply these skills only to files you are allowed to analyze.
ELF: one file, two views
ELF is defined by the System V ABI and documented on Linux in the elf(5) manual page. The same file is described twice, for two different readers:
offset 0 ┌──────────────────────────┐
│ ELF header (64 bytes) │ says where the two tables are
e_phoff ├──────────────────────────┤
│ program header table │ segments: what the LOADER maps
├──────────────────────────┤
│ .interp .dynsym .rela.* │ ┐
│ .text .plt │ │ bytes covered by LOAD segments
│ .rodata .eh_frame │ │
│ .data .got │ ┘
│ .comment .symtab .strtab │ for tools only, never mapped
e_shoff ├──────────────────────────┤
│ section header table │ sections: what LINKERS and analysts use
└──────────────────────────┘
- A section is a unit for the linker: a name, a type, flags, an address, a file offset and a size. Common types are
PROGBITS(bytes stored in the file),NOBITS(.bss: no bytes in the file),SYMTAB/DYNSYM,STRTAB,RELAandDYNAMIC. Flags: A (alloc: occupies memory at run time), W (write), X (execute). - A segment (one program header) is a unit for the loader: a type (
LOAD,INTERP,DYNAMIC,GNU_STACK,GNU_RELRO...), a file offset, a virtual address, a size in the file (p_filesz), a size in memory (p_memsz) and flags R W E. One segment usually holds several sections that need the same permissions. Whenp_memszis larger thanp_filesz, the loader fills the difference with zeros: that is where.bsslives.
The loader needs only the program headers. The section table is optional for running a program, which is why a stripped, or deliberately damaged, section table does not stop a binary from working, and why you should never trust section names blindly.
Here is real output for this lesson's small program (a 16064-byte PIE built by gcc 13 on Ubuntu 24.04), shortened:
$ readelf -h hello
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 14080 (bytes into file)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
Section names are not stored in the section headers themselves but as offsets into a string table; the last field says which section holds those names (.shstrtab, number 30). Note that 14080 + 31 × 64 = 16064, the size of the file: the section header table sits at the very end.
$ readelf -lW hello (the four LOAD segments and GNU_STACK)
Type Offset VirtAddr FileSiz MemSiz Flg
LOAD 0x000000 0x0000000000000000 0x000628 0x000628 R
LOAD 0x001000 0x0000000000001000 0x00018d 0x00018d R E
LOAD 0x002000 0x0000000000002000 0x000100 0x000100 R
LOAD 0x002db8 0x0000000000003db8 0x00025c 0x000268 RW
GNU_STACK 0x000000 0x0000000000000000 0x000000 0x000000 RW
Section to Segment mapping: (readelf's numbers: 00 is PHDR, 01 is INTERP)
02 .interp .note.gnu.property ... .dynsym .dynstr ... .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
How to read it: segment 03 is the only executable one and holds all the code. Segment 05 has a FileSiz of 0x25c but a MemSiz of 0x268; the extra 0xc bytes are .bss, zero-filled at load time. GNU_STACK with RW and no E means the stack is not executable; an RWE stack would be a red flag. The type DYN with addresses starting near 0 means the file is position-independent: the loader chooses a random base (ASLR) and every address above is an offset from it.
struct reads a binary layout from a format string: < is little-endian, H 2 bytes, I 4 bytes, Q 8 bytes. Unpacking starts at offset 16, right after e_ident. Run it and check that the numbers match the readelf -h output above, including the 16064-byte file size. Then change e_phnum and predict where the program header table ends.
import struct
# The 64-byte ELF header of this lesson's program, rebuilt from its readelf -h output.
ident = b"\x7fELF" + bytes([2, 1, 1, 0]) + bytes(8) # magic, 64-bit, little-endian, version, padding
header = ident + struct.pack(
"<HHIQQQIHHHHHH",
3, 62, 1, # e_type = DYN, e_machine = x86-64, e_version
0x1060, # e_entry
64, 14080, # e_phoff, e_shoff
0, # e_flags
64, 56, 13, # e_ehsize, e_phentsize, e_phnum
64, 31, 30, # e_shentsize, e_shnum, e_shstrndx
)
names = ("e_type e_machine e_version e_entry e_phoff e_shoff e_flags "
"e_ehsize e_phentsize e_phnum e_shentsize e_shnum e_shstrndx").split()
h = dict(zip(names, struct.unpack_from("<HHIQQQIHHHHHH", header, 16)))
print("header size:", len(header), "bytes")
print("entry point:", hex(h["e_entry"]))
ph_end = h["e_phoff"] + h["e_phnum"] * h["e_phentsize"]
sh_end = h["e_shoff"] + h["e_shnum"] * h["e_shentsize"]
print(f"program header table: bytes {h['e_phoff']}..{ph_end - 1}")
print(f"section header table: bytes {h['e_shoff']}..{sh_end - 1}")
print("so the file is at least", sh_end, "bytes long")Lab exercise 1
PythonGraded in Code LabMap sections to segments
readelf -l ends with a "Section to Segment mapping". Rebuild it from the two tables, and flag dangerous permissions while you are at it.
Input
- a line
segments N, thenNlinesTYPE VADDR MEMSZ FLAGS(FLAGS is any of the lettersR,W,Ewritten together, such asREorRW) - a line
sections M, thenMlinesNAME ADDR SIZE SFLAGS(SFLAGS as inreadelf -S, such asA,AX,WA,MS; a single-means no flags)
Numbers are hex with a 0x prefix.
Rules
- A section belongs to a segment when its flags contain
A, its size is not 0, and[ADDR, ADDR+SIZE)lies completely inside[VADDR, VADDR+MEMSZ). A section may belong to several segments. - For each segment, in input order, print
<index as two digits> <TYPE> <perms>: <its sections in input order>, or(none). Perms arer,w,xor-(forR,W,E). - Then print
not loaded:and every section of non-zero size that is in no LOAD segment, or(none). - Last, for every segment whose flags contain both
WandE, printwarning: <index> <TYPE> is writable and executable, with the index as two digits again.
Example
Input:
segments 3
LOAD 0x1000 0x200 RE
LOAD 0x3000 0x100 RW
GNU_STACK 0x0 0x0 RW
sections 4
.text 0x1000 0x1f0 AX
.data 0x3000 0x20 WA
.bss 0x3020 0x10 WA
.comment 0x0 0x2d MS
Output:
00 LOAD r-x: .text
01 LOAD rw-: .data .bss
02 GNU_STACK rw-: (none)
not loaded: .comment
- The worked example
- The lesson's real PIE: sections in several segments at once
- One writable and executable segment, and an executable stack
- Boundaries: an exact fit, one byte too many, and a zero-size section
- An object file (.o) has no program headers at all
- Only LOAD segments are loaded: a section covered by nothing but a NOTE segment
Example run
- Input
segments 3 LOAD 0x1000 0x200 RE LOAD 0x3000 0x100 RW GNU_STACK 0x0 0x0 RW sections 4 .text 0x1000 0x1f0 AX .data 0x3000 0x20 WA .bss 0x3020 0x10 WA .comment 0x0 0x2d MS
- Expected output
00 LOAD r-x: .text 01 LOAD rw-: .data .bss 02 GNU_STACK rw-: (none) not loaded: .comment
import sys
rows = [line.split() for line in sys.stdin.read().split("\n") if line.strip()]
pos = 0
segments = [] # (type, vaddr, memsz, flags)
count = int(rows[pos][1]); pos += 1
for _ in range(count):
kind, vaddr, memsz, flags = rows[pos]; pos += 1
segments.append((kind, int(vaddr, 16), int(memsz, 16), flags))
sections = [] # (name, addr, size, sflags)More lines in Code Lab: 11
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Symbols, imports and exports
A symbol is a name with a value (usually an address), a size, a type (function or object) and a binding (local, global or weak). An ELF executable can carry two symbol tables:
.symtab: every name the linker knew, includingstaticfunctions. It is only for tools, andstripremoves it.dynsym: only the names needed for dynamic linking, that is the functions the program imports from shared libraries and the ones a library exports. It is allocated memory and must survive stripping, or the program could not be linked at run time
nm prints symbols with a one-letter type; upper case means global, lower case local. Real output for our program, shortened:
$ nm hello
0000000000001149 T add3
0000000000004010 D counter
0000000000001152 T main
U puts@GLIBC_2.2.5
0000000000002000 R _IO_stdin_used
0000000000004018 B zeroed
0000000000001090 t deregister_tm_clones
w __gmon_start__
T/tcode in.text;D/dinitialized data;B/b.bss;R/rread-only dataUmeans undefined here: it lives in another file, so it is an import.wis a weak reference that may stay unresolved@GLIBC_2.2.5is a symbol version: the program needs theputsthat glibc's interface has offered since version 2.2.5
Now strip a copy. nm answers no symbols, yet nm -D still lists puts and __libc_start_main: imports cannot be stripped, which is why the import list is such a reliable clue in triage. Our own names add3 and main are gone, and Ghidra would show them as FUN_00101149-style labels.
A symbol is not proof that code is used. This build used -O1, and the compiler inlined add3 into main:
$ objdump -d -M intel --no-show-raw-insn hello (main, shortened)
115a: mov eax,DWORD PTR [rip+0x2eb0] # 4010 <counter>
1160: add eax,0x5 # add3(counter, 2) = counter + 2 + 3
1163: mov DWORD PTR [rip+0x2eaf],eax # 4018 <zeroed>
1170: call 1050 <puts@plt>
Exports go the other way: the functions a shared library offers to others. nm -D --defined-only libfoo.so or readelf --dyn-syms libfoo.so lists them, and a library exporting names such as compress_block and verify_signature tells a story before you read any code. C++ names arrive mangled (_ZN3Foo3barEv); nm -C or c++filt turns them back into Foo::bar().
Relocations: the loader's to-do list
When the linker writes a file it cannot know everything: where the program will be loaded (ASLR picks a random base) or where puts will sit inside libc. Every such hole is recorded as a relocation: "at this address, write this value". readelf -r lists them:
$ readelf -rW hello (shortened)
Relocation section '.rela.dyn' at offset 0x550 contains 8 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000003db8 0000000000000008 R_X86_64_RELATIVE 1140
0000000000003fd8 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
Relocation section '.rela.plt' at offset 0x610 contains 1 entry:
0000000000003fd0 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 puts@GLIBC_2.2.5 + 0
The three types you meet in almost every x86-64 executable (the formulas come from the x86-64 psABI, where B is the load base, S the symbol's address and A the addend):
R_X86_64_RELATIVEwrites B + A. Slot 0x3db8 is in.init_arrayand must hold the run-time address of the function at offset 0x1140, which only exists once the base is chosenR_X86_64_GLOB_DATwrites S into a.gotslot, for global data and function pointersR_X86_64_JUMP_SLOTwrites S into the.gotslot that the.pltstub ofputsjumps through:call 1050 <puts@plt>lands on that stub- (In object files you will also see
R_X86_64_64, S + A, and PC-relative types that the static linker resolves.)
Lazy or immediate binding. Traditionally JUMP_SLOT entries were resolved lazily, the first time each function is called. Linking with -z now (the dynamic flag BIND_NOW, which this Ubuntu build has by default: readelf -d shows FLAGS BIND_NOW) resolves everything at start-up, and RELRO then makes those GOT pages read-only, so a memory-corruption bug cannot redirect them. The GNU_RELRO segment marks exactly that range.
For an analyst, relocations are gold. In a stripped binary, the relocation that names puts tells you that slot 0x3fd0 means "puts", so every indirect call through it is a call to puts. Disassemblers use exactly this to print call puts@plt instead of a bare address.
Run it, then change base to another value, as if ASLR had chosen a different address: every RELATIVE result moves with it, while the imports depend only on where libc was loaded. __gmon_start__ is a weak reference (w in nm), so a missing definition becomes 0 instead of an error.
// A toy dynamic loader applying the relocations readelf printed for our program.
const base = 0x555555554000n; // GDB's usual base for a PIE when ASLR is off
const libc = { puts: 0x7ffff7c80e50n, __libc_start_main: 0x7ffff7c29dc0n }; // made-up libc addresses
const relocs = [
{ offset: 0x3db8n, type: "R_X86_64_RELATIVE", addend: 0x1140n },
{ offset: 0x3dc0n, type: "R_X86_64_RELATIVE", addend: 0x1100n },
{ offset: 0x3fd8n, type: "R_X86_64_GLOB_DAT", symbol: "__libc_start_main" },
{ offset: 0x3fe8n, type: "R_X86_64_GLOB_DAT", symbol: "__gmon_start__" },
{ offset: 0x3fd0n, type: "R_X86_64_JUMP_SLOT", symbol: "puts" },
];
const hex = (v) => "0x" + v.toString(16);
for (const r of relocs) {
const slot = base + r.offset; // the slot itself moves with the base
const value = r.type === "R_X86_64_RELATIVE" ? base + r.addend : libc[r.symbol];
if (value === undefined) {
console.log(hex(slot), r.type.padEnd(19), "<- 0 (weak symbol, not found)");
} else {
console.log(hex(slot), r.type.padEnd(19), "<-", hex(value), r.symbol ?? "");
}
}PE: the Windows container
Windows programs (.exe, .dll, .sys) use the Portable Executable format, specified by Microsoft in the PE Format documentation. It descends from COFF and keeps an MS-DOS header at the front for compatibility.
offset 0 ┌─────────────────────────────┐
│ DOS header "MZ" │ e_lfanew (4 bytes at 0x3C) = offset of the PE signature
│ DOS stub │ prints "This program cannot be run in DOS mode."
e_lfanew ├─────────────────────────────┤
│ "PE\0\0" signature │
│ COFF file header (20 bytes) │ Machine, NumberOfSections, TimeDateStamp,
│ │ SizeOfOptionalHeader, Characteristics
│ optional header │ Magic, AddressOfEntryPoint, ImageBase, alignments,
│ │ SizeOfHeaders, data directories
├─────────────────────────────┤
│ section table (40 B each) │ Name, VirtualSize, VirtualAddress,
│ │ SizeOfRawData, PointerToRawData, Characteristics
├─────────────────────────────┤
│ .text .rdata .data .rsrc │
│ .reloc ... │
└─────────────────────────────┘
- COFF file header.
Machineis 0x14c for x86, 0x8664 for x64 and 0xaa64 for ARM64.Characteristicsis a bit set: 0x0002 means an executable image, 0x2000 a DLL.TimeDateStampis meant to be the link time, but deterministic builds may store a hash there, so do not treat it as evidence on its own. - Optional header (required for images despite the name).
Magicis 0x10b for PE32 (32-bit) and 0x20b for PE32+ (64-bit).AddressOfEntryPointis at offset 16 of this header;ImageBaseis at offset 28 (4 bytes) in PE32 and at offset 24 (8 bytes) in PE32+. Microsoft's linker defaults to 0x400000 for 32-bit EXEs, 0x10000000 for 32-bit DLLs, 0x140000000 for 64-bit EXEs and 0x180000000 for 64-bit DLLs. - RVA (relative virtual address). Almost every address inside a PE is an offset from the image base after loading: VA = ImageBase + RVA. To find the bytes of an RVA in the file you need the section table: file offset = PointerToRawData + (RVA − VirtualAddress). Sections are aligned to
FileAlignmenton disk (default 0x200) and toSectionAlignmentin memory (default: the page size, 0x1000 on x86), which is why offsets and RVAs differ. - Data directories. Sixteen (RVA, size) pairs at the end of the optional header point to the export table, import table, resources, base relocations, debug data, the Import Address Table (IAT), the .NET header and more.
- Imports. For every DLL (
KERNEL32.dll,WS2_32.dll...) the import table lists functions by name or by ordinal number. At load time the loader writes their real addresses into the IAT, and code calls through it (call qword ptr [rip+...]). The IAT is PE's equivalent of the GOT. - Exports. A DLL's export directory lists the names and ordinals it offers.
- Base relocations (
.reloc). The places to patch when the image is not loaded at its preferredImageBase; with ASLR (theDYNAMIC_BASEflag) that is the normal case.
On Linux you can read PE files with objdump -x program.exe (binutils understands PE), Python's pefile library, rabin2 -I -i -E from radare2, or Ghidra. None of these runs the file.
Lab exercise 2
CGraded in Code LabRead a PE header by hand
Write the first step of every PE parser: follow e_lfanew to the PE header and print the key facts. PE files are always little-endian.
Input: one or more files, each given as its first bytes in whitespace-separated hex (any number per line). A line === separates two files. The starter code already reads the files one by one, calls report() for each, and prints === between two reports: you write report().
Layout
- bytes 0-1:
4d 5a("MZ"); bytes 0x3c-0x3f:e_lfanew, a 32-bit offset - at
e_lfanew: the signature50 45 00 00("PE\0\0") - right after it, the 20-byte COFF header:
Machine(2 bytes, offset 0),NumberOfSections(2, offset 2),Characteristics(2, offset 18) - right after that, the optional header:
Magic(2 bytes, offset 0),AddressOfEntryPoint(4, offset 16), andImageBase: 4 bytes at offset 28 for PE32 (0x10b) or 8 bytes at offset 24 for PE32+ (0x20b)
Output: for each file, seven lines:
format: PE32 | PE32+
machine: x86 | x64 | ARM64 | other (0x<hex>) (0x14c, 0x8664, 0xaa64)
sections: <decimal>
kind: DLL | EXE | not executable (bit 0x2000 set, else bit 0x0002 set, else neither)
entry RVA: 0x<hex>
image base: 0x<hex>
entry VA: 0x<hex> (image base + entry RVA)
Hex is lowercase without padding. Errors: if the file does not start with "MZ", print not an MZ file. If the signature at e_lfanew is not "PE\0\0", print MZ file without a PE header. If a needed byte is missing (the input must reach 32 bytes into the optional header) or Magic is neither value, print truncated or corrupt PE header.
Example (a 64-bit EXE, e_lfanew = 0x40)
4d 5a 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 00
50 45 00 00 64 86 03 00 00 00 00 00 00 00 00 00
00 00 00 00 f0 00 22 00 0b 02 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 14 00 00 00 00 00 00
00 00 00 40 01 00 00 00
Output:
format: PE32+
machine: x64
sections: 3
kind: EXE
entry RVA: 0x1400
image base: 0x140000000
entry VA: 0x140001400
(Real files have a DOS stub between the DOS header and the PE signature, so e_lfanew is usually larger.)
- The worked example: a 64-bit EXE
- A 32-bit DLL: ImageBase moves to offset 28
- An ARM64 EXE whose PE header starts at 0x80
- Not PE files: an old 16-bit NE file (MZ, but no PE signature), then an ELF file
- Another machine (0x1c4, ARM Thumb-2) whose linker did not set the executable bit
- Damaged headers: cut off inside the optional header, then an unknown Magic (0x107)
Example run
- Input
4d 5a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 00 00 00 50 45 00 00 64 86 03 00 00 00 00 00 00 00 00 00 00 00 00 00 f0 00 22 00 0b 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 00 00 00 00 00 00 40 01 00 00 00
- Expected output
format: PE32+ machine: x64 sections: 3 kind: EXE entry RVA: 0x1400 image base: 0x140000000 entry VA: 0x140001400
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define MAX 4096
static unsigned char data[MAX];
static size_t len = 0;
/* Little-endian readers: PE files are always little-endian. */
static uint32_t u16(size_t off) { return data[off] | (data[off + 1] << 8); }
static uint32_t u32(size_t off) { return u16(off) | ((uint32_t)u16(off + 2) << 16); }More lines in Code Lab: 27
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Lab exercise 3
GoGraded in Code LabFrom RVA to file offset
A string reference in a disassembler says "RVA 0x3100", but you want to patch or extract the bytes from the file on disk. Convert RVAs to file offsets using the section table: the core calculation behind pefile's get_offset_from_rva (real tools also round some fields to the file alignment, which this lab leaves out).
Input
headers H:SizeOfHeaders. RVAs belowHare inside the headers, which are mapped 1:1 (offset = RVA)sections N, thenNlines:NAME VirtualAddress VirtualSize PointerToRawData SizeOfRawDataqueries Q, thenQRVAs, one per line
All numbers are hex with a 0x prefix.
Rules
- A section covers the RVAs
[VirtualAddress, VirtualAddress + VirtualSize). IfVirtualSizeis 0 (some linkers leave it empty) useSizeOfRawDatainstead. - Inside a section,
delta = RVA - VirtualAddress. Ifdelta < SizeOfRawDatathe byte is in the file atPointerToRawData + delta. Otherwise it exists only in memory (the loader zero-fills it, like.bss).
Output: one line per query, numbers in lowercase hex with 0x:
<rva> -> headers at file offset <rva>
<rva> -> <name>+<delta> at file offset <offset>
<rva> -> <name>+<delta> not in the file (zero-filled at load)
<rva> -> not in any section
Example
Input:
headers 0x400
sections 3
.text 0x1000 0x1a2c 0x400 0x1c00
.rdata 0x3000 0x7f0 0x2000 0x800
.data 0x4000 0x2000 0x2800 0x200
queries 4
0x1010
0x3100
0x4500
0x9000
Output:
0x1010 -> .text+0x10 at file offset 0x410
0x3100 -> .rdata+0x100 at file offset 0x2100
0x4500 -> .data+0x500 not in the file (zero-filled at load)
0x9000 -> not in any section
- The worked example
- Headers and the edges of a section
- A section with no bytes in the file
- VirtualSize 0: fall back to SizeOfRawData
- A larger image, with upper-case hex in the queries
Example run
- Input
headers 0x400 sections 3 .text 0x1000 0x1a2c 0x400 0x1c00 .rdata 0x3000 0x7f0 0x2000 0x800 .data 0x4000 0x2000 0x2800 0x200 queries 4 0x1010 0x3100 0x4500 0x9000
- Expected output
0x1010 -> .text+0x10 at file offset 0x410 0x3100 -> .rdata+0x100 at file offset 0x2100 0x4500 -> .data+0x500 not in the file (zero-filled at load) 0x9000 -> not in any section
package main
import (
"bufio"
"fmt"
"os"
"strconv"
)
type section struct {
name string
rva, vsize, rawOff, rawSize uint64More lines in Code Lab: 38
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Your toolbelt: which tool answers which question
- What is it?
filereads the magic bytes and the header. - How is it built?
readelf -h -l -Sfor ELF;objdump -xfor ELF and PE.readelfparses ELF directly, whileobjdumpgoes through the BFD library, which understands many formats. - What does it need and offer?
readelf -d(look forNEEDED),nm -D,readelf --dyn-syms; for PE the import and export directories (objdump -x, pefile,rabin2 -i/-E). - What does the loader patch?
readelf -r, and for PE the base relocation directory. - What does the code do?
objdump -d -M intelfor a quick look, then a disassembler and decompiler: Ghidra (free, released as open source by the NSA in 2019), radare2, Cutter (the graphical interface of Rizin, a fork of radare2), IDA or Binary Ninja.
A first session in Ghidra: create a project, import the file (it recognizes ELF or PE and the CPU), accept the auto-analysis, then use the Symbol Tree (Imports, Exports, Functions), the Defined Strings window, the Memory Map (it shows the segments and sections from this lesson, with their permissions) and the cross-references from the static-analysis lesson. Rename things as you understand them.
A practical order for an unknown ELF:
file sample # format, architecture, PIE, stripped?
readelf -h sample # entry point, table positions
readelf -lW sample # segments and permissions: anything RWE?
readelf -SW sample # sections: odd names, huge or high-entropy sections?
readelf -d sample | grep NEEDED # libraries
nm -D sample # imports and exports
readelf -rW sample # which slots get which symbols
objdump -d -M intel sample | less
None of these commands executes the file. The exception to remember from the first lesson is ldd, which may run the program's loader: prefer readelf -d. Unusual structure is itself a finding: a writable and executable segment, a section table that disagrees with the segments, an entry point outside .text, or almost no imports in a large file (a common sign of packing) are all worth a note.
Question 1 of 5
Which table does the loader need in order to run an ELF program?
Question 2 of 5
A LOAD segment has FileSiz 0x25c and MemSiz 0x268. What happens to the extra 0xc bytes?
Question 3 of 5
After `strip`, `nm hello` prints "no symbols" but `nm -D hello` still lists puts. Why?
Question 4 of 5
A PIE is loaded at base 0x555555554000. What does an R_X86_64_RELATIVE relocation with addend 0x1140 write?
Question 5 of 5
A PE's .text section has VirtualAddress 0x1000 and PointerToRawData 0x400. At which file offset are the bytes of RVA 0x1234?
Summary and what to practice next
- An ELF file has two views: sections for linkers and analysts, segments for the loader; only the segments are needed to run it
p_memsz > p_fileszmeans zero-filled memory (.bss); segment permissions matter, and writable plus executable is a red flag- Imports live in
.dynsymand survive stripping; exports are what a library offers; relocations say which slot receives which address (RELATIVE = base + addend) - A PE starts with MZ,
e_lfanewpoints to "PE\0\0", then the COFF header, the optional header (PE32 or PE32+) and the section table; convert an RVA with PointerToRawData + (RVA − VirtualAddress) - Use
file,readelf,nm,objdumpfirst, then a disassembler such as Ghidra; none of them runs the file
Practice next: compile a small C program twice, once with -O0 and once with -O2 -s (stripped), and compare readelf -lW, nm and nm -D for both. Build a tiny shared library with one exported function and find it with nm -D --defined-only. If you have a Windows SDK sample or an open-source Windows program you are allowed to study, load it with pefile and walk its import table. Next: how defenders use all of this to study malicious software safely.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2CNot passed yet Try in Code Lab
- Lab exercise 3GoNot passed yet Try in Code Lab