Unmapped 003: Wrapping it up
The servermapper looks impressive—DRM protected, no PE on disk, server-controlled delivery. But the core technique is just memory mapping with extra steps. We reversed it by capturing 195 memory writes, extracting the import table, and building our own internal loader.
Welcome back. In Part 1, we dumped 195 memory writes from a servermapper injection using an NtWriteVirtualMemory hook. In Part 2, we analyzed those writes—9 section blobs followed by 186 IAT entries—and figured out how to map each captured address back to its export name.
Now we execute.
This post walks through the actual mapper implementation in the illusicrack github, explaining how we take those memory dumps and import tables and turn them into a working cheat loader. No servermapper required.
The Core Problem
Remember: the servermapper sends us a headerless binary blob. No MZ signature. No section table. No import directory. Just raw code and data, already relocated, already IAT-populated.
When we captured those writes, we got exactly what the servermapper built. But there's a catch: those 186 IAT addresses we captured are only valid for that specific session. ASLR means kernel32.dll loads at a different address every time. The address for CreateRemoteThread we saw at last boot? Garbage today.
So our mapper needs to:
1. Allocate at the exact base address the servermapper used
2. Copy in the section data (first 9 writes, either individually or grouped together as one)
3. Resolve imports fresh for this session
4. Manually invoke the entrypoint
Let's break down how each piece works.
Allocation Strategy: Lock the Base Address
The servermapper built this binary working with the base address 0xE0D40000. All the internal pointers—function addresses, string references, virtual tables—assume that address. If we load it somewhere else, those pointers point to garbage.
Normal DLLs handle this with relocation tables. Windows patches every affected pointer when the DLL loads at a different base. But our blob has no relocation table. It was already relocated server-side.
So we do the simplest thing possible: allocate at the same address.
namespace Context {
inline std::uintptr_t ImageBaseAddress = (0xE0D40000);
inline std::uintptr_t EntryBaseAddress = (0xE0D40100);
}Then we just copy the binary straight in:
memcpy_s(reinterpret_cast<LPVOID>(Context::ImageBaseAddress),
sizeof(CheatBinary), CheatBinary, sizeof(CheatBinary));CheatBinary is the embedded blob—the 9 section writes we captured, concatenated into a single byte array. We allocate memory at 0xE0D40000 and slam it in. Done.
Why this works: Because we're using the same base address, every relative offset is already correct. Code at 0xE0D4XXXX calls a function at 0xE0D4YYYY? Still valid. String reference at 0xE0D4A000? Still there.
We've sidestepped the entire problem. This "technique" was explained to me by a47 in ±2018, thank you.
Import Resolution: Rebuilding the IAT
The IAT (Import Address Table) is where function pointers live. When you call CreateRemoteThread, you're actually doing an indirect call through a pointer stored in the IAT. Normally the Windows loader fills this in at DLL load time.
We don't have that luxury. Our blob has no import directory to tell the loader what to resolve. And the captured IAT addresses are stale.
So we resolve everything manually.
The SolveImports() Pattern
Our job becomes kinda easy, because our dumper already gave us all the import table info, which then (in Part 2) gave us a mapping: "offset 0x581060 needs kernel32!QueryPerformanceFrequency". We turn that into code:
LoadLibraryA("KERNEL32.DLL");
*reinterpret_cast<std::uintptr_t*>(0xE12C1060) =
(std::uintptr_t)GetProcAddress(GetModuleHandleA("KERNEL32.DLL"),
"QueryPerformanceFrequency");Let's unpack this:
- LoadLibraryA("KERNEL32.DLL") ensures the DLL is loaded in our process
- 0xE12C1060 is ImageBase + 0x581060—the exact IAT slot for this import
- GetModuleHandleA + GetProcAddress gives us the fresh address of the export
- We write that address directly into the IAT slot
Then with a little bit of python scripting, we repeat this ~90 times for every import:
// kernel32 imports
*reinterpret_cast<std::uintptr_t*>(0xE12C1000) =
(std::uintptr_t)GetProcAddress(GetModuleHandleA("KERNEL32.DLL"), "Sleep");
*reinterpret_cast<std::uintptr_t*>(0xE12C1008) =
(std::uintptr_t)GetProcAddress(GetModuleHandleA("KERNEL32.DLL"), "GetCurrentProcess");
// advapi32 imports
LoadLibraryA("ADVAPI32.DLL");
*reinterpret_cast<std::uintptr_t*>(0xE12C1200) =
(std::uintptr_t)GetProcAddress(GetModuleHandleA("ADVAPI32.DLL"), "RegOpenKeyExA");
// ntdll imports
*reinterpret_cast<std::uintptr_t*>(0xE12C1400) =
(std::uintptr_t)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");The actual SolveImports() function is ~100 lines of this pattern.
Why this works:
When the cheat code calls CreateRemoteThread, it's compiled as:
call qword ptr [0xE12C1050] ; indirect call through IATAs long as that memory location contains a valid pointer to kernel32!CreateRemoteThread, the call succeeds. Doesn't matter if the address is different from what the servermapper used. We've resolved it fresh for this boot, this ASLR state, this session.
The servermapper did the same thing—it just did it server-side. We're doing it client-side at injection time, essentially reconstructing the mechanics.
Entrypoint: Manual DllMain Invocation
Normal DLLs have PE headers. Windows sees the DOS stub, validates the NT headers, parses the Optional Header, finds the entry point RVA, and calls DllMain(hInstance, DLL_PROCESS_ATTACH, lpReserved).
Our blob has none of that. The entry point offset is 0x54F108 (from our dumps), meaning the actual address is 0xE128F108. But Windows can't find it—there's no Optional Header to read.
So we write shellcode to call it manually:
inline unsigned char EntryShellcode[] = {
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28 (shadow space)
0x48, 0xB9, 0x00, 0x40, 0x0D, 0xE0, 0x00, 0x00, 0x00, 0x00, // mov rcx, 0xE0D40000
0x48, 0xC7, 0xC2, 0x01, 0x00, 0x00, 0x00, // mov rdx, 1 (DLL_PROCESS_ATTACH)
0x4D, 0x31, 0xC0, // xor r8, r8 (NULL)
0x48, 0xB8, 0x08, 0xF1, 0x28, 0xE1, 0x00, 0x00, 0x00, 0x00, // mov rax, 0xE128F108
0xFF, 0xD0, // call rax
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0xC3 // ret
};Breaking it down
Shadow space allocation (sub rsp, 0x28):
x64 Windows calling convention requires the caller to reserve 32 bytes (0x20) of stack space for the callee, plus 8 bytes for alignment. Total = 0x28.
Register setup for DllMain:
- RCX = 0xE0D40000 (hInstance—the base address of our module)
- RDX = 1 (DLL_PROCESS_ATTACH—telling the DLL we're attaching)
- R8 = NULL (lpReserved—typically unused)
Call the entry point:
mov rax, 0xE128F108 loads the entry point address, then call rax invokes it.
Return cleanly:
After DllMain returns, we restore the stack and exit the thread. We copy this shellcode to 0xE0D40100 (right after our image in memory), then spawn a thread pointed at it:
memcpy_s(reinterpret_cast<LPVOID>(Context::EntryBaseAddress),
sizeof(EntryShellcode), EntryShellcode, sizeof(EntryShellcode));
HANDLE thread = CreateRemoteThread(GetCurrentProcess(), nullptr, 0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(Context::EntryBaseAddress),
nullptr, 0, nullptr);That thread executes our shellcode, which calls the cheat's DllMain, which initializes everything, and we're running.
The Full Flow
The "server.dll" wait is just a synchronization trick—the loader DLL waits for the game to fully initialize and ensure that dependencies are ready. Not critical to understanding the mapping process.
The key steps are:
1. Copy binary
2. Resolve imports
3. Invoke entry point
Why This Works Without PE Headers
We've replicated the Windows loader's work using the knowledge we extracted in Parts 1 and 2. The servermapper did this on the server. We're doing it on the client. Same result.
One Gotcha: The NOP patch
There's a small detail in the code worth mentioning. At line 365 of Core.cpp:
std::vector<uint8_t> nops = {
{ 0x90,0x90,0x90,0x90,0x90,0x90 }
};
DBG(("Fixing init error..."));
Sleep(600);
uintptr_t displayMsgAddress = 0xE0D746CA;
memcpy_s(reinterpret_cast<LPVOID>(displayMsgAddress), nops.size(), nops.data(), nops.size());This patches a few bytes—changing some instructions to a slide of NOPs. Without diving into disassembly, this is likely bypassing an initialization check or anti-debug measure in the cheat itself. I honestly do not remember at this point, since this crack was done half a year before being blogged about. Not part of the mapping process, just a runtime patch to make the cheat behave.
The exact reason doesn't matter for our purposes. Point is: once you've got the binary loaded and imports resolved, you can patch whatever you want.
Conclusion
To the naked eye, to the little guy — the servermapper looks impressive— no PE on disk, server-controlled delivery. But the core technique is just:
1. Map a PE file (relocate, resolve imports)
2. Send only the mapped result
3. Client allocates at base address and executes
We reversed that by:
1. Capturing the mapped result
2. Extracting the import mapping
3. Allocating at the same base and re-resolving imports locally
No server needed. No network traffic. Just a DLL that, when injected, does the mapping work itself.
The repository shows the full implementation - a few lines of some resolvers, and a few dozen lines of scaffolding. That's all it takes to replace a "secure" servermapper. A servermapper is a very powerful technique to use in cheat delivery systems, but not when done like this, as the possibilities are almost endless on what can be done. Alot of people live under the impression that because they server map, their product becomes uncrackable against every attacker, and that's not the true. The same goes vice versa - where people deliberately decide not to analyze a product once they get to know theres a servermapper involved.
Reverse engineering isn't about magic. It's about understanding what the code actually does, then doing the same thing yourself. The servermapper did memory mapping. We captured that state, figured out the pattern, and replicated it.
For now, this works.
The code is on Github. Enjoy. :pray: