Chapter 04

PE Format for Developers

Malware development is constant PE manipulation — you hollow processes, reflectively load DLLs, fix import tables, stomp headers, and inject into sections. Every one of those operations requires reading and writing the Portable Executable format at the byte level. This chapter builds the mental model you'll reach for every time you touch an executable file.

What a PE File Actually Is

A PE (Portable Executable) file is the binary format Windows uses for executables (.exe), dynamic libraries (.dll), kernel drivers (.sys), and several other file types. Every one of them has the same structure. The format is decades old but still exactly what ships on every Windows system today.

At the highest level, a PE file on disk is a map of what the Windows loader should put into virtual memory, and how to configure the process around it. The headers describe the layout. The sections contain the content. The data directories point to special tables — imports, exports, relocations, resources — that the loader processes to make the file executable.

PE file layout — on disk and in memory
  ON DISK                          IN MEMORY (after loader maps it)
  ┌──────────────────────┐         ┌──────────────────────────────────┐
  │  DOS Header (64 B)   │         │  DOS Header                      │
  │  MZ magic at 0x00    │         │  (sometimes stomped by malware)  │
  │  e_lfanew at 0x3C    │─────────│─► offset to NT headers           │
  ├──────────────────────┤         ├──────────────────────────────────┤
  │  DOS Stub            │         │  DOS Stub                        │
  │  (tiny 16-bit prog)  │         │  "This program cannot be run..." │
  ├──────────────────────┤         ├──────────────────────────────────┤
  │  PE Signature        │         │  PE Signature  ("PE\0\0")        │
  │  "PE\0\0"            │         ├──────────────────────────────────┤
  ├──────────────────────┤         │  File Header (COFF)              │
  │  File Header         │         │  Optional Header                 │
  │  Optional Header     │         │  DataDirectory[16]               │
  │  DataDirectory[16]   │         ├──────────────────────────────────┤
  ├──────────────────────┤         │  Section Headers                 │
  │  Section Headers     │         │  (one per section)               │
  ├──────────────────────┤         ├──────────────────────────────────┤
  │  .text (raw data)    │   map   │  .text mapped at VirtualAddress  │
  │  [file-aligned]      │ ──────► │  [section-aligned, padded]       │
  ├──────────────────────┤         ├──────────────────────────────────┤
  │  .rdata (raw data)   │   map   │  .rdata mapped at VirtualAddress │
  ├──────────────────────┤ ──────► ├──────────────────────────────────┤
  │  .data (raw data)    │   map   │  .data mapped at VirtualAddress  │
  └──────────────────────┘ ──────► └──────────────────────────────────┘

  Key difference: on disk, sections are FileAlignment-aligned (usually 0x200).
  In memory, sections are SectionAlignment-aligned (usually 0x1000 = 4KB page).
  The loader maps each section from its file offset to its virtual address.
        

The Header Chain

Navigating a PE always starts from the very first byte of the file and follows a fixed chain of offsets. In C, every step is a simple pointer cast — the Windows SDK provides all the structure definitions in <windows.h>.

DOS Header — IMAGE_DOS_HEADER

IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
// dos->e_magic  == 0x5A4D  ('MZ' — Mark Zbikowski's initials)
// dos->e_lfanew == byte offset from base to the PE signature

The DOS header exists purely for backwards compatibility with MS-DOS. The only field that matters for modern PE parsing is e_lfanew: it holds the offset to the actual PE signature. Everything between the DOS header and that offset is the DOS stub — the small 16-bit program that prints "This program cannot be run in DOS mode." Most malware stomps the MZ magic bytes and the DOS stub after loading into memory (Part 7), because memory scanners search for these patterns to identify PE images.

NT Headers — IMAGE_NT_HEADERS64

IMAGE_NT_HEADERS64* nt = (IMAGE_NT_HEADERS64*)((BYTE*)base + dos->e_lfanew);
// nt->Signature              == 0x00004550  ('PE\0\0')
// nt->FileHeader             (IMAGE_FILE_HEADER)
// nt->OptionalHeader         (IMAGE_OPTIONAL_HEADER64)
IMAGE_FILE_HEADER fields that matter
  Field               Size   Meaning
  ──────────────────────────────────────────────────────────────────
  Machine             WORD   Architecture: 0x8664 = x64, 0x014C = x86
  NumberOfSections    WORD   How many sections follow the headers
  TimeDateStamp       DWORD  Compile timestamp (often zeroed in malware)
  SizeOfOptionalHeader WORD  Size of the Optional Header (varies)
  Characteristics     WORD   Flags: 0x0002 = executable file,
                              0x2000 = DLL, 0x0100 = 32-bit only
        
IMAGE_OPTIONAL_HEADER64 fields that matter
  Field                   Size    Meaning
  ──────────────────────────────────────────────────────────────────
  Magic                   WORD    0x020B = PE32+ (64-bit)
                                  0x010B = PE32  (32-bit)
  AddressOfEntryPoint     DWORD   RVA of the entry point function
  ImageBase               ULONGLONG Preferred load address (usually
                                    0x140000000 for EXE, 0x180000000 for DLL)
  SectionAlignment        DWORD   Section alignment in memory (usually 0x1000)
  FileAlignment           DWORD   Section alignment on disk (usually 0x200)
  SizeOfImage             DWORD   Total size needed in virtual memory
  SizeOfHeaders           DWORD   Combined size of all headers (file-aligned)
  CheckSum                DWORD   Integrity checksum (only validated for drivers)
  Subsystem               WORD    2 = GUI, 3 = console, 1 = native (drivers)
  DllCharacteristics      WORD    Flags: ASLR, DEP, CFG, etc.
  DataDirectory[16]       array   16 pointers to special tables (imports, exports, etc.)
        

Section Headers — IMAGE_SECTION_HEADER

Immediately after the Optional Header comes an array of section headers — one per section, NumberOfSections entries total.

// First section header starts right after the NT headers
IMAGE_SECTION_HEADER* sections = (IMAGE_SECTION_HEADER*)(
    (BYTE*)&nt->OptionalHeader + nt->FileHeader.SizeOfOptionalHeader
);
// sections[0] = first section (.text usually)
// sections[1] = second section (.rdata usually)
// ... up to sections[nt->FileHeader.NumberOfSections - 1]
IMAGE_SECTION_HEADER fields
  Field               Size    Meaning
  ──────────────────────────────────────────────────────────────────
  Name                8 bytes ASCII name (NOT null-terminated if exactly 8 chars)
                              Common names: .text, .data, .rdata, .bss, .rsrc, .reloc
  VirtualSize         DWORD   Actual size of section content in memory
  VirtualAddress      DWORD   RVA where this section maps in memory
  SizeOfRawData       DWORD   Size of section on disk (file-aligned)
  PointerToRawData    DWORD   File offset where section data begins on disk
  Characteristics     DWORD   Permission flags:
                                0x20000000 = executable
                                0x40000000 = readable
                                0x80000000 = writable
                              Combine: code section = 0x60000020
                                       data section = 0xC0000040
        

RVA vs VA vs File Offset — The Most Common Source of Confusion

Nearly every number inside a PE header is a Relative Virtual Address (RVA). This is an offset from the image base — not from the start of the file, and not an absolute memory address. Getting confused between these three coordinate systems is responsible for most PE parsing bugs.

Three coordinate systems
  RVA (Relative Virtual Address)
  ──────────────────────────────────────────────────────────────────
  What it is:  Offset from the start of the image when loaded in memory.
  Example:     AddressOfEntryPoint = 0x1000
               Means: the entry point is 0x1000 bytes past the image base.
  To get VA:   VA = ImageBase + RVA
               If image loaded at 0x140000000: entry at 0x140001000

  VA (Virtual Address)
  ──────────────────────────────────────────────────────────────────
  What it is:  Absolute address in the process's virtual address space.
  Used for:    Pointers at runtime, setting thread context (RIP/EIP).
  Note:        The image may NOT be at its preferred ImageBase (ASLR).
               Always compute VA from actual load address, not ImageBase.

  File Offset
  ──────────────────────────────────────────────────────────────────
  What it is:  Byte offset from the very start of the PE file on disk.
  Used for:    Reading sections from disk (process hollowing, reflective load).
  NOT the same as RVA — sections are laid out differently on disk vs memory.

  Converting RVA → File Offset:
  ──────────────────────────────────────────────────────────────────
  For each section header:
    if (RVA >= section.VirtualAddress &&
        RVA <  section.VirtualAddress + section.SizeOfRawData):
        FileOffset = RVA - section.VirtualAddress + section.PointerToRawData

  Converting RVA → VA (at runtime, image loaded at actual_base):
  ──────────────────────────────────────────────────────────────────
  VA = (BYTE*)actual_base + RVA
        
// RVA to file offset conversion — used when reading a PE from disk
DWORD rva_to_file_offset(IMAGE_NT_HEADERS64* nt, DWORD rva) {
    IMAGE_SECTION_HEADER* sec = (IMAGE_SECTION_HEADER*)(
        (BYTE*)&nt->OptionalHeader + nt->FileHeader.SizeOfOptionalHeader);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        DWORD sec_start = sec[i].VirtualAddress;
        DWORD sec_end   = sec_start + sec[i].SizeOfRawData;
        if (rva >= sec_start && rva < sec_end)
            return rva - sec_start + sec[i].PointerToRawData;
    }
    return 0; // not found in any section — may be in the header area
}

// RVA to virtual address (runtime, image already mapped)
// base = actual load address (DllBase from LDR entry, or allocation base)
void* rva_to_va(void* base, DWORD rva) {
    return (BYTE*)base + rva;
}

Data Directories

The Optional Header's DataDirectory array has 16 entries. Each entry holds an RVA and a size pointing to a special table. You will work with almost all of these at some point in this book:

The 16 data directories and when you use them
  Index  Name                      When you use it
  ──────────────────────────────────────────────────────────────────────
   0     IMAGE_DIRECTORY_ENTRY_EXPORT       Ch03 (PEB walk), Ch06 (shellcode)
   1     IMAGE_DIRECTORY_ENTRY_IMPORT       Right now (fixing IAT in loaders)
   2     IMAGE_DIRECTORY_ENTRY_RESOURCE     Ch07 (PE resource spoofing)
   3     IMAGE_DIRECTORY_ENTRY_EXCEPTION    Stack unwinding (.pdata for x64)
   4     IMAGE_DIRECTORY_ENTRY_SECURITY     Authenticode signature (Ch07)
   5     IMAGE_DIRECTORY_ENTRY_BASERELOC    Relocation table (reflective loader)
   6     IMAGE_DIRECTORY_ENTRY_DEBUG        Debug info / PDB path (stomp this)
   7     IMAGE_DIRECTORY_ENTRY_ARCHITECTURE (unused on x64)
   8     IMAGE_DIRECTORY_ENTRY_GLOBALPTR    (unused on x64)
   9     IMAGE_DIRECTORY_ENTRY_TLS          TLS callbacks (anti-debug tricks)
  10     IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG  CFG, SEH handler table, stack cookie
  11     IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (legacy pre-binding, ignore)
  12     IMAGE_DIRECTORY_ENTRY_IAT          Import Address Table hint
  13     IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT Delay-loaded imports
  14     IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR .NET CLR header (Ch16)
  15     (reserved)
        
// Accessing a data directory
IMAGE_DATA_DIRECTORY* dir = &nt->OptionalHeader.DataDirectory[1]; // imports
if (dir->VirtualAddress == 0) {
    // This directory doesn't exist in this PE
}
IMAGE_IMPORT_DESCRIPTOR* imports =
    (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)base + dir->VirtualAddress);

The Import Table — IAT vs INT

The import directory (DataDirectory[1]) is the most important structure for shellcode and injection work. It describes every DLL the PE depends on and every function it imports. Understanding the difference between the Import Name Table (INT) and the Import Address Table (IAT) is critical — they look identical when the file is on disk but have very different roles at runtime.

Import descriptor for one DLL
  IMAGE_IMPORT_DESCRIPTOR  (one per imported DLL, null-terminated array)
  ┌──────────────────────────────────────────────────────────────┐
  │ OriginalFirstThunk  DWORD (RVA)  → Import Name Table (INT)   │
  │ TimeDateStamp       DWORD        (0 before binding)          │
  │ ForwarderChain      DWORD        (ignore for now)            │
  │ Name                DWORD (RVA)  → "kernel32.dll\0"          │
  │ FirstThunk          DWORD (RVA)  → Import Address Table (IAT)│
  └──────────────────────────────────────────────────────────────┘

  Both INT and IAT point to arrays of IMAGE_THUNK_DATA64 (8-byte entries):

  Before loading (on disk):              After loading (in memory):
  ┌────────────────────────┐             ┌────────────────────────┐
  │ INT[0]  RVA→"VirtualAlloc\0"│        │ INT[0]  (unchanged)   │
  │ INT[1]  RVA→"VirtualFree\0" │        │ INT[1]  (unchanged)   │
  │ INT[2]  0 (terminator) │             │ INT[2]  0             │
  └────────────────────────┘             └────────────────────────┘

  ┌────────────────────────┐             ┌────────────────────────┐
  │ IAT[0]  RVA→"VirtualAlloc\0"│  ──►  │ IAT[0]  0x7FFB1234ABCD│ ← actual addr
  │ IAT[1]  RVA→"VirtualFree\0" │  ──►  │ IAT[1]  0x7FFB1234EF01│ ← actual addr
  │ IAT[2]  0 (terminator) │             │ IAT[2]  0             │
  └────────────────────────┘             └────────────────────────┘

  The Windows loader overwrites the IAT entries with real addresses.
  The INT stays unchanged — it's the original reference that tells the
  loader what to look for.

  At runtime, your call to VirtualAlloc goes:
  CALL [IAT entry for VirtualAlloc]  →  0x7FFB1234ABCD (actual VirtualAlloc)
        

Each IMAGE_THUNK_DATA64 entry is a union with two meanings. If the high bit (bit 63 on x64) is set, the remaining 63 bits are an ordinal number. If the high bit is clear, the value is an RVA pointing to an IMAGE_IMPORT_BY_NAME structure containing a hint (ordinal hint for fast lookup) followed by the function name string.

// Walking the import table — what a reflective loader does
void fix_imports(void* base) {
    IMAGE_NT_HEADERS64* nt = get_nt_headers(base);
    IMAGE_IMPORT_DESCRIPTOR* imp = (IMAGE_IMPORT_DESCRIPTOR*)(
        (BYTE*)base + nt->OptionalHeader.DataDirectory[1].VirtualAddress);

    // Walk each imported DLL (array ends with a null entry)
    for (; imp->Name; imp++) {
        char* dll_name = (char*)((BYTE*)base + imp->Name);
        HMODULE dll = LoadLibraryA(dll_name);
        if (!dll) continue;

        // Walk the IAT entries for this DLL
        IMAGE_THUNK_DATA64* iat = (IMAGE_THUNK_DATA64*)(
            (BYTE*)base + imp->FirstThunk);
        IMAGE_THUNK_DATA64* intt = (IMAGE_THUNK_DATA64*)(
            (BYTE*)base + imp->OriginalFirstThunk);

        for (; intt->u1.AddressOfData; iat++, intt++) {
            FARPROC func;
            if (IMAGE_SNAP_BY_ORDINAL64(intt->u1.Ordinal)) {
                // Import by ordinal
                func = GetProcAddress(dll,
                    (LPCSTR)(ULONG_PTR)IMAGE_ORDINAL64(intt->u1.Ordinal));
            } else {
                // Import by name
                IMAGE_IMPORT_BY_NAME* ibn = (IMAGE_IMPORT_BY_NAME*)(
                    (BYTE*)base + intt->u1.AddressOfData);
                func = GetProcAddress(dll, (LPCSTR)ibn->Name);
            }
            // Write the resolved address into the IAT
            iat->u1.Function = (ULONGLONG)func;
        }
    }
}

This is the import-fixing loop at the core of every reflective DLL loader — the code that maps a PE into memory and manually does what the Windows loader would normally do. You will write variations of this function many times across Parts 3 and 4.

Relocations — When the Image Isn't Where It Expected to Be

Every PE has a preferred ImageBase — the address where it assumes it will be loaded. All absolute addresses in the binary's code and data are compiled with that assumption. If the image actually loads at a different address (because ASLR moved it, or because you injected it somewhere else), every one of those absolute references is wrong.

The relocation table (DataDirectory[5]) is the fix: it lists every location in the image that contains an absolute address that needs to be adjusted. The loader applies a delta (actual load address − preferred ImageBase) to each location.

// Apply relocations — required when mapping a PE to an arbitrary address
// actual_base: where you actually mapped the image
void apply_relocations(void* actual_base) {
    IMAGE_NT_HEADERS64* nt = get_nt_headers(actual_base);
    ULONGLONG delta = (ULONGLONG)actual_base - nt->OptionalHeader.ImageBase;
    if (delta == 0) return; // loaded at preferred base, no fixup needed

    IMAGE_DATA_DIRECTORY* reloc_dir =
        &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
    if (!reloc_dir->VirtualAddress) return; // no relocation table

    IMAGE_BASE_RELOCATION* reloc = (IMAGE_BASE_RELOCATION*)(
        (BYTE*)actual_base + reloc_dir->VirtualAddress);

    // Each block covers a 4KB page
    while (reloc->VirtualAddress) {
        DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
        WORD* entries = (WORD*)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION));

        for (DWORD i = 0; i < count; i++) {
            WORD type   = entries[i] >> 12;    // top 4 bits = type
            WORD offset = entries[i] & 0x0FFF; // low 12 bits = offset within page

            if (type == IMAGE_REL_BASED_DIR64) {  // type 10 = 64-bit address
                ULONGLONG* ptr = (ULONGLONG*)(
                    (BYTE*)actual_base + reloc->VirtualAddress + offset);
                *ptr += delta;  // apply the delta
            }
            // type 0 = padding, skip
        }

        // Advance to the next relocation block
        reloc = (IMAGE_BASE_RELOCATION*)((BYTE*)reloc + reloc->SizeOfBlock);
    }
}
Why some PEs have no relocation table
If a PE is compiled with /FIXED (MSVC) or -no-pie (GCC), it has no relocation table and cannot load at any address other than its preferred ImageBase. The loader will fail if that address is unavailable. For injection, this means such a PE can only be used with process hollowing at its exact preferred base — not reflective injection at an arbitrary address. Most modern PE files are position-independent by default (ASLR-compatible) and do have a relocation table.

A Complete PE Reader

Here is a utility that takes a PE loaded in memory and prints its key properties. This is the kind of diagnostic code you'll write in the early stages of every new tool to verify your parsing is correct:

// pe_info.c — print PE structure information (CRT-free, for x64)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>

// We need printf for output here — link against CRT just for this diagnostic tool
#include <stdio.h>

void print_pe_info(void* base) {
    BYTE* b = (BYTE*)base;
    IMAGE_DOS_HEADER*    dos = (IMAGE_DOS_HEADER*)b;
    IMAGE_NT_HEADERS64*  nt  = (IMAGE_NT_HEADERS64*)(b + dos->e_lfanew);
    IMAGE_OPTIONAL_HEADER64* opt = &nt->OptionalHeader;

    printf("=== PE Information ===\n");
    printf("Architecture:     %s\n",
        nt->FileHeader.Machine == 0x8664 ? "x64" : "x86/other");
    printf("Entry point RVA:  0x%08X\n",  opt->AddressOfEntryPoint);
    printf("ImageBase:        0x%016llX\n", opt->ImageBase);
    printf("SizeOfImage:      0x%08X\n",  opt->SizeOfImage);
    printf("Sections:         %d\n\n",    nt->FileHeader.NumberOfSections);

    // Print sections
    IMAGE_SECTION_HEADER* sec = (IMAGE_SECTION_HEADER*)(
        (BYTE*)opt + nt->FileHeader.SizeOfOptionalHeader);
    printf("%-10s  %-10s  %-10s  %-10s  %s\n",
        "Name", "VirtAddr", "VirtSize", "FileOff", "Flags");
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        char name[9] = {0};
        for (int j = 0; j < 8 && sec[i].Name[j]; j++) name[j] = sec[i].Name[j];
        printf("%-10s  0x%08X  0x%08X  0x%08X  0x%08X\n",
            name, sec[i].VirtualAddress, sec[i].Misc.VirtualSize,
            sec[i].PointerToRawData, sec[i].Characteristics);
    }

    // Print data directories
    printf("\n=== Data Directories ===\n");
    const char* dir_names[] = {
        "Export", "Import", "Resource", "Exception",
        "Security", "BaseReloc", "Debug", "Architecture",
        "GlobalPtr", "TLS", "LoadConfig", "BoundImport",
        "IAT", "DelayImport", "CLR Header", "Reserved"
    };
    for (int i = 0; i < 16; i++) {
        IMAGE_DATA_DIRECTORY* d = &opt->DataDirectory[i];
        if (d->VirtualAddress)
            printf("  [%2d] %-14s  RVA=0x%08X  Size=0x%X\n",
                i, dir_names[i], d->VirtualAddress, d->Size);
    }

    // Print imports
    if (opt->DataDirectory[1].VirtualAddress) {
        printf("\n=== Imports ===\n");
        IMAGE_IMPORT_DESCRIPTOR* imp = (IMAGE_IMPORT_DESCRIPTOR*)(
            b + opt->DataDirectory[1].VirtualAddress);
        for (; imp->Name; imp++) {
            printf("  %s\n", b + imp->Name);
            IMAGE_THUNK_DATA64* thunk = (IMAGE_THUNK_DATA64*)(
                b + imp->OriginalFirstThunk);
            for (; thunk->u1.AddressOfData; thunk++) {
                if (!IMAGE_SNAP_BY_ORDINAL64(thunk->u1.Ordinal)) {
                    IMAGE_IMPORT_BY_NAME* ibn = (IMAGE_IMPORT_BY_NAME*)(
                        b + thunk->u1.AddressOfData);
                    printf("    %s\n", ibn->Name);
                } else {
                    printf("    #%llu (ordinal)\n",
                        IMAGE_ORDINAL64(thunk->u1.Ordinal));
                }
            }
        }
    }
}

int main(void) {
    // Print info about ourselves
    print_pe_info(GetModuleHandleA(NULL));
    return 0;
}

Run this and compare the output against PE-bear on the same binary. Every section, data directory, and import should match exactly. This validation step — writing your own parser and comparing against a trusted tool — is how you build confidence in PE manipulation code before you rely on it inside an injector or reflective loader.

Key Offsets to Memorize

When debugging PE manipulation code in x64dbg, you'll be reading raw memory at specific offsets constantly. These are the offsets you'll reach for most often:

PE offsets worth memorizing
  From base (DOS header start):
  ┌──────────────────────────────────────────────────────────────┐
  │ base+0x00   e_magic  (0x5A4D = 'MZ')                        │
  │ base+0x3C   e_lfanew (offset to PE signature)               │
  │ base+[e_lfanew]+0x00   PE signature (0x4550)                 │
  │ base+[e_lfanew]+0x04   Machine (0x8664 = x64)               │
  │ base+[e_lfanew]+0x06   NumberOfSections                     │
  │ base+[e_lfanew]+0x14   SizeOfOptionalHeader                 │
  │ base+[e_lfanew]+0x18   Optional Header start                │
  │ base+[e_lfanew]+0x18+0x10  AddressOfEntryPoint (RVA)        │
  │ base+[e_lfanew]+0x18+0x18  ImageBase                        │
  │ base+[e_lfanew]+0x18+0x70  DataDirectory[0] (Export)        │
  │ base+[e_lfanew]+0x18+0x78  DataDirectory[1] (Import)        │
  │ base+[e_lfanew]+0x18+0x98  DataDirectory[5] (BaseReloc)     │
  │ base+[e_lfanew]+0x18+0xA0  DataDirectory[6] (Debug)         │
  └──────────────────────────────────────────────────────────────┘

  From PEB (via GS:[0x60]):
  ┌──────────────────────────────────────────────────────────────┐
  │ PEB+0x02    BeingDebugged flag                               │
  │ PEB+0x10    ImageBaseAddress (base of the EXE)               │
  │ PEB+0x18    Ldr pointer → PEB_LDR_DATA                       │
  │ PEB+0x20    ProcessParameters                               │
  │ PEB+0x68    NtGlobalFlag (used in anti-debug checks)        │
  └──────────────────────────────────────────────────────────────┘
        

Questions & Answers

Why does a PE file start with the letters MZ?

MZ are the initials of Mark Zbikowski, one of the original designers of the MS-DOS executable format in the early 1980s. The PE format is a superset of the older MZ format — it kept the same header magic so that tools written for MS-DOS executables could at least recognize the file type even if they couldn't run it. The full DOS stub (the "cannot be run in DOS mode" program) is a legitimate 16-bit DOS program that prints that message and exits. Malware developers sometimes replace it with arbitrary data, but PE parsers don't care what's there as long as e_lfanew points correctly to the PE signature.

Why are there two tables (INT and IAT) instead of just one?

The INT (OriginalFirstThunk) is read-only — the loader never modifies it. It's the record of what function was originally requested. The IAT (FirstThunk) starts as a copy of the INT but gets overwritten by the loader with real addresses. Having both means: (1) you can always go back to the original import names even after the loader has overwritten the IAT, and (2) security tools can compare the current IAT values against what the INT says they should point to — a mismatch indicates IAT hooking (a technique EDRs use, and a technique you'll defeat in Part 5). When you write a reflective loader, you must rebuild the IAT; the INT is your source of truth for what to look up.

What happens if I try to inject a PE that has no relocation table?

If the PE was compiled with /FIXED and has no relocation table, and you try to map it to any address other than its preferred ImageBase, any absolute addresses in its code will be wrong. Calls to global variables, indirect function calls through pointer tables, and any hardcoded addresses will read from or write to the wrong locations — usually causing an access violation or silent data corruption. The fix is either: (1) map the PE at exactly its preferred ImageBase (use process hollowing and allocate at that address), or (2) don't use such PEs for injection — recompile with ASLR support (/DYNAMICBASE), which generates a relocation table. Modern compilers generate relocations by default.

What is the SizeOfImage and why does it matter for injection?

SizeOfImage in the Optional Header is the total size of the image as it should appear in virtual memory — all headers and all sections combined, rounded up to SectionAlignment. When you manually map a PE into memory (reflective loading, process hollowing), you must allocate at least SizeOfImage bytes. If you allocate less, the later sections won't fit and you'll write out of bounds. In practice, always use VirtualAllocEx with SizeOfImage as the size, starting at the preferred ImageBase (or whatever address you choose), before copying the headers and sections in.