Local Module Stomping / DLL Hollowing
Code execution by replacing a module (DLL) .text section with shellcode
Last updated
Was this helpful?
Code execution by replacing a module (DLL) .text section with shellcode
Last updated
Was this helpful?
The term "stomping" means replacing the content of a specific memory region. in module stomping (A.K.A. DLL hollowing), a malicious code (shellcode) replaces the content of .text
section of a legitimate loaded module (DLL).
In classic code execution/injection techniques where shellcode is injected in memory using memory allocation APIs (e.g., VirtualAlloc/Ex), the allocated memory region is not backed by a module on disk (for example a benign DLL path likeC:\windos\system32\user32.dll
).
This behavior looks suspicious in the eyes of security solutions and malware analysts. to avoid allocating such memory regions, we can load a benign DLL into our process and overwrite its content with our malicious shellcode; then, we execute the DLL entry point which in turn will invoke the shellcode.
There are two things to consider in this technique:
CFG can potentially block module stomping if the malicious code execution relies on indirect calls or jumps.
The .text section of the loaded DLL should be large enough for the shellcode to fit in.
Control Flow Guard (CFG) is a security mechanism introduced in Windows to prevent indirect control flow attacks, such as code injection or Return-Oriented Programming (ROP). CFG ensures that only valid, pre-verified function entry points can be used as destinations for indirect calls or jumps.
In case of module stomping, CFG verifies that a function pointer (or indirect jump/call destination) points to a legitimate entry point of a function within an executable module. Shellcode injected into a DLL via module stomping does not reside at a valid function entry point recognized by CFG. so if the shellcode is executed via an indirect call or jump, CFG will intercept the execution attempt because the overwritten memory does not point to a valid function entry point.
In this blog post, we are hollowing a DLL in local process so the call to DLL entry point is direct (no jumps or indirect calls to a remote process). in this case the CFG won't be a problem.
However, when we get to process injection techniques, we have to find a way around CFG when performing remote module stomping.
The execution flow of this technique is like this:
Load a legitimate DLL into current process
Retrieve module information using GetModuleInformation
Find the DLL entry point address in memory
Stomping the .text
section of module with our shellcode
Executing DLL entry point which will invoke the shellcode
I covered DLL loading process in previous posts in the series, checkout Dynamic Load Library for more info.
Loading the module is as simple as calling LoadLibraryW
API and pass the name of DLL:
To get the module information such as different PE headers and sections, we use the GetModuleInformation
function.
This function takes a handle to target process (in this case, from GetCurrentProcess()
) and a handle to target module (hModule
), then returns a structure of type MODULEINFO
:
This structure contains 3 members, we only need the EntryPoint
parameter.
The code to retrieve the structure looks like this:
The address retrieved from MODULEINFO
points to the base address module PE in memory. in order to find the address of module entry point (the first function executed when the module is loaded into process memory. e.g., DllMain
) we have to walk the PE headers to get to the AddressOfEntryPoint offset. the flow looks like this:
And here is the code for finding the entry point:
Breakdown of the code:
(DWORD_PTR)hModule
:
hModule
is a handle to the loaded DLL. It's a pointer to the base address of the module in memory.
We cast it to DWORD_PTR
(which is typically a uintptr_t
type), ensuring that we can perform pointer arithmetic on it (i.e., adding and offsetting memory addresses).
(PIMAGE_DOS_HEADER)hModule
:
A PIMAGE_DOS_HEADER
is a pointer to the DOS header of the executable (in this case, the DLL).
The DOS header is the first part of an executable file and contains various fields. One of the key fields is the e_lfanew, which points to the start of the PE (Portable Executable) header.
((PIMAGE_DOS_HEADER)hModule)->e_lfanew
:
e_lfanew
is the offset to the PE header from the beginning of the file. This value is stored in the DOS_HEADER
structure. In the context of a loaded DLL, e_lfanew
will give us the offset from hModule
to the PE header.
(PIMAGE_NT_HEADERS)((DWORD_PTR)hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew)
:
Now, we use the value from e_lfanew
to locate the PE header in memory. The PE header contains vital information about the executable, including the OptionalHeader, which holds information about the entry point.
We cast the address to PIMAGE_NT_HEADERS
(a pointer to the PE header structure) so we can access its fields.
((PIMAGE_NT_HEADERS)((DWORD_PTR)hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew))->OptionalHeader.AddressOfEntryPoint
:
The AddressOfEntryPoint
field in the OptionalHeader
tells us the relative offset (from the base address of the module) to the entry point of the DLL.
This field provides the location where the execution of the DLL starts, often the DllMain
function or any custom entry point defined by the DLL.
(void*)((DWORD_PTR)hModule + AddressOfEntryPoint)
:
Finally, we add the AddressOfEntryPoint
to the base address of the module (hModule
) to calculate the absolute address of the entry point in memory.
The result is a pointer (void*
) to the entry point of the loaded DLL.
To stomp the module with our shellcode, we have to change its memory protection to RW (read/write) to be able to overwrite the memory content at that address. then we have to copy the shellcode into that address and finally, change the memory protection back to RX (read/execute):
Finally, to invoke our shellcode which now resides in the address of module entry point, we simply call the DLL entry point using a function pointer:
The complete module stomping code will be this:
If we run the code, the module base address and the address of entry point is printed in the terminal:
As you can see, the memory region is backed by amsi.dll
module on disk.
If we check the module properties in System Informer, we see that its a Microsoft signed binary:
And the address of entry point:
But when we hit Enter, the calc will pop up :)
Code snippets are available on GitHub: