Module 4: COFF Section Loading
Parsing section headers, allocating memory, and copying raw section data into executable pages.
Why This Module?
Before COFFLoader can resolve symbols or apply relocations, it must load the raw section data into memory. This module covers exactly how COFFLoader parses the COFF file header, locates the section table, allocates memory for each section, and copies the raw data. This is the first phase of the RunCOFF() function.
Lab Files: step04-section-loading
First version of the loader. Reads a COFF file, parses headers, allocates RWX memory for each section, and copies raw data. Cannot execute BOFs yet -- no symbol resolution or relocations.
RunCOFF Entry Point
The main function that orchestrates everything is RunCOFF(). Its signature:
Cint RunCOFF(
char* functionname, // entry function name (e.g., "go")
unsigned char* coff_data, // raw COFF file bytes in memory
uint32_t filesize, // size of the COFF data
unsigned char* argumentdata, // packed argument buffer (or NULL)
int argumentSize // size of argument buffer
);
The first thing RunCOFF() does is cast the raw buffer to the COFF header structure and extract the critical offsets:
C// Step 1: Parse the COFF file header
coff_file_header_t* coff_header = (coff_file_header_t*)coff_data;
// Step 2: Locate the section table (immediately after the file header)
coff_sect_t* sections = (coff_sect_t*)(coff_data + sizeof(coff_file_header_t));
// Step 3: Locate the symbol table
coff_sym_t* symbols = (coff_sym_t*)(coff_data + coff_header->PointerToSymbolTable);
// Step 4: Locate the string table (immediately after symbol table)
char* string_table = ((char*)symbols) + (coff_header->NumberOfSymbols * sizeof(coff_sym_t));
Pointer Setup in RunCOFF
raw buffer start
+0 (20 bytes)
+20 (40 bytes each)
variable
Section Memory Allocation
COFFLoader allocates a separate memory region for each section using VirtualAlloc.[1] The key detail: all sections are allocated with PAGE_EXECUTE_READWRITE permissions, regardless of whether they contain code or data.[6]
C// COFFLoader allocates memory for each section
// sectionMapping[] stores the base address of each allocated region
char** sectionMapping = (char**)calloc(
coff_header->NumberOfSections,
sizeof(char*)
);
for (int i = 0; i < coff_header->NumberOfSections; i++) {
sectionMapping[i] = (char*)VirtualAlloc(
NULL, // let OS choose the address
sections[i].SizeOfRawData, // size of this section
MEM_COMMIT | MEM_RESERVE, // commit the pages immediately
PAGE_EXECUTE_READWRITE // RWX permissions
);
// Copy the raw section data from the COFF file into the new allocation
if (sections[i].SizeOfRawData > 0) {
memcpy(
sectionMapping[i],
coff_data + sections[i].PointerToRawData,
sections[i].SizeOfRawData
);
}
}
Why RWX for Everything?
COFFLoader allocates all sections as PAGE_EXECUTE_READWRITE.[6] This is a simplification: ideally, .text would be RX, .rdata would be R, and .data/.bss would be RW. Using RWX for everything avoids the complexity of tracking per-section permissions but creates a larger detection surface. RWX memory regions are uncommon in legitimate applications and are flagged by many EDR heuristics.[5] Production-quality loaders (like bof-launcher) apply proper per-section protections after relocations are applied.[9]
Security Implications of RWX Memory
Allocating memory with PAGE_EXECUTE_READWRITE is one of the most significant OPSEC concerns in any in-memory loader.[5] Understanding why this matters -- and how to mitigate it -- is essential for anyone building or studying loader technology.
Why EDRs Flag RWX Regions
In normal application execution, memory pages follow the W^X (write XOR execute) principle: a page is either writable or executable, but rarely both simultaneously.[6] Legitimate programs almost never allocate RWX memory. The Windows PE loader itself maps .text sections as PAGE_EXECUTE_READ and .data sections as PAGE_READWRITE. When an EDR detects a VirtualAlloc call requesting PAGE_EXECUTE_READWRITE, it raises the suspicion score for that process significantly.[5]
Modern EDRs employ several techniques to detect RWX memory:
- Userland hooking: Hooks on
NtAllocateVirtualMemoryintercept allocation requests and log any that include both EXECUTE and WRITE permissions.[8] - Periodic memory scanning: EDR agents periodically walk the process virtual address space using
VirtualQuery, flagging any regions with RWX protection that were not part of the original PE image.[8] - ETW telemetry: Event Tracing for Windows can surface memory allocation events to the kernel-level EDR driver, making userland unhooking insufficient to evade detection.
The VirtualProtect Mitigation
A production-quality loader avoids holding RWX memory for longer than necessary. The standard mitigation is a two-phase approach using VirtualProtect:[4]
- Allocation phase: Allocate all sections with
PAGE_READWRITE. This allows writing section data and applying relocations without triggering execute-plus-write detection. - Protection phase: After all relocations are resolved and patched, call
VirtualProtecton each section to set the correct final protection based on the section'sCharacteristicsflags --PAGE_EXECUTE_READfor .text,PAGE_READONLYfor .rdata,PAGE_READWRITEfor .data and .bss.[6]
This approach matches the behavior of the legitimate Windows PE loader and significantly reduces the detection surface. The bof-launcher project demonstrates this pattern in its section loading implementation.[9]
Handling Different Section Types
Different sections require different handling during the loading phase:
| Section | SizeOfRawData | PointerToRawData | Loading Behavior |
|---|---|---|---|
| .text | Non-zero | Non-zero | Copy raw bytes (compiled machine code) |
| .data | Non-zero or 0 | Non-zero or 0 | Copy initialized data, or allocate zero-filled |
| .rdata | Non-zero | Non-zero | Copy read-only data (string literals, constants) |
| .bss | 0 | 0 | Allocate zero-filled memory (VirtualAlloc zero-initializes) |
| .xdata | Non-zero | Non-zero | Copy exception unwind data |
| .pdata | Non-zero | Non-zero | Copy function table entries |
The .bss Section
The .bss section is special: it contains uninitialized data (global variables declared without an initial value). It has SizeOfRawData == 0 because there is nothing to store in the file -- all values are zero at startup.[2] The section's VirtualSize field indicates how much memory to allocate. Since VirtualAlloc zero-initializes committed pages, the .bss data is automatically correct after allocation.[1]
C// For .bss sections:
// sections[i].SizeOfRawData == 0
// sections[i].Characteristics & IMAGE_SCN_CNT_UNINITIALIZED_DATA
//
// COFFLoader checks: if SizeOfRawData > 0, copy data.
// Otherwise, the VirtualAlloc already provides zero-filled memory.
if (sections[i].SizeOfRawData > 0) {
memcpy(sectionMapping[i],
coff_data + sections[i].PointerToRawData,
sections[i].SizeOfRawData);
}
Edge Cases in Section Loading
While the basic loading loop handles common sections correctly, several edge cases require careful attention in a robust loader:
Zero-size sections beyond .bss: Not only .bss but any section can have SizeOfRawData of zero. Some compilers emit empty .data sections when there are no initialized globals, or empty .rdata sections when there are no string constants. COFFLoader's guard if (SizeOfRawData > 0) correctly skips the memcpy for these, but passing zero to VirtualAlloc as the size parameter causes the call to fail -- the function requires a non-zero dwSize argument.[1] A defensive loader should detect this condition and either skip the allocation entirely or allocate a minimum page size (4096 bytes) to hold a valid mapping entry.
The IMAGE_SCN_CNT_UNINITIALIZED_DATA flag: Sections containing uninitialized data set the IMAGE_SCN_CNT_UNINITIALIZED_DATA (0x00000080) characteristic flag.[2] While .bss is the canonical example, the COFF specification allows any section name to carry this flag. A loader that makes decisions based on section names (comparing against the string ".bss") rather than characteristic flags will miss non-standard uninitialized sections. COFFLoader avoids this pitfall by checking SizeOfRawData rather than the section name, making it resilient to unusual compiler output.
Alignment requirements: Section headers encode alignment requirements in bits 20 through 23 of the Characteristics field.[10] Common values include 1-byte (IMAGE_SCN_ALIGN_1BYTES), 4-byte, and 16-byte alignment. While VirtualAlloc always returns page-aligned addresses (4096-byte boundary on x86 and x64), which satisfies any section alignment requirement,[1] a loader that allocates a single contiguous block and partitions it into sections must manually ensure each section starts at its required alignment boundary. Failing to align properly can cause access violations on SIMD instructions that require 16-byte aligned operands (such as SSE movaps), or subtle data corruption from misaligned structure field access.
Section Characteristics Deep Dive
The Characteristics field in each section header is a bitmask that describes the section's properties.[2] COFFLoader defines these flags:
C// Content type flags
#define IMAGE_SCN_CNT_CODE 0x00000020 // contains executable code
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // contains uninitialized data
// Memory permission flags
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // can be executed
#define IMAGE_SCN_MEM_READ 0x40000000 // can be read
#define IMAGE_SCN_MEM_WRITE 0x80000000 // can be written
// Other flags
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // can be discarded after loading
#define IMAGE_SCN_ALIGN_16BYTES 0x00500000 // 16-byte alignment requirement
Determining Section Purpose from Characteristics
C// How to identify section types from characteristics:
// .text section: code + execute + read
// Characteristics = 0x60000020
// IMAGE_SCN_CNT_CODE (0x20) | IMAGE_SCN_MEM_EXECUTE (0x20000000) | IMAGE_SCN_MEM_READ (0x40000000)
// .data section: initialized + read + write
// Characteristics = 0xC0000040
// IMAGE_SCN_CNT_INITIALIZED_DATA (0x40) | IMAGE_SCN_MEM_READ (0x40000000) | IMAGE_SCN_MEM_WRITE (0x80000000)
// .rdata section: initialized + read only
// Characteristics = 0x40000040
// IMAGE_SCN_CNT_INITIALIZED_DATA (0x40) | IMAGE_SCN_MEM_READ (0x40000000)
// .bss section: uninitialized + read + write
// Characteristics = 0xC0000080
// IMAGE_SCN_CNT_UNINITIALIZED_DATA (0x80) | IMAGE_SCN_MEM_READ (0x40000000) | IMAGE_SCN_MEM_WRITE (0x80000000)
The sectionMapping Array
After loading, the sectionMapping array is the bridge between the COFF file's section indices (0-based) and the actual memory addresses where those sections live. Every subsequent operation (symbol resolution, relocation application) uses this array to convert section references to real pointers.
TEXTsectionMapping[] after loading a typical BOF:
Index Section Base Address Size
[0] .text 0x00007FF8A1230000 0x15C (348 bytes of code)
[1] .rdata 0x00007FF8A1240000 0x02A (42 bytes of strings)
[2] .data 0x00007FF8A1250000 0x010 (16 bytes of globals)
[3] .xdata 0x00007FF8A1260000 0x00C (12 bytes of unwind info)
[4] .pdata 0x00007FF8A1270000 0x00C (12 bytes of func table)
Each address is independent (VirtualAlloc does not guarantee contiguous pages).
Relocations must account for the actual distance between sections.
Why Separate Allocations?
COFFLoader allocates each section independently rather than as one contiguous block. This means sections can be at arbitrary addresses, potentially far apart in the virtual address space. This is important for relocation processing: REL32 relocations encode a 32-bit signed offset, which has a range of +/- 2 GB.[2] If sections are more than 2 GB apart, REL32 relocations will fail. In practice, VirtualAlloc typically returns addresses in the same region, but a production loader should allocate a single contiguous block and partition it into sections to guarantee proximity.
Memory Fragmentation and Address Space Concerns
COFFLoader's approach of calling VirtualAlloc independently for each section introduces several practical concerns related to memory layout:
Virtual address space fragmentation: Each VirtualAlloc call reserves at minimum one page (4096 bytes) even for small sections.[1] A BOF with six sections, each only a few dozen bytes, consumes six full pages -- 24 KB of committed memory for potentially less than 500 bytes of actual data. More importantly, each allocation creates a separate region in the virtual address descriptor (VAD) tree maintained by the Windows memory manager, adding overhead to page table management and increasing the loader's memory footprint visible to diagnostic tools.
REL32 reach limitations: On x64, IMAGE_REL_AMD64_REL32 relocations encode the distance between two addresses as a 32-bit signed integer, limiting the range to approximately +/- 2 GB.[2] While VirtualAlloc typically returns addresses from the same general region of the address space, there is no contractual guarantee.[1] Under memory pressure or with high ASLR entropy, sections could theoretically be placed far enough apart that REL32 relocations overflow, producing silent corruption or an access violation. A single contiguous allocation eliminates this risk entirely.
Cleanup complexity: With N separate allocations, cleanup requires N calls to VirtualFree. If the loader encounters an error midway through allocation (for example, VirtualAlloc fails on the fourth of six sections due to memory exhaustion), it must track which allocations succeeded in order to free them properly. A single contiguous allocation simplifies this to one VirtualFree call regardless of the failure point, and makes the cleanup path significantly less error-prone.
Function Pointer Table Allocation
After loading sections, COFFLoader allocates a function pointer table[3] -- a block of memory that stores resolved addresses for external function call relocations. This is where the __imp_ prefix convention connects: when the BOF code references __imp_KERNEL32$GetProcAddress, it expects to read a function pointer from a known memory location. COFFLoader allocates this table based on the total number of relocations across all sections and fills it during relocation processing (Module 6).
C// Count total relocations across all sections
int totalRelocations = 0;
for (int i = 0; i < coff_header->NumberOfSections; i++) {
totalRelocations += sections[i].NumberOfRelocations;
}
// Allocate the function pointer table
// One 8-byte slot per relocation (on x64)
char* functionMapping = (char*)VirtualAlloc(
NULL,
totalRelocations * sizeof(uint64_t), // 8 bytes per relocation
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // needs to be readable for pointer dereference
);
The functionMapping buffer is indexed by a sequential counter that increments each time an external function call relocation is processed. When the N-th external function relocation is resolved to address 0x00007FFA12340000, that address is stored at functionMapping[N * 8]. The relocation is then patched to reference this slot. Note: the index is NOT the symbol table index -- it is a counter that tracks how many external function relocations have been processed so far.
Complete Loading Phase Summary
TEXTSection Loading Phase (RunCOFF steps 1-5):
1. Cast coff_data to coff_file_header_t*
2. Compute pointers: sections[], symbols[], string_table
3. Allocate sectionMapping[NumberOfSections] array
4. For each section i:
a. VirtualAlloc(NULL, SizeOfRawData, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
b. if SizeOfRawData > 0: memcpy(sectionMapping[i], coff_data + PointerToRawData, SizeOfRawData)
5. Count total relocations across all sections (relocationCount)
6. Allocate functionMapping[relocationCount * 8] for resolved function pointers
After this phase:
- All section data is in RWX memory
- sectionMapping[] maps section index -> memory address
- functionMapping is allocated but empty (filled during relocation processing in Module 6)
- No relocations applied yet (code contains placeholder offsets)
Pop Quiz: Section Loading
Q1: What memory protection does COFFLoader use when allocating section memory?
Q2: How does COFFLoader handle .bss sections that have SizeOfRawData == 0?
Q3: What is the purpose of the functionMapping buffer?
References
- Microsoft, "VirtualAlloc function (memoryapi.h)," Microsoft Learn, 2024. learn.microsoft.com
- Microsoft, "PE Format -- Section Table (Section Headers)," Microsoft Learn, 2024. learn.microsoft.com
- TrustedSec, "COFFLoader -- In-memory COFF/BOF Loader," GitHub, 2022. github.com/trustedsec/COFFLoader
- Microsoft, "VirtualProtect function (memoryapi.h)," Microsoft Learn, 2024. learn.microsoft.com
- Elastic Security Labs, "Hunting In Memory," Elastic Blog, 2022. elastic.co/security-labs/hunting-memory
- Microsoft, "Memory Protection Constants," Microsoft Learn, 2024. learn.microsoft.com
- Microsoft, "PE Format -- Section Flags," Microsoft Learn, 2024. learn.microsoft.com
- CrowdStrike, "Enhancing Fileless Attack Detection with Memory Scanning," CrowdStrike Blog, 2024. crowdstrike.com/blog
- The-Z-Labs, "bof-launcher -- BOF Loader Library in Zig," GitHub, 2023. github.com/The-Z-Labs/bof-launcher
- Microsoft, "IMAGE_SECTION_HEADER structure (winnt.h)," Microsoft Learn, 2024. learn.microsoft.com
Further Reading
- Virtual Memory Functions (Microsoft Learn) — Complete reference for VirtualAlloc, VirtualProtect, VirtualFree, and VirtualQuery with usage examples and parameter details.
- PE Format Specification (Microsoft Learn) — The authoritative reference for COFF and PE format internals, including section headers, characteristics flags, and relocation types.
- TrustedSec COFFLoader (GitHub) — The original open-source COFFLoader implementation that this course is based on. Study the RunCOFF function for the complete section loading flow.
- bof-launcher (GitHub) — A production-quality BOF loader written in Zig that demonstrates proper per-section memory protections and single-block allocation strategies.
- Elastic Security Labs -- ETW and Memory Detection — Research on how EDR products leverage ETW tracing and memory scanning to detect in-memory threats including RWX allocations.
- No Code Execution? No Problem! Living The Age of VBS, HVCI, and Kernel CFG (Connor McGarr) — Deep dive into how HVCI prevents RWX memory in kernel mode, relevant context for understanding why W^X enforcement matters for in-memory loaders.
- Carving PE Files With pecheck.py (Didier Stevens) — Practical walkthrough of parsing PE/COFF structures with Didier Stevens' pecheck tool, demonstrating section header analysis and structural validation.