Systems Programming · Binary Formats

Section Headers, String Tables, and the Bookkeeping a Linker Needs

What the section table adds that segments do not carry, how names are resolved through a string table by offset, and why a stripped binary can still run without it.

A running process never consults the section table. The kernel maps segments, the dynamic linker reads the dynamic section, and neither needs to know that the read-only data was assembled from three input sections named .rodata, .rodata.str1.1, and .rodata.cst8. Yet every object file a compiler emits is built around sections, every linker operates on them, and every debugger and disassembler navigates by them. The section table is the linker’s data model, and understanding why it exists separately from the segment view explains a large share of the confusing output produced by binary inspection tools.

The distinction is historical and structural at once. ELF was designed to serve three consumers with different needs: the loader, which wants page-aligned regions with permissions; the linker, which wants independently relocatable named units; and the debugger, which wants address ranges tied to source constructs. One flat table could not serve all three, so ELF carries two tables over the same bytes, and the overlap between them is a convention rather than a guarantee.

Names live in a separate section

Section names are not stored inline in section headers. Each Elf64_Shdr holds a sh_name field, but that field is an offset into a separate string table, conventionally named .shstrtab, whose index is recorded in the file header’s e_shstrndx field. The name is the NUL-terminated byte sequence beginning at that offset within the table’s bytes.

This indirection exists for two reasons. Fixed-size headers cannot hold variable-length names without either wasting space on short ones or imposing a maximum length. And many sections share name suffixes or prefixes, so a string table lets the linker merge them: the name .rela.text and the name .text can share the tail text, with their offsets differing only in the prefix. Compilers emit merged string tables routinely, and a reader that assumes each name starts at the beginning of a distinct entry will misreport section names.

The offsets are not sorted, not unique, and not required to point at the start of a string. Two sections can legitimately have sh_name values that resolve to the same byte sequence, which is how duplicate section names arise in relocatable objects. A reader must bound-check the offset against the string table’s size and scan forward for the terminating NUL within that bound; an offset equal to the table size points at nothing, and an offset past it is corrupt. Section name resolution is one of the classic places where a malformed file turns a naive parser into an out-of-bounds read, a theme taken up in Parsing Untrusted Binaries Without Trusting Their Length Fields.

The header fields and what each one constrains

Each section header is 64 bytes in ELF64 and 40 bytes in ELF32. The fields are sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link, sh_info, sh_addralign, and sh_entsize. Their meanings depend on sh_type, which is the part that trips up readers who treat the structure as uniform.

For SHT_PROGBITS, which holds arbitrary program data such as machine code, sh_offset and sh_size locate the bytes in the file and sh_addr gives the virtual address if the section is allocated. For SHT_NOBITS, the section occupies sh_size bytes in memory but zero bytes in the file: sh_offset is present for bookkeeping but no data follows it. .bss is the canonical example, and a tool that reads sh_size bytes at sh_offset for a SHT_NOBITS section will read past the end of the file or into unrelated content.

For SHT_SYMTAB and SHT_DYNSYM, which hold arrays of symbol table entries, sh_entsize gives the size of one entry, sh_link gives the index of the associated string table, and sh_info gives the index of the first non-local symbol, so that the local symbols form a contiguous prefix. The symbol table’s string table is a different section from the section-name table, conventionally .strtab or .dynstr. Confusing the two is a common bug: the section name table holds section names, the symbol string table holds symbol names, and they are indexed by different fields.

sh_link and sh_info are the mechanism by which ELF expresses relationships between sections without pointers. Relocation sections use sh_info to name the section they apply to and sh_link to name the symbol table their entries index. SHT_GROUP sections use sh_info to name a signature symbol and sh_link to name the symbol table. Because these are indices into the section header table, they must be validated against e_shnum before use, and a value equal to SHN_UNDEF (zero) means no relationship.

/* Print each section's name and type, resolving names through .shstrtab. */
#include <elf.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv) {
    FILE *f = fopen(argv[1], "rb");
    if (!f) return 1;
    Elf64_Ehdr eh;
    if (fread(&eh, sizeof eh, 1, f) != 1) return 1;
    if (memcmp(eh.e_ident, ELFMAG, SELFMAG) != 0) return 1;
    if (eh.e_shentsize < sizeof(Elf64_Shdr)) return 1;

    Elf64_Shdr *sh = calloc(eh.e_shnum ? eh.e_shnum : 1, sizeof *sh);
    for (unsigned i = 0; i < eh.e_shnum; i++) {
        /* Advance by e_shentsize, not by sizeof: the entry may be larger. */
        long off = (long)eh.e_shoff + (long)i * eh.e_shentsize;
        if (fseek(f, off, SEEK_SET) != 0) return 1;
        if (fread(&sh[i], sizeof sh[i], 1, f) != 1) return 1;
    }

    /* The name table is itself a section; its bytes are loaded separately. */
    if (eh.e_shstrndx >= eh.e_shnum) return 1;
    Elf64_Shdr *strsh = &sh[eh.e_shstrndx];
    char *strs = calloc(1, strsh->sh_size + 1);   /* +1 keeps a zero-size table valid */
    if (!strs) return 1;
    fseek(f, (long)strsh->sh_offset, SEEK_SET);
    if (fread(strs, 1, strsh->sh_size, f) != strsh->sh_size) return 1;

    for (unsigned i = 0; i < eh.e_shnum; i++) {
        if (sh[i].sh_name >= strsh->sh_size) continue;   /* corrupt offset */
        printf("%-20s type=%u size=%lu\n", strs + sh[i].sh_name,
               sh[i].sh_type, (unsigned long)sh[i].sh_size);
    }
    return 0;
}

The bound check on sh_name is not defensive padding, and neither is the check on e_shstrndx: both fields are read from the file before anything has validated them. A file with e_shstrndx equal to SHN_XINDEX uses an escape mechanism, and a file whose e_shnum is zero stores the real count elsewhere, in both cases requiring the extended numbering rules described below.

Allocated and non-allocated sections

The SHF_ALLOC flag marks a section whose contents occupy memory in the running process. SHF_EXECINSTR marks executable bytes, SHF_WRITE marks writable bytes, and SHF_MERGE with SHF_STRINGS marks a section whose entries the linker may deduplicate. Non-allocated sections, which lack SHF_ALLOC, are metadata that never reaches memory: the symbol table, the string tables, relocation entries in a relocatable object, and the section header table itself.

The linker’s task is essentially to take every allocated section from every input object, merge same-named sections according to their flags, assign addresses, and then synthesise the program headers so that the merged result can be mapped. Because the segment table is generated from the allocated sections, the two views agree on content but not on granularity. The .text segment in a linked executable may contain contributions from a dozen .text.* sections; the segment boundary is chosen to satisfy page alignment, not to follow any section boundary. This is why readelf reports a segment’s file range as spanning several sections with padding between them.

The relationship is worth stating precisely: every allocated section lies within some PT_LOAD segment, but not every byte of a PT_LOAD segment belongs to an allocated section. The gaps are alignment padding, and they are zero-filled or arbitrary depending on how the linker emitted them. Tools that reconstruct section tables from segments, as some binary rewriting frameworks do, must invent names for those gaps.

Why a stripped binary still runs

The strip utility removes the symbol table, the debug sections, and, with the appropriate option, the section header table entirely, replacing e_shoff with zero and e_shnum with zero. The resulting file loads and executes normally. The loader never read the section table, and the dynamic linker finds everything it needs through the dynamic segment: the dynamic symbol table, the string table, and the hash tables are all reachable from PT_DYNAMIC entries, not from section headers.

What disappears is the ability to answer questions the runtime never asks. Without sections there is no .symtab, so static symbol names are gone, though dynamic symbols exported for other objects remain in .dynsym because the runtime needs them. There is no .debug_info, so line-level debugging is impossible until a separate debug file is located. There is no .comment, so the compiler identification string is lost. The .dynstr section, being reachable through the dynamic segment, survives; the section header that would have described it does not.

A subtlety: section headers carry no checksum and no cross-reference from the segment table, so a file with a damaged section table can still execute while every tool that reads sections fails. Corrupting e_shoff alone produces a binary that runs and that objdump refuses to describe. Conversely, a binary whose section table is intact but whose program headers are damaged will not load at all. The two tables fail independently, and the failure modes point in opposite directions.

Extended numbering and the escape values

The original ELF header reserved 16 bits each for e_shnum and e_phnum and 16 bits for e_shstrndx, which caps a file at 65,535 sections and a section-name table index below that. Large object files, especially those produced by aggressive template instantiation or by section-per-function compilation, exceed the section count. ELF handles this with escape values rather than by widening the header.

When e_shnum is zero, the real count is stored in the sh_size field of section header entry zero. When e_shstrndx equals SHN_XINDEX (0xffff), the real index is stored in the sh_link field of entry zero. When e_phnum equals PN_XNUM (0xffff), the real count is in sh_info of entry zero. Entry zero is otherwise reserved and has all fields zeroed, which is what makes it usable as a spillover location: a reader that finds zeros there knows the file is using extended numbering, and one that finds real values knows where to look.

A parser that reads e_shnum, sees zero, and stops therefore sees an empty section table for a file that may have tens of thousands of sections. The two cases are distinguishable because a file with no section table also has e_shoff equal to zero, while an extended-numbering file has a real offset and a count in entry zero. This is a version-compatibility artefact rather than corruption, and it is invisible in ordinary binaries, which is exactly why it goes unimplemented until it is hit.

C++ templates and inline functions create a problem: the same function may be emitted in many object files, and the linker must keep exactly one copy. A section of type SHT_GROUP solves it, marked with the SHF_GROUP flag. The group section lists a set of member section indices and a signature symbol; when two input objects contribute groups with the same signature, the linker keeps the members of one and discards the others. sh_link names the symbol table that holds the signature symbol, and sh_info names that symbol.

The SHF_GNU_RETAIN flag prevents the linker from discarding a section under --gc-sections. Sections marked with it survive even when nothing references them, which matters for code that is invoked through a mechanism the linker cannot see, such as a function pointer stored in a table the linker does not model.

Relocation entries attached to discarded groups must themselves be discarded, which is why relocation sections carry sh_info pointing at their target: the linker needs to know which relocations belong to a section it has just eliminated. This is also why a relocation section whose sh_info is wrong produces either unresolved symbols or relocations applied to the wrong bytes, a failure discussed further in Relocation Entries and the Arithmetic a Linker Performs.

Reading section tables as a diagnostic

The practical value of understanding this layer is that most binary problems present as section-level symptoms. A missing .note.gnu.build-id means the binary cannot be matched to its debug symbols. A .gnu.version_r section that references a library version not present on the system produces a dynamic linker error naming the version, not the section. A .eh_frame section that is present but empty means unwinding will fail and a thrown exception will terminate the process rather than propagate.

None of those failures are visible in the segment table, and none of them are repaired by the loader. They are properties of the bookkeeping layer, which is exactly the layer that a stripped binary discards and that a debugger depends on entirely.

References

  1. Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification — TIS Committee
  2. elf(5) — Linux man-pages — Linux man-pages project
  3. GNU Binary Utilities — objcopy — GNU Project