A full walkthrough of loading a DLL into another process without LoadLibrary. PE headers, section mapping, relocations, imports, TLS, and the dispatch stub that starts the code running.

Manual Mapping Explained, End to End
36 mins
7229 words
Loading views

Most writing about DLL injection starts at the “how do I get the thread to spawn” part and quietly skips everything that happens before it. The blocker is never the thread. The blocker is that nobody loads your DLL in a way that lets you control the process. So this post goes the other way around. We start at the file on disk and stop only when our code is executing in a process that never asked for it.

This is manual mapping. You become the PE loader. Windows is never asked to load the module, so no loader data structures are created for it and nothing about it shows up in the module list. That has a cost and a payoff, and I want to show you both.

A note on the code before we start. Every snippet in here is representative. They map to real documented structures and real helper routines, but they are trimmed and do not compose into one copy paste project by themselves. The point of each one is to show the shape of the step, not to hand you a finished program. Where a name like TranslateSectionFlags or GetDirectoryRva appears, assume it is the small wrapper that reads a data directory entry and converts an RVA to a pointer against the current base. Those helpers are boring and the interesting logic lives around them.

What LoadLibrary actually doesh2

Before you replace a tool, you should know what the tool does. LoadLibraryA hands the file to the Windows loader, which is the machinery in ntdll behind the name LdrLoadDll. The loader then runs a very specific sequence. I will run the same sequence by hand later, so list the parts now.

First it maps the file into memory. It reads the headers to figure out how much virtual space the image wants, reserves that space, and copies the sections into it. Then it handles the base address. If the space is free at the image’s preferred base, you get the file exactly as compiled. If not, the loader fixes relocations so every absolute pointer still points at the right thing.

Next come imports. The loader walks the import table, loads every DLL the module depends on, finds each imported function, and writes the real addresses into the import address table. After that it processes the TLS directory and runs any TLS callbacks. Then it registers the module in the process wide module lists inside the PEB. Then it configures exception handling and the security cookie that protects buffer overrun checks. Only after all of that does it call the entry point.

DllMain with DLL_PROCESS_ATTACH is the ninth step of a nine step process. People write injectors all the time and think they can skip steps three through eight. That works until your DLL does anything real, and then it crashes in ways that look random.

The Windows loader also cleans up after itself. It rolls back if a section cannot be committed, releases loaded dependencies if a later import fails, and tears down everything if the entry point returns FALSE. A good manual mapper has to think about that too, because the OS is not going to do it for you.

One thing worth being honest about up front. When your code eventually runs DllMain, there is still a live loader in the process, and the loader owns a lock that every module entry point runs under. A manually mapped module that calls GetModuleHandle or loads another DLL from inside its initialization can touch that lock without ever being in the module list. The trick never makes you invisible to that lock. It makes you responsible for it, with none of the guard rails the real loader provides.

The rules of the gameh2

The whole design hinges on one sentence, so I am putting it first and every example after it will obey it.

Parsing and every fixup happen in the mapper’s own process against a buffer the mapper owns. The target only ever receives a finished, already fixed image and a tiny stub that knows how to call the entry point.

That rule exists for a reason that is mechanical. A 64 bit process cannot dereference a pointer into another process’s address space. ReadProcessMemory and WriteProcessMemory exist precisely because ordinary C++ pointers do not cross process boundaries. An external mapper that tries to walk the import table at imageBase + 0x3000 while imageBase lives in a different process is dereferencing a pointer that is meaningless here. It will read from the mapper’s own memory at that address, which is garbage, and then everyone spends the weekend confused about why a perfectly good import walk crashes.

So we never do that. The mapper keeps its own copy of the image, fixes everything against that copy with plain memory operations, and ships the fixed result across with one batch of remote writes. The stub that runs in the target does not need to parse anything, because by the time it runs the image is already complete.

If you have seen mappers written the other way, with the loader logic shipped into the target as shellcode, that is a real and older design. Fewer’s reflective loader and the family of tools descended from it move the fixups into the target on purpose. I will come back to why that design exists and what it buys you in the import section. For now, the external design is easier to reason about and this post stays consistent with it.

Reading the fileh2

Everything starts with bytes. Open the DLL, read it into a buffer, and check that the buffer is actually a PE. Reading the whole file at once is fine for a mapper. DLLs are usually a few hundred kilobytes, and even a big one is small enough to hold in memory while you parse it.

The values you need live behind the DOS header which lives at offset zero. The DOS header is a pile of MS DOS compatibility data plus two fields that matter. The e_magic field holds the MZ signature and e_lfanew holds the file offset of the PE header that follows.

PIMAGE_NT_HEADERS ParseHeaders(void* buffer, SIZE_T size)
{
if (size < sizeof(IMAGE_DOS_HEADER))
{
return nullptr;
}
auto* dos = reinterpret_cast<PIMAGE_DOS_HEADER>(buffer);
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
{
return nullptr;
}
ULONG ntOffset = dos->e_lfanew;
if (ntOffset > size - sizeof(IMAGE_NT_HEADERS))
{
return nullptr;
}
auto* nt = reinterpret_cast<PIMAGE_NT_HEADERS>(reinterpret_cast<uint8_t*>(buffer) + ntOffset);
if (nt->Signature != IMAGE_NT_SIGNATURE)
{
return nullptr;
}
if (nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
{
return nullptr;
}
return nt;
}

Notice the checks and why each one earns its place. The size check stops us reading the DOS header from a buffer that is smaller than the header. The e_lfanew bounds check stops us jumping to an offset past the end of the file, which is the classic malformed header trick, because e_lfanew is just a value in the file and nothing stops an attacker from setting it to a huge number. The signature check is the famous PE\0\0. The magic check is where we refuse 32 bit images and anything whose optional header layout we do not want to interpret. This is the level of validation the PE spec itself asks for. The exact wording in the Microsoft format document is that NumberOfRvaAndSizes should be checked before probing any given data directory, and that the optional header magic must be validated for format compatibility.

None of this is exotic. It is four comparisons and two range checks, and it converts a crash inside a foreign process into a clean refusal right here.

The headers that matterh2

IMAGE_NT_HEADERS is two things bolted together. A file header with metadata, then an optional header that is not optional no matter what its name claims. The optional header is where the loader reads almost everything it needs.

The Magic field tells you the architecture. 0x10b is a 32 bit image, 0x20b is 64 bit. The layout of the optional header itself changes between the two, so every structure you index after it depends on that choice.

The commonly used fields are not obscure. SizeOfImage is the total virtual size the mapped image will occupy. SizeOfHeaders is how many bytes should be written up front. ImageBase is the preferred load address the file was compiled against. AddressOfEntryPoint is the RVA of the entry point, which for a normal CRT linked DLL resolves to DllMain or to a small CRT wrapper that calls it. SectionAlignment and FileAlignment tell you how the loader rounds sections on disk versus in memory.

Then comes the data directory, which is the real treasure and the least respected part of the format. It is an array of IMAGE_DATA_DIRECTORY entries, each one an RVA and a size. The Windows format defines sixteen of them, indexed from zero to fifteen. The spec is explicit that the count is not fixed, because older or unusual images can carry fewer, and the correct move is to check NumberOfRvaAndSizes before probing any directory.

bool GetDirectory(const BYTE* imageBase, const PIMAGE_NT_HEADERS nt, ULONG index, const BYTE** out, SIZE_T* outSize)
{
if (index >= nt->OptionalHeader.NumberOfRvaAndSizes)
{
return false;
}
const IMAGE_DATA_DIRECTORY& dir = nt->OptionalHeader.DataDirectory[index];
if (dir.VirtualAddress == 0)
{
return false;
}
*out = imageBase + dir.VirtualAddress;
*outSize = dir.Size;
return true;
}

The index numbers are fixed by the spec. Export is zero. Import is one. Resource is two. Exception is three. Security is four. Relocations, which live in the .reloc section, are five. TLS is nine. Load configuration ten. The import address table is twelve. Delay import is thirteen. Knowing a few of these by heart makes reading the loader’s code much faster, because the loader never searches for anything. It reads the number at a fixed index and jumps.

The other lesson of this function is the discipline. A directory entry with a zero VirtualAddress means the image simply does not carry that directory, and a healthy loader treats that as “nothing here”, not as “this file is broken”. NumberOfRvaAndSizes smaller than the index you wanted is the same answer. Refusing these images is also valid, but the more useful behavior is to treat the absent directory as a legitimate state, because plenty of small DLLs are built without a relocation table or without a load config.

Raw size versus virtual sizeh2

The section headers are an array that follows the optional header. Each IMAGE_SECTION_HEADER names a section and gives you four numbers that control the copy. VirtualAddress is where the section lands in memory, relative to the image base. Misc.VirtualSize is how big the section is in memory. PointerToRawData is where the bytes live in the file, and SizeOfRawData is how many file bytes that section backs.

The difference between the two sizes is the whole game. Sections on disk are stored contiguously and aligned to FileAlignment, usually 512 bytes. In memory they are aligned to SectionAlignment, usually 4096. A section can be smaller on disk than it is in memory, because the loader zero fills the gap. Uninitialized data works exactly this way. A large array of globals is a few hundred bytes of file followed by a vast zero region.

So the correct copy is not a direct byte blit. You take SizeOfRawData bytes from the file at PointerToRawData and place them at the image base plus VirtualAddress. Then you zero every byte from the end of that copy up to VirtualSize. That describes the resulting memory layout accurately. What needs care is what happens when the two sizes disagree in the other direction.

The case to handle is SizeOfRawData greater than VirtualSize. That happens with some linkers and it means the data that matters is only the first VirtualSize bytes; the rest is slack that another section’s mapped data can legally overlap in the file. So the rule is not a blind subtraction. Compute the copy length as the smaller of the two sizes, write that, and only then zero fill the remainder of VirtualSize. And clamp every section’s VirtualAddress plus VirtualSize to stay inside SizeOfImage, because a crafted image can advertise a section that runs off the end of the allocation.

bool MapSection(uint8_t* localBase, const void* fileBase, const PIMAGE_SECTION_HEADER section, SIZE_T imageSize)
{
SIZE_T virtualSize = section->Misc.VirtualSize ? section->Misc.VirtualSize : section->SizeOfRawData;
if (virtualSize > imageSize - section->VirtualAddress)
{
return false;
}
SIZE_T copySize = min(section->SizeOfRawData, virtualSize);
if (copySize)
{
memcpy(localBase + section->VirtualAddress,
static_cast<const uint8_t*>(fileBase) + section->PointerToRawData, copySize);
}
if (virtualSize > copySize)
{
memset(localBase + section->VirtualAddress + copySize, 0, virtualSize - copySize);
}
return true;
}

The section copy is the one step where the format’s on-disk layout and its memory layout disagree, and it is the step that produces the most confusing crashes when skipped. A DLL whose timestamps or string tables live in a section that was never fully zero filled looks fine in a debugger and misbehaves at runtime, because the reading code assumes memory after the raw data is clean.

The loadbase decisionh2

Where does the image end up? The textbook answer is to try the preferred ImageBase first, because the linker derived it to make loading cheap. But in a busy target process the preferred base is usually taken, and every modern Windows binary has ASLR forcing a random base anyway. The preferred base is a hint, not a guarantee.

So we let the operating system choose. VirtualAllocEx at address NULL returns whatever free space the kernel found, which is almost never the preferred base. That is fine. The whole fixup pipeline exists so the loaded base does not have to match. What matters is the delta between the actual remote base and the preferred base, and applying that delta to every relocated address.

uintptr_t remoteBase = reinterpret_cast<uintptr_t>(
VirtualAllocEx(g_proc, nullptr, nt->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE));
if (remoteBase == 0ULL)
{
return 0ULL;
}

The permissions are the lazy choice and I flag it now because the detection section at the end comes back to it. You can absolutely commit each section with tighter permissions later. Many of the simple public mappers do not, and a memory scanner can tell.

There is a design tension here worth saying out loud. Allocate at the preferred base and you skip every relocation. Allocate anywhere and you get a fresh reliable base and a correct, complete loader. I ship the second one. A loader that only works when the address happens to be free is not a loader.

The local mirror, and why it existsh2

Here is where the rule from the start of the post pays for itself. Instead of trying to fix the image while it sits in a process we cannot see, we build a local mirror. We ask our own process for a private block of SizeOfImage bytes, copy the headers and every section into it, and hold onto it as our working copy of the loaded image.

uintptr_t localBase = reinterpret_cast<uintptr_t>(
VirtualAlloc(nullptr, nt->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (localBase == 0ULL)
{
return 0ULL;
}
memcpy(reinterpret_cast<void*>(localBase), fileBase, min(nt->OptionalHeader.SizeOfHeaders, fileSize));

The mirror is our sandbox. We write the headers into it, then map each section into it with the function above. From this point forward the mirror behaves exactly like a loaded image except that it lives in our address space, which means every one of the fixup loops in the next two sections can use honest pointers. imageBase in those loops is localBase. Reading a relocation slot, patching it, and writing it back is a plain memory access.

The delta we compute is the tricky part and it involves both bases. The relocation pass and the import pass must produce pointers that are only correct once the image is in the target. So the delta used for relocations is remoteBase - preferredBase, and we apply it against the mirror. The pieces that held preferredBase-relative absolute addresses get rewritten to point at their future remoteBase homes. When the mirror is later copied verbatim into the target at remoteBase, every address in it is then correct.

ptrdiff_t delta = static_cast<ptrdiff_t>(remoteBase) - static_cast<ptrdiff_t>(nt->OptionalHeader.ImageBase);

The import table gets the same treatment from a different angle, which we will see shortly. The addresses the image imports are function pointers into system DLLs, and system DLLs load at the same base in every process on a given boot. So a pointer resolved inside our process is already correct inside the target, and the mirror simply carries it over.

Relocationsh2

All of the image thinks it lives at ImageBase, and it will actually live at remoteBase. Every absolute address embedded in the code is wrong by the delta. The base relocation directory tells you where those addresses are.

The directory is an array of IMAGE_BASE_RELOCATION blocks. Each block describes one 4096 byte page that contains relocatable addresses. The block header holds a VirtualAddress, which is the page base RVA, and a SizeOfBlock, which covers the header plus the entries that follow. Each entry is a 16 bit value whose top 4 bits are the relocation type and whose bottom 12 bits are the offset of the address within that page.

Only one type matters on x64. IMAGE_REL_BASED_DIR64, value ten, means the full 64 bit quantity at that address is an absolute pointer and needs the delta added. The other entry worth knowing is IMAGE_REL_BASED_ABSOLUTE, value zero, which is padding. Real relocation blocks end with padding to keep each block aligned to a 32 bit boundary, and a correct loop treats absolute entries as “patch nothing”.

bool FixRelocations(uintptr_t localBase, const BYTE* localImage, const BYTE* relocDir, SIZE_T relocSize, ptrdiff_t delta)
{
const BYTE* blockPtr = relocDir;
const BYTE* blockEnd = relocDir + relocSize;
while (blockPtr + sizeof(IMAGE_BASE_RELOCATION) <= blockEnd)
{
const auto* block = reinterpret_cast<const IMAGE_BASE_RELOCATION*>(blockPtr);
if (block->SizeOfBlock == 0)
{
break;
}
if (block->SizeOfBlock < sizeof(IMAGE_BASE_RELOCATION) || (block->SizeOfBlock & 3))
{
return false;
}
if (blockPtr + block->SizeOfBlock > blockEnd)
{
return false;
}
BYTE* page = const_cast<BYTE*>(localImage + block->VirtualAddress);
DWORD count = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
const WORD* entry = reinterpret_cast<const WORD*>(blockPtr + sizeof(IMAGE_BASE_RELOCATION));
for (DWORD i = 0; i < count; ++i, ++entry)
{
WORD type = static_cast<WORD>((*entry) >> 12);
WORD offset = static_cast<WORD>((*entry) & 0x0FFF);
if (type == IMAGE_REL_BASED_DIR64)
{
auto* slot = reinterpret_cast<uintptr_t*>(page + offset);
*slot = static_cast<uintptr_t>(static_cast<ptrdiff_t>(*slot) + delta);
}
}
blockPtr += block->SizeOfBlock;
}
return true;
}

Two details deserve attention because they are where broken mappers leak memory mistakes. First, the walk is bounded by the directory’s size, which came from the data directory entry, and not by a magic terminating entry. A lot of old example code stops when VirtualAddress becomes zero, and that works on well formed files. The spec does not promise that every directory is terminated that way, and the loop above needs no terminator at all because it runs until it consumes relocSize bytes. Second, SizeOfBlock must be at least the struct size and aligned to a 32 bit boundary. The 32 bit alignment is required by the format, and rejecting a block that violates it turns a malformed file into a clean refusal instead of a misaligned read.

The delta is signed. The actual base can be higher or lower than the preferred base, and the cast from remoteBase - preferredBase to ptrdiff_t keeps the arithmetic honest in both directions.

It is worth deliberately testing the beyond preferred base case. The easiest mistake in this whole post is computing the delta with the mirror base instead of the remote base, or never computing it at all. Link a DLL with a hand picked base, map it into a process where that base is occupied, and verify every function pointer still works. If they do, the delta logic is right. If they do not, the bug is almost always in the block walk, not in the add.

Fixing the import tableh2

The imports are what separates a demo from a working loader. A DLL compiled to use GetModuleHandleA and the rest of the usual suspects needs real function addresses written into its import address table before its first instruction runs. Without that, every call into system code faults.

The import directory is an array of IMAGE_IMPORT_DESCRIPTOR entries, one per imported DLL, terminated by an entry whose name is empty. Each descriptor has a Name field pointing at the DLL name string, and two parallel arrays. OriginalFirstThunk points at the import lookup table, which holds the hints and names. FirstThunk points at the import address table, where resolved addresses go. On most modern binaries both arrays sit in the same place, because the loader may overwrite the lookup table in place. Do not rely on that. Use OriginalFirstThunk for reading and FirstThunk for writing, and fall back to reading through FirstThunk only when the original thunk is empty.

Each thunk entry is one of two shapes. If the high bit is set, the entry is an ordinal import and the rest of the value is the ordinal number. If the high bit is clear, the entry is an RVA to an IMAGE_IMPORT_BY_NAME structure, which contains a hint word followed by the null terminated function name.

bool FixImports(uintptr_t localBase, const BYTE* localImage)
{
const BYTE* importDir = nullptr;
SIZE_T importSize = 0;
if (!GetDirectory(localImage, reinterpret_cast<PIMAGE_NT_HEADERS>(localImage + Min2()), DIR_IMPORT, &importDir, &importSize))
{
return true;
}
const auto* d = reinterpret_cast<const IMAGE_IMPORT_DESCRIPTOR*>(importDir);
while (d->Name)
{
const char* dllName = reinterpret_cast<const char*>(localImage + d->Name);
HMODULE library = LoadLibraryA(dllName);
if (library == nullptr)
{
return false;
}
auto* thunkRead = d->OriginalFirstThunk
? reinterpret_cast<const ULONG_PTR*>(localImage + d->OriginalFirstThunk)
: reinterpret_cast<const ULONG_PTR*>(localImage + d->FirstThunk);
auto* thunkWrite = reinterpret_cast<ULONG_PTR*>(localImage + d->FirstThunk);
while (*thunkRead)
{
ULONG_PTR value = *thunkRead;
if (value & IMAGE_ORDINAL_FLAG64)
{
*thunkWrite = reinterpret_cast<ULONG_PTR>(GetProcAddress(library, reinterpret_cast<LPCSTR>(value & 0xFFFF)));
}
else
{
auto* byName = reinterpret_cast<PIMAGE_IMPORT_BY_NAME>(localImage + value);
*thunkWrite = reinterpret_cast<ULONG_PTR>(GetProcAddress(library, byName->Name));
}
++thunkRead;
++thunkWrite;
}
++d;
}
return true;
}

This is the pipe dream version if you stare at the helper names, so let me clear up the one that looks odd on purpose. The Min2() call in the middle is placeholder noise and would not survive a real program. In a real mapper that line reads the NT headers off the mirror base the same way ParseHeaders does. The point of the snippet is the loop structure, which is the part that matters.

Three failure modes show up over and over. A function name that exists in the DLL but not in the export table makes GetProcAddress return null, and writing null into the IAT crashes the module moments later. A dependent DLL that is not present makes LoadLibraryA return null. A forwarder chain, where an export points to another DLL’s export by name, usually resolves fine through these same APIs, so it rarely bites. The fix for the first two is the same. Check every return and bail out with a real error instead of writing garbage.

Now the honest caveat about the trick this whole section depends on. We are resolving imports by calling the loader in our own process, and then carrying those addresses across to the target. That only produces correct targets for DLLs that load at the same base in both processes on the same boot. System DLLs, kernel32 and ntdll and user32 and the rest, behave that way in practice on a shared boot because ASLR randomizes their base once per boot, not once per process. Third party DLLs do not have that guarantee. A plugin DLL that lives in the game’s folder can be loaded into our mapper at a different address than the one the target game chose, and copying our pointer into the target would be wrong.

Production mappers handle that split in two ways. The clean way is to resolve only kernel32 functions from the local mirror and do the rest of the table inside the target through a stub that calls LoadLibraryA and GetProcAddress in the target’s context. That is the remote resolver design, the one Fewer’s reflective loader popularized, and it pays for itself in correctness. The quick way is to resolve everything locally and hope the target loads the same third party DLLs at the same bases, which is a coin flip. Which one you pick depends on whether you value correctness or line count. I value correctness, and the mirror design makes both options available because the local copy is what gets shipped.

The same caveat is also the detection tell, and I will not pretend otherwise. An IAT full of pointers into kernel32 for a module that exists in no loaded library list is exactly the shape a memory forensics tool looks for. More on that later.

Shipping the finished imageh2

The mirror is done. Relocations applied, imports resolved, every entry correct for remoteBase. The next step is mechanical. Copy the mirror into the target.

The cleanest shipping pass writes the headers first, then each section at its VirtualAddress. Because the mirror at localBase and the image at remoteBase have identical layout, each copy is a single WriteProcessMemory with the size we already computed during mapping.

bool ShipImage(const BYTE* localImage, uintptr_t remoteBase, SIZE_T headerSize, const PIMAGE_SECTION_HEADER sections, WORD count)
{
SIZE_T written;
if (WriteProcessMemory(g_proc, reinterpret_cast<LPVOID>(remoteBase), localImage, headerSize, &written) == FALSE)
{
return false;
}
for (WORD i = 0; i < count; ++i)
{
SIZE_T virtualSize = sections[i].Misc.VirtualSize ? sections[i].Misc.VirtualSize : sections[i].SizeOfRawData;
if (virtualSize == 0)
{
continue;
}
uintptr_t remoteSection = remoteBase + sections[i].VirtualAddress;
const BYTE* localSection = localImage + sections[i].VirtualAddress;
if (WriteProcessMemory(g_proc, reinterpret_cast<LPVOID>(remoteSection), localSection, virtualSize, &written) == FALSE)
{
return false;
}
}
return true;
}

One subtlety worth noting. We ship the full VirtualSize per section, including the zero fill we applied to the mirror. That is correct and simplest. A variant that only ships the raw data and lets the target’s fresh pages stay zero is also fine, because freshly committed memory is already zero. Shipping the mirror as is is a single code path and it cannot forget a padding region, so I keep it.

After the ship, the target holds a complete, correct, loadable image and no thread has run yet. That ordering matters more than it looks. It means every failure up to this point happens before anything executed inside the target, so the error path never has to unwind code that is already running.

The bootstrap stubh2

The image is in the target. Now we need a thread that ends up at the module’s entry point while executing in the target’s address space. The only tool Windows hands you from outside is CreateRemoteThread, pointed at code that already lives in the target.

Normally a thread start address is the address of an actual function, and the kernel unwinds through RtlUserThreadStart, which is the documented native entry laid down for every user thread. That function takes two arguments, the thread start routine and a parameter, reads them from the thread stack, and calls the routine. The classic trick for a mapper is to make the start routine point at a tiny blob of stub code we wrote into the target next to the image.

The stub only needs to do one thing. Read its parameter, which is a pointer to a small struct holding the module base and the entry point, and jump to the entry point with the three arguments DllMain expects. Because the image is already fully fixed, the stub does no parsing. It is just scenery that turns a remote thread into a call.

#pragma pack(push, 1)
struct DispatchContext
{
BYTE opcode;
BYTE prologue[10];
PVOID baseAddress;
PVOID entryPoint;
uintptr_t rcx;
uintptr_t rdx;
uintptr_t r8;
};
#pragma pack(pop)

How the stub reaches the entry point varies and every version has the same skeleton. On x64 the first argument to a function arrives in RCX, the second in RDX, the third in R8. A minimal stub spills nothing, moves the module base and the attach reason into the right registers, and jumps. Some implementations keep the loader in the target and call it as a function, which is how the remote resolver design hands control to its own mapping loop. Some rotate through a small assembly shim because the C++ compiler optimizes a direct call cleanly enough. All of them end the same way, with RtlUserThreadStart calling our start address.

There are two refinements people use and both are worth knowing because they show up in real tooling. The first is trivial. Point the start address at RtlUserThreadStart plus a small offset, like the entry point used to be plus 0x1000, so that a thread inspector sees a start address that hoovers over a real ntdll function instead of a private allocation. Whether that fools anyone is a separate question, but it is why you will see adjusted start addresses in mature tools and not in first drafts.

The second refinement is to skip CreateRemoteThread entirely. SetThreadContext on a suspended thread or NtQueueApcThread on a waiting one routes around the public API and its callbacks. The MetaSploit migrate stub shows the whole shape, a small blob that calls RtlCreateUserThread and arranges its own context. The API used matters mostly for visibility, since every thread the OS starts is visible regardless of how it was born. The thread start address remains the interesting forensic artifact, and it is the artifact that never lies about being inside a private, unmapped allocation.

TLS callbacks and the entry pointh2

Two facts about thread local storage break people’s assumptions and both matter for a manual map.

First, the TLS directory fields are not stored like the import directory. An import descriptor stores RVAs, offsets relative to the image base. The TLS directory stores virtual addresses, absolute addresses that assume a specific load address. AddressOfCallBacks in a 64 bit image is a ULONGLONG holding the full absolute address of the callback array. That means a correct TLS directory participates in relocation, the same as any other absolute pointer in the image, and the loader adjusts it when the image does not load at its preferred base. A manual mapper that treats these fields as RVAs and adds the base a second time will compute a callback address that is off by the base, and then wonder why the callback never runs or crashes the process.

Second, TLS is more than callbacks. The directory describes a template of thread local data, an index that each thread’s local slot is keyed against, and a callback array. In a real load the loader calls TlsAlloc, writes the index, allocates the thread data when the first thread touches it, and invokes callbacks from LdrpInitializeThread for threads that attach later. A manual map has none of that machinery, because the loader never saw the module. The module’s TLS index slot is never allocated, per thread data is never copied, and code that relies on __declspec(thread) data may quietly read zeros or collide with another module’s index. Production mappers call the callbacks and skip the runtime TLS plumbing, and they ship with that as a documented limitation.

bool CallTlsCallbacks(const BYTE* imageBase)
{
const BYTE* tlsDir = nullptr;
SIZE_T tlsSize = 0;
if (!GetDirectory(imageBase, reinterpret_cast<PIMAGE_NT_HEADERS>(basePlus(imageBase)), DIR_TLS, &tlsDir, &tlsSize))
{
return true;
}
const auto* tls = reinterpret_cast<const IMAGE_TLS_DIRECTORY*>(tlsDir);
if (tls->AddressOfCallBacks == 0)
{
return true;
}
auto* callbacks = reinterpret_cast<const PVOID*>(tls->AddressOfCallBacks);
for (const PVOID* current = callbacks; *current != nullptr; ++current)
{
auto callback = reinterpret_cast<PIMAGE_TLS_CALLBACK>(*current);
callback(reinterpret_cast<PVOID>(imageBase), DLL_PROCESS_ATTACH, nullptr);
}
return true;
}

The helper basePlus is a stand in for the NT header retrieval that a real version needs, because the callback array is absolute and lives at AddressOfCallBacks directly rather than at imageBase plus an RVA. Getting this wrong in either direction, treating the VA as an RVA or jumping straight to the array without validating its address, is how the loop above earns its checks.

After callbacks, the entry point. The entry point address is imageBase + AddressOfEntryPoint. For a standard CRT linked DLL, AddressOfEntryPoint points at DllMain or at a CRT wrapper that calls it. The PE spec does not guarantee that every image follows that convention, and unusual images like language runtimes or custom packers can point at their own startup. So the correct statement is that the RVA normally resolves to DllMain, and the mapper should treat it as an opaque routine and call it with the attach signature.

bool CallEntryPoint(uintptr_t remoteBase, const PIMAGE_NT_HEADERS nt)
{
auto entry = reinterpret_cast<DLLENTRYPOINT>(remoteBase + nt->OptionalHeader.AddressOfEntryPoint);
return entry(reinterpret_cast<HINSTANCE>(remoteBase), DLL_PROCESS_ATTACH, nullptr) != FALSE;
}

There is a real design decision in how the entry runs, because the loader lock is involved. In a normal load the loader calls DllMain while holding the loader lock, which is the process wide lock that serializes every DLL initialization. That is why the documented best practice forbids calling LoadLibrary, GetModuleHandle, or synchronization routines from DllMain; each one can re enter the lock that is already held and deadlock.

A manual mapper is not following that path, so it has a choice. It can call the entry point on the stub’s thread directly. Or it can have the stub spawn a thread whose body calls the entry point while the mapping thread waits on an event. That second shape is a workaround for specific deadlock cases, and I want to be precise about what it does and does not buy. The spawned thread keeps one thread from holding the lock while it touches loader structures, which avoids a class of deadlock where the mapping thread blocks on something that needs the lock to proceed. But it is not a reproduction of the loader’s real initialization semantics, and if the second thread is created and then waited on immediately, that wait is exactly the pattern that deadlocks, the same trap documented in the thread creation guidance. The correct framing is that entry point threading is a limitation to design around, not a trick that makes DllMain safe.

Restoring section permissionsh2

We wrote every page with PAGE_EXECUTE_READWRITE and then ducked out of it. That is fine for a lab, bad for anything that runs longer than a minute. After the image is shipped, walk the section headers again and set each section’s protection to match what the loader would give it. Code becomes read and execute. Data becomes read and write. Read only data stays read only.

bool SetSectionProtection(uintptr_t remoteBase, const PIMAGE_NT_HEADERS nt, const PIMAGE_SECTION_HEADER sections, WORD count)
{
for (WORD i = 0; i < count; ++i)
{
DWORD protection = TranslateSectionFlags(sections[i].Characteristics);
SIZE_T size = sections[i].Misc.VirtualSize ? sections[i].Misc.VirtualSize : sections[i].SizeOfRawData;
DWORD old;
VirtualProtectEx(g_proc, reinterpret_cast<LPVOID>(remoteBase + sections[i].VirtualAddress), size, protection, &old);
}
return true;
}

TranslateSectionFlags reads IMAGE_SCN_MEM_EXECUTE, IMAGE_SCN_MEM_READ, and IMAGE_SCN_MEM_WRITE out of the section characteristics and maps them to page constants. Code with no read flag still needs execute. Data with no write flag is genuinely read only. The mapping engine must not force write onto a section the image declares read only, because when that happens later the access violation lands in whatever code touched the section, nowhere near the mistake.

The usual offenders are the import and export structures. A DLL whose writable IAT overlays a section the linker marked read only will fault the first time the loader writes an address into it. That rarely happens with a well formed modern linker, but a hand assembled or heavily patched image will teach you that lesson in twenty minutes.

What a production mapper actually bothers withh2

The sequence so far gets a DLL running in a lab. The tools that ship, the ones that survive in the wild, add a handful of steps that are each small and individually forgettable. The manual mapping flags in the guided hacking injector library are a convenient checklist of exactly these concerns, because each flag maps to one real gap.

Cleaning the data directory. After the image is loaded and fixed, some mappers zero out the directory entries that no longer have any meaning, on the theory that a scanner looking for a valid import directory will find nothing to classify. If you clean, you must also set page protections, because cleaning makes the image look less like a PE and the two options are documented as exclusive.

Resolving delay loaded imports. Not every dependency is eagerly loaded. A DLL can defer the load of another library until a function in it is first called. If the image uses delay imports and the mapper skips them, the first call into that library faults. The fix is the same import walk, run against the delay import descriptor.

Executing TLS callbacks and handling exceptions. We covered TLS. Exception handling is a quieter gap. A mapped module that wants structured exception handling needs its exception directory registered, which is work the loader normally does. Skip it and any DLL that throws or catches faults loudly.

Setting page protections per section, covered above.

Initializing the security cookie. Modern CRT builds use a process wide security cookie to check that buffer overruns did not corrupt the return address. The cookie’s address is usually part of the load config directory. A mapper that initializes it puts the mapped module on par with a normally loaded one. A mapper that skips it leaves the cookie at whatever garbage the fresh page contained.

Running the entry point under the loader lock. The real loader holds the loader lock when it calls DllMain, and a minority of DLLs are written to depend on that. Some mappers re acquire the lock before calling the entry point to match those expectations. It is the strangest of the flags and it exists because not every DLL was written to survive a lockless attach.

Each of these is a sentence in this post and an afternoon in the debugger when it goes wrong. The honest model is that the loader does all of them, and the work of a production mapper is enumerating them and deciding which to replicate.

Error handling and cleanuph2

This is what separates the mappers that work on one machine from the ones that survive three. Every step can fail, and a failure in the middle leaves the target holding a half built image. There is no partial bake. If the mirror is built but the ship fails, the remote allocation leaks. If the entry point starts and initializes half its state, none of the cleanup code above runs.

So the design rule is symmetry. Every allocation has a matching free. Every free happens before a thread starts doing work. The error path runs in exactly one place and it only ever cleans up things it actually created. The classic shape tracks each successful step and unwinds on the first failure, freeing the mirror and the remote allocation, and never spawning a thread if the image is not fully shipped.

BOOL MapIntoRemote(const void* fileBytes, SIZE_T fileSize, uintptr_t* outBase, HandleState& state)
{
auto* nt = ParseHeaders(const_cast<void*>(fileBytes), fileSize);
if (nt == nullptr)
{
return FALSE;
}
state.localBase = BuildMirror(fileBytes, fileSize, nt);
if (state.localBase == 0ULL)
{
return FALSE;
}
state.remoteBase = AllocateRemote(nt);
if (state.remoteBase == 0ULL)
{
Rollback(state);
return FALSE;
}
ptrdiff_t delta = static_cast<ptrdiff_t>(state.remoteBase) - static_cast<ptrdiff_t>(nt->OptionalHeader.ImageBase);
if (FixRelocations(state.localBase, nt, delta) == FALSE)
{
Rollback(state);
return FALSE;
}
if (FixImports(state.localBase, nt) == FALSE)
{
Rollback(state);
return FALSE;
}
if (ShipImage(state.localBase, state.remoteBase, nt) == FALSE)
{
Rollback(state);
return FALSE;
}
if (CallTlsCallbacks(state.localBase, nt) == FALSE)
{
Rollback(state);
return FALSE;
}
SetSectionProtection(state.remoteBase, nt);
*outBase = state.remoteBase;
return TRUE;
}

Note what is not in this function. There is no thread creation inside it. Thread creation is the caller’s job, after the image is verified complete. Keeping the spawn separate means the deep error path never has to consider a thread that might still be running. The image either ships completely or it is released, and nothing inside the target ever executed, so no lock state leaks from our actions.

The other cleanup nobody remembers is the stub blob. Once the entry point is running and the thread has passed the dispatch, the stub region is dead weight and a freshly recognizable one, since it is executable memory whose owner no one can name. Freeing it from inside the stub is finicky because you are freeing the memory you are currently standing on. The safer pattern is to free it from the mapper process after the remote thread signals that it reached the entry point. That handshake is worth the extra wait, because it is the difference between a mapped image with a clean tail and one that leaves its bootstrap waving in the wind.

Why you still get caught anywayh2

Time to say the unpopular thing. Manual mapping hides a module from the module list. It does not hide a module from the people who scan for the behavior of a mapped module. The gap between those two sentences is where every detection lives.

The defenders do not spend their day in the PEB’s module lists. They scan memory. The memory scanning stack is documented public knowledge, so this is not speculation. User mode code walks the virtual address space with VirtualQueryEx, which loops over MEMORY_BASIC_INFORMATION records, and the underlying syscall is NtQueryVirtualMemory, which is also what kernel drivers call directly. A VirtualAllocEx block with no file backing, sized like an image, with PAGE_EXECUTE_READWRITE pages, is a screaming signature. Tools built on these APIs enumerate executable private regions and publish entire scanners whose dedicated job is finding exactly the allocations this post describes.

The interesting shift of the last few years is that the scanners stopped depending on finding the markers a manual mapper might cleanly destroy. The old generation looked for an MZ header and a PE signature inside a private allocation, and countermeasures like erasing the PE header after load defeated them. The newer generation keeps a baseline. A paper on in memory threat detection published in 2025 describes a kernel engine that snapshots the address space on a timer, hashes committed regions, and flags a region that appears after a baseline pass and whose pages are executable and not backed by any known module. That detects a manual map whether or not its header survives, because the map changed the address space and left a mark the baseline did not have. The same paper notes the header based scanners, the classic approach, miss headerless mappings entirely, which is why the stateful engines exist.

The thread is the other stable artifact. Every thread has a start address. A start address inside a private allocation that does not belong to any module is suspicious on its own, and the starting address plus a PEB walk to confirm there is no module owner is a route any anti cheat can take. Note the subtlety here. The entry point itself can be moved, and the stub can be freed, but the thread’s start address, whatever it points at, sits in a region that either is or is not part of a loaded module. If it is not, the inspector has found our hole. This is why you see tooling that shifts the thread start to hover over a real image, an adjustment that changes what the artifact looks like without changing the underlying existence of the thread.

Other heuristics round out the picture. High entropy in executable private pages, which catches packed payloads. Hidden PE signatures in readable memory. Sections whose protections say read and execute when the file they came from said otherwise. Module stomping, where a legitimate module’s code region is overwritten with our code, flagged by memory engines that hash the contents of image regions and compare against a baseline. All of these are documented, public tooling. The stateful engines are kernel work and they reach into mappings without asking anyone’s permission.

So the honest takeaway. Manual mapping belongs in an arms race, and its value is mostly operational. It works when the scanner is generic and stateless. It fails when detection is built around the concept of mapped memory, not around the concept of a file, which is exactly where public research has been heading. If you are building against a serious anti cheat, plan like every mapping gets read, and treat the technique as one layer in a system that includes integrity checks and behavior heuristics. There is no free lunch and no sane person believes there is.