Leçon 2.5 · Formats binaires· 35 min
ELF for Malware Analysts
The ELF essentials for Linux malware: headers, sections versus segments, dynamic linking, and why stripped, static and header-less binaries still run.
Cette leçon n’est disponible qu’en anglais pour le moment.
Objectifs
- Read an ELF header and identify class, endianness, type, architecture and entry point
- Explain the difference between sections and segments and which one the kernel uses
- Follow a call through the PLT and GOT and describe lazy versus immediate binding
- Recognise static, stripped and section-less binaries and adapt your tooling
Windows dominates malware volume, but Linux is where much of the infrastructure lives: web servers, containers, cloud workloads, routers, cameras and every other embedded device. IoT botnets in the Mirai lineage, cryptominers dropped through exposed services, and long-lived implants on network appliances are all Linux binaries — and they all use the Executable and Linkable Format (ELF).
You already know the PE format, so this lesson focuses on what differs and on the properties of ELF that Linux malware exploits.
ELF versus PE at a glance
| Concept | PE (Windows) | ELF (Linux, BSD, most IoT) |
|---|---|---|
| Magic | MZ … PE\0\0 | \x7fELF |
| Load description | Section table | Program headers (segments) |
| Link/analysis description | Section table (same) | Section headers (optional at runtime) |
| Imports | Import directory + IAT | .dynamic, dynamic symbols, relocations, GOT |
| Call stubs | Indirect call through IAT | PLT stubs through the GOT |
| Loader | ntdll loader | Kernel + ld-linux (the interpreter) |
| Early code | TLS callbacks | .init_array constructors |
| Architectures seen in malware | x86, x64, some ARM64 | x86-64, ARM, MIPS, PowerPC, SPARC, SuperH… |
That last row matters. A single Mirai-style campaign may ship the same bot compiled for a dozen architectures, so the first thing you check is which CPU a sample targets.
The ELF header
The file starts with a fixed header (Elf64_Ehdr or Elf32_Ehdr). Its first 16
bytes, e_ident, are architecture-neutral so any tool can decode the rest:
| Offset | Field | Meaning |
|---|---|---|
| 0x00 | EI_MAG0–3 | 7f 45 4c 46 — \x7fELF |
| 0x04 | EI_CLASS | 1 = 32-bit, 2 = 64-bit |
| 0x05 | EI_DATA | 1 = little-endian, 2 = big-endian |
| 0x06 | EI_VERSION | Always 1 |
| 0x07 | EI_OSABI | Usually 0 (System V) regardless of OS |
After e_ident come the fields you read during triage:
e_type—ET_EXEC(2) for a fixed-address executable,ET_DYN(3) for shared objects and position-independent executables (PIE). Modern distributions build PIE by default, so "DYN" does not mean "library".e_machine— the target CPU: 3 = x86, 62 = x86-64, 40 = ARM, 183 = AArch64, 8 = MIPS.e_entry— the virtual address of the first instruction (_start, notmain).e_phoff,e_phnum— where the program header table is, and how many entries it has.e_shoff,e_shnum,e_shstrndx— the same for the section header table and the index of the section holding section names.
file decodes all of this in one line; readelf -h shows every field.
Sections versus segments
This is the most important idea in the lesson. ELF describes the same bytes two ways:
File on disk
┌──────────────────────────────────────────┐
│ ELF header │
│ Program header table ──► segments │ used by the kernel & loader
├──────────────────────────────────────────┤
│ .interp .dynsym .dynstr .rela.plt ... │ ┐
│ .init .plt .text .fini │ │ PT_LOAD (R-X)
│ .rodata .eh_frame │ ┘ PT_LOAD (R--)
│ .init_array .dynamic .got .data .bss │ ── PT_LOAD (RW-)
│ .symtab .strtab .comment ... │ (not loaded)
├──────────────────────────────────────────┤
│ Section header table ──► sections │ used by linkers & analysis tools
└──────────────────────────────────────────┘- Segments (program headers) tell the kernel and dynamic loader what to map,
where, and with which permissions. Key types:
PT_LOAD(map this range),PT_INTERP(path of the dynamic loader),PT_DYNAMIC(location of the dynamic linking table),PT_GNU_STACK(stack executability). - Sections (section headers) are a finer-grained, named view for the linker
and for tools:
.text,.rodata,.data,.bss,.symtaband so on.
The kernel never reads section headers. They are optional for execution.
Malware authors exploit this by stripping, zeroing or corrupting the section
header table: the binary runs normally, while objdump, nm and older
disassemblers that rely on sections see nothing. Robust tools (Ghidra, IDA,
readelf -l) fall back to segments.
Tip: When a sample confuses your tools, run
readelf -lfirst. Program headers are the ground truth of what actually gets loaded.
Dynamic linking: .dynamic, PLT and GOT
A dynamically linked executable has a PT_INTERP segment naming the loader
(/lib64/ld-linux-x86-64.so.2) and a .dynamic section listing what it needs:
DT_NEEDED entries for each library, pointers to the dynamic symbol and string
tables, and relocation tables. readelf -d prints it — the ELF equivalent of
reading a PE import directory.
Calls to library functions go through two tables:
- PLT (Procedure Linkage Table) — small code stubs in an executable section, one per imported function.
- GOT (Global Offset Table) — a writable table of addresses. Each PLT stub jumps indirectly through its GOT slot.
call puts@plt ──► puts@plt: jmp *[GOT slot for puts]
│
first call (lazy): └──► resolver stub ──► ld-linux finds puts,
writes its address
into the GOT slot
later calls: ──► straight to puts in libcWith lazy binding, each GOT slot initially points back into the PLT so the
first call triggers the resolver. With immediate binding (-z now, often
combined with full RELRO) everything is resolved at startup and the GOT is then
made read-only. Many current distributions default to immediate binding for
hardened binaries. The GOT and PLT reference page covers
the instruction-level details.
For analysts this means imported functions appear as puts@plt style names in
disassembly, and a GOT that is written to after startup is suspicious: GOT
overwriting is a classic hooking and exploitation technique.
Early code: .init_array
Like PE TLS callbacks, ELF offers ways to run code before main: function
pointers in .init_array (what __attribute__((constructor)) produces), the
legacy .init section, and the DT_PREINIT_ARRAY of executables. Shared
libraries run their constructors when loaded — which is why LD_PRELOAD and
/etc/ld.so.preload are popular persistence and hooking mechanisms in Linux
malware. Always check .init_array entries when you are hunting for the first
malicious instruction.
The shapes Linux malware takes
Real Linux samples tend to fall into a few recognisable shapes:
- Statically linked and stripped. Common for IoT bots: no dependency on the
target's libc version, and no symbols.
filereportsstatically linked, stripped; there is noPT_INTERPand no.dynamic. Thousands of libc functions are embedded and unnamed — use signature matching (Ghidra's Function ID, IDA FLIRT) to label them. - UPX-packed. Very common. Look for
UPX!markers, two or threePT_LOADsegments with no section headers, and high entropy. Headers are sometimes tampered with to breakupx -d; see UPX packing. - Section-less or corrupted headers. As above, the program still runs.
- Direct system calls. Static bots often wrap
syscall/int 0x80/svcthemselves; see the syscall reference. - Anti-debugging via
ptrace(PTRACE_TRACEME)— a process can only be traced once. See ptrace anti-debug.
Lab: sections are optional
Use a Linux VM or container (x86-64). You need gcc, binutils, gdb and
Python 3.
-
Create
beacon.c:c #include <stdio.h> #include <unistd.h> __attribute__((constructor)) static void early(void) { puts("[init_array] runs before main"); } int main(void) { puts("checking in with update.example.com"); printf("pid=%d\n", getpid()); return 0; } -
Build three variants:
bash gcc -o beacon-pie beacon.c # default: PIE, dynamic gcc -no-pie -Wl,-z,lazy -o beacon-dyn beacon.c # fixed address, lazy binding gcc -static -s -o beacon-static-stripped beacon.c # like many IoT bots -
Compare them:
bash file beacon-* readelf -h beacon-pie | grep -E 'Type|Machine|Entry' readelf -l beacon-dyn | grep -E 'LOAD|INTERP|DYNAMIC' readelf -d beacon-dyn | grep NEEDED readelf -d beacon-static-stripped ls -l beacon-*Note the
Typeof the PIE build (DYN), the missing interpreter and dynamic section in the static build, and how much larger it is. -
Remove the section headers from a copy with
noshdr.py:python import struct, sys src, dst = sys.argv[1], sys.argv[2] data = bytearray(open(src, "rb").read()) assert data[:4] == b"\x7fELF" and data[4] == 2, "expected 64-bit ELF" end = "<" if data[5] == 1 else ">" (e_shoff,) = struct.unpack_from(end + "Q", data, 0x28) e_shentsize, e_shnum = struct.unpack_from(end + "HH", data, 0x3A) # The section header table normally sits at the end of the file: cut it off, # then zero e_shoff, e_shnum and e_shstrndx. del data[e_shoff:e_shoff + e_shentsize * e_shnum] struct.pack_into(end + "Q", data, 0x28, 0) struct.pack_into(end + "HH", data, 0x3C, 0, 0) open(dst, "wb").write(data)bash python3 noshdr.py beacon-dyn beacon-noshdr && chmod +x beacon-noshdr ./beacon-noshdr readelf -S beacon-noshdr objdump -d beacon-noshdr | head readelf -l beacon-noshdrThe program runs exactly as before, yet
readelf -Sfinds no sections andobjdump -dshows no code. The program headers are untouched. -
Watch lazy binding in
beacon-dyn:bash gdb -q ./beacon-dyn (gdb) break main (gdb) run (gdb) disassemble 'puts@plt' (gdb) x/gx <address used by the first jmp> (gdb) next (gdb) next (gdb) x/gx <same address>Before the first call the GOT slot points back into the PLT; afterwards it holds the address of
putsinside libc.
Questions to answer: Which output line shows the constructor ran before
main? Where would you look for it in a stripped binary? Why does the kernel not
care that the section headers are gone? In which variant does strings still
find update.example.com?
Key takeaways
- ELF describes a file twice: segments for loading, sections for tools. Only segments are needed to run, so section headers may be missing or false.
e_typeDYN usually means a PIE executable, not a library;e_machinetells you which of many architectures an IoT sample targets.- Imports go through
.dynamic, the PLT and the GOT;readelf -dis your import table and a GOT modified after startup deserves attention. - Constructors in
.init_arrayand preloaded libraries run code beforemain— check them when hunting for the first malicious instruction. - Expect statically linked, stripped, UPX-packed and header-damaged samples, and pick tools that trust program headers.