☰
进程空中技术:手动实现PE内存加载与导入表重定位解析
2026/10/20 7:45:18 网站建设 项目流程

简介:这份资源围绕进程空中技术(RunPE / Process Hollowing)展开,面向安全研究人员、逆向工程师以及希望深入理解 Windows 底层机制的学习者,帮助读者掌握在不将 exe 写入磁盘的前提下于内存中动态加载并执行代码的核心思路。内容涵盖承载进程选择、目标 exe 节区读取、内存状态保存与清空、入口点重设以及 NtResumeThread 恢复执行等关键环节,并延伸至隐蔽执行、逆向工程与动态调试等典型应用场景。压缩包内共 1 个文件,为一份 cpp 源码,整体约 2KB,体量轻便,便于直接阅读与二次实验。目前已有 1209 人学习下载,适合作为理解 PE 文件格式、进程注入与内存加载机制的入门参考,也可为后续分析相关防御检测手段提供实践基础。

1. 进程空中技术:为什么一个 exe 能在不落盘的情况下跑起来

一个 exe 文件明明躺在磁盘上,双击它,操作系统加载器把它读进内存、重定位、解析导入表、跳到入口点——这套流程我们太熟了。但如果我告诉你,有一个 exe 从头到尾没有以文件形式出现在磁盘上,它却照样跑起来了,进程列表里能看到它,功能一切正常,你会不会觉得哪里不对劲?

这就是进程空中技术要解决的问题:把一个 PE 文件直接在内存中加载执行,绕过操作系统的加载器。听起来像是玄学,但拆开看,它不过是把 Windows 加载器干的活自己重写了一遍。搞懂这件事,你会对 PE 格式、内存映射、导入表解析、重定位这些底层机制有一次彻底的通透理解。而这份资源,就是把这套机制从原理到代码完整拆开的实战材料。

2. PE 文件格式:加载器到底替你做了什么

2.1 从磁盘上的字节到内存里的镜像

一个 exe 文件在磁盘上的布局和它在内存中的布局是不一样的。磁盘上,PE 文件按 FileAlignment 对齐,通常是 512 字节;加载到内存后,按 SectionAlignment 对齐,通常是 4096 字节。这意味着同一个节区在磁盘上和内存中的偏移完全不同。

加载器做的事情,核心就是把这个映射关系建立起来。它读取 DOS 头,找到 e_lfanew 字段指向的 NT 头,从 NT 头里拿到 OptionalHeader,再根据 SectionHeader 数组把每个节区从磁盘偏移搬到内存偏移。这个过程不是简单的 memcpy,因为节区在磁盘上可能是压缩的、可能没有实际数据(比如 .bss 段),加载器需要根据 Characteristics 字段判断怎么处理。

常见的实现思路是:先读取整个文件到缓冲区,解析 NT 头拿到 SizeOfImage,用 VirtualAlloc 在目标进程空间分配一块大小为 SizeOfImage 的内存,然后逐节区拷贝。拷贝的时候要注意,如果节的 PointerToRawData 为 0,说明这个节在磁盘上没有数据,只需要在内存中保留空间并清零即可。

2.2 导入表:那些 dll 函数是怎么被找到的

PE 文件里有一个非常重要的数据结构叫导入表(Import Table),它记录了这个 exe 依赖哪些 dll、用到了哪些函数。在正常加载流程中,加载器会遍历导入表,把每个 dll 加载到进程空间,然后拿到函数地址,填入 IAT(Import Address Table)。

内存加载的时候,这一步必须自己来做。你需要遍历 IMAGE_IMPORT_DESCRIPTOR 数组,每个描述符对应一个 dll。对于每个 dll,先用 LoadLibraryA 把它加载进来,然后遍历 OriginalFirstThunk 指向的数组,拿到每个函数的名称或序号,再用 GetProcAddress 拿到实际地址,写入 FirstThunk 指向的 IAT 位置。

这里有个容易翻车的地方:如果 OriginalFirstThunk 为 0,说明这个 PE 文件没有原始导入表,只能用 FirstThunk 来解析。但此时 FirstThunk 已经被绑定过了,里面的值可能是函数地址而不是名称 RVA。这种情况在加壳或某些编译器产物中会出现,处理不当就会解析出错误的函数地址。

// 遍历导入表,解析每个 dll 的函数地址 IMAGE_IMPORT_DESCRIPTOR* importDesc = (IMAGE_IMPORT_DESCRIPTOR*)(base + importDir->VirtualAddress); while (importDesc->Name != 0) { const char* dllName = (const char*)(base + importDesc->Name); HMODULE hDll = LoadLibraryA(dllName); if (!hDll) return FALSE; // OriginalFirstThunk 为 0 时回退到 FirstThunk DWORD thunkRVA = importDesc->OriginalFirstThunk ? importDesc->OriginalFirstThunk : importDesc->FirstThunk; IMAGE_THUNK_DATA* thunk = (IMAGE_THUNK_DATA*)(base + thunkRVA); IMAGE_THUNK_DATA* iat = (IMAGE_THUNK_DATA*)(base + importDesc->FirstThunk); while (thunk->u1.AddressOfData != 0) { FARPROC funcAddr; if (thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) { // 按序号导入 funcAddr = GetProcAddress(hDll, (LPCSTR)(thunk->u1.Ordinal & 0xFFFF)); } else { // 按名称导入 IMAGE_IMPORT_BY_NAME* importByName = (IMAGE_IMPORT_BY_NAME*)(base + thunk->u1.AddressOfData); funcAddr = GetProcAddress(hDll, importByName->Name); } if (!funcAddr) return FALSE; iat->u1.Function = (ULONG_PTR)funcAddr; thunk++; iat++; } importDesc++; }

这段代码的核心逻辑是:对每个依赖的 dll,先加载它,然后逐个解析函数地址并填入 IAT。参数说明上,base 是内存中 PE 镜像的基址,importDir 是数据目录中导入表的 RVA。注意 thunk 和 iat 是两个独立的指针,它们分别遍历 OriginalFirstThunk 和 FirstThunk,步调必须一致。如果某个函数解析失败,整个加载过程就应该中止,否则后续调用会跳到非法地址直接崩溃。

2.3 重定位:为什么基址不是你想选就能选

PE 文件有一个 preferred base address,也就是编译时假设自己会被加载到的地址。如果实际加载地址和这个不一样,所有写死的绝对地址都需要修正,这就是重定位。正常加载时,如果首选基址被占用,加载器会选一个新基址并应用重定位表。内存加载时,你可以选择用 VirtualAlloc 指定首选基址,如果分配成功就不需要重定位;如果失败,就必须手动处理重定位表。

重定位表的结构是分块的,每个块对应一个 4KB 页面,块头记录了页面的 RVA 和块大小,后面跟着若干个 16 位的偏移项。每个偏移项的低 12 位是页内偏移,高 4 位是重定位类型。在 32 位 PE 中,最常见的类型是 IMAGE_REL_BASED_HIGHLOW,表示一个 32 位的绝对地址需要加上差值。

// 应用重定位表 IMAGE_BASE_RELOCATION* reloc = (IMAGE_BASE_RELOCATION*)(base + relocDir->VirtualAddress); DWORD delta = (DWORD)((ULONG_PTR)base - imageNtHeaders->OptionalHeader.ImageBase); while (reloc->VirtualAddress != 0) { DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* items = (WORD*)(reloc + 1); for (DWORD i = 0; i < count; i++) { WORD type = items[i] >> 12; WORD offset = items[i] & 0x0FFF; if (type == IMAGE_REL_BASED_HIGHLOW) { DWORD* patchAddr = (DWORD*)(base + reloc->VirtualAddress + offset); *patchAddr += delta; } } reloc = (IMAGE_BASE_RELOCATION*)((BYTE*)reloc + reloc->SizeOfBlock); }

delta 是实际基址和首选基址的差值,每个需要重定位的地址都加上这个差值。注意 64 位 PE 用的是 IMAGE_REL_BASED_DIR64,处理的是 64 位地址,类型判断不能写死。如果重定位表不存在(RelocDir 的 VirtualAddress 为 0),说明这个 PE 不支持重定位,必须加载到首选基址,否则只能放弃。

3. 内存加载的核心流程:从读取文件到跳转入口

3.1 分配内存与拷贝节区

整个内存加载流程可以拆成几个关键步骤。第一步是读取文件内容到缓冲区,解析 DOS 头和 NT 头,拿到 SizeOfImage、SizeOfHeaders、SectionAlignment 等关键字段。第二步是用 VirtualAlloc 分配内存,大小是 SizeOfImage,权限先给 PAGE_EXECUTE_READWRITE,方便后续写入。第三步是拷贝 PE 头,大小是 SizeOfHeaders,直接 memcpy 即可。

第四步是逐节区拷贝。遍历 SectionHeader 数组,对每个节区,如果 PointerToRawData 不为 0,就把磁盘上对应位置的数据拷贝到 base + VirtualAddress 处,拷贝长度是 SizeOfRawData。如果 VirtualSize 大于 SizeOfRawData,多出来的部分要清零。这一步做完,内存中的镜像就和正常加载后的布局基本一致了。

// 逐节区拷贝 IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(ntHeaders); for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) { if (section[i].SizeOfRawData > 0) { memcpy( (BYTE*)base + section[i].VirtualAddress, (BYTE*)fileBuffer + section[i].PointerToRawData, section[i].SizeOfRawData ); } // 如果虚拟大小大于原始大小,多余部分清零 if (section[i].Misc.VirtualSize > section[i].SizeOfRawData) { memset( (BYTE*)base + section[i].VirtualAddress + section[i].SizeOfRawData, 0, section[i].Misc.VirtualSize - section[i].SizeOfRawData ); } }

这里有个细节:SizeOfRawData 在磁盘上是对齐到 FileAlignment 的,可能比实际数据大,拷贝多了也没关系,因为后面会被 VirtualSize 截断。但如果 VirtualSize 小于 SizeOfRawData,拷贝时应该以 VirtualSize 为准,否则会覆盖到下一个节区的数据。我一般会取两者中较小的那个作为拷贝长度,这样最安全。

3.2 处理导入表与重定位的先后顺序

导入表和重定位的处理顺序是有讲究的。必须先做重定位,再做导入表解析。原因很简单:导入表的 IAT 本身可能包含需要重定位的地址,如果你先解析导入表填入了真实函数地址,再做重定位,那些函数地址就会被错误地加上 delta,导致调用时跳到非法地址。

这个顺序问题在实际编码中很容易搞反,因为从逻辑上看,先解析导入表似乎更自然。但 PE 规范里明确说了,重定位表可能包含 IAT 项,所以必须先修正基址相关的地址,再填充 IAT。翻车的时候现象很诡异:程序能启动,但一调用某个 API 就崩溃,而且崩溃地址看起来像是函数地址加了一个奇怪的偏移。

3.3 设置节区权限与跳转入口

所有数据都就位之后,需要根据每个节区的 Characteristics 字段设置内存权限。代码节通常是可执行可读,数据节是可读可写,资源节是只读。用 VirtualProtect 逐节区设置,不要图省事整个镜像给一个 PAGE_EXECUTE_READWRITE,那样虽然能跑,但不符合安全实践,也容易被某些保护机制拦截。

最后一步是跳转到入口点。入口点的 RVA 在 OptionalHeader.AddressOfEntryPoint 里,实际地址是 base + AddressOfEntryPoint。对于 exe,入口点通常是 mainCRTStartup 或 WinMainCRTStartup,调用它就能启动程序。但要注意,如果这个 exe 有 TLS 回调,还需要先处理 TLS 目录,否则某些依赖 TLS 初始化的程序会出问题。

// 设置节区权限 for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) { DWORD protect = PAGE_READONLY; if (section[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) protect = PAGE_EXECUTE_READ; if (section[i].Characteristics & IMAGE_SCN_MEM_WRITE) protect = PAGE_READWRITE; if ((section[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) && (section[i].Characteristics & IMAGE_SCN_MEM_WRITE)) protect = PAGE_EXECUTE_READWRITE; VirtualProtect((BYTE*)base + section[i].VirtualAddress, section[i].Misc.VirtualSize, protect, &oldProtect); } // 跳转入口点 typedef int (WINAPI *EntryPointFunc)(); EntryPointFunc entry = (EntryPointFunc)((BYTE*)base + ntHeaders->OptionalHeader.AddressOfEntryPoint); entry();

权限设置这一步不能省,尤其是当你的加载器本身运行在受保护的环境里时,缺少权限设置会导致执行代码节时触发访问违例。入口点调用之后,控制权就交给了被加载的程序,加载器本身可以继续运行也可以退出,取决于具体需求。

4. 避坑与排查:内存加载中最容易翻车的几个点

4.1 加载后程序闪退,没有任何报错

现象是调用入口点后进程直接退出,没有弹窗也没有日志。原因通常是导入表解析失败但错误被吞掉了,或者重定位没做导致入口点代码里的绝对地址跳飞了。解决方法是逐步验证:先确认 VirtualAlloc 返回的基址是否等于首选基址,如果不等于,检查重定位表是否被正确应用;再检查导入表解析时每个 GetProcAddress 是否返回非空,任何一步失败都应该立即中止并输出错误码。

4.2 某些 API 调用时崩溃,地址看起来像函数地址加偏移

这是典型的先解析导入表后做重定位导致的。IAT 里的函数地址被重定位逻辑错误地加上了 delta,调用时自然跳到非法位置。解决方法是严格保证顺序:先重定位,再解析导入表。如果你不确定,可以在重定位完成后打印几个 IAT 项的值,确认它们还是原始的函数地址而不是被修正过的。

4.3 64 位程序加载后行为异常

32 位和 64 位的 PE 结构有差异,最容易忽略的是重定位类型。64 位 PE 用的是 IMAGE_REL_BASED_DIR64,处理的是 64 位地址,如果你按 32 位的 HIGHLOW 类型去处理,只会修正低 32 位,高 32 位不变,导致地址错乱。另外 64 位的 IMAGE_THUNK_DATA 是 8 字节,遍历导入表时指针步长要对应调整,否则会解析出错误的函数名。

4.4 加载带 TLS 的程序时初始化失败

TLS(线程本地存储)目录在数据目录的第 10 项,正常加载时加载器会处理 TLS 回调。内存加载时如果跳过这一步,依赖 TLS 初始化的程序(很多 C++ 运行时库都会用)会在启动时崩溃。解决方法是解析 TLS 目录,手动调用 TLS 回调函数,并正确设置 TLS 索引。这个步骤比较繁琐,但如果不做,很多现代编译器产出的 exe 都跑不起来。

4.5 杀软拦截或进程被终止

内存加载技术本身的行为特征和某些恶意软件相似,可能触发安全软件的启发式检测。现象是加载过程中进程被终止,或者 VirtualAlloc 返回失败。解决方法是确保你的加载器有合法的用途和签名,在受控环境中测试。另外,分配内存时不要用 PAGE_EXECUTE_READWRITE 这种敏感权限,先分配为 PAGE_READWRITE,写入完成后再逐节区改为可执行,能降低被拦截的概率。

5. 设计取舍:为什么有人选择内存加载而不是正常加载

内存加载 exe 这件事,本质上是在绕过操作系统的加载器,自己实现一套等价的逻辑。它的价值不在于「炫技」,而在于某些场景下正常加载流程走不通。比如你需要加载一个没有写磁盘权限的程序,或者你想在加载前对 PE 文件做动态修改(打补丁、去校验),又或者你需要在一个受限环境中运行一个依赖特定基址的程序。

但代价也很明显。正常加载器经过了几十年的打磨,处理了无数边界情况:延迟导入、绑定导入、清单文件、API Set、Forwarder DLL、SxS 依赖,这些在内存加载中都需要自己处理,漏掉任何一个都可能导致程序行为异常。而且内存加载的程序在系统看来是一个「匿名」的可执行映像,调试、崩溃转储、性能分析都会变得更困难。

我自己的习惯是:能用正常加载就用正常加载,内存加载只在确实需要绕过文件系统或需要动态修改 PE 时才用。用的时候,我会先把目标 exe 在一个干净环境里正常跑一遍,记录它加载了哪些 dll、有没有 TLS 回调、有没有延迟导入,然后对照着把这些都实现一遍。从那以后我每次做内存加载,都会强制走一遍「正常加载对比」的流程,确保没有遗漏任何加载器会做的事情。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询