Systems Programming · Systems Debugging

What an ELF Core File Preserves of a Process

How the kernel encodes a dead process as an ELF object: loadable segments for memory, note records for registers and mappings, and what the dump deliberately leaves out.

A core file looks like an executable to file(1) but is not one. It shares the ELF container — the same 16-byte identification field, the same header, the same program-header table — while carrying a completely different payload: a snapshot of a process that no longer exists. The kernel writes it from inside the fault path, using no user-space library and no filesystem abstractions beyond the basic write path, which constrains the format severely. Understanding those constraints explains what a debugger can recover from a crash and what it cannot.

The most useful mental model is that a core file is a set of memory images plus a small amount of metadata, glued together by an ELF header that describes where each image belonged in the virtual address space.

The header describes segments, not sections

An ordinary relocatable or executable object carries both a program-header table and a section-header table. A core file normally has only the former. e_type in the ELF header is ET_CORE (value 4), e_phoff and e_phnum point at the segment table, and e_shoff is typically zero. Tools that assume section headers exist — anything that walks .text or .symtab by section index — either fail or silently report nothing. The symbol information needed to make sense of a core file lives in the original executable, not in the dump, which is why a core file is only useful alongside the exact binary and its debug information.

Each program header of type PT_LOAD describes one contiguous region of the address space that was dumped. p_vaddr is the address the region occupied in the crashing process, not an offset in the file. p_offset gives the byte offset in the core file where the region’s contents begin, and p_filesz the number of bytes present. p_memsz is normally equal to p_filesz, but it can be larger: the kernel records the full extent of a mapping while declining to write some of its pages.

That gap between p_filesz and p_memsz is the first place a post-mortem analysis goes wrong. A region may be listed because it was mapped, yet contain no data because the kernel chose not to dump it, because the dump hit a size limit, or because the pages were file-backed and could be re-read from disk. Reading past p_filesz yields zeros or a short read, not the process’s bytes.

The segment table is also where address-space layout randomisation is undone. The load addresses recorded in the dump are the randomised ones. Debuggers reconstruct the load bias by comparing the lowest PT_LOAD address of a mapping against the corresponding segment’s virtual address in the on-disk executable, then subtract that bias from every runtime address before looking up a symbol. Without the mapping information described below, that comparison is guesswork.

Note records: the part that is not memory

Alongside PT_LOAD segments, a core file contains one or more PT_NOTE segments. Notes are a generic ELF mechanism: each record begins with a 12-byte header holding namesz, descsz, and type, followed by a name padded to a four-byte boundary and a descriptor likewise padded. The name is conventionally CORE for process notes, and the type selects which structure the descriptor holds.

The essential record is NT_PRSTATUS, one per thread. Its descriptor contains a copy of the signal information that terminated or stopped the thread, the pending and blocked signal masks, the process and thread identifiers, accumulated user and system time, and a register array laid out in the order the architecture’s elf_gregset_t defines. On x86-64 that array holds the general-purpose registers in a fixed sequence including the instruction pointer, stack pointer, flags, and segment selectors. This record is why a debugger can show a backtrace from a core file at all: without saved registers there is no starting point for unwinding, and without the faulting instruction address there is no way to know where the process died.

Other notes fill in the surroundings. NT_PRPSINFO carries the executable name, the arguments truncated to a fixed field, and the credentials of the process. NT_AUXV preserves the auxiliary vector, which among other things holds the address of the program headers and the entry point, and is needed to locate the dynamic linker’s state. NT_FPREGSET and NT_X86_XSTATE save floating-point and extended register state, the latter including vector registers. NT_SIGINFO carries the siginfo_t for the fatal signal, including si_code and si_addr, so the dump itself distinguishes an invalid address from a protection violation.

NT_FILE is the one that saves hours of reconstruction. It lists every file-backed mapping as a triple of start address, end address, and file page offset, followed by the path names. From it a debugger can tell which shared library occupied a range and at what offset, so a crash inside libc can be attributed to the right build of libc even when the process had mapped several.

A small parser makes the layout concrete:

#include <elf.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main(int argc, char **argv)
{
    int fd = open(argv[1], O_RDONLY);
    Elf64_Ehdr eh;
    pread(fd, &eh, sizeof eh, 0);
    if (eh.e_type != ET_CORE) return 1;

    for (int i = 0; i < eh.e_phnum; i++) {
        Elf64_Phdr ph;
        pread(fd, &ph, sizeof ph, eh.e_phoff + i * sizeof ph);
        if (ph.p_type == PT_NOTE) {
            /* Notes start at p_offset and run for p_filesz bytes. */
            printf("note segment at file offset %#lx, %lu bytes\n",
                   (unsigned long)ph.p_offset, (unsigned long)ph.p_filesz);
        }
    }
    close(fd);
    return 0;
}

Walking the notes themselves requires iterating the 12-byte headers and advancing by the padded name and descriptor lengths, since neither is guaranteed to end on a four-byte boundary by itself.

Which pages the kernel chooses to write

The dump is not a copy of the entire address space. Three separate gates decide what lands in the file, and each of them can produce a core that looks complete but is not.

The first gate is the process’s dumpability. A process that has changed its credentials through a set-user-ID transition, or that has explicitly called prctl(PR_SET_DUMPABLE, 0), is not dumped by default. The fs.suid_dumpable sysctl can relax this, but the default behaviour exists because a core file contains whatever secrets the process held in memory, from private keys to decrypted session state.

The second gate is RLIMIT_CORE. If the limit is zero, no file is written at all, and the shell reports only that the process dumped core. A nonzero limit is a byte ceiling; the kernel stops writing when it is reached, so the file can end mid-segment. Every segment after the truncation point is missing, and the last one is short. Increasing the limit after a crash does not help, because the file is gone.

The third gate is the per-process coredump_filter, a bitmask documented in proc(5) that selects classes of pages: anonymous private, anonymous shared, file-backed private, file-backed shared, ELF headers, huge pages, and a few others. The default mask on Linux dumps anonymous private pages and ELF headers while omitting most file-backed pages, on the reasoning that file-backed pages can be recovered from their files. That reasoning holds for read-only text and for libraries, and fails for MAP_SHARED file mappings that were modified in memory, because the file on disk may no longer match what the process saw.

The same mechanism explains why some core files are far smaller than the process’s resident size. Pages that are not resident are not written; they are simply absent from the corresponding segment’s p_filesz. A debugger reading such a page gets zeros, which is indistinguishable from a page that genuinely contained zeros unless the segment sizes are checked.

Where the metadata ends and the guesswork begins

Given the header, the segments, and the notes, a debugger can reconstruct the register state of every thread, the mapping layout, and the contents of the dumped pages. It cannot reconstruct what was never written: the contents of registers not saved by the note format, kernel-side state such as open file descriptions beyond the file descriptor table, the state of other processes, or memory that the filter excluded.

That boundary is worth internalising because it determines which questions a core file can answer. A crash caused by a wild pointer is fully answerable: the faulting address is in NT_SIGINFO, the instruction is in a dumped text segment, and the stack is in the anonymous region that was dumped. A crash caused by reading a memory-mapped file after it was truncated by another process may be only partly answerable, because the file-backed pages that produced the fault were never in the dump. The distinction between a segmentation fault and a bus error, and the fields that carry it, is covered in how a fault becomes a signal; the register state described here is the raw material that unwinding consumes, which is the subject of unwinding a stack without frame pointers.

One practical consequence: core files are worth treating as evidence to be preserved rather than as logs to be rotated away. The binary, its build identifier, and the shared libraries at their exact versions are part of the evidence, and a dump without them is a memory image with no map.

References

  1. core(5) — Linux man-pages — Linux man-pages project
  2. proc(5) — Linux man-pages — Linux man-pages project
  3. ELF-64 Object File Format, version 1.5 — Tool Interface Standard (TIS) Committee