Safe Penetration Testing Labs · intermediate · ~25 min

ELF header — the binary's calling card

## What you will learn - Recognise an ELF file from its 4 magic bytes (`0x7F 'E' 'L' 'F'`) and explain why every Linux binary starts with them. - Read the `e_ident` block to determine word size (32- vs 64-bit) and byte order (little- vs big-endian) *before* parsing anything else. - Decode the key fields of an `Elf64_Ehdr`: `e_type`, `e_machine`, `e_entry`, `e_phoff`, and `e_shoff`. - Build the values of multi-byte fields from raw bytes by hand, respecting the binary's declared endianness — applying what you learned in *Bitwise operations* and *Endianness and byte order*. - Write a safe, **read-only** parser that validates the magic, refuses short reads, and never trusts an in-file offset without bounds-checking it. - Cross-check your parser against `readelf -h` and `file(1)` so you know it is correct.

Overview

ELF stands for Executable and Linkable Format. It is the on-disk shape of nearly everything executable on Linux: programs (/bin/ls), shared libraries (libc.so), object files (.o), and even crash dumps (core files). When the kernel runs a program, the very first thing it reads is the ELF header. When a forensic analyst finds a mysterious file dropped on a server, the first thing they read is the ELF header too. It is the binary's calling card — a fixed, well-documented block of bytes that announces what the file is and how the rest of it is organised.

The ELF header sits at the very start of the file. On a 64-bit system it is exactly 64 bytes long (Elf64_Ehdr); on a 32-bit system it is 52 bytes (Elf32_Ehdr). Those bytes tell you three things you cannot do without:

  • The machine type — which CPU the binary targets (x86-64, ARM64, etc.).
  • The entry-point address — the virtual address where execution begins.
  • The table offsets — where the program-header table (e_phoff) and section-header table (e_shoff) live, which is where the rest of the file's structure is described.

This lesson connects directly to your prerequisites. From Endianness and byte order you already know that a multi-byte integer can be stored low-byte-first (little-endian) or high-byte-first (big-endian); the ELF header literally has a byte that tells you which one this file uses, and you must honour it or every number you read will be garbage. From Bitwise operations you know how to combine individual bytes into a wider integer with shifts and OR — exactly the technique we use to read e_machine, e_entry, and the offsets by hand.

One discipline runs through the whole lesson: we read the header, we never modify it. Parsing untrusted binaries is a defensive, read-only activity. Treat every byte that came from the file as hostile until you have validated it.

Why it matters

The ELF header is the universal binary fingerprint, and reading it correctly is the foundation of two real jobs:

  • Defensive forensics and incident response. When you discover an unknown file on a host — a suspected dropper, a payload staged in /tmp, an artifact pulled from a memory image — the first question is always "what is this?" The header answers it: Is it even ELF? 32- or 64-bit? Which CPU? Is it a PIE executable, a shared object, or a core dump? You can answer all of this from the first 64 bytes without ever executing the file, which is critical: you must never run a suspicious sample on a production host.

  • Triage and tooling. Tools like file(1), readelf, objdump, and antivirus engines all begin by parsing this header. Package managers verify the architecture of an executable before installing it. If your parser disagrees with readelf, you have learned something about your bug or about a deliberately malformed file — malware sometimes corrupts header fields to confuse naive parsers.

Getting this wrong has security consequences. A parser that blindly trusts e_phoff or e_shoff and seeks to that offset can be steered out of bounds by a crafted file, turning a 'safe' analysis tool into a crash or a memory-disclosure bug. Robust header parsing is itself a security skill.

Core concepts

We will teach each field as its own concept. Throughout, remember the golden rule: validate before you trust.

1. Magic bytes — "is this even ELF?"

Definition. The first four bytes of every ELF file are fixed: 0x7F, 'E' (0x45), 'L' (0x4C), 'F' (0x46).

Why it works this way. A leading non-printable byte (0x7F) followed by the ASCII letters ELF is a magic number: a signature unlikely to appear by accident, easy to test for, and human-readable in a hex dump. Many formats use this trick — MZ for Windows PE, \x89PNG for PNG, %PDF for PDF.

How to use it. Read at least 4 bytes, compare with memcmp, and reject anything that does not match. This is forensic triage step one.

Pitfall. Comparing as a C string. The first byte is 0x7F, not a printable character, and the buffer is binary — never use strcmp/strncmp on it, and never assume a NUL terminator. Use memcmp with an explicit length.

Knowledge check (concept): Why is memcmp(buf, "\x7f" "ELF", 4) correct here but strncmp(buf, "\x7fELF", 4) risky? (Hint: think about what strncmp does with embedded bytes vs. how the literal is encoded.)

2. e_ident — the identification block (bytes 0–15)

Definition. The first 16 bytes form e_ident. After the 4 magic bytes it records, by index:

  • e_ident[4] = EI_CLASS: 1 = ELFCLASS32, 2 = ELFCLASS64.
  • e_ident[5] = EI_DATA: 1 = ELFDATA2LSB (little-endian), 2 = ELFDATA2MSB (big-endian).
  • e_ident[6] = EI_VERSION: file version, 1 = current.
  • e_ident[7] = EI_OSABI: target OS ABI (0 = System V, 3 = Linux, etc.).
  • e_ident[8] = EI_ABIVERSION, then padding to byte 15.

Why it matters most. You must read e_ident first, because EI_CLASS tells you whether the rest of the header is 52 or 64 bytes, and EI_DATA tells you how to interpret every multi-byte field. Get these two single bytes wrong and everything after them is meaningless. They are single bytes, so they have no endianness of their own — that is deliberate, so a parser can read them before it knows the byte order.

Offset:  0    1    2    3    4      5     6        7       8           9 .. 15
Byte:   7F   45   4C   46  CLASS  DATA  VERSION  OSABI  ABIVERSION   padding
        \------magic------/  \---------- identity ----------/   \-- reserved --/
        is it ELF?           32/64?  LE/BE?  ...

When NOT to skip it. Never hardcode "64-bit little-endian" because that is what your laptop is. A captured sample may target ARM big-endian; honouring EI_CLASS/EI_DATA is what makes your tool a forensic tool rather than a toy.

Pitfall. Casting the buffer straight to Elf64_Ehdr before checking EI_CLASS. On a 32-bit binary the field offsets differ, so you read the wrong bytes.

Knowledge check (predict the output): A file begins with 7F 45 4C 46 02 01 01 00 .... What is its class, and is it little- or big-endian?

3. e_type — what kind of object is this?

Definition. A 16-bit field naming the file's role:

Value Name Meaning
1 ET_REL relocatable object file (.o)
2 ET_EXEC classic fixed-address executable
3 ET_DYN shared library or PIE executable
4 ET_CORE core dump

Forensic note. Modern Linux executables are almost always ET_DYN (PIE — Position-Independent Executable), the same value as a shared library. Distinguishing a PIE program from a .so needs more than e_type (e.g. the presence of an INTERP program header), but e_type is your first clue. Whether a binary is PIE affects how ASLR randomises it, which matters in exploit and mitigation analysis.

4. e_machine — which CPU?

Definition. A 16-bit field naming the target architecture. Common values:

  • 0x3E (62) = EM_X86_64
  • 0xB7 (183) = EM_AARCH64 (64-bit ARM)
  • 0x28 (40) = EM_ARM (32-bit ARM)
  • 0x03 (3) = EM_386 (32-bit x86)

Use. If e_machine does not match the host CPU, the binary cannot run natively here — a useful triage signal. Combined with EI_DATA, a wrong-endianness or wrong-machine file is immediately suspicious or simply foreign.

5. e_entry, e_phoff, e_shoff — addresses and offsets

Definition. On ELF64 these are 64-bit values. e_entry is the virtual address of the first instruction. e_phoff is the file offset of the program-header table (used at load time). e_shoff is the file offset of the section-header table (used by linkers and tools).

Critical distinction. e_entry is a virtual address (where the code lives in the process's memory) — do not seek to it in the file. e_phoff/e_shoff are file offsets (byte positions in the file) — you may seek to them, but only after bounds-checking against the file size.

FILE on disk                              PROCESS memory at runtime
+------------------+ 0                    +---------------------+
| ELF header (64B) |                      |  ...                |
+------------------+                      | code @ e_entry  <---+-- execution starts
| program headers  | <-- e_phoff          |  ...                |
+------------------+                      +---------------------+
| .text, .data ... |
+------------------+
| section headers  | <-- e_shoff
+------------------+

Pitfall (security-relevant). Trusting e_shoff blindly: lseek(fd, e_shoff, SEEK_SET) followed by a read can be driven past end-of-file by a malicious header, causing a short read your code mishandles, or — if you used it to index into a buffer — an out-of-bounds access. Always verify offset + size <= file_size.

Knowledge check (find the bug): lseek(fd, ehdr.e_shoff, SEEK_SET); read(fd, sh, sizeof(sh)); — what is missing, and how could a crafted file abuse it?

Authorization and ethics

Parsing ELF headers is a read-only, defensive activity and is safe to practise on your own files. The moment a binary is suspicious, treat it as live malware: analyse it only inside an isolated, authorised lab (a local VM or container you own), never on a production host, and never execute it. Do not download or handle real-world malware samples without explicit authorisation and proper containment.

Threat model (analysing an unknown ELF)
  Asset:          analyst workstation, its data, the wider network
  Entry point:    bytes of the untrusted file
  Trust boundary: ---- everything inside the file is UNTRUSTED ----
  Defences:       read-only parsing, bounds checks, no execution,
                  isolated VM/container, no network egress from the lab

Syntax notes

The system header <elf.h> provides ready-made ELF types and constants, so you rarely declare the structures yourself.

#include <elf.h>     /* Elf64_Ehdr, EI_CLASS, ELFCLASS64, EM_X86_64, ... */
#include <stdint.h>  /* uint16_t, uint64_t */

/* Selected fields of the 64-bit header (declared for you by <elf.h>): */
/* typedef struct {                                                   */
/*     unsigned char e_ident[16]; // magic + class/data/version/abi    */
/*     uint16_t      e_type;      // ET_EXEC, ET_DYN, ...              */
/*     uint16_t      e_machine;   // EM_X86_64 = 0x3E, ...             */
/*     uint32_t      e_version;                                       */
/*     uint64_t      e_entry;     // virtual address of entry point    */
/*     uint64_t      e_phoff;     // file offset: program headers      */
/*     uint64_t      e_shoff;     // file offset: section headers       */
/*     // ... e_flags, e_ehsize, e_phentsize, ... 24 more bytes ...    */
/* } Elf64_Ehdr;                                                       */

Two practical reminders:

  • The e_ident index constants (EI_CLASS, EI_DATA, EI_VERSION, EI_OSABI) and the value constants (ELFCLASS64, ELFDATA2LSB, EM_X86_64) all come from <elf.h> — prefer them over magic numbers.
  • To build a multi-byte field from raw bytes for a little-endian file, OR the bytes in with increasing shifts: lo | (hi << 8) for 16-bit, and so on up to 56 for 64-bit.

Lesson

Every Linux executable, shared library, and core dump begins with an ELF header.

The layout of those first bytes is fixed:

  • Bytes 0-3: the magic value \x7fELF.
  • Bytes 4-15: the identification bytes, giving class (32- or 64-bit), endianness, ABI, and version.
  • The remaining 48 bytes: a table that records what kind of file this is and where to find its sections.

This lesson is about reading the header only. We never modify it.

Code examples

/* elf_header_reader.c — read-only ELF header triage tool.
 * Build:  cc -std=c11 -Wall -Wextra -o elfhdr elf_header_reader.c
 * Run:    ./elfhdr /bin/ls
 *
 * We parse the e_ident block BY HAND (no struct cast) so the logic is
 * explicit and so it works regardless of the host's own byte order.
 * This program never modifies the file: it opens it read-only.
 */
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <elf.h>      /* EI_*, ELFCLASS*, ELFDATA*, EM_*, ET_* constants */

/* Read a 16-bit field from buf at off, honouring the file's byte order. */
static uint16_t rd16(const unsigned char *buf, size_t off, int little) {
    return little
        ? (uint16_t)(buf[off] | (buf[off + 1] << 8))
        : (uint16_t)((buf[off] << 8) | buf[off + 1]);
}

/* Read a 64-bit field from buf at off, honouring the file's byte order. */
static uint64_t rd64(const unsigned char *buf, size_t off, int little) {
    uint64_t v = 0;
    for (int i = 0; i < 8; i++) {
        unsigned shift = little ? (unsigned)(i * 8) : (unsigned)((7 - i) * 8);
        v |= (uint64_t)buf[off + i] << shift;
    }
    return v;
}

static const char *machine_name(uint16_t m) {
    switch (m) {
        case EM_X86_64:  return "x86-64";
        case EM_AARCH64: return "AArch64";
        case EM_ARM:     return "ARM";
        case EM_386:     return "x86";
        default:         return "unknown";
    }
}

static const char *type_name(uint16_t t) {
    switch (t) {
        case ET_REL:  return "REL (object file)";
        case ET_EXEC: return "EXEC (fixed-address executable)";
        case ET_DYN:  return "DYN (shared object / PIE)";
        case ET_CORE: return "CORE (core dump)";
        default:      return "unknown";
    }
}

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <file>\n", argv[0]);
        return 2;
    }

    int fd = open(argv[1], O_RDONLY);   /* read-only: we never write */
    if (fd < 0) { perror("open"); return 1; }

    unsigned char buf[64];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n < 64) {                       /* refuse short reads up front */
        fprintf(stderr, "not enough bytes for an ELF64 header (got %zd)\n", n);
        close(fd);
        return 1;
    }

    /* 1) Validate magic before trusting anything else. */
    if (memcmp(buf, "\x7f" "ELF", 4) != 0) {
        fprintf(stderr, "not an ELF file (bad magic)\n");
        close(fd);
        return 1;
    }

    /* 2) Read the single-byte identity fields (no endianness involved). */
    int klass = buf[EI_CLASS];          /* 1=32-bit, 2=64-bit */
    int data  = buf[EI_DATA];           /* 1=LE, 2=BE         */
    if (klass != ELFCLASS64) {
        fprintf(stderr, "this demo only parses ELF64 (class=%d)\n", klass);
        close(fd);
        return 1;
    }
    int little = (data == ELFDATA2LSB);

    /* 3) Now multi-byte fields, interpreted in the file's byte order. */
    uint16_t e_type    = rd16(buf, 16, little);
    uint16_t e_machine = rd16(buf, 18, little);
    uint64_t e_entry   = rd64(buf, 24, little);
    uint64_t e_phoff   = rd64(buf, 32, little);
    uint64_t e_shoff   = rd64(buf, 40, little);

    /* 4) Bounds-check the in-file offsets against the real file size
     *    before any caller would trust them to seek. */
    off_t size = lseek(fd, 0, SEEK_END);
    if (size < 0) { perror("lseek"); close(fd); return 1; }
    int phoff_ok = (e_phoff == 0) || (e_phoff < (uint64_t)size);
    int shoff_ok = (e_shoff == 0) || (e_shoff < (uint64_t)size);

    printf("Class:        ELF64\n");
    printf("Data:         %s\n", little ? "little-endian" : "big-endian");
    printf("Type:         %s\n", type_name(e_type));
    printf("Machine:      %s (0x%X)\n", machine_name(e_machine), e_machine);
    printf("Entry point:  0x%llx\n", (unsigned long long)e_entry);
    printf("Phdr offset:  %llu %s\n", (unsigned long long)e_phoff,
           phoff_ok ? "(in bounds)" : "(OUT OF BOUNDS - suspicious)");
    printf("Shdr offset:  %llu %s\n", (unsigned long long)e_shoff,
           shoff_ok ? "(in bounds)" : "(OUT OF BOUNDS - suspicious)");

    close(fd);                          /* release the descriptor */
    return 0;
}

What it does. It opens a file read-only, reads the first 64 bytes, refuses anything that is too short or lacks the ELF magic, decodes the identity bytes, then reads e_type, e_machine, e_entry, e_phoff, and e_shoff in the file's declared byte order, and finally bounds-checks the two offsets against the actual file size.

Expected output (run on a typical 64-bit Linux /bin/ls, a PIE binary):

Class:        ELF64
Data:         little-endian
Type:         DYN (shared object / PIE)
Machine:      x86-64 (0x3E)
Entry point:  0x6ab0
Phdr offset:  64 (in bounds)
Shdr offset:  140000 (in bounds)

The exact entry-point and section-header offset vary per binary; compare them against readelf -h /bin/ls.

Edge cases. A non-ELF file is rejected at the magic check. A 32-bit (ELFCLASS32) file is reported and skipped, because its field offsets differ — extending the tool to ELF32 means using the 52-byte layout. A truncated file (fewer than 64 bytes) is rejected before any field is read. A big-endian sample is handled correctly because rd16/rd64 honour data.

Line by line

We trace the program on a normal 64-bit little-endian PIE executable.

  1. Argument check. If exactly one path was not supplied, print usage and exit with code 2. Defensive interfaces fail loudly.
  2. open(..., O_RDONLY). We request read-only access. This is the single most important safety choice: the parser physically cannot modify the file.
  3. read(fd, buf, 64). We pull the first 64 bytes into a stack buffer. read returns the count actually read into n.
  4. Short-read guard (n < 64). If the file is shorter than a 64-bit header, we stop now. Continuing would read uninitialised stack bytes — undefined behaviour and a classic info-leak bug.
  5. Magic check (memcmp). We compare the first 4 bytes against 0x7F 'E' 'L' 'F'. Using memcmp (not a string function) is correct because the buffer is binary and the first byte is 0x7F.
  6. klass = buf[EI_CLASS] (byte 4). A single byte: 2 means ELFCLASS64. We require 64-bit here and bail otherwise. Single bytes have no endianness, which is exactly why we can read them before we know the byte order.
  7. data = buf[EI_DATA] (byte 5). 1 means little-endian; we set little = 1. Every multi-byte read below depends on this.
  8. rd16(buf, 16, little)e_type. Bytes 16–17. For little-endian that is buf[16] | (buf[17] << 8). For a PIE this yields 3 (ET_DYN).
  9. rd16(buf, 18, little)e_machine. Bytes 18–19. On x86-64 this is 0x3E.
  10. rd64(buf, 24, little)e_entry. Bytes 24–31 combined into a 64-bit value. For a PIE this is a small file-relative address such as 0x6ab0 (it is added to the load base at runtime).
  11. rd64(buf, 32, little) / rd64(buf, 40, little)e_phoff / e_shoff. The program- and section-header table file offsets. e_phoff is typically 64 (the headers immediately follow the ELF header).
  12. lseek(fd, 0, SEEK_END)size. We learn the true file size, then check that each non-zero offset is strictly less than it. A crafted file with e_shoff past EOF would be flagged "OUT OF BOUNDS - suspicious" instead of trusted.
  13. Print + close(fd). Report the fields, then release the descriptor. Always close what you open.
Step Source bytes Field Example value
5 0–3 magic 7F 45 4C 46 (valid)
6 4 EI_CLASS 2 → ELF64
7 5 EI_DATA 1 → little-endian
8 16–17 e_type 3 → ET_DYN
9 18–19 e_machine 0x3E → x86-64
10 24–31 e_entry 0x6ab0
11 32–39 e_phoff 64

Common mistakes

Mistake 1 — Assuming the host's byte order matches the binary's

Wrong:

uint16_t e_machine = *(uint16_t *)(buf + 18); /* uses HOST endianness */

On a little-endian laptop reading a big-endian ARM binary, this produces a byte-swapped, nonsense value. It also dereferences a possibly-misaligned pointer, which is undefined behaviour on some platforms.

Why wrong. The file declares its own byte order in e_ident[EI_DATA]. Your host's order is irrelevant to what the file means.

Corrected:

int little = (buf[EI_DATA] == ELFDATA2LSB);
uint16_t e_machine = rd16(buf, 18, little); /* honours the file */

Prevent/recognise it. If your parser works on local binaries but disagrees with readelf on a foreign-arch sample, suspect endianness.

Mistake 2 — Casting straight to Elf64_Ehdr without checking class

Wrong:

Elf64_Ehdr *h = (Elf64_Ehdr *)buf;       /* assumes 64-bit */
uint64_t entry = h->e_entry;

For a 32-bit (ELFCLASS32) file the layout is the 52-byte Elf32_Ehdr; the 64-bit field offsets are wrong, so entry reads the wrong bytes. The cast may also be unaligned.

Corrected: check buf[EI_CLASS] == ELFCLASS64 first, then use the matching layout (or parse by hand as in the lesson).

Mistake 3 — Using string functions on the magic

Wrong: if (strncmp(buf, "\x7fELF", 4) == 0) — the C escape \x7fELF is ambiguous (the compiler may read more hex digits into \x7f), and strncmp stops at a NUL. Binary data is not a string.

Corrected: if (memcmp(buf, "\x7f" "ELF", 4) == 0) — the split string literal makes the bytes unambiguous, and memcmp compares an exact length with no NUL assumption.

Mistake 4 — Trusting in-file offsets

Wrong: lseek(fd, ehdr.e_shoff, SEEK_SET); read(fd, sh, sizeof(sh)); with no validation. A malformed file sets e_shoff past EOF (short read mishandled) or to a huge value.

Corrected: verify e_shoff != 0 && e_shoff + needed <= file_size before seeking, and treat a failed check as a malformed/suspicious file.

Debugging tips

Compiler errors.

  • error: unknown type name 'Elf64_Ehdr' → you forgot #include <elf.h>.
  • error: 'EM_AARCH64' undeclared → same fix; the constants live in <elf.h> too.
  • Warnings about %llx vs uint64_t → cast to (unsigned long long) when printing 64-bit values portably, as the example does.

Runtime errors.

  • open: No such file or directory → wrong path; pass a real binary like /bin/ls.
  • "not enough bytes" on a file you expected to parse → the file really is short, or you read from a pipe/socket where one read returns fewer bytes; loop until you have 64 bytes (or use pread).
  • A crash when seeking to an offset → you trusted an unvalidated e_phoff/e_shoff; add the bounds check.

Logic errors.

  • Numbers look byte-swapped (e.g. machine prints as 0x3E00) → you read with the wrong endianness; recheck little.
  • e_type prints unknown for a normal program → you may be reading from the wrong offset; remember e_type is bytes 16–17, not 14–15.

Cross-check against ground truth.

readelf -h /bin/ls     # full parsed header — the authoritative reference
file /bin/ls           # one-line summary (class, machine, PIE, stripped...)
xxd -l 64 /bin/ls      # raw first 64 bytes, to eyeball your offsets

Diff your tool's output line by line against readelf -h. If they disagree, ask: am I reading the right offset? am I honouring EI_DATA? did I check EI_CLASS?

Questions to ask when it doesn't work. Did the magic match? What does EI_CLASS/EI_DATA say? Are my field offsets correct for ELF64? Am I building multi-byte values in the file's byte order? Did I read all 64 bytes?

Memory safety

Security & safety

Parsing an untrusted binary means every byte is attacker-controlled. The header itself is a parser-attack surface, so apply defensive practices throughout.

Bounds and short reads. Never act on fewer bytes than a field needs. The example refuses any read under 64 bytes before touching a field; reading past the end of buf is a buffer over-read and undefined behaviour, and on a partially-initialised stack buffer it can leak unrelated memory into your output.

Never trust in-file offsets. e_phoff and e_shoff come from the file. Validate offset + element_size <= file_size before any lseek/read or buffer index. An unchecked offset is the classic way a crafted ELF turns a parser into an out-of-bounds read or a crash (CVE history for binary parsers is full of exactly this).

Integer overflow. When computing offset + count * size for a table, do the arithmetic in a wide unsigned type and check it does not wrap. A malicious e_shnum/e_shentsize multiplied naively can overflow and slip past a bounds check.

Alignment. Do not cast unsigned char *buf + N directly to a wider pointer and dereference it; the address may be misaligned for the target type, which is undefined behaviour on strict-alignment CPUs. Reading byte-by-byte (as rd16/rd64 do) sidesteps this entirely.

Read-only, no execution. Open with O_RDONLY. Never mmap an untrusted file as executable, never run the sample. Analysis happens on bytes, not on a running process.

Authorization. Practise only on binaries you are authorised to inspect. Handle suspicious samples solely inside an isolated, authorised lab (a VM/container you own, no network egress), never on a production or shared host.

Detection & logging. When a tool rejects a file, log what failed — bad magic, short file, out-of-bounds offset, unexpected class/machine — plus a file hash (e.g. SHA-256) and the path, so analysts can correlate. Never log file contents that might contain secrets, and never log credentials or tokens.

Verifying a mitigation. To confirm your bounds check works, craft a local test fixture (not real malware): copy a benign ELF, set e_shoff to a value larger than the file using a hex editor on your own copy, and confirm your tool reports "OUT OF BOUNDS - suspicious" and does not crash. That is mitigation verification — proof the defence triggers.

Real-world uses

Where this shows up.

  • file(1), readelf, objdump, nm, and every reverse-engineering framework (Ghidra, radare2) begin by parsing this header.
  • Incident responders and EDR/antivirus engines fingerprint dropped files from the header before deeper analysis.
  • Package managers and CI pipelines verify that an executable's e_machine matches the target platform before shipping or installing it.
  • Build and supply-chain tooling reads e_type/e_ident to confirm artifacts are the expected kind (PIE, stripped, correct ABI).

Professional best-practice habits.

Beginner rules:

  • Validate the magic first; reject non-ELF early.
  • Read EI_CLASS and EI_DATA before any multi-byte field.
  • Refuse short reads; never touch a field you did not fully read.
  • Open untrusted files O_RDONLY; never execute a sample.
  • Use the <elf.h> constants instead of magic numbers, and meaningful names.

Advanced habits:

  • Bounds-check and overflow-check every in-file offset and table size before use.
  • Parse byte-by-byte (or with explicit endianness helpers) to stay alignment- and endianness-correct across architectures.
  • Treat the parser as a hardened component: fuzz it with malformed headers, and ensure malformed input yields a clean rejection, not a crash.
  • Log structured triage results (hash, path, reason for rejection) without logging file contents or secrets.
  • Keep analysis isolated (VM/container, no egress) and authorised.

Practice tasks

Beginner

1. Magic detector. Write a program that takes a file path, reads the first 4 bytes, and prints ELF or not ELF.

  • Requirements: open O_RDONLY, handle a file shorter than 4 bytes, use memcmp.
  • Example: ./magic /bin/lsELF; ./magic /etc/hostnamenot ELF.
  • Concepts: magic bytes, short-read guard.

2. Class and data reader. Extend task 1 to also print whether the file is 32- or 64-bit and little- or big-endian.

  • Requirements: read at least 6 bytes; interpret buf[EI_CLASS] and buf[EI_DATA] using the <elf.h> constants.
  • Example output: ELF64, little-endian.
  • Concepts: e_ident, EI_CLASS, EI_DATA.

Intermediate

3. Machine name printer. Read e_machine (bytes 18–19) honouring the file's byte order and print a human-readable name for x86-64, AArch64, ARM, and x86, else unknown (0x..).

  • Requirements: a rd16 helper that takes a little flag; do not use a struct cast.
  • Hint: compare your output to readelf -h <file> line Machine:.
  • Concepts: endianness-correct field reads, e_machine.

4. Offset bounds-checker. Read e_phoff and e_shoff (ELF64) and report, for each, whether it lies within the actual file size.

  • Requirements: get the size with lseek(fd, 0, SEEK_END); treat offset 0 as 'absent, ok'; flag anything >= size as suspicious.
  • Example: on a copy whose e_shoff you enlarged with a hex editor, print Shdr offset: ... (OUT OF BOUNDS - suspicious).
  • Concepts: file offsets vs. addresses, bounds checking, defensive parsing.

Challenge

5. Mini readelf -h (ELF32 + ELF64). Combine everything into one tool that prints Class, Data, Type, Machine, Entry, Phdr offset, and Shdr offset for both 32- and 64-bit ELF files, choosing the correct layout from EI_CLASS.

  • Requirements: parse by hand (no struct cast); honour endianness; bounds-check both offsets; reject non-ELF and short files cleanly with distinct messages; open O_RDONLY.
  • Constraints: no global mutable state; close the descriptor on every exit path.
  • Hint: on ELF32, e_entry/e_phoff/e_shoff are 32-bit and at different offsets than ELF64 — derive them from the 52-byte layout.
  • Concepts: full e_ident handling, layout selection, endianness, bounds checks, robust error handling. Do not just shell out to readelf — implement the parsing yourself.

Summary

  • Every Linux binary begins with an ELF header — 64 bytes on ELF64, 52 on ELF32 — starting with the magic 0x7F 'E' 'L' 'F'.
  • Read e_ident first: EI_CLASS (32- vs 64-bit) tells you the layout, and EI_DATA (LE vs BE) tells you how to interpret every multi-byte field. These are single bytes with no endianness of their own.
  • Key fields: e_type (REL/EXEC/DYN/CORE — modern executables are usually DYN/PIE), e_machine (target CPU), e_entry (virtual address of first instruction), and e_phoff/e_shoff (file offsets of the header tables).
  • Distinguish a virtual address (e_entry) from a file offset (e_phoff/e_shoff) — never seek to an address.
  • Most common mistakes: assuming host endianness, casting before checking class, using string functions on binary magic, and trusting unvalidated offsets.
  • Defensive musts: refuse short reads, validate the magic, bounds- and overflow-check every offset, parse byte-by-byte to stay endianness- and alignment-safe, open O_RDONLY, never execute a sample, and analyse suspicious files only in an isolated, authorised lab. Cross-check your work with readelf -h and file(1).

Practice with these exercises