Every Windows process carries a list of the modules loaded into it — main.exe, ntdll.dll, kernel32.dll — chained together in a structure the OS keeps inside the process’s own memory. Scanners walk it, EDRs watch it, Sysmon logs every addition to it.
The screenshot below shows a MessageBoxA that popped from a DLL called testdll.dll. In System Informer’s module list of that process, testdll.dll does not exist. No LDR_DATA_TABLE_ENTRY, no kernel image-load callback, no Sysmon Event ID 7. The code ran anyway.

This article is the full path I took to get there — not by copying a loader from a framework, but by rebuilding, layer by layer, what the Windows loader itself does when you call LoadLibrary: the syscall boundary, page permissions, the PEB module lists, the PE file format, the export and import tables, and finally a six-step manual mapper that uses none of the APIs it replaces.
Everything here comes from my OS-Internals study repository — labs run on a Windows 11 VM with MSVC, x64dbg, WinDbg, System Informer, and PE-bear. Every address, hash, and byte dump below is real output from those labs.
The Only Door to the Kernel
Let’s start with the constraint everything else follows from: your code cannot touch RAM, disk, or hardware. On x86_64, the CPU enforces two privilege levels that matter — Ring 3, where your program runs with hardware-blocked access to physical resources, and Ring 0, where the Windows kernel (ntoskrnl.exe) and drivers run with full access. A user-mode program that jumps to a kernel address doesn’t get an error; it gets an immediate 0xC0000005 (STATUS_ACCESS_VIOLATION) raised by the CPU itself.
So how does notepad.exe ever allocate memory or open a file? Through exactly one door: the syscall instruction. When Ring 3 code executes syscall, the CPU switches to Ring 0, jumps to a fixed kernel entry point, and the kernel decides whether your request is allowed.
Now the layering. When you call VirtualAlloc in C, the machine code you execute does not live in your binary. Three DLLs are mapped into every standard Windows process, and your call walks down through them:
1. main.exe calls VirtualAlloc(NULL, 4096, 0x3000, 0x04)
2. kernel32.dll the public Win32 front door; mostly forwards to ->
3. kernelbase.dll the actual Win32 logic; converts your friendly
arguments into NT structures, passes (HANDLE)-1
("current process"), &base, &size
4. ntdll.dll NtAllocateVirtualMemory: loads the syscall number
into EAX and executes 'syscall'
-------------------- CPU switches Ring 3 -> Ring 0 --------------------
5. ntoskrnl.exe KiSystemCall64: reads EAX, indexes the SSDT,
dispatches to the kernel's NtAllocateVirtualMemory
6. ntoskrnl.exe updates page tables / VAD tree, zeroes memory,
writes the allocated address back to user mode
Why three DLLs for one call? Windows NT was designed to host multiple OS personalities (Win32, POSIX, OS/2). The stable, documented surface is Win32 (kernel32 → kernelbase); the true internal interface is the Native API in ntdll, deliberately undocumented so Microsoft could change the kernel between releases without breaking applications. Around Windows 7, most of kernel32’s implementation moved into kernelbase.dll; today kernel32 often just forwards.
If you open ntdll.dll in a debugger and look at NtAllocateVirtualMemory, the entire function is six instructions:
mov r10, rcx ; 1. copy 1st argument (RCX) into R10
mov eax, 18h ; 2. System Service Number (SSN = 0x18) into EAX
test byte ptr [7FFE0308h], 1 ; 3. legacy check, always 0 on modern x64
jne short +3 ; 4. never jumps on modern 64-bit Windows
syscall ; 5. enter Ring 0; kernel reads EAX + R10, RDX, R8, R9
ret ; 6. return; EAX holds the NTSTATUS code
Two of those lines reward a closer look. mov r10, rcx exists because the x64 calling convention passes the first four arguments in RCX, RDX, R8, R9 — but the syscall instruction destroys RCX by definition (the CPU saves the return address there). So the stub parks the first argument in R10 first. And mov eax, 18h is the System Service Number: ~470 Nt* functions all execute the same syscall instruction, and the kernel tells them apart by the number in EAX, used as an index into the SSDT (System Service Descriptor Table).
In my first lab I called NtAllocateVirtualMemory directly and asked for 100 bytes. The kernel set regionSize to 4096 — 4 KB is the smallest unit of memory the MMU can map or protect, so that’s the granularity you get. In x64dbg, the breakpoint on the function showed mov eax, 18h exactly as documented, with neighboring stubs spaced 0x20 bytes apart:

Calling the Native API directly also forces its conventions on you, and they explain a lot about how the OS thinks:
NTSTATUSreturn codes, not pointers.0x00000000is success; negative values like0xC0000005are failures.- Output parameters are
IN OUTpointers. You declarePVOID base = NULL; SIZE_T size = 100;and pass&base,&size. The kernel writes results into your variables in place — that’s how you get the allocated address out of a function whose return value is a status code. - One syscall serves both
VirtualAllocandVirtualAllocEx. The difference is only the first parameter,ProcessHandle; for your own process you pass the pseudo-handle(HANDLE)-1.
Why EDRs hook the user-mode side
This architecture also explains where security products sit. An EDR would love to intercept memory operations in the kernel — but PatchGuard blue-screens any driver that patches ntoskrnl (learned the hard way by XP-era antivirus, whose SSDT hooks caused epidemic BSODs), and Microsoft deliberately provides no synchronous kernel callbacks for memory APIs: process and image-load events are rare enough to block on (PsSetCreateProcessNotifyRoutineEx, PsSetLoadImageNotifyRoutine), but memory allocation and permission changes happen thousands of times per second system-wide. Synchronous inspection there would crawl the whole OS. Kernel ETW-Ti telemetry exists, but it’s asynchronous — the syscall has already completed by the time the event is read.
So EDRs inject a DLL into your process and overwrite the first bytes of ntdll’s stubs — living, ironically, in memory you can inspect:
Normal ntdll!NtAllocateVirtualMemory: Hooked by an EDR:
mov r10, rcx jmp <EDR_inspect_function> ; first 5 bytes
mov eax, 18h ...
syscall syscall
Keep that asymmetry in mind. It’s the reason a loader that avoids the monitored entry points is quiet by default.
Pages, Permissions, and W^X
The kernel’s answer to “give me memory” is a page. Every 64-bit process sees its own 128 TB user-mode address space (0x00000000'00000000–0x00007FFF'FFFFFFFF), with the kernel mapped in the top half (0xFFFF8000'... and up), unreachable from Ring 3. The CPU’s MMU translates virtual to physical addresses through page tables in 4,096-byte units — which is why the 100-byte request became 4,096, why every page-aligned address ends in 000, and why permissions apply to whole pages. You cannot make half a page read-only.
System Informer shows three properties for every region, and two of them matter a great deal later:
- State —
Free(touching it faults),Reserved(address space held, no backing — still faults),Committed(backed by RAM or the pagefile, usable). - Type —
Image(a PE mapped from disk by the OS loader),Mapped(a data file or shared section),Private(allocated at runtime byVirtualAlloc— nothing on disk backs it).
Here is the scanner heuristic that runs through this whole field: legitimate executable code almost always lives in Image memory. A page that is Private and executable is, by default, an anomaly — machine code that came from no file. Tools like PE-sieve and Moneta hunt exactly that combination, and an entire genre of evasion technique exists to answer it.
Then there’s DEP. Every page-table entry carries an NX (no-execute) bit. PAGE_READWRITE sets it; the nanosecond RIP enters a non-executable page, the CPU raises 0xC0000005 — with “Faulting module name: unknown” in Event Viewer, because the faulting address belongs to no file. I tested this by writing six bytes of raw machine code into an RW page and calling it:
mov eax, 1337 ; B8 39 05 00 00 (little-endian: 0x539 = 1337)
ret ; C3
The crash is instant. The fix is not PAGE_EXECUTE_READWRITE — W^X (Write XOR Execute) is the principle that no legitimate page needs to be writable and executable at the same time, and an RWX allocation is one of the loudest signals an EDR can see. The disciplined pattern is a two-step:
- Allocate
PAGE_READWRITE, copy your code in. - Flip to
PAGE_EXECUTE_READwithNtProtectVirtualMemory, then call it.
After the flip, System Informer shows the page as Private: Commit, protection RX, containing exactly b8 39 05 00 00 c3 — and the call returns 1337 in EAX:

One Native API trap worth its own warning: NtProtectVirtualMemory’s fifth parameter, OldProtect, is a pointer to a ULONG the kernel writes the previous protection into. Pass NULL and the call fails with 0xC0000005 — the kernel is trying to write through your pointer.
Finding Modules Without Asking the OS
Sooner or later every loader needs to answer: where is ntdll.dll in this process? The obvious call is GetModuleHandle — but here’s the thing I wanted to verify for myself: GetModuleHandle never enters the kernel at all. Everything it needs is already inside your process, in two structures the kernel builds for you at creation time:
- The PEB (Process Environment Block), one per process, holding
BeingDebugged(offset+0x02— the entire implementation ofIsDebuggerPresent()), the executable’s base (+0x10), and most importantlyLdr(+0x18). - The TEB (Thread Environment Block), one per thread, holding the thread ID, last-error value, and stack bounds.
The reason they live in Ring 3 is performance: GetLastError, GetCurrentProcessId, GetModuleHandle would otherwise each cost a full Ring 3 → Ring 0 round trip. Instead, when the kernel schedules a thread, it points the GS segment register base at that thread’s TEB. Two offsets do all the work — gs:[0x30] is a self-pointer to the TEB, and gs:[0x60] is the PEB. In C on MSVC that’s one intrinsic, __readgsqword(0x60), which compiles to a single mov rax, gs:[60h].
The module list is a ring of keys
PEB->Ldr points to PEB_LDR_DATA, which owns three circular doubly-linked lists of every loaded module — in load order, memory order, and initialization order. The first entry of InLoadOrderModuleList is your own .exe, then ntdll.dll, kernel32.dll, kernelbase.dll.
Windows linked lists are not textbook linked lists. A LIST_ENTRY is a 16-byte { Flink, Blink } pair embedded inside a larger struct — and Flink points to the matching LIST_ENTRY field inside the next struct, not to the start of that struct. For LDR_DATA_TABLE_ENTRY, that matters enormously depending on which list you walk:
+0x00 InLoadOrderLinks <- walking InLoadOrderModuleList ends up here
+0x10 InMemoryOrderLinks <- walking InMemoryOrderModuleList lands 0x10 in
+0x20 InInitializationOrderLinks
+0x30 DllBase <- the HMODULE: what we actually want
+0x38 EntryPoint
+0x40 SizeOfImage
+0x48 FullDllName (UNICODE_STRING)
+0x58 BaseDllName (UNICODE_STRING)
Walk InLoadOrderModuleList and Flink lands on offset +0x00 — the struct’s start — so a PLIST_ENTRY casts straight to PLDR_DATA_TABLE_ENTRY with no pointer math. Walk InMemoryOrderModuleList and you’re 16 bytes in, forever subtracting 0x10 (that’s what CONTAINING_RECORD is for). This is why loaders walk the load-order list.
Because the list is circular, termination is “when the cursor comes back to the head.” And the head is not a module — it’s a bare 16-byte anchor inside PEB_LDR_DATA itself:
+---------------------------------------------+
| PEB_LDR_DATA |
| +0x10: InLoadOrderModuleList (Flink) <----+--- the keychain RING
+---------------------------------------------+ (no DllBase, not a module)
|
| listHead->Flink
v
+---------------------------------------------+
| LDR_DATA_TABLE_ENTRY #1 (main.exe) | the first real key
| +0x00: InLoadOrderLinks (Flink) |
| +0x30: DllBase = 0x7FF7... |
| +0x58: BaseDllName = "main.exe" |
+---------------------------------------------+
| Flink ... until the last module's
v Flink wraps back to the head: stop there
My lab walked this ring, printed every module, and matched the results against GetModuleHandleW byte for byte — then rebuilt the walk as a function that resolves a module by name with zero API calls:
HMODULE CustomGetModuleHandleA(const char* targetModuleName)
{
ULONG64 pebAddress = __readgsqword(0x60);
PPEB peb = (PPEB)pebAddress;
PLIST_ENTRY listHead = &peb->Ldr->InLoadOrderModuleList;
PLIST_ENTRY currEntry = listHead->Flink;
while(currEntry != listHead)
{
PLDR_DATA_TABLE_ENTRY currLdrEntry = (PLDR_DATA_TABLE_ENTRY)currEntry;
char* currModuleName = (char*)calloc(currLdrEntry->BaseDllName.Length, sizeof(char));
wcstombs(currModuleName, currLdrEntry->BaseDllName.Buffer, currLdrEntry->BaseDllName.Length);
if(!targetModuleName || !strcmpi(targetModuleName, currModuleName))
{
free(currModuleName);
return (HMODULE)currLdrEntry->DllBase;
}
free(currModuleName);
currEntry = currEntry->Flink;
}
return NULL;
}
(The structs — UNICODE_STRING, PEB_LDR_DATA, LDR_DATA_TABLE_ENTRY, PEB up to +0x18 — are defined by hand rather than pulled from <winternl.h>, which Microsoft deliberately guts with Reserved byte arrays. Sourced from ntdoc and WinDbg’s dt ntdll!_PEB.)

The output settled the load order question precisely: main.exe → ntdll.dll → KERNEL32.DLL → KERNELBASE.dll, with my custom function and the Win32 API returning identical addresses. Remember this function — in the final loader, it’s how dependencies get found without waking a single EDR hook.
The Blueprint: PE Headers and the Disk-to-Memory Stretch
The next question: what is actually at a module’s base address? Not instructions — a blueprint. Before the loader runs any code it reads the PE (Portable Executable) headers to learn the architecture, how much memory to reserve, which DLLs to resolve, and where to point RIP.
The navigation chain is short. Byte zero is the 64-byte IMAGE_DOS_HEADER, of which the loader cares about exactly two fields: e_magic (MZ, 0x5A4D) and e_lfanew at +0x3C — a file offset pointing at the real headers. That’s where IMAGE_NT_HEADERS64 sits: a PE\0\0 signature, a 20-byte FileHeader (architecture, NumberOfSections), and the 240-byte OptionalHeader — “optional” only in a 1993 naming accident; every real binary has one, and it is the loader’s instruction manual:
| Field | Meaning to a loader |
|---|---|
Magic | 0x020B = 64-bit PE32+ |
AddressOfEntryPoint | RVA where RIP starts (DllMain for a DLL) |
ImageBase | Preferred load address (0x180000000 for a 64-bit DLL) |
FileAlignment / SectionAlignment | 0x200 on disk vs 0x1000 in memory — see below |
SizeOfImage | Total bytes of memory the image needs |
DataDirectory[16] | RVAs to Export, Import, Relocation, IAT tables… |
One C pointer-arithmetic trap is worth quoting because it crashes every PE parser once: e_lfanew is a byte offset, and pointer arithmetic in C scales by sizeof(*ptr). dosHeader + dosHeader->e_lfanew with a 64-byte struct jumps 0xF0 × 64 bytes past the base — 15 KB into nowhere. The rule: add offsets to a BYTE* cast, never to a struct*.
The stretch: why a file on disk ≠ an image in memory
The single most important concept in the PE format: a PE file is packed for disk and stretched for memory, and the same bytes live at different offsets in each.
On disk, sections sit on 512-byte (FileAlignment) boundaries to keep files small. In memory, the loader stretches them onto 4,096-byte page boundaries (SectionAlignment) — because that’s the only granularity at which .text can be RX and .data can be RW as separate pages:
ON DISK (FileAlignment = 0x200) IN MEMORY (SectionAlignment = 0x1000)
+---------------------------+ 0x0000 +---------------------------+ +0x0000
| PE Headers | | PE Headers |
+---------------------------+ 0x0400 | (zero-padded to 0x1000) |
| .text (File Offset | +---------------------------+ +0x1000
| = 0x0400) | | .text (RVA 0x1000, RX) |
+---------------------------+ 0x0A00 +---------------------------+ +0x2000
| .rdata (File Offset | | .rdata (RVA 0x2000, R) |
| = 0x0A00) | +---------------------------+ +0x3000
+---------------------------+ 0x1000 | .data (RVA 0x3000, RW) |
| .data (File Offset | +---------------------------+
| = 0x1000) |
+---------------------------+ VA = ImageBase + RVA
Hence the three address vocabularies of every PE discussion: VA (the live 64-bit pointer), RVA (offset from the base — RVA = VA - ImageBase; nearly every header field stores one), and file offset (where the bytes sit on disk — PointerToRawData). Read a DLL off disk into a buffer and an RVA like 0x1000 is not at byte 0x1000 of your buffer; inspect a module already mapped in memory and it literally is base + RVA.
Each 40-byte IMAGE_SECTION_HEADER carries the translation for one section: VirtualAddress (RVA), PointerToRawData (file offset), SizeOfRawData, and Characteristics (R/W/X flags). PE-bear makes the split directly visible:

A detail that pays off later: my main.exe had .text at RVA 0x1000 but file offset 0x400 — the stretch in action. ntdll.dll, compiled with both alignments equal to 0x1000, shows identical values until .data, whose VirtualSize exceeds SizeOfRawData (uninitialized globals cost disk nothing).
Resolving Exports Without Strings
GetProcAddress is the next “API” that turns out not to need the OS. It is 100% user-mode PE parsing: jump to DataDirectory[0] in the module’s .rdata, walk the Export Directory’s arrays, return base + RVA.
The export table centers on a 40-byte IMAGE_EXPORT_DIRECTORY and three parallel arrays:
AddressOfNames—DWORD[], each an RVA to an ASCII function name. Sorted alphabetically (so the loader can binary-search).AddressOfNameOrdinals—WORD[](2-byte elements — a size trap that silently returns the wrong function if you cast it toDWORD*).AddressOfFunctions—DWORD[], the actual EAT: RVAs to machine code, ordered by ordinal.
Two arrays instead of one because names are alphabetical while functions are ordinal-ordered; the little WORD bridge array converts between the orderings. A lookup is three hops:
AddressOfNames AddressOfNameOrdinals AddressOfFunctions (EAT)
(DWORD* nameRVAs) (WORD* ordinals) (DWORD* funcRVAs)
+---------------------+ +-----------------+ +--------------------------+
| [0] -> "AlphaFunc" | --> | [0] WORD = 2 | --+ | [0] 0x1010 (nameless) |
| [1] -> "BetaFunc" | -+ | [1] WORD = 3 | --+-> | [1] 0x1040 (GammaFunc!) |
| [2] -> "GammaFunc" | -+> | [2] WORD = 1 | ---+ | [2] 0x1080 (AlphaFunc) |
+---------------------+ | +-----------------+ | | | [3] 0x10C0 (BetaFunc) |
+----------------------|-+> +--------------------------+
'---- ordinals[2] = 1 ------^ funcRVAs[1] = GammaFunc's RVA
Find "GammaFunc" at name index i = 2; read ordinals[2] = 1; take funcRVAs[1]. Skip the middle array and funcRVAs[2] hands you AlphaFunc. In the real thing — ntdll.dll with 2,523 exported functions — my lab measured it exactly: NtAllocateVirtualMemory sits at name index i = 221, but ordinals[221] = 222, because one nameless ordinal-only export sits ahead of it. Skipping the bridge would have resolved the neighboring syscall stub.
Two refinements complete the picture. Forwarded exports: if a function’s RVA lands inside the export directory’s own bounds, it isn’t code at all — it’s a string like "NTDLL.RtlAllocateHeap" telling you to go ask another DLL (this is literally how much of kernel32 “implements” things). And API hashing: passing hash constants instead of name strings, so the binary never contains "NtAllocateVirtualMemory" in plaintext for a YARA rule or an analyst’s strings run to find. DJB2 is the classic — multiply by 33, add the character:
DWORD HashStringDjb2(const char* str)
{
ULONG hash = 5381;
while(*str)
{
hash = ((hash << 5) + hash) + *str;
str++;
}
return hash;
}
The custom resolver is the EAT walk in code — same three arrays, same WORD cast, comparing hashes instead of strings:
FARPROC CustomGetProcAddressByDjb2Hash(HMODULE baseModule, DWORD targetFuncHash)
{
BYTE* baseModuleAddress = (BYTE*)baseModule;
PIMAGE_DOS_HEADER moduleImageDosHeader = (PIMAGE_DOS_HEADER)baseModuleAddress;
PIMAGE_NT_HEADERS64 moduleNtHeaders = (PIMAGE_NT_HEADERS64)(baseModuleAddress + moduleImageDosHeader->e_lfanew);
PIMAGE_EXPORT_DIRECTORY moduleExportDirectory = (PIMAGE_EXPORT_DIRECTORY)(baseModuleAddress + moduleNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* nameRVAs = (DWORD*)(baseModuleAddress + moduleExportDirectory->AddressOfNames);
WORD* ordinals = (WORD*)(baseModuleAddress + moduleExportDirectory->AddressOfNameOrdinals);
DWORD* funcRVAs = (DWORD*)(baseModuleAddress + moduleExportDirectory->AddressOfFunctions);
for(DWORD i = 0; i < moduleExportDirectory->NumberOfNames; i++)
{
const char* currFuncName = (const char*)(baseModuleAddress + nameRVAs[i]);
if(targetFuncHash == HashStringDjb2(currFuncName))
{
return (FARPROC)(baseModuleAddress + funcRVAs[ordinals[i]]);
}
}
return NULL;
}
PE-bear confirmed the numbers end to end — NtAllocateVirtualMemory at ordinal 0xE6, function RVA 0x162040, matching what the walker returned:

And the hashes my lab computed became constants I would hardcode weeks later:
DJB2("NtAllocateVirtualMemory") = 0x6793C34C
DJB2("NtProtectVirtualMemory") = 0x082962C8
The Loader’s Two Write-Chores: IAT and Relocations
Everything above is reading a PE. Two structures exist because a loader must also write into the image before it can run — and both become loops in the final loader.
The IAT: function pointers the compiler couldn’t know
When MSVC compiles MessageBoxA(NULL, "Hello", "Title", MB_OK), it cannot emit a direct call — it has no idea where user32.dll will land tomorrow, or which MessageBoxA address Windows 11’s build will have. So the linker reserves an 8-byte slot in .rdata per imported function, and every call site emits call qword ptr [rip + offset_to_slot]. That slot array is the Import Address Table. At launch, the OS loader resolves each import and writes the live address into the slot.
Each imported DLL gets an IMAGE_IMPORT_DESCRIPTOR with two parallel thunk arrays, and the duality is the point:
BEFORE the loader runs (on disk) — both arrays point at names:
OriginalFirstThunk (ILT) FirstThunk (IAT)
[0] RVA = 0x5000 -> "MessageBoxA" [0] RVA = 0x5000 -> "MessageBoxA"
[1] RVA = 0x5020 -> "GetAsyncKey..." [1] RVA = 0x5020 -> "GetAsyncKey..."
[2] 0 (end) [2] 0 (end)
AFTER the loader runs (in memory) — the IAT is overwritten in place:
OriginalFirstThunk (ILT) FirstThunk (IAT)
[0] RVA = 0x5000 -> "MessageBoxA" [0] 0x00007FFB41238890 -> user32!MessageBoxA
[1] RVA = 0x5020 -> "GetAsyncKey..." [1] 0x00007FFB41214410 -> user32!GetAsyncKeyState
[2] 0 (end) [2] 0 (end)
The loader overwrites FirstThunk — the table machine code dereferences — and leaves OriginalFirstThunk untouched as the permanent record of which names the slots correspond to. In a running process, names come from the ILT, pointers live in the IAT.
A side experiment made the mechanism tangible: walk the import table of my own main.exe, find the MessageBoxA slot, flip its page R → RW (the IAT sits in read-only .rdata — writing it without the flip is an instant access violation), overwrite the slot with my own function, restore R. The next MessageBoxA call landed in my hook, which rewrote the caption before calling the original:

That’s IAT hooking — the same mechanism EDRs use on you, running in a few dozen lines.
.reloc: surviving a load address you didn’t choose
Most x64 code is position-independent (RIP-relative), but a global pointer initialized to a string’s address carries an absolute 64-bit value, compiled assuming the preferred ImageBase (0x180000000 for our DLL). Load elsewhere — which ASLR essentially guarantees — and every such pointer points at unmapped memory.
The fix is arithmetic plus a ledger. Compute the delta, delta = ActualBase - ImageBase, then walk the Base Relocation Table (DataDirectory[5], the .reloc section): a series of blocks, one per 4 KB page, each an 8-byte header (VirtualAddress = page RVA, SizeOfBlock) followed by packed 16-bit entries — upper 4 bits the type (10 = IMAGE_REL_BASED_DIR64, “add delta to the 8-byte pointer at this offset”), lower 12 bits the offset within the page:
WORD type = entries[i] >> 12;
WORD offset = entries[i] & 0x0FFF;
if (type == IMAGE_REL_BASED_DIR64) {
ULONGLONG* patchAddr = (ULONGLONG*)(mappedBase + relocBlock->VirtualAddress + offset);
*patchAddr += delta;
}
That’s the whole mechanism. Now we have every piece the real loader uses. Time to be the loader.
Building the Reflective Loader
Goal: load testdll.dll from a raw file buffer into executable memory without calling LoadLibraryA, GetProcAddress, or leaving the module registered anywhere — and with final page permissions indistinguishable from a genuine OS mapping.
First, prove the problem. Read the DLL’s bytes into rawPe with CreateFileA + ReadFile, make the buffer executable, jump to rawPe + AddressOfEntryPoint. Result: 0xC0000005 within milliseconds, for four compounding reasons — each one mapping to a step of the loader:
DISK BUFFER rawPe (3,072 bytes, FileAlignment = 0x200)
+----------------------------------------------------------------------+
| +0x0400 .text | PROBLEM 1: code compiled for RVA 0x1000, packed |
| | at 0x400 — every RIP-relative ref is wrong |
| +0x0600 .rdata | PROBLEM 2: IAT FirstThunk[0] holds an RVA to the |
| | name "MessageBoxA", not a function address |
| +0x0800 .data | PROBLEM 3: g_Message = 0x180000000 + 0x2050 — |
| | hardcoded preferred base, mapped nowhere |
| whole buffer | PROBLEM 4: RW heap memory — NX bit kills execution |
+----------------------------------------------------------------------+
The payload is deliberately minimal, and deliberately built to exercise every step — two non-const global pointers guarantee .reloc entries exist, one import gives the IAT exactly one slot to patch:
#include <windows.h>
const char* g_Message = "Hello from Manually Mapped testdll.dll!";
const char* g_Title = "Lab 4.1 - Reflective / Manual Mapping";
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
if (fdwReason == DLL_PROCESS_ATTACH) {
MessageBoxA(NULL, g_Message, g_Title, MB_OK);
}
return TRUE;
}
cl.exe /nologo /LD /GS- testdll.cpp /link /ENTRY:DllMain /NODEFAULTLIB user32.lib
No CRT, no stack cookies, entry straight at DllMain: a 3,584-byte DLL with SizeOfImage = 0x6000.
The bootstrap: five lines where everything converges
Before mapping anything, the loader needs its two NT functions. This is where the whole journey pays off in one look — PEB walk for the module, EAT walk with hardcoded hashes, no strings, no imports, no monitored APIs:
GetDesktopWindow(); // benign startup call: forces USER32.dll to load
HMODULE hNtdll = CustomGetModuleHandleA("ntdll.dll");
fnNtAllocateVirtualMemory NtAllocateVirtualMemory =
(fnNtAllocateVirtualMemory)CustomGetProcAddressByDjb2Hash(hNtdll, 0x6793C34C);
fnNtProtectVirtualMemory NtProtectVirtualMemory =
(fnNtProtectVirtualMemory)CustomGetProcAddressByDjb2Hash(hNtdll, 0x082962C8);
Those two constants were computed in the Phase 2 lab — DJB2("NtAllocateVirtualMemory") and DJB2("NtProtectVirtualMemory"). The .exe contains no API-name strings at all.
Steps 1–2: allocate, then stretch
Parse the headers in the raw buffer, allocate SizeOfImage bytes of fresh PAGE_READWRITE memory, copy SizeOfHeaders bytes across, then loop over NumberOfSections — the entire “stretch” from the disk-vs-memory diagram, as one memcpy loop:
memcpy(mappedBase, rawPe, ntHeaders->OptionalHeader.SizeOfHeaders);
PIMAGE_SECTION_HEADER sectionHeader = IMAGE_FIRST_SECTION(ntHeaders);
for(WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++)
{
if(sectionHeader[i].SizeOfRawData > 0)
{
BYTE* destSection = mappedBase + sectionHeader[i].VirtualAddress;
BYTE* srcSection = rawPe + sectionHeader[i].PointerToRawData;
memcpy(destSection, srcSection, sectionHeader[i].SizeOfRawData);
}
}
PointerToRawData in, VirtualAddress out. Problems 1 solved: .text now sits at RVA 0x1000 where the compiler promised it would be.
Step 3: relocations
The delta loop from the previous section, verbatim in the loader — walk .reloc blocks, patch every DIR64 entry:
LONGLONG delta = (LONGLONG)(mappedBase - (BYTE*)ntHeaders->OptionalHeader.ImageBase);
IMAGE_DATA_DIRECTORY relocDir = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if(delta != 0 && relocDir.VirtualAddress != 0 && relocDir.Size > 0)
{
PIMAGE_BASE_RELOCATION relocBlock = (PIMAGE_BASE_RELOCATION)(mappedBase + relocDir.VirtualAddress);
while(relocBlock->VirtualAddress)
{
DWORD count = (relocBlock->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
WORD* entries = (WORD*)((BYTE*)relocBlock + sizeof(IMAGE_BASE_RELOCATION));
for(DWORD i = 0; i < count; i++)
{
WORD type = entries[i] >> 12;
WORD offset = entries[i] & 0x0FFF;
if(type == IMAGE_REL_BASED_DIR64)
{
ULONGLONG* patchAddr = (ULONGLONG*)(mappedBase + relocBlock->VirtualAddress + offset);
*patchAddr += delta;
}
}
relocBlock = (PIMAGE_BASE_RELOCATION)((BYTE*)relocBlock + relocBlock->SizeOfBlock);
}
}
With mappedBase = 0x0000021A40050000 against a preferred 0x180000000, g_Message gets rewritten from 0x0000000180002050 to 0x0000021A40052050 — now genuinely pointing at the copied string in .rdata. Problem 3 solved.
Step 4: imports, with zero loader APIs
For each imported DLL, resolve its base with the PEB walk (it’s already in the process — no LoadLibraryA, no kernel image-load callback) and each function with the EAT walk, writing live addresses into the IAT slots:
PIMAGE_IMPORT_DESCRIPTOR importDesc = (PIMAGE_IMPORT_DESCRIPTOR)(mappedBase + importDir.VirtualAddress);
while(importDesc->Name)
{
const char* dllName = (const char*)(mappedBase + importDesc->Name);
HMODULE hDepModule = CustomGetModuleHandleA(dllName);
if(!hDepModule)
{
// Module dependency not loaded by default,
// hDepModule = GetModuleHandleA(dllName);
// can be used, but we don't want EDR notifiers
printf("[-] %s module not loaded by default, not triggering EDRs via GetModuleHandleA.\n", dllName);
return 1;
}
PIMAGE_THUNK_DATA64 origThunk = (PIMAGE_THUNK_DATA64)(mappedBase + (importDesc->OriginalFirstThunk ? importDesc->OriginalFirstThunk : importDesc->FirstThunk));
PIMAGE_THUNK_DATA64 iatThunk = (PIMAGE_THUNK_DATA64)(mappedBase + importDesc->FirstThunk);
while(origThunk->u1.AddressOfData != 0)
{
FARPROC funcAddr = NULL;
if(IMAGE_SNAP_BY_ORDINAL64(origThunk->u1.Ordinal))
{
WORD ordinal = (WORD)IMAGE_ORDINAL64(origThunk->u1.Ordinal);
funcAddr = CustomGetProcAddressByOrdinal(hDepModule, ordinal);
}
else {
PIMAGE_IMPORT_BY_NAME importByName = (PIMAGE_IMPORT_BY_NAME)(mappedBase + origThunk->u1.AddressOfData);
funcAddr = CustomGetProcAddress(hDepModule, importByName->Name);
}
iatThunk->u1.Function = (ULONGLONG)funcAddr;
origThunk++;
iatThunk++;
}
importDesc++;
}
The ILT gives the names, the IAT slot receives 0x00007FF9D7FBFC90 — the live address of USER32.dll!MessageBoxA. Problem 2 solved. (Note the failure path: if a dependency isn’t already loaded, the loader refuses rather than reach for GetModuleHandleA — in a lab, correctness of the decision matters more than convenience.)
Step 5: per-section protections
Everything so far was written through RW pages. Now lock the image down exactly as the OS loader would — translate each section’s Characteristics to page flags and protect section by section:
ULONG GetSectionProtection(DWORD characteristics)
{
if(characteristics & IMAGE_SCN_MEM_EXECUTE)
{
if(characteristics & IMAGE_SCN_MEM_WRITE)
{
return PAGE_EXECUTE_READWRITE;
}
if(characteristics & IMAGE_SCN_MEM_READ)
{
return PAGE_EXECUTE_READ;
}
return PAGE_EXECUTE;
}
if(characteristics & IMAGE_SCN_MEM_WRITE)
{
return PAGE_READWRITE;
}
if(characteristics & IMAGE_SCN_MEM_READ)
{
return PAGE_READONLY;
}
return PAGE_NOACCESS;
}
for(i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++)
{
PVOID secBase = mappedBase + sectionHeader[i].VirtualAddress;
SIZE_T secSize = sectionHeader[i].Misc.VirtualSize;
if(!secSize) {
secSize = sectionHeader[i].SizeOfRawData;
}
ULONG protect = GetSectionProtection(sectionHeader[i].Characteristics);
ULONG oldProtection = 0;
ntStatus = NtProtectVirtualMemory((HANDLE)-1, &secBase, (PULONG)&secSize, protect, &oldProtection);
This step is a detection conversation, not an afterthought. A genuine loader mapping ends with .text → RX, .rdata → R, .data → RW. A sloppy manual mapper leaves everything RW — and an .rdata (strings + IAT) marked RW in Private memory is precisely what memory scanners like PE-sieve flag as an unfinalized injected PE. During Step 4 the page must be writable; after Step 4, leaving it writable is the tell. Problem 4 solved — the W^X discipline from the second section, applied per-page.
Step 6: execute
fnDllMain DllMain = (fnDllMain)(mappedBase + ntHeaders->OptionalHeader.AddressOfEntryPoint);
DllMain((HINSTANCE)mappedBase, DLL_PROCESS_ATTACH, NULL);
The execution trace runs through everything the loader built: RIP enters .text (RX — Step 5), DllMain reads g_Message from .data (relocated — Step 3), calls through the IAT slot in .rdata (resolved — Step 4), and the message box appears.
Proof: A DLL That Isn’t There
The full run:
c:\Users\cyber\OneDrive\Desktop\labs\lab-4.1>main.exe
[!] Nt functions resolved successfully.
[+] Read testdll.dll, bytes read: 3584
[!] Initiating Step 1: Parse the RAW PE Headers & Allocate SizeOfImage Bytes
[+] Allocated 24576 bytes of virtual memory for mapped base.
[!] Initiating Step 2: Copy PE Headers & Stretch Each Section into mappedBase
[+] Copied and stretched each section into mappedBase.
[!] Initiating Step 3: Process Base Relocations
[+] Processed base relocations using delta: 0x2B4A7AC0000
[!] Initiating Step 4: Resolve Imports & Patch IAT
[+] Resolved imports and patched IAT.
[!] Initiating Step 5: Enforce Per-Section Memory Protections
[+] Protected 5 sections of virtual memory.
[!] Initiating Step 6: Call AddressOfEntryPoint of testdll.dll
With the message box still open, System Informer tells the story in three tabs.
Modules — testdll.dll is absent. No LdrLoadDll ever ran, so no LDR_DATA_TABLE_ENTRY was ever inserted into PEB->Ldr — the module list has no idea the code exists:

Memory — at mappedBase = 0x2b627ac0000, five private pages with distinct, authentic-looking protections:
0x2b627ac0000 4 kB Private: Commit RW PE headers
0x2b627ac1000 4 kB Private: Commit RX .text
0x2b627ac2000 4 kB Private: Commit R .rdata (locked IAT + strings)
0x2b627ac3000 4 kB Private: Commit RW .data (relocated globals)
0x2b627ac4000 8 kB Private: Commit R .reloc

Hex dump — the first eight bytes of .rdata read 90 fc 8b c7 ff 7f 00 00: little-endian for 0x00007FFFC78BFC90, the live USER32.dll!MessageBoxA pointer sitting in the IAT slot where a name-RVA used to be, followed by the relocated string bytes.
To be clear about what this is and isn’t: this is a self-education artifact, on my own VM, loading my own DLL — not an implant. Its footprint (Private executable memory with no backing image) is exactly what a competent scanner looks for, and modern EDRs do catch it. Which is rather the point of building it: you cannot reason about detection — or about what LoadLibrary actually does — at this depth without once being the loader yourself.
Where This Goes Next
The loader here still calls ntdll’s exported stubs — the same stubs an EDR hooks before the syscall. Phase 4 continues exactly there: Module 4.2 rebuilds syscall invocation itself (SSN resolution, direct and indirect syscalls that never touch a hooked stub), and Module 4.3 covers call-stack spoofing and .pdata unwinding — because once your code no longer calls monitored APIs, the next thing investigators trust is your call stack.
Notes, lab sources, and the full curriculum live in the OS-Internals repository. If you’re walking this path yourself, my advice is the same one the material enforces: don’t import a loader — write the walk, print the addresses, and check them against the API. Every single time.