简介:围绕进程空中技术(RunPE)的实践代码,面向希望理解Windows内存加载exe机制的安全研究人员、逆向工程师与系统编程学习者。它聚焦不落盘执行可执行文件的核心流程,涵盖选择承载进程、读取目标映像、写入内存、修正PE头入口点以及通过NtResumeThread恢复执行等关键环节,并延伸到PE文件格式、进程注入行为与检测对抗思路,可作为动态调试和隐蔽执行原理的入门样本。压缩包仅2KB,内含1个cpp源文件,便于直接阅读、编译实验与二次修改,体量轻巧但覆盖的知识点较密集。已有1210人学习,适合用于建立RunPE整体认知、理解Windows进程管理底层机制,以及从防御视角梳理异常进程行为的排查要点。
1. 进程空中技术:exe 不落盘也能跑,靠的不是魔法
第一次看到一个 exe 没有落到磁盘、任务管理器里却多出一个正常进程时,我的第一反应是“不可能”。后来把 PE 格式认真读了一遍才想明白:Windows 加载器干的事无非三件——把节区按内存对齐铺开、把导入表填上、把入口点设好。这三件事只要有人替它做完,文件在不在磁盘上根本不重要。所谓进程空中技术(在内存中加载 exe),核心就是把这套加载逻辑搬到自己的代码里,再借一个合法进程的外壳把线程拉起来。这篇不聊玄学,只讲两件事:每一步到底在改哪块内存,以及从防守方看,哪几个位置一定会留下痕迹。新手能照着在自己的实验环境里走通一遍,熟手应该能从参数和偏移的细节里看到判断依据。
2. PE 内存映射:加载器替你做的三件事和两个坑
想搞懂进程空中,先得知道一个正常的 exe 是怎么“站起来”的。很多人卡在这里,不是因为 API 不会调,而是脑子里只有“把文件读进内存”这一个模糊印象,一旦对不上就完全不知道该看哪儿。我把加载器的工作拆成两个层面:一是地址换算,二是三件必须补的活。前者是纯算术,后者决定了你的内存映像是“能跑”还是“一跑就崩”。
2.1 磁盘布局和内存布局:两套偏移,别混着用
PE 文件在磁盘上是紧凑排列的,在内存里是按页松开的。控制这两套规则的,是可选头里的两个字段:FileAlignment(文件对齐,通常是 0x200)和SectionAlignment(内存对齐,通常是 0x1000)。节表里每个节同时给了两套坐标:PointerToRawData是它在文件里的起始偏移,VirtualAddress是它在内存里的 RVA。
换算关系记住两句就够:
- 内存地址 = 映像基址 +
VirtualAddress+ 节内偏移 - 文件偏移 =
PointerToRawData+ 节内偏移
常见的一个错误做法是把整个文件原样memcpy到一块SizeOfImage大小的空间里。头是对的,节区全错位——因为文件里节与节之间只隔 0x200 的整数倍,而内存里要隔 0x1000 的整数倍。这种错位不会立刻崩,往往表现为“进程起来了,界面也出来了,但某几个功能一用就访问违例”,排查起来非常费时间。
几个关键字段先摆出来,后面所有计算都基于它们:
| 字段 | 位置 | 含义 | x64 Release 常见值 |
|---|---|---|---|
| ImageBase | 可选头 +24(PE32+) | 首选加载基址 | 0x140000000 |
| SectionAlignment | 可选头 +32 | 内存对齐粒度 | 0x1000 |
| FileAlignment | 可选头 +36 | 文件对齐粒度 | 0x200 |
| SizeOfImage | 可选头 +56 | 映射完成后的总大小 | 各节对齐求和 |
| SizeOfHeaders | 可选头 +60 | 头部总大小 | 0x400 左右 |
| AddressOfEntryPoint | 可选头 +16 | 入口点 RVA | 相对基址 |
注意:SizeOfImage不等于文件大小,也不等于各节VirtualSize简单相加,而是每个节按SectionAlignment向上取整后求和,再加上对齐后的头部。手工映射时如果你分配的缓冲区比真实SizeOfImage小,写最后一节就会越界,而这种越界在WriteProcessMemory里可能只返回一个很含糊的错误码。
2.2 三件必须补的活:重定位、导入表、TLS
第一件是重定位。PE 里有很多硬编码的绝对地址,比如全局指针、字符串常量地址、虚表项。如果实际加载基址和ImageBase不一致,这些地址全错。修正依据是重定位表(.reloc节,数据目录第 5 项),里面按 4KB 一个块记录需要修补的位置和类型。x64 上最常见的是IMAGE_REL_BASED_DIR64(加 8 字节偏移量),x86 上是IMAGE_REL_BASED_HIGHLOW(加 4 字节)。算法很朴素:差值 = 实际基址 − 首选基址,遍历每个块,把对应位置的字段加上差值。
这里有个关键判断:如果加载地址正好等于ImageBase,重定位可以整段跳过。所以老手的第一反应永远是“能不能在原基址上要空间”,而不是“先把重定位写一遍”。这也是后面流程里为什么先尝试VirtualAllocEx指定lpAddress。
第二件是导入表。数据目录第 1 项指向导入描述符数组,每项 20 字节,OriginalFirstThunk指向名字表(INT),FirstThunk指向地址表(IAT)。加载时按 INT 遍历:遇到最高位为 1 的项,说明是按序号导入,否则按名字导入。每个名字走一次LoadLibrary+GetProcAddress,把结果写回 IAT 对应位置。要做对有两个细节:一是LoadLibrary加载的模块名要区分系统目录和当前目录,路径不对会加载到同名的高危 DLL;二是绑定导入(Bound Import)那一项要跳过,它记录的是某个特定版本下的地址,跨机器必然失效。
第三件是 TLS。数据目录第 9 项指向 TLS 目录,AddressOfCallBacks是一个以 NULL 结尾的函数指针数组,这些回调在入口点之前执行。不少壳、反调试检查、运行时初始化都塞在这里。漏掉它的典型表现是:程序能启动,主界面也在,但某些依赖 TLS 初始化的全局状态是空的,用着用着就出问题。这类问题没有崩溃栈可看,只能靠对照排除。
还有一件容易被忽略的:x64 下的异常处理表(.pdata,数据目录第 3 项)。x64 的异常展开是基于表的,不像 x86 靠栈上的 SEH 链。你自己映射出来的映像,系统并不知道这张表在哪,一旦程序抛异常(包括 C++ 异常、__try之外的访问违例),展开就会断,表现为莫名其妙的崩溃。解决办法是调用RtlAddFunctionTable把.pdata的范围注册进去,记得主机进程退出前RtlDeleteFunctionTable。
2.3 两个坑:内存属性和 exe 的特殊性
第一个坑是内存属性。LoadLibrary走的是内核的 section 对象加内存映射文件,映射出来的页面类型是MEM_IMAGE,可共享、可丢弃、带SEC_IMAGE标志。而自己VirtualAllocEx出来的是MEM_PRIVATE+MEM_COMMIT,两者在系统眼里完全是两种东西。这个差异不只是检测层面的抓手,也会影响行为:比如某些 API 在MEM_IMAGE页面上有特殊处理,代码完整性检查、控制流保护(CFG)对非映像页面的处理也不一样。很多人写完之后发现“功能都实现了但就是跑不稳”,根子在这。
第二个坑是 exe 和 dll 不一样。dll 通常有导出、有重定位、入口是DllMain;exe 可能根本没有重定位表(链接时用了固定基址),入口是 CRT 启动代码,里面可能包含安全 Cookie 初始化、堆初始化、全局构造。这些代码默认自己运行在一个“由加载器正确初始化过”的进程里,你手工把它搬过去,加载器那一层是空的。所以 exe 的内存加载比 dll 更容易踩到“看起来正常、实际状态缺失”的坑。
理解这几层,比背 API 名字重要得多。API 就那么几个,参数含义翻文档就有;但知道“为什么这一步非做不可”,才能在失败的时候定位到是哪一层没补上。
3. 进程镂空全流程:六个动作拆到 API 参数级
进程镂空(Process Hollowing)是这套思路里最经典的一种落地方式:先创建一个正常进程并让它停住,把它原本的映像从内存里挖掉,再把自己的 exe 填进去,最后让主线程从新的入口点跑起来。整个动作可以拆成六步,每步都有明确的参数含义和失败模式,下面逐个过一遍。
3.1 第一步:先找一个合法的外壳进程
外壳进程的选择标准很实际:位数必须和目标 exe 一致(32 位宿主跑不了 64 位代码,反之亦然)、路径最好在系统目录、启动快、体积小、不弹窗、不联网。位数不一致是最常见的事故来源,跨位数写入会直接返回ERROR_PARTIAL_COPY(299)。
创建时用CREATE_SUSPENDED标志,进程对象建立后主线程停在挂起点,入口点一行代码都没执行,这时候它的地址空间基本是干净的。
STARTUPINFOW si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; // CREATE_SUSPENDED:进程已创建,主线程挂起在入口点之前,此时映像完整但未运行 BOOL ok = CreateProcessW( hostPath, // 宿主 exe 的完整路径,建议用系统目录下的绝对路径 NULL, // 命令行,留空即可 NULL, NULL, // 进程/线程安全属性 FALSE, // 不继承句柄 CREATE_SUSPENDED, NULL, // 环境块,NULL 表示继承 NULL, // 工作目录 &si, &pi);这里有个容易被忽视的点:CREATE_SUSPENDED只挂起了主线程,进程的地址空间、PEB、模块链表都是完整建立的。也就是说,宿主 exe 的主映像已经在内存里了,你要做的是把它换掉,而不是从零建一个。
3.2 第二步:拿到宿主的映像基址,把它挖空
基址不在PROCESS_INFORMATION里,得去 PEB 读。路径是NtQueryInformationProcess的ProcessBasicInformation信息类,返回结构里有PebBaseAddress,再从这个地址偏移读出映像基址——x64 是 PEB + 0x10,x86 是 PEB + 0x08。这两个偏移是硬编码的,跨系统版本基本稳定,但自己心里要有数:这是未文档化结构,换了架构一定要重算。
PROCESS_BASIC_INFORMATION pbi = { 0 }; NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), NULL); PVOID hostImageBase = NULL; #ifdef _WIN64 const SIZE_T kImageBaseOffset = 0x10; // x64: PEB->ImageBaseAddress #else const SIZE_T kImageBaseOffset = 0x08; // x86: PEB->ImageBaseAddress #endif ReadProcessMemory(pi.hProcess, (PBYTE)pbi.PebBaseAddress + kImageBaseOffset, &hostImageBase, sizeof(hostImageBase), NULL);拿到基址后,“挖空”有两种写法。硬写法是直接调NtUnmapViewOfSection(pi.hProcess, hostImageBase)把主映像解除映射。这个调用在部分系统版本上对主映像会失败,返回STATUS_CONFLICTING_ADDRESSES之类;所以稳妥的做法是先试一次,失败了就退回软写法——不解除映射,直接在别的地址分配空间。软写法的代价是目标基址和ImageBase大概率不一致,必须完整做一遍重定位。
3.3 第三步:分配空间并把映像写进去
分配优先指定原基址,这样能省掉重定位,而且看起来更“自然”。
SIZE_T imageSize = nt->OptionalHeader.SizeOfImage; // 优先在原基址上申请:命中则不需要重定位 PVOID newBase = VirtualAllocEx(pi.hProcess, hostImageBase, imageSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); // 原基址被占(软写法下没解除映射),退而求其次 if (!newBase) newBase = VirtualAllocEx(pi.hProcess, NULL, imageSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);注意这里用PAGE_READWRITE而不是一步到位的PAGE_EXECUTE_READWRITE。原因很实在:RWX 是内存扫描引擎最敏感的特征之一,而且很多环境下 RWX 页会被直接拒绝或标记。写入阶段本来也不需要执行权限。
写入顺序是先头后节,分块进行。头部一次写完SizeOfHeaders字节;然后遍历节表,每个节按VirtualAddress算出目标地址,按PointerToRawData从文件缓冲里取数据,长度用SizeOfRawData(不是VirtualSize,后者只表示内存中的有效大小,前者才是文件里实际有的字节数)。
// 1) 写头部:DOS 头 + NT 头 + 节表,共 SizeOfHeaders 字节 WriteProcessMemory(pi.hProcess, newBase, fileBuf, nt->OptionalHeader.SizeOfHeaders, NULL); // 2) 逐节写入:目标地址走 VirtualAddress,源地址走 PointerToRawData PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt); for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sec) { if (sec->SizeOfRawData == 0) continue; // .bss 这类节文件里没有数据 WriteProcessMemory(pi.hProcess, (PBYTE)newBase + sec->VirtualAddress, fileBuf + sec->PointerToRawData, sec->SizeOfRawData, NULL); }写完把属性改回来:
DWORD oldProt = 0; VirtualProtectEx(pi.hProcess, newBase, imageSize, PAGE_EXECUTE_READ, &oldProt);这一步的细节在于“什么时候改属性”。一次性把整个映像改成可执行,比按节精细设置简单,但也会让数据段带上执行权限,特征更明显。想做得干净一点,就按节表里的Characteristics分别处理,只给含IMAGE_SCN_MEM_EXECUTE的节设执行权限。
3.4 第四步:同步 PEB 里的映像基址
这一步最容易被跳过,而跳过的后果非常隐蔽:程序能跑,界面正常,但程序自己调GetModuleFileName拿到的是宿主 exe 的路径,枚举模块拿到的是宿主的模块链表。任何依赖“我在哪”的逻辑都会错。
// 把 PEB->ImageBaseAddress 改成新的映像基址 WriteProcessMemory(pi.hProcess, (PBYTE)pbi.PebBaseAddress + kImageBaseOffset, &newBase, sizeof(newBase), NULL);如果目标程序会遍历 PEB 里的Ldr模块链表做校验,光改ImageBaseAddress还不够,链表里宿主 exe 的那一项也得一起处理。这就是为什么有些场景下“进程跑起来了但几秒后自己退了”——它自己检查完把自己结束了。
3.5 第五步:改入口点,恢复线程
挂起线程的上下文里,寄存器保存着即将执行的指令地址。x64 下线程入口的第一个参数放在RCX,x86 放在EAX,把入口点写进去,再ResumeThread。
CONTEXT ctx = { 0 }; ctx.ContextFlags = CONTEXT_FULL; // 只取需要的部分也行,但整数+控制寄存器要包含 GetThreadContext(pi.hThread, &ctx); PVOID entry = (PBYTE)newBase + nt->OptionalHeader.AddressOfEntryPoint; #ifdef _WIN64 ctx.Rcx = (DWORD64)entry; // x64:第一个参数走 RCX #else ctx.Eax = (DWORD)entry; // x86:第一个参数走 EAX #endif SetThreadContext(pi.hThread, &ctx); ResumeThread(pi.hThread);位数搞混是这里最典型的翻车:在 32 位代码里写ctx.Rcx不会编译报错(结构体里有这个字段,只是不生效),运行时表现是进程起来了但立刻退出,或者跑到了错误的地址直接访问违例。判断方法很简单——崩的时候看异常地址,如果落在一个完全无关的模块里,八成是入口点写错了寄存器。
3.6 第六步:善后,以及这条路的几个变体
线程恢复之后要做的是收尾:关掉不再需要的句柄、处理宿主进程可能带的子进程、监听目标进程退出以便回收。如果是做研究,还要注意宿主进程的环境块、命令行、窗口标题这些“外层信息”和目标程序不匹配,任何一处都能被人工看出来。
这条路后来演化出几个变体,思路都是同一个:让磁盘上的文件和内存里的映像对不上。Doppelgänging 借 NTFS 事务,在事务里创建 section 映射成映像,然后把事务回滚,文件系统上什么都没留下;Ghosting 用一个允许删除的临时文件建立 section,删掉文件后再映射,句柄还在但文件已经不在;Herpaderping 是先映射,再把磁盘上文件的内容改掉,最后关句柄。三者的共同点是都绕开了“写一个完整 exe 到目标进程”的动作,把映射这一步交给内核去做,副作用是内存页是MEM_IMAGE类型,属性上更“正常”。
4. 手工复现:用 x64dbg 和一段 Python 把映射算清楚
理论过完,得动手。这一段的目标不是“跑通一个成品”,而是把每一步摊开,让偏移和参数在调试器里变得可见。我一直觉得,这类技术光看代码是学不会的,必须亲眼看到 PEB 里那个 8 字节从宿主基址变成你的基址。
4.1 环境准备:三个东西就够
| 项目 | 用途 | 备注 |
|---|---|---|
| 虚拟机 | 隔离实验环境 | 快照留一份干净的,崩了就回滚 |
| x64dbg | 观察 PEB、寄存器、内存属性 | 装个 Scylla 插件方便看导入表 |
| Python 3 | 解析 PE、算映射 | 只用标准库,不装第三方包 |
被测样本不要用现成的真实软件,自己用任意编译器编一个Hello World,Release x64,加一个MessageBox或者一段printf循环,这样进程起来之后你能明确知道“入口点执行到了”。宿主进程也选最简单的,能挂起、能读 PEB 就行。
4.2 在调试器里读 PEB 的映像基址
WinDbg 下最直接的是!peb命令,它会打印 PEB 的关键字段,其中ImageBaseAddress就是你要的那个值。dt ntdll!_PEB @$peb能看到完整结构体和偏移。x64dbg 里在命令栏敲peb(),它会返回 PEB 地址,然后到内存窗口跳到这个地址 + 0x10,以 8 字节指针格式看,就是映像基址。
这个操作的实战价值在于:你在自己的注入代码里改完 PEB 之后,回到调试器里再读一次,如果值没变,说明WriteProcessMemory写错地址了或者写失败了。这比靠“程序行为对不对”来判断要快得多。
4.3 一段 Python,把节区映射算出来
下面这段脚本只做一件事:读一个 PE 文件,把节表、对齐参数、SizeOfImage全打出来,并且自己算一遍镜像大小和头里声明的值对照。写注入代码之前先跑一遍,能省掉大量“缓冲区小了 0x1000”这类低级错误。
import struct import sys def u16(b, o): return struct.unpack_from("<H", b, o)[0] def u32(b, o): return struct.unpack_from("<I", b, o)[0] def u64(b, o): return struct.unpack_from("<Q", b, o)[0] def parse_pe(path): buf = open(path, "rb").read() # 1) DOS 头:偏移 0x3C 处是 e_lfanew,指向 NT 头的起始位置 e_lfanew = u32(buf, 0x3C) assert buf[e_lfanew:e_lfanew + 4] == b"PE\0\0", "签名不对,不是有效 PE" coff = e_lfanew + 4 nsect = u16(buf, coff + 2) # NumberOfSections opt_size = u16(buf, coff + 16) # SizeOfOptionalHeader opt = coff + 20 # 可选头起点 magic = u16(buf, opt) # 0x10B=PE32, 0x20B=PE32+ is64 = (magic == 0x20B) # 2) 可选头里的加载参数,注意 PE32 和 PE32+ 的字段偏移不同 entry = u32(buf, opt + 16) # AddressOfEntryPoint image_base = u64(buf, opt + 24) if is64 else u32(buf, opt + 28) sect_align = u32(buf, opt + 32) # SectionAlignment file_align = u32(buf, opt + 36) # FileAlignment size_image = u32(buf, opt + 56) # SizeOfImage size_hdr = u32(buf, opt + 60) # SizeOfHeaders print(f"格式={'PE32+' if is64 else 'PE32'} 首选基址={image_base:#x} 入口RVA={entry:#x}") print(f"SectionAlignment={sect_align:#x} FileAlignment={file_align:#x} " f"SizeOfHeaders={size_hdr:#x} SizeOfImage={size_image:#x}") # 3) 节表紧跟在可选头之后,每项 40 字节 sec = opt + opt_size print(f"\n{'节名':<10}{'RVA':>12}{'虚拟大小':>12}{'文件偏移':>12}{'文件大小':>12}") end = size_hdr for i in range(nsect): o = sec + i * 40 name = buf[o:o + 8].rstrip(b"\0").decode("ascii", "replace") vsize = u32(buf, o + 8) rva = u32(buf, o + 12) rsize = u32(buf, o + 16) raw = u32(buf, o + 20) print(f"{name:<10}{rva:>#12x}{vsize:>#12x}{raw:>#12x}{rsize:>#12x}") # 4) 按 SectionAlignment 向上取整,累加出真实镜像大小 if vsize: end = max(end, rva + ((vsize + sect_align - 1) // sect_align) * sect_align) flag = "一致" if end == size_image else "不一致,头部声明可能被改过" print(f"\n按节表推算镜像大小={end:#x},头里声明={size_image:#x},{flag}") if __name__ == "__main__": parse_pe(sys.argv[1])跑法:
python parse_pe.py demo_x64.exe输出里重点看三个数。第一是SizeOfHeaders和第一节的RVA的关系——如果头部是 0x400、第一节 RVA 是 0x1000,中间那 0xC00 就是内存里多出来的空洞。第二是每个节的文件偏移和RVA的差值,这个差值是固定模式的,一旦你手算的对不上,说明你取错了字段。第三是最后的“一致/不一致”判断,如果你拿去解析的样本这两个数对不上,那它大概率被处理过,节表或头被改过,直接用会导致分配大小出错。
4.4 复现失败时,先看这四个现象
第一,进程刚恢复就退,退出码0xC0000005。八成是入口点算错,或者基址和ImageBase不一致但没做重定位。先在调试器里看崩溃时的指令地址落在哪个区间,再对一下newBase + AddressOfEntryPoint是否等于那个地址。
第二,退出码0xC0000142。这是 DLL 初始化失败,通常意味着导入表没填全,或者某个依赖模块没加载上。用调试器的模块窗口对一下,看宿主进程里缺了哪个 DLL。
第三,进程活着,窗口没出来,CPU 占用很低。检查是不是把入口点写进了Eax而不是Rcx,或者反过来。位数和寄存器是对应的,写错不会报错,只会静默跑到别处。
第四,程序功能正常,但调用GetModuleFileName返回的是宿主 exe 的名字。这就是 PEB 没同步,回到 3.4 那一步补上。
5. 避坑与排查:五次失败换来的五条硬经验
这一节是我自己踩过的坑,按“现象 → 原因 → 解决”记,都是复现过程中真实卡住过的点。有些坑当时觉得是玄学,事后看全是没搞清楚状态。
5.1 现象:写入返回 299,什么都写不进去
现象很干脆:WriteProcessMemory返回 FALSE,GetLastError()是 299(ERROR_PARTIAL_COPY),换个地址还是失败。
原因是位数不匹配。32 位宿主进程的地址空间是 WOW64 隔离的,你在 64 位进程里拿到的地址在它那边根本不存在;反过来 64 位宿主用 32 位代码去写也一样。这个错误码本身不直观,很容易被误判成“权限不够”。
解决方式是先确定目标位数:用IsWow64Process2或读宿主 exe 的 PE 头,位数定了再决定编译目标。真要做跨位数,就得走NtWow64WriteVirtualMemory64这条路径,复杂度和坑都会翻倍,非必要不碰。
5.2 现象:程序跑起来了,但自己把自己结束了
进程起来几秒后自动退出,退出码正常(0 或者自己定义的),没有任何崩溃信息。用调试器跟一下会发现它在启动早期做了一次自检。
原因通常是它读了 PEB 里的ImageBaseAddress或者遍历Ldr模块链表,发现“我自己的路径”和预期不符,或者模块链表里有个不该存在的条目。很多商业软件和部分安全组件都有这个检查。
解决办法是 PEB 同步要彻底,不只是ImageBaseAddress那 8 个字节,模块链表里对应宿主 exe 的那一项也要一起处理。这活儿没有通用解,得看目标程序具体检查了什么。判断方法是下断点看它启动早期读了哪些内存区域,读 PEB 偏移 0x10 附近就说明在做基址校验。
5.3 现象:分配在原基址失败,退到别的地址就崩
VirtualAllocEx指定原基址返回 NULL,改成 NULL 让系统随便挑一个地址就成功了,但恢复线程后立刻访问违例。
原因是没做重定位。退到别的基址意味着映像里所有硬编码的绝对地址都是错的,包括全局变量指针、字符串地址、虚表项。如果这个 exe 编译时用了固定基址(/FIXED)或者重定位表被剥掉了,那就完全没法搬移。
解决办法按优先级排:先确认能不能解除映射腾出原基址;不行就检查重定位表是否存在(数据目录第 5 项的Size是否为 0);再不行就换一个允许重定位的样本。强搬一个没有重定位表的 exe,理论上不可能跑对。
5.4 现象:功能都正常,一抛异常就崩得莫名其妙
主流程跑得好好的,一到异常处理路径(包括 C++ 的throw)就直接挂掉,栈回溯也是断的,调试器显示“调用栈可能不完整”。
原因是 x64 的异常展开依赖.pdata表,而这张表需要显式注册。系统只知道正常加载的映像的.pdata在哪,你手工映射的那份它不认识。异常发生时要查表找 unwind 信息,查不到就崩。
解决办法是映射完成后调用RtlAddFunctionTable,把.pdata节的范围和映像基址传给系统,退出前用RtlDeleteFunctionTable注销。x86 不用管,它的异常链挂在栈上,跟着线程栈走,不依赖外部表。这也是 32 位版本比 64 位好写的原因之一。
5.5 现象:本地跑得好好的,换个环境直接被拦
在自己机器上一切正常,拿到另一台开了安全软件的机器上,进程刚创建就被结束,日志里能看到“可疑内存分配”一类的记录。
原因是一步到位的PAGE_EXECUTE_READWRITE。RWX 权限的私有内存页是内存扫描最基础的规则之一,尤其是它出现在一个刚刚挂起过的进程里,前后因果太明显。加上CreateProcess(CREATE_SUSPENDED)+ 跨进程写入 +SetThreadContext这组调用,本身就是一条完整的可疑链。
解决办法是拆开做:分配时只要PAGE_READWRITE,写完再VirtualProtectEx改成PAGE_EXECUTE_READ,按节精细设置权限,别给数据段执行权限。写入也分批,别一次性写几百 KB。这些改动不能让你“隐身”,但能让行为看起来更像正常加载。顺便说一句,做这类实验一定要在自己的隔离环境里,别拿生产机器试。
6. 检测视角:这套技术在四个位置必然留痕
从防守方看,这套动作再怎么优化,有几个位置是绕不过去的,因为它们是“自己实现加载器”这个前提带来的结构性代价。
第一个位置是内存类型。正常的 exe 映像是MEM_IMAGE,由内核 section 建立,页面上带映像标志。手工映射出来的必然是MEM_PRIVATE+MEM_COMMIT,而且这个私有区域里会出现完整的 MZ/PE 头。扫描规则很简单:遍历目标进程的所有私有可提交区域,检查起始处是不是MZ且偏移 0x3C 处的e_lfanew指向PE\0\0。这个检查成本很低,误报主要来自 JIT 和某些解压代码,配合区域大小和权限可以降下来。
第二个位置是映像路径不一致。PEB 里的ImageBaseAddress指向的内存区域,其对应文件路径(通过QueryVirtualMemory的MemoryMappedFilenameInformation查)和进程的可执行文件路径对不上。正常的进程这两个值是一致的,对不上就说明内存里的东西来路不明。
第三个位置是线程起始地址。主线程恢复后,它的起始地址应该落在某个已映射映像的范围内。如果起始地址落在私有可执行页里,且这个页不属于任何已加载模块,这就是一个非常强的信号。检测方法是在进程创建时挂内核回调,拿到线程的起始地址,再和模块列表做区间比对。
第四个位置是调用链。CreateProcess带CREATE_SUSPENDED、紧接着一轮跨进程内存写入、然后SetThreadContext改上下文、最后ResumeThread,这四个动作在时间上挨得太近,在同一个父进程上反复出现就更可疑。ETW 的 Threat-Intelligence 提供程序、内核的进程/线程通知回调都能拿到这条链。单看每一个调用都合法,串起来看意图就很清楚。
提示:做检测验证的时候,先用自己写的样本跑一遍,确认规则能命中,再拿正常软件做误报测试。只测命中不测误报的规则,上线就是灾难。
回过头看,我在这条路上最大的收获不是“会写了”,而是知道了每一层抽象是干什么用的。加载器不是黑匣子,它做的每一件事都能被拆开、被观察、被检测。从那以后我每次分析这类样本,都会强制走一遍“内存类型 → 映像路径 → 线程起始地址 → 调用链”这四步,哪怕前面三步都正常,也要看第四步。这份流程和我整理的偏移对照表、PE 解析脚本放在一起,需要的话可以拿来对着自己的实验环境走一遍。希望帮到你。
本文还有配套的精品资源,点击获取