Skip to content

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

ConceptPE (Windows)ELF (Linux, BSD, most IoT)
MagicMZ … PE\0\0\x7fELF
Load descriptionSection tableProgram headers (segments)
Link/analysis descriptionSection table (same)Section headers (optional at runtime)
ImportsImport directory + IAT.dynamic, dynamic symbols, relocations, GOT
Call stubsIndirect call through IATPLT stubs through the GOT
Loaderntdll loaderKernel + ld-linux (the interpreter)
Early codeTLS callbacks.init_array constructors
Architectures seen in malwarex86, x64, some ARM64x86-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:

OffsetFieldMeaning
0x00EI_MAG0–37f 45 4c 46 — \x7fELF
0x04EI_CLASS1 = 32-bit, 2 = 64-bit
0x05EI_DATA1 = little-endian, 2 = big-endian
0x06EI_VERSIONAlways 1
0x07EI_OSABIUsually 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, not main).
  • 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:

text
                 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, .symtab and 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 -l first. 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.
text
  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 libc

With 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. file reports statically linked, stripped; there is no PT_INTERP and 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 three PT_LOAD segments with no section headers, and high entropy. Headers are sometimes tampered with to break upx -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 / svc themselves; 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.

  1. 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;
    }
  2. 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
  3. 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 Type of the PIE build (DYN), the missing interpreter and dynamic section in the static build, and how much larger it is.

  4. 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-noshdr

    The program runs exactly as before, yet readelf -S finds no sections and objdump -d shows no code. The program headers are untouched.

  5. 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 puts inside 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_type DYN usually means a PIE executable, not a library; e_machine tells you which of many architectures an IoT sample targets.
  • Imports go through .dynamic, the PLT and the GOT; readelf -d is your import table and a GOT modified after startup deserves attention.
  • Constructors in .init_array and preloaded libraries run code before main — 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.