Difficulty: Beginner

Module 2: The PE File Format

Every .exe and .dll is a PE. AceLdr manually parses one in memory.

The Big Picture

A Portable Executable (PE) is the file format for Windows executables, DLLs, and drivers. When AceLdr "reflectively loads" Beacon, it's manually doing what the Windows loader (ntdll!LdrLoadDll) normally does: parsing the PE, mapping sections, resolving imports, and applying relocations. Understanding the PE format is mandatory.

PE Structure Overview

PE File Layout

DOS Header (IMAGE_DOS_HEADER) - starts with "MZ"
DOS Stub (legacy, usually "This program cannot be run in DOS mode")
NT Headers (IMAGE_NT_HEADERS) - starts with "PE\0\0"
├ Signature
├ FileHeader (machine, number of sections, timestamp)
└ OptionalHeader (entry point, image base, data directories)
Section Headers[] - .text, .rdata, .data, .reloc, etc.
Section Data (actual code and data)

The DOS Header

Every PE file starts with the IMAGE_DOS_HEADER structure. The very first two bytes are 0x4D 0x5A ("MZ" - Mark Zbikowski's initials, one of the original MS-DOS architects). The most important field for modern use is e_lfanew, a 4-byte offset at position 0x3C that tells you where the NT Headers begin.

The NT Headers

Located at the offset specified by e_lfanew, the NT Headers start with the signature 0x50 0x45 0x00 0x00 ("PE\0\0"). They contain two sub-structures:

Key Structures in AceLdr

AceLdr's ace.c directly accesses these PE structures:

C - from ace.c calculateRegions()// The DOS header is the first thing in any PE
pReg->Dos = C_PTR( G_END() );   // Points to start of Beacon PE

// e_lfanew tells us where NT headers begin (offset from DOS header)
pReg->NT  = C_PTR( U_PTR( pReg->Dos ) + pReg->Dos->e_lfanew );

// SizeOfImage = total virtual size needed when mapped into memory
ILn = ( ( ( pReg->NT->OptionalHeader.SizeOfImage ) + 0x1000 - 1 )
        &~( 0x1000 - 1 ) );  // Round up to page boundary

Understanding the Page Alignment

The expression (size + 0x1000 - 1) & ~(0x1000 - 1) is a common bit trick to round up to the nearest 4 KB page boundary. For example, if SizeOfImage is 0x3200 (12,800 bytes), this rounds it up to 0x4000 (16,384 = 4 pages). This ensures the allocation is properly aligned for virtual memory management.

Sections: Where Code and Data Live

A PE is divided into sections. Each has a name, virtual address, raw data offset, and characteristics (permissions):

SectionContainsTypical Protection
.textExecutable codeRX (Read + Execute)
.rdataRead-only data, import tables, stringsR (Read Only)
.dataGlobal/static initialized variablesRW (Read + Write)
.relocBase relocation tableR (Read Only)

How AceLdr Copies Sections

After allocating memory for the entire image, AceLdr iterates through each section header and copies the raw data to its correct virtual address offset:

C - from ace.c copyBeaconSections()// Map = destination base address (after our stub)
Map = C_PTR( U_PTR( buffer ) + reg.Exec );

// Get pointer to first section header
Sec = IMAGE_FIRST_SECTION( reg.NT );

// Copy each section to its virtual address
for( int i = 0; i < reg.NT->FileHeader.NumberOfSections; ++i )
{
    Destination = C_PTR( U_PTR( Map ) + Sec[i].VirtualAddress );
    Source      = C_PTR( U_PTR( reg.Dos ) + Sec[i].PointerToRawData );
    Length      = Sec[i].SizeOfRawData;
    memcpy( Destination, Source, Length );
}

Key Insight: Virtual vs Raw Addresses

VirtualAddress is the RVA (Relative Virtual Address) where the section should be mapped in memory. PointerToRawData is the offset in the file where the section's data actually lives. These are different because the PE file on disk is packed tightly, but in memory each section is aligned to page boundaries (typically 0x1000).

The Import Address Table (IAT)

When a PE calls Sleep() or HeapAlloc(), it doesn't know the addresses at compile time. Instead, it uses the IAT - a table of function pointers filled in at load time. AceLdr fills this table manually via LdrProcessIat() and then overwrites specific entries with hooks.

IAT Hook Flow

Beacon calls
Sleep()
IAT Entry
(overwritten)
AceLdr's
Sleep_Hook()
Encrypt + APC
chain sleep

The IAT resolution process works in two passes:

  1. First pass (LdrProcessIat): Walk each import descriptor, load the required DLL (or find it already loaded), and fill in every function pointer in the IAT with the real address.
  2. Second pass (installHooks): Overwrite 6 specific IAT entries with pointers to AceLdr's hook functions. Beacon will call these hooks instead of the real Windows APIs.

Base Relocations

A PE is compiled with a preferred base address (OptionalHeader.ImageBase). If it gets loaded at a different address (which AceLdr guarantees, since it uses NtAllocateVirtualMemory with no preferred base), all absolute addresses in the code are wrong. Relocations fix this by adding the delta between the preferred and actual base:

C - from util.c LdrProcessRel()// Calculate offset between actual and preferred address
Ofs = U_PTR( U_PTR( image ) - U_PTR( imageBase ) );

while ( Ibr->VirtualAddress != 0 ) {
    Rel = ( PIMAGE_RELOC )( Ibr + 1 );
    while ( C_PTR( Rel ) != C_PTR( U_PTR( Ibr ) + Ibr->SizeOfBlock ) )
    {
        switch( Rel->Type ) {
            case IMAGE_REL_BASED_DIR64:  // 64-bit absolute fixup
                *( DWORD64 * )( U_PTR( image ) + Ibr->VirtualAddress
                    + Rel->Offset ) += ( DWORD64 )( Ofs );
                break;
            case IMAGE_REL_BASED_HIGHLOW: // 32-bit absolute fixup
                *( DWORD32 * )( U_PTR( image ) + Ibr->VirtualAddress
                    + Rel->Offset ) += ( DWORD32 )( Ofs );
                break;
        }
        ++Rel;
    }
    Ibr = C_PTR( Rel );
}

Understanding Relocations

The relocation table is organized into blocks. Each block covers a page (identified by VirtualAddress) and contains a list of entries. Each entry has a Type and an Offset within that page. For IMAGE_REL_BASED_DIR64 (the most common on x64), the fix is simple: add the delta to the 8-byte value at the specified address. This patches all absolute addresses so the code works at its new location.

Pop Quiz: PE Format

Q1: What does e_lfanew in the DOS header point to?

e_lfanew is an offset from the start of the DOS header to the PE signature and NT headers. It's the bridge from the legacy DOS header to the modern PE structure.

Q2: Why does AceLdr need to process base relocations?

When a PE is loaded at a different base address than it was compiled for, absolute addresses in the code are wrong. Relocations patch these addresses by adding the delta. AceLdr uses NtAllocateVirtualMemory which gives an arbitrary address, so relocations are essential.

Q3: What does AceLdr do to the IAT after populating it normally?

After LdrProcessIat() fills in all the normal function pointers, installHooks() uses LdrHookImport() to replace specific entries (Sleep, GetProcessHeap, HeapAlloc, InternetConnectA, NtWaitForSingleObject, RtlAllocateHeap) with AceLdr's custom hook functions.