Difficulty: Beginner

Module 2: COFF File Format Deep Dive

Every byte matters: headers, sections, symbols, strings, and relocations in raw binary.

Why This Module?

COFFLoader is fundamentally a COFF parser. To understand how it loads and executes BOFs, you must first understand the binary layout it parses. This module walks through the five major components of a COFF object file: the file header, section table, symbol table, string table, and relocation entries. These are the exact structures defined in COFFLoader.h that the loader reads.

Lab Files: step02-coff-inspector

A standalone COFF binary dumper. Compile hello_bof.c into a .o, then run coff_inspector hello_bof.o to see every COFF structure.

COFF Binary Layout

A COFF object file has a well-defined binary layout. Unlike a PE, there is no DOS header, no PE signature, and no optional header.[1] The file begins immediately with the COFF file header.

TEXTCOFF Object File Layout:

Offset 0x00:  +--------------------------+
              | COFF File Header         |  20 bytes
              | (coff_file_header_t)     |
              +--------------------------+
              | Section Header 1         |  40 bytes each
              | (coff_sect_t)            |
              +--------------------------+
              | Section Header 2         |
              +--------------------------+
              | ...                      |
              +--------------------------+
              | Section Header N         |
              +--------------------------+
              | Section 1 Raw Data       |  variable size
              +--------------------------+
              | Section 1 Relocations    |  10 bytes each
              +--------------------------+
              | Section 2 Raw Data       |
              +--------------------------+
              | Section 2 Relocations    |
              +--------------------------+
              | ...                      |
              +--------------------------+
              | Symbol Table             |  18 bytes per symbol
              +--------------------------+
              | String Table             |  variable size
              +--------------------------+

COFF File Structure Overview

File Header
20 bytes
Section Table
N × 40 bytes
Raw Data + Relocs
Variable size
Symbol Table
N × 18 bytes
String Table
Variable size

The COFF File Header

The file header is always 20 bytes and sits at offset 0.[1] COFFLoader defines it as coff_file_header_t:

Ctypedef struct coff_file_header {
    uint16_t Machine;              // 0x8664 = AMD64, 0x14C = i386
    uint16_t NumberOfSections;     // how many section headers follow
    uint32_t TimeDateStamp;        // compilation timestamp (often zero)
    uint32_t PointerToSymbolTable; // file offset to the symbol table
    uint32_t NumberOfSymbols;      // total entries in symbol table
    uint16_t SizeOfOptionalHeader; // always 0 for object files
    uint16_t Characteristics;      // flags (usually 0 for .obj)
} coff_file_header_t;
FieldOffsetSizePurpose
Machine0x002Target architecture. COFFLoader checks for 0x8664 (AMD64) or 0x14C (i386)[2]
NumberOfSections0x022Count of section headers immediately following this header
TimeDateStamp0x044Unix timestamp of compilation. Not used by the loader
PointerToSymbolTable0x084File offset to the symbol table. Critical for symbol resolution
NumberOfSymbols0x0C4Number of entries (including aux symbols). Used to locate the string table[3]
SizeOfOptionalHeader0x102Always 0 for object files (no optional header)
Characteristics0x122Flags. Usually 0 for unlinked objects

Key Insight: Finding the String Table

The string table immediately follows the symbol table. Since each symbol entry is exactly 18 bytes, the string table starts at: PointerToSymbolTable + (NumberOfSymbols * 18). The first 4 bytes of the string table are a uint32_t giving the total size of the string table (including those 4 bytes). Symbol names longer than 8 characters are stored here and referenced by offset.

The Section Table

Immediately following the 20-byte file header is an array of section headers. Each header is 40 bytes,[4] defined as coff_sect_t:

C#pragma pack(push, 1)
typedef struct coff_sect {
    char     Name[8];                // section name (e.g., ".text\0\0\0")
    uint32_t VirtualSize;            // 0 for object files
    uint32_t VirtualAddress;         // 0 for object files
    uint32_t SizeOfRawData;          // size of section data in the file
    uint32_t PointerToRawData;       // file offset to the raw data
    uint32_t PointerToRelocations;   // file offset to relocation entries
    uint32_t PointerToLineNumbers;   // file offset to line numbers (usually 0)
    uint16_t NumberOfRelocations;    // count of relocation entries for this section
    uint16_t NumberOfLinenumbers;    // count of line number entries (usually 0)
    uint32_t Characteristics;        // flags: executable, readable, writable, etc.
} coff_sect_t;
#pragma pack(pop)

Common Sections in a BOF

SectionCharacteristicsContent
.textCODE | EXECUTE | READCompiled machine code (the go() function and helpers)
.dataINITIALIZED | READ | WRITEInitialized global/static variables
.rdataINITIALIZED | READRead-only data: string literals, constant tables
.bssUNINITIALIZED | READ | WRITEZero-initialized globals. SizeOfRawData is 0 (no file data)
.xdataINITIALIZED | READException handling unwind data (x64)
.pdataINITIALIZED | READFunction table for structured exception handling

Section Characteristics Flags

The Characteristics field is a bitmask.[5] COFFLoader defines the relevant flags:

C#define IMAGE_SCN_CNT_CODE               0x00000020  // section contains code
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080  // section contains uninitialized data (.bss)
#define IMAGE_SCN_MEM_EXECUTE            0x20000000  // section is executable
#define IMAGE_SCN_MEM_READ               0x40000000  // section is readable
#define IMAGE_SCN_MEM_WRITE              0x80000000  // section is writable
#define IMAGE_SCN_MEM_DISCARDABLE        0x02000000  // section can be discarded

The Symbol Table

The symbol table is an array of 18-byte entries[6] located at the file offset specified by PointerToSymbolTable. Each entry is defined as coff_sym_t:

Ctypedef struct coff_sym {
    union {
        char     Name[8];      // short name (if <= 8 chars)
        uint32_t value[2];     // value[0]==0 means value[1] is string table offset
    } first;
    uint32_t Value;            // value depends on StorageClass and SectionNumber
    uint16_t SectionNumber;    // 1-based index of the section, or special values
    uint16_t Type;             // symbol type (0x20 = function)
    uint8_t  StorageClass;     // IMAGE_SYM_CLASS_EXTERNAL (2), STATIC (3), etc.
    uint8_t  NumberOfAuxSymbols; // number of auxiliary symbol entries that follow
} coff_sym_t;

Symbol Name Resolution

Symbol names can be stored in two ways, depending on length:

TEXTIf the name is 8 characters or shorter:
  first.Name[0..7] contains the name directly (null-padded)

If the name is longer than 8 characters:
  first.value[0] == 0x00000000   (sentinel: first 4 bytes are zero)
  first.value[1] == offset into string table

Example: Symbol name "__imp_KERNEL32$GetCurrentProcessId"
  first.value[0] = 0x00000000
  first.value[1] = 0x0000004A  --> string table offset 0x4A

Important Symbol Fields

FieldKey ValuesMeaning
SectionNumber1, 2, 3...1-based index of the section containing this symbol
SectionNumber0IMAGE_SYM_UNDEFINED -- external symbol, must be resolved
StorageClass2 (EXTERNAL)Symbol is globally visible or needs to be imported
StorageClass3 (STATIC)Symbol is local to the section (e.g., section name)
Value(offset)For defined symbols: offset within the section. For undefined: 0
NumberOfAuxSymbols0 or 1Auxiliary entries follow (e.g., section definition aux records)

How COFFLoader Classifies Symbols

COFFLoader uses two helper functions to classify symbols.[7] A symbol is defined if its SectionNumber is greater than 0 (it exists in a section). A symbol is external if its StorageClass is IMAGE_SYM_CLASS_EXTERNAL (2).[6] An external symbol with SectionNumber == 0 is an unresolved import that must be linked at load time.

C// From COFFLoader -- symbol classification helpers
int coff_symbol_is_defined(coff_sym_t* sym) {
    return (sym->SectionNumber > 0);
}

int coff_symbol_is_external(coff_sym_t* sym) {
    return (sym->StorageClass == IMAGE_SYM_CLASS_EXTERNAL);  // StorageClass == 2
}

The String Table

The string table immediately follows the symbol table. Its structure is simple:

TEXTString Table Layout:
  Offset 0: uint32_t Size;          // total size of string table (including this field)
  Offset 4: char[] strings;         // null-terminated strings packed sequentially

Example:
  04 00 00 00  2E 74 65 78  74 00 5F 67  6F 00 5F 5F   ....text._go.__
  69 6D 70 5F  4B 45 52 4E  45 4C 33 32  24 47 65 74   imp_KERNEL32$Get
  ...

Symbols reference strings by offset from the START of the string table.
  value[1] = 4  --> ".text"
  value[1] = 10 --> "_go"
  value[1] = 14 --> "__imp_KERNEL32$GetCurrentProcessId"

The first 4 bytes are the size field itself, so valid string offsets start at 4.[8] If the string table only contains the size field (size == 4), there are no long symbol names.

Relocation Entries

Each section can have its own relocation table. The relocation entries tell the loader which bytes in the section need to be patched once the final addresses of symbols are known. Each entry is 10 bytes:[9]

Ctypedef struct coff_reloc {
    uint32_t VirtualAddress;      // offset within the section to patch
    uint32_t SymbolTableIndex;    // index into the symbol table
    uint16_t Type;                // relocation type (architecture-specific)
} coff_reloc_t;
FieldSizePurpose
VirtualAddress4Byte offset within the section where the fixup must be applied
SymbolTableIndex4Index into the symbol table identifying the target symbol
Type2How to compute the fixup value (architecture-dependent)

AMD64 Relocation Types (from COFFLoader.h)

C#define IMAGE_REL_AMD64_ADDR64    0x0001  // 64-bit absolute address
#define IMAGE_REL_AMD64_ADDR32NB  0x0003  // 32-bit address without image base (RVA)
#define IMAGE_REL_AMD64_REL32     0x0004  // 32-bit relative (RIP-relative)
#define IMAGE_REL_AMD64_REL32_1   0x0005  // REL32 + 1 byte displacement
#define IMAGE_REL_AMD64_REL32_2   0x0006  // REL32 + 2 byte displacement
#define IMAGE_REL_AMD64_REL32_3   0x0007  // REL32 + 3 bytes displacement
#define IMAGE_REL_AMD64_REL32_4   0x0008  // REL32 + 4 bytes displacement
#define IMAGE_REL_AMD64_REL32_5   0x0009  // REL32 + 5 bytes displacement

The REL32 type is the most common in x64 BOFs.[10] It computes: target_address - (fixup_address + 4). The variants REL32_1 through REL32_5 add an additional displacement of 1-5 bytes to account for instruction encodings where the relocation is not the last part of the instruction.[10]

Putting It All Together

When the compiler generates a call to BeaconPrintf, it emits a CALL instruction with a placeholder 32-bit offset, a symbol table entry for __imp_BeaconPrintf (or the architecture-specific variant), and a relocation entry pointing from the CALL instruction to the symbol. At load time, COFFLoader resolves the symbol to an actual memory address and patches the CALL instruction's offset to reach that address.

Edge Cases in COFF Parsing

A robust COFF parser must handle several edge cases that arise in real-world object files. Compilers do not always produce clean, predictable output, and a loader that assumes ideal input will crash or produce incorrect results.

Zero-Size Sections

The .bss section is the most common example of a section with SizeOfRawData == 0. Because .bss holds uninitialized data, the compiler sets its raw data size to zero and its PointerToRawData to zero as well.[4] The loader must still allocate memory for this section based on the VirtualSize field (or the symbol-derived size) and zero-fill it. COFFLoader handles this by checking SizeOfRawData before attempting to read section data from the file. If the loader blindly tries to memcpy zero bytes from a null pointer, the result is undefined behavior. Some compilers also emit empty .data sections when no initialized globals exist -- the loader should treat any section with SizeOfRawData == 0 as requiring allocation but no file read.

Sections With No Relocations

Read-only data sections like .rdata frequently have zero relocations. The NumberOfRelocations field will be 0 and PointerToRelocations may be 0 or may point to a valid but empty area. The loader must check NumberOfRelocations before iterating the relocation table. Attempting to dereference PointerToRelocations when there are no entries can lead to reading garbage data. Debug sections (.debug$S, .debug$T) and exception handling sections (.pdata, .xdata) may or may not carry relocations depending on compiler options.

Auxiliary Symbol Entries

When a symbol has NumberOfAuxSymbols > 0, the entries immediately following it in the symbol table are auxiliary records, not regular symbols.[6] Each auxiliary entry occupies the same 18 bytes as a regular symbol but has a completely different internal layout. Section definition symbols (StorageClass == IMAGE_SYM_CLASS_STATIC with a section name) typically have one auxiliary record containing the section length, number of relocations, and checksum. The loader must skip over auxiliary entries when iterating the symbol table. A common bug is to treat auxiliary entries as regular symbols, which corrupts symbol resolution. COFFLoader handles this by advancing the loop index by 1 + NumberOfAuxSymbols after processing each symbol.

Limitations of the COFF Format

COFF object files are intermediate artifacts -- they are designed to be consumed by a linker, not executed directly. This means COFF lacks many features present in the final PE executable format, and a COFF loader must work around these absences.

No ASLR Metadata

PE files include a DllCharacteristics field in the optional header with the IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE flag to signal ASLR compatibility.[1] COFF object files have no optional header at all, so there is no mechanism to declare ASLR support. The loader must position code in memory and handle all address computation through relocations without any base relocation directory. This is actually what makes COFF loading simpler in one sense: all addresses are computed from relocations rather than rebased from a preferred load address.

No TLS Directory

Thread Local Storage (TLS) in PE files is managed through the TLS directory in the optional header, which points to TLS callback functions and template data. COFF objects have no such directory. If a BOF uses __declspec(thread) variables, the resulting TLS references will appear as relocations against TLS-related sections, but there is no infrastructure for the loader to initialize per-thread storage. In practice, BOFs should avoid TLS variables entirely because the COFF loader has no way to set up the TLS index or call TLS callbacks.

No Exception Handling Registration

While COFF objects can contain .pdata and .xdata sections with unwind information, there is no mechanism to register this data with the Windows structured exception handling (SEH) system. In a PE file, the exception directory in the optional header points to the runtime function table. A COFF loader would need to manually call RtlAddFunctionTable to register unwind handlers, and most BOF loaders (including COFFLoader) skip this step.[7] This means that if a BOF triggers an exception, the stack unwinder will not find the BOF's frames, potentially causing process termination instead of graceful exception handling.

No Import or Export Directories

PE files have import and export directories that list DLL dependencies and exported functions. COFF objects express external dependencies purely through undefined symbols in the symbol table. The naming convention __imp_DLLNAME$FunctionName is a convention that COFFLoader parses to determine which DLL and function to resolve, but this is not a formal import table -- it is a string pattern that the loader interprets.[7]

Visualizing a Real BOF

Here is what a minimal BOF looks like at the binary level after compilation:

TEXTSource: void go(char* a, int l) { BeaconPrintf(0, "hello"); }

After compilation (x86_64-w64-mingw32-gcc -c):

COFF Header:       Machine=0x8664, Sections=4, Symbols=12
Section 1: .text   Size=0x2A, 1 relocation  (the go() code)
Section 2: .data   Size=0x00, 0 relocations (empty)
Section 3: .rdata  Size=0x06, 0 relocations ("hello\0")
Section 4: .xdata  Size=0x08, 0 relocations (unwind info)

Symbol Table:
  [0] .text     Section=1, Class=STATIC, Value=0
  [2] .data     Section=2, Class=STATIC, Value=0
  [4] .rdata    Section=3, Class=STATIC, Value=0
  [6] go        Section=1, Class=EXTERNAL, Value=0  <-- entry point
  [7] __imp_BeaconPrintf  Section=0, Class=EXTERNAL  <-- unresolved import

Relocations for .text:
  Offset=0x0F, Symbol=4 (.rdata), Type=REL32   <-- reference to "hello" string
  Offset=0x1A, Symbol=7 (__imp_BeaconPrintf), Type=REL32  <-- call to BeaconPrintf

Pop Quiz: COFF Format

Q1: How does COFFLoader find the string table in a COFF file?

The string table immediately follows the symbol table. Since each symbol entry is exactly 18 bytes, the string table offset is PointerToSymbolTable + (NumberOfSymbols * sizeof(coff_sym_t)). The first 4 bytes of the string table give its total size.

Q2: A symbol has SectionNumber=0 and StorageClass=2. What does this mean?

StorageClass 2 is IMAGE_SYM_CLASS_EXTERNAL. SectionNumber 0 means IMAGE_SYM_UNDEFINED -- the symbol is not defined in any section of this object file. Combined, this means it is an external import that the loader must resolve (e.g., a DLL function or Beacon API call).

Q3: How are symbol names longer than 8 characters stored in the COFF symbol table?

In the coff_sym_t union, if first.value[0] is zero, then first.value[1] is an offset into the string table where the full null-terminated name is stored. This is the standard COFF convention for long symbol names, and COFFLoader uses this to look up names like "__imp_KERNEL32$GetProcAddress".

References

  1. Microsoft Corporation, "PE Format -- COFF File Header (Object and Image)," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image.
  2. Microsoft Corporation, "PE Format -- Machine Types," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#machine-types.
  3. Microsoft Corporation, "PE Format -- COFF File Header," Microsoft Learn. The NumberOfSymbols field counts all entries including auxiliary symbol records.
  4. Microsoft Corporation, "PE Format -- Section Table (Section Headers)," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-table-section-headers.
  5. Microsoft Corporation, "PE Format -- Section Flags," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-flags.
  6. Microsoft Corporation, "PE Format -- COFF Symbol Table," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-symbol-table.
  7. TrustedSec, "COFFLoader -- Beacon Object File Loader," GitHub, github.com/trustedsec/COFFLoader.
  8. Microsoft Corporation, "PE Format -- COFF String Table," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-string-table.
  9. Microsoft Corporation, "PE Format -- COFF Relocations (Object Only)," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-relocations-object-only.
  10. Microsoft Corporation, "PE Format -- x64 Processors (Type Indicators)," Microsoft Learn, learn.microsoft.com/en-us/windows/win32/debug/pe-format#x64-processors.

Further Reading