Developer Tools · Debuggers

Core Dumps: Notes, Mappings, and Unwinding a Dead Process

A core file is an ELF object whose segments are memory rather than code. How registers, mappings, and file identities are recorded so a backtrace can be rebuilt later.

Everything a debugger normally asks a live process for, from registers to memory to thread state, is unavailable once the process has exited. A core dump is the kernel’s answer to that: before tearing the process down, it writes an ELF file containing the register sets of every thread and the contents of selected memory mappings. The debugger then treats that file as a target, reading memory from segments instead of through ptrace and reconstructing a backtrace from the same unwind tables it would have used while the process was alive. The format is ordinary ELF, which is what makes that substitution possible.

An ELF file whose segments are memory

A core file has the usual ELF header and program header table, but almost none of the sections an executable has. There is no code, no symbol table, and no relocation information. The program headers describe two kinds of segment: PT_NOTE, carrying metadata, and PT_LOAD, carrying a snapshot of a virtual address range.

Each PT_LOAD segment records a virtual address, a size in the file, a size in memory, and permissions. For a mapping that was fully resident, the two sizes match. For a mapping that was only partly written, the file size can be smaller, and the rest reads as zeros, which is indistinguishable from a page that was genuinely zero when seen from the debugger’s side. A segment whose file size is zero represents a mapping that was reserved but never populated, and its contents are lost rather than zeroed.

The set of mappings that gets written is not the whole address space. The kernel dumps private writable mappings and the stack, and skips file-backed read-only mappings because their contents can be recovered from the file on disk. That decision is what keeps a core file proportional to the process’s private state rather than to everything it has ever mapped. It is also the reason a core file is not self-contained: the code that was executing is not in it, and the debugger must find the same executable and libraries on disk to disassemble anything or to unwind.

The selection is tunable per process through a bit mask that names categories of mapping: private anonymous, shared anonymous, private file-backed, shared file-backed, ELF headers, and huge pages among others. Turning on file-backed private mappings makes a core self-contained for those mappings at the cost of size; leaving them off means a core taken after a library upgrade may no longer match its own code.

Notes are a linked list of fixed headers

The metadata is a sequence of notes, each with a three-word header: name size, descriptor size, and type, followed by a name padded to a four-byte boundary and a descriptor padded the same way. The name is usually empty or CORE for the standard types, and GNU for the build identifier. Walking the list is a matter of reading the header, skipping the padded fields, and repeating until the segment ends.

import struct

def notes(data: bytes):
    off = 0
    while off + 12 <= len(data):
        namesz, descsz, ntype = struct.unpack_from("<III", data, off)
        off += 12
        name = data[off:off + namesz].rstrip(b"\0")
        off += (namesz + 3) & ~3
        desc = data[off:off + descsz]
        off += (descsz + 3) & ~3
        yield name, ntype, desc

The types that matter for debugging are a small set. NT_PRSTATUS carries one thread’s state: the process and thread identifiers, pending and held signal masks, the general-purpose register set, and a validity flag for the floating-point state. There is one of these per thread, and the register layout inside is architecture-specific, which is why a core from one machine cannot be fully interpreted on another without the right register description.

NT_PRPSINFO carries the command line and the executable name, which is how a debugger knows what to load. NT_AUXV carries the auxiliary vector, including the program entry point and the location of the program headers, which lets the debugger reconstruct the load addresses of the main executable and of the dynamic loader. NT_FILE carries the mapping table in a compact form: a count, the page size, an array of triples (start, end, file offset), and then the file names. That table is what allows a debugger to say that an address belongs to a particular shared object at a particular offset, and therefore to find that object’s unwind tables.

NT_GNU_BUILD_ID carries the linker-generated identifier of the executable. It is the one field that makes a core usable after the binary has been rebuilt: a debugger can compare the identifier in the core against the identifier of the file on disk and refuse to symbolicate when they differ, rather than producing a confident trace against the wrong code. Debug-information servers key their lookup on exactly this value.

Unwinding without a process

The unwinding algorithm does not change. The debugger needs, at each step, the current program counter, the current stack pointer, and the rule that maps the current frame to the caller’s. The rules come from the .eh_frame of whichever object contains the program counter, and the object is found through NT_FILE.

What changes is where the memory reads go. Reading a stack slot means locating the PT_LOAD segment whose address range contains the address and copying bytes out of the file at the corresponding offset. There is no fault to handle and no process to stop, so an address that falls outside every segment is simply unreadable, and unwinding stops there. The same is true of the register set: it is read once, from NT_PRSTATUS, and every frame’s registers are derived from it arithmetically.

Three failure modes are specific to this setting.

The first is a mismatched object. The core records that the faulting instruction was at an offset into a library, but the copy of that library on the analysing machine is a different build. The unwind rules are then wrong in a way that produces plausible frames from the wrong function. The build identifier is the defence, and a debugger that ignores it is trusting a filename.

The second is a missing mapping. If the crashing thread’s stack was in a mapping the kernel chose not to dump, or if the stack was grown by the fault itself and the new page was not captured, the backtrace ends early. The same applies to a thread whose stack was unmapped by the program before it died.

The third is a truncated file. A core written while the process was still running, or truncated by a file size limit, has a valid header and incomplete segments. The note list may end mid-entry, and a reader that trusts the descriptor sizes can run off the end of the buffer. Every field in the file is attacker-controlled from the reader’s point of view, and a parser that allocates based on a size field without checking it against the remaining bytes is a security bug in the debugger rather than in the program that crashed.

What the file cannot answer

A core is a snapshot of memory, not a record of execution. It cannot say how the process reached the state it is in, only what that state was. Reconstructing a sequence of events requires either a trace recorded before the crash or a reproduction.

It also cannot answer questions about state that was never in memory. A value held in a register at the time of the crash is in NT_PRSTATUS for that thread, but a value that was in a register and got spilled, or one that the optimiser kept only as a recomputable expression, is subject to the same location-list limits as a live session: the rules are in the debug information, and the memory they refer to may or may not have been dumped. The unwinding machinery that rebuilds the call stack reads the same tables here, with the difference that the stack it reads is a file.

And it cannot substitute for the process’s own view of its threads. Threads that had already exited before the dump have no NT_PRSTATUS, so a lock held by a thread that died is invisible; the remaining threads show as blocked with no explanation. This is the case where a core file is least useful and where a live inspection through ptrace, before the process is allowed to die, is the only way to see the whole picture.

References

  1. core(5) — Linux manual page — Linux man-pages project
  2. elf(5) — Linux manual page — Linux man-pages project
  3. Linux kernel source: fs/binfmt_elf.c (core dump writer) — Linux kernel