
简介这是一份围绕进程空中技术RunPE又称 Process Hollowing的入门级代码示例面向希望理解在内存中加载并执行 exe 的安全研究人员、逆向工程学习者与系统编程开发者。压缩包仅 2KB包含 1 个 cpp 源文件便于直接阅读和对照分析重点呈现选择承载进程、读取目标 exe、替换内存映像、设置入口点及恢复线程执行等关键环节。已有 1210 人学习说明该主题在进程注入与 PE 结构学习中具有一定关注度。读者可借助这份精简示例梳理 RunPE 的整体流程理解 Windows 进程管理、PE 文件格式与 API 调用之间的关系并在此基础上思考内存加载行为的检测与防御思路适合作为概念验证和课堂实验的参考素材。1. 内存里跑起来的 exe磁盘上找不到文件进程为什么已经活了一个 exe 躺在磁盘上双击它系统加载器把文件映射进地址空间、修好导入表、跳到入口点——这条路几乎所有开发者都走过。可如果把「映射文件」这一步整个拿掉让 PE 的字节流从别处直接落进某个进程的内存进程照样能跑起来磁盘上从头到尾没出现过这个文件。「在内存中加载 exe」讲的就是这件事业内也常把它叫作进程空中技术映像只存在于内存不落到文件系统。它值得搞懂不是因为猎奇而是因为这条路把加载器平时藏在黑匣子里的每一步都摊到了台面上——谁分配地址、谁修重定位、谁填 IAT、谁设页保护。把这些搞明白再看正常的进程启动、DLL 加载顺序、以及「文件没变行为却变了」的告警判断力会完全不一样。2. 系统加载器的工作清单一个 PE 从文件变成进程要过几道手普通进程启动时开发者看到的是一次CreateProcess调用看不到的是内核和用户态加载器接力完成的十几步。手工映射之所以难写根本原因不是「写内存」这件事有多复杂而是这十几步平时都有人代劳一旦绕开文件映射全部要自己接手。所以先把系统那份工作清单列清楚后面每一块代码才知道自己在补哪一环。2.1 从 CreateProcess 到入口点中间隔着什么第一步在内核。CreateProcess会创建一个进程对象和一个挂起的初始线程然后为 exe 建立一个段对象section object用SEC_IMAGE标志把文件映射进去。这个标志很关键它告诉内存管理器「这不是一堆字节这是一个 PE 映像」于是映射出来的视图会按SectionAlignment展开、按节的Characteristics设置初始页保护、并且页是文件后备的file-backed谁改了页就触发写时复制原始文件仍是干净的。第二步回到用户态由 ntdll 里的加载器接手。它读 PEB、建立模块链表项、递归加载导入表里点名的每一个 DLL把每个 DLL 的基址写进 IAT如果某个模块的首选基址被别人占了就遍历.reloc逐项修正绝对地址接着处理 TLS 目录里的回调数组逐个以DLL_PROCESS_ATTACH调用最后才把控制权交给 PE 头里AddressOfEntryPoint指向的那条指令。这中间还有一个容易被忽略的细节exe 的入口点和 DLL 的入口点签名不一样。exe 走的是运行时启动例程最终调main/wmainDLL 的DllMain拿的是(HINSTANCE, DWORD, LPVOID)三个参数。手工映射时如果直接把 exe 的入口点当函数指针调用或者在 x64 下忘了入口点第一个参数走的是RCX而不是栈进程崩在第一行指令上就是必然的。2.2 顺序上的硬约束为什么重定位、导入、TLS 不能随便调换各家加载器实现里重定位和导入修复的先后并不完全一致但存在几条绕不过去的硬约束理解了它们顺序就不用死记任何会往映像内写入绝对地址的操作必须排在重定位之后。IAT 本身就是一张绝对地址表如果在重定位之前就把真实函数地址填进去随后的 delta 修正会把这些地址当成「需要调整的绝对地址」再加一遍结果就是每个导入函数指针都偏移了一个 delta调用时直接跳到野地址。任何跨模块调用必须排在 IAT 填好之后。包括 TLS 回调——很多 TLS 回调里会调GetModuleHandle之类如果 IAT 还是一片 RVA回调一执行就翻车。页保护必须在代码执行之前设好。加载器先按节的属性给.text设PAGE_EXECUTE_READ、给.data设PAGE_READWRITE。手工映射时图省事把整块内存留成PAGE_EXECUTE_READWRITE功能上没问题但这一条恰恰是后面被扫描出来的头号特征。TLS 回调必须早于入口点但要晚于映像可写。回调里修改全局变量是常规操作页还锁着只读就会访问违例。把这些约束记牢你就有了判断任何一份加载代码对错的尺子不看它写了多少行只看这四条的相对位置有没有错。2.3 手工映射的对照表哪些活系统替你干了阶段系统加载器负责手工映射要自己补地址空间内核建 section选基址按SectionAlignment展开VirtualAlloc一段SizeOfImage大小的私有内存页来源文件后备按需调页可共享私有提交全部常驻无文件关联节区拷贝由映像视图自动完成按节表逐节memcpy尾部补零重定位基址冲突时自动修正自己算 delta遍历.reloc逐项加导入表递归加载依赖填 IATLoadLibraryGetProcAddress手工填TLS 回调入口点前逐个调用自己解析 TLS 目录并调用模块登记写 PEB 的InLoadOrderModuleList默认没有GetModuleHandle找不到自己页保护按节属性设置自己VirtualProtect这张表最后两行值得多看两眼。模块没登记意味着映像里所有依赖GetModuleHandle(NULL)、GetModuleFileName的代码拿到的都是宿主进程的信息页保护没设意味着一块又大又可写的可执行内存明晃晃挂在那里。功能能跑通和状态「像正常进程」是两个完全不同的目标绝大多数踩坑都发生在这两者的落差里。3. 手工映射的骨架把 PE 字节流搬进内存要动哪几块数据绕开加载器之后剩下的工作本质上就是一件事把一个躺在缓冲区里的字节流还原成加载器眼里那个「已经映射好的映像视图」。这中间要动的数据结构只有四块——头部、节表、重定位表、导入表。四块都对齐了映像就是合法的任何一块错位症状都会以「莫名其妙崩在别处」的形式出现。3.1 头部解析e_lfanew、节表与数据目录怎么串起来PE 的入口藏在 DOS 头的一个 4 字节字段里。IMAGE_DOS_HEADER的e_lfanew偏移0x3C指向真正的 NT 头NT 头里FileHeader给出节数量和可选头长度OptionalHeader里才有ImageBase、SizeOfImage、SectionAlignment和那张 16 项的DataDirectory。这条链上每一步都要做边界校验因为它同时也是解析器最容易被畸形样本打穿的地方。// 只读地走一遍 PE 头确认这份字节流值不值得继续往下搬 #include windows.h bool parse_pe(const BYTE* buf, size_t len, PIMAGE_NT_HEADERS* out) { if (len sizeof(IMAGE_DOS_HEADER)) return false; auto* dos (PIMAGE_DOS_HEADER)buf; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return false; // MZ // e_lfanew 是外部可控的, 必须校验它加上 NT 头长度没越界 if (dos-e_lfanew 0 || (size_t)dos-e_lfanew sizeof(DWORD) sizeof(IMAGE_FILE_HEADER) len) return false; auto* nt (PIMAGE_NT_HEADERS)(buf dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return false; // PE\0\0 // PE32 是 0x10B, PE32 是 0x20B, 两者可选头长度不同 if (nt-FileHeader.SizeOfOptionalHeader sizeof(IMAGE_OPTIONAL_HEADER)) return false; *out nt; return true; }parse_pe里没有一行是「业务逻辑」全是防御。e_lfanew可以是负数IMAGE_DOS_HEADER里它是LONG也可以是几百万直接拿去做指针加法就是一次越界读。SizeOfOptionalHeader的校验同样不能省有些加壳产物把这个值改小后面按IMAGE_OPTIONAL_HEADER全尺寸去读DataDirectory读到的就是相邻节表的内容导入表位置于是完全错乱。这类问题在调试器里表现为「什么都没干就崩了」实际是解析阶段就已经读错了地方。拿到nt之后IMAGE_FIRST_SECTION(nt)给出节表首地址NumberOfSections是节数。真正需要立刻算出的是SizeOfImage——如果字节流里的这个值不可信就自己算取最后一个节的VirtualAddress max(VirtualSize, SizeOfRawData)按SectionAlignment向上取整。这个值决定了VirtualAlloc要多少内存算小了后面写节区就是堆溢出。3.2 节区落点FileAlignment 与 SectionAlignment 错位会怎样文件里的节是紧凑排列的PointerToRawData是文件偏移内存里的节是按页对齐展开的VirtualAddress是 RVA。这两个世界之间没有线性关系只能靠节表一项一项翻译。最常见的错误是拿 RVA 直接当文件偏移用——在小文件、单节的情况下它偶尔能跑通一旦节多了就彻底乱套。// 按节表把每个节的原始数据搬到 base VirtualAddress, 剩下的补零 for (WORD i 0; i nt-FileHeader.NumberOfSections; i) { auto* s IMAGE_FIRST_SECTION(nt) i; BYTE* dst base s-VirtualAddress; SIZE_T raw s-SizeOfRawData; // 文件里实际有多少字节 SIZE_T vsz s-Misc.VirtualSize ? s-Misc.VirtualSize : raw; // 内存里要占多大 SIZE_T cpy raw vsz ? raw : vsz; // 文件比内存长时只取 vsz if (cpy) memcpy(dst, file s-PointerToRawData, cpy); if (vsz cpy) memset(dst cpy, 0, vsz - cpy); // .bss 和尾部填充靠清零 }这段的核心是那两个长度字段的语义差异。SizeOfRawData按FileAlignment通常 0x200对齐VirtualSize按SectionAlignment通常 0x1000对齐所以SizeOfRawData常常比VirtualSize大——多出来的是文件对齐填充。如果无脑memcpy整个SizeOfRawData就会把下一个节的起始几字节覆盖掉如果只看VirtualSize又可能漏掉文件里真实存在的数据。cpy min(raw, vsz)是唯一稳妥的写法。后半个memset同样不能省。BSS 段未初始化全局变量在文件里不占空间SizeOfRawData为 0 或很小只有VirtualSize才反映真实大小不清零就意味着那些全局变量初值是上一份内存的残留内容。这个 bug 的隐蔽之处在于它只在特定分配顺序下复现本地调半天好的换台机器就崩。3.3 基址重定位为什么要区分 delta 和绝对地址映像里的绝对地址全局指针初值、虚表指针、字符串地址常量都是按ImageBase这个「首选基址」写死的。如果实际分配到的地址不是它所有绝对地址都要整体平移一个差值。.reloc目录就是编译器留下的账本哪些位置写的是绝对地址。// delta 实际基址 - 首选基址, 只对账本里登记过的位置加一次 ULONG_PTR delta (ULONG_PTR)base - (ULONG_PTR)nt-OptionalHeader.ImageBase; if (delta) { auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; auto* blk (PIMAGE_BASE_RELOCATION)(base dir.VirtualAddress); BYTE* end (BYTE*)blk dir.Size; while ((BYTE*)blk end blk-SizeOfBlock) { WORD* entry (WORD*)(blk 1); // 块头后面跟一串 WORD int n (blk-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); for (int i 0; i n; i) { int type entry[i] 12; // 高 4 位: 修正类型 int off entry[i] 0x0FFF; // 低 12 位: 页内偏移 BYTE* p base blk-VirtualAddress off; if (type IMAGE_REL_BASED_HIGHLOW) // 32 位绝对地址 *(DWORD*)p (DWORD)delta; else if (type IMAGE_REL_BASED_DIR64) // 64 位绝对地址 *(ULONG_PTR*)p delta; // type IMAGE_REL_BASED_ABSOLUTE(0) 是块内填充, 跳过 } blk (PIMAGE_BASE_RELOCATION)((BYTE*)blk blk-SizeOfBlock); } }低 12 位是偏移不是巧合——一个重定位块按 4KB 页组织页内偏移正好 12 位装得下所以一个SizeOfBlock覆盖一页内的若干位置。类型 0 是填充项为了让SizeOfBlock4 字节对齐而存在看到就跳过别当错误。类型 3HIGHLOW出现在 32 位映像类型 10DIR64出现在 64 位映像两者混用会把 8 字节地址截成 4 字节崩得毫无规律。还有一个必须提前确认的前提.reloc目录可能压根不存在。链接时加了固定基址选项或者用某些工具处理过这个目录会被整个剥掉DataDirectory[5].Size为 0。这种情况下唯一的出路是把映像分配到首选基址上——分配不到就只能失败退出硬着头皮跑下去就是随机崩溃。3.4 导入表修复IAT 里的那一格到底填什么导入表是一串以全零描述符结尾的IMAGE_IMPORT_DESCRIPTOR。每个描述符里有NameDLL 名字的 RVA、OriginalFirstThunkINT名字数组、FirstThunkIAT最终要填的地址数组。两个数组长度一样、顺序一样第 i 项一一对应。有个历史遗留要当心某些链接器产物的OriginalFirstThunk为 0这时 INT 和 IAT 是同一块内存得退回用FirstThunk读名字。// 遍历导入描述符, 用 LoadLibrary GetProcAddress 把 IAT 一格一格填实 auto idir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; auto* desc (PIMAGE_IMPORT_DESCRIPTOR)(base idir.VirtualAddress); for (; desc-Name; desc) { HMODULE h LoadLibraryA((char*)(base desc-Name)); // 按名字找依赖模块 if (!h) return false; // 缺 DLL 就地失败, 别硬撑 auto* oft (ULONG_PTR*)(base (desc-OriginalFirstThunk ? desc-OriginalFirstThunk : desc-FirstThunk)); auto* iat (ULONG_PTR*)(base desc-FirstThunk); for (; *oft; oft, iat) { if (*oft IMAGE_ORDINAL_FLAG) // 最高位为 1: 按序号导入 *iat (ULONG_PTR)GetProcAddress(h, (LPCSTR)(*oft 0xFFFF)); else { // 否则是 RVA, 指向 Hint名字 auto* byname (IMAGE_IMPORT_BY_NAME*)(base *oft); *iat (ULONG_PTR)GetProcAddress(h, (LPCSTR)byname-Name); } } }IMAGE_ORDINAL_FLAG在 32 位下是0x8000000064 位下是0x8000000000000000。这个标志位不是随意的——GetProcAddress的第二个参数要么是LPCSTR名字要么是用MAKEINTRESOURCE包装的序号靠高位来区分两者。硬编码成 32 位常量在 64 位映像上会误判每一个名字导入。循环里还漏了两个真实世界的问题。一个是导入转发forwarderkernel32.dll里大量函数其实是转发到kernelbase.dll的GetProcAddress会自己跟到底这一步不用管但如果直接读导出表自己解析转发链必须跟进。另一个是延迟导入delay import它在DataDirectory的第 13 项结构完全不同不处理的话程序只有在第一次调用那个函数时才会崩——这是最难定位的一类因为崩溃点离加载点可能隔了几分钟。4. 进程镂空的时序为什么被换掉映像的进程还能继续跑手工映射解决的是「凭空造一个映像」但很多场景下更省事的做法是反过来先让系统正常起一个进程再把它的映像内容整体换掉。这个思路通常被叫作进程镂空process hollowing本质上和手工映射是同一套活只不过地址空间、PEB、模块链表都借用了宿主现成的少干一半事也少了一半「像不像正常进程」的伪装。4.1 挂起、解映射、写入、唤醒四步时序里每一步的副作用第一步用挂起标志启动宿主进程。此时内核已经把宿主自己的 exe 按SEC_IMAGE映射完成PEB、TEB、堆、ntdll、kernel32全都就位初始线程创建了但一条指令都没执行。这是整条路里最巧的一点你拿到的是一个「已经初始化好、但还没开始跑」的进程。第二步把宿主的映像解映射掉。读 PEB 的ImageBaseAddress拿到基址直接调NtUnmapViewOfSection把它卸了。这一步之后宿主原来的代码和数据全部消失但进程对象、句柄表、令牌、会话都还在——这就是能继续跑的原因进程身份和映像内容是两回事。第三步在同一个地址上重新分配内存把新的 PE 头部和节区写进去修重定位、填 IAT。这里有个天然优势宿主进程的ntdll、kernel32已经在同样的地址上加载了新映像的导入表里只要涉及这些模块地址和系统加载器算出来的完全一致很多情况下 IAT 甚至不需要重写。第四步改线程上下文。从挂起的线程里GetThreadContext把入口点地址写进指令指针64 位下还要处理RCXSetThreadContext写回去然后恢复线程。第一条指令从新映像的入口点开始执行。4.2 为什么必须挑一个「活的」进程当宿主自己造一个进程从头跑起来要处理的边角多到离谱PEB 里的ProcessParameters命令行、当前目录、环境变量块、堆的初始化、CSRSS 那边的连接、以及各种「进程启动时必须发生一次」的副作用。借用宿主等于把这些全部免掉——宿主已经是一个完全合法的进程它有自己的父进程、自己的令牌、自己的窗口站任何从外部观察的角度看都挑不出毛病。代价同样明显。宿主进程的原始映像被解映射之后任何还指向旧映像的指针、任何延迟加载、任何依赖原始模块基址的数据结构全都会指向一片已经释放的地址。如果新映像写入失败或者重定位算错进程不是「报个错」而是直接带崩没有后悔药可吃。这也是为什么这类代码里失败路径特别难写——你必须保证在任何一步失败时要么能干净退出要么干脆不开始。4.3 映像被换掉之后内存里还剩下哪些可被追查的痕迹换掉映像只骗过了「从文件角度看」的那一层。从内存角度看痕迹反而更多内存区域属性。系统映射的映像页是文件后备的可执行但通常不可写镂空写入的是私有提交页如果保护留成可读可写可执行在一堆正常模块里非常突兀。模块链表。PEB 里的三个模块链表是按加载顺序维护的新映像没有走LdrLoadDll所以链表里根本没有它。任何遍历模块的代码都看不到它这既是伪装也是破绽——「有一大块代码在跑但没有任何模块对应它」本身就是异常。线程起始地址。线程的起始地址落在哪个模块范围内是可以查的落在私有内存里就是明确信号。映像大小吻合度。私有区域的提交大小恰好等于某个 PE 的SizeOfImage又是页对齐的这种特征做聚类几乎一抓一个准。页内容。即使把MZ/PE头擦掉节区之间的空洞、导入函数地址的分布、字符串常量的排布都还留着强特征。看到这里应该能体会到一个规律这类技术能骗过的都是「只看文件」的检查骗不过的都是「看内存布局」的检查。后面那一类近几年成本降得非常快。5. 排查与避坑内存加载最常见的五类翻车原理讲清楚之后真正消耗时间的永远是调试。下面这几条是这条路线上反复出现的坑每条都按现象、原因、解决写清楚遇到对得上的症状可以直接顺着查。5.1 加载完成一跳入口点就崩连一行日志都没有现象所有写入都返回成功VirtualProtect也没报错一改线程上下文或者一调入口点进程立刻消失异常记录里只有一条访问违例。原因九成是入口点调用约定不对。exe 的AddressOfEntryPoint指向的是运行时启动例程它内部会去拿PEB和命令行如果 PEB 里的ProcessParameters是空的或者是宿主的第一步就炸。DLL 的入口点则必须是(HINSTANCE, DWORD, LPVOID)三参数形式参数个数不对在 x64 下就是寄存器里的垃圾值被当成指针解引用。解决先分清映像类型。FileHeader.Characteristics里的IMAGE_FILE_DLL标志决定入口点签名别靠文件扩展名猜。如果是 exe 且宿主 PEB 不完整就别直接跳入口点改成调LoadLibrary的等价流程或者干脆给宿主补一份像样的ProcessParameters再跳。5.2 同一份 exe这台机器能跑那台机器必崩现象行为完全取决于环境跟 exe 本身没关系。开发机上稳定测试机上偶发崩溃崩溃地址每次都不一样。原因首选基址被占。系统加载器把 exe 映射到ImageBase是大概率事件但在 ASLR 开启或者地址空间紧张的情况下不保证手工映射用的是VirtualAlloc如果不指定基址拿到的地址几乎必然不是首选基址重定位就必须做。而.reloc目录有可能在链接时被优化掉。解决加载前先检查DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size。为 0 就只接受「分配到的地址等于首选基址」这一种结果拿不到就干净失败不为 0 就老老实实走重定位循环并且注意 32 位和 64 位的类型区分。判断有没有真的做对可以对比加载后的映像和磁盘文件里同一处绝对地址的差值是否恰好等于 delta。5.3 程序能起来一调某个特定 API 就崩现象主流程跑得好好的执行到某个功能点时崩换一台机器可能崩在另一个功能点上。原因IAT 只填了一部分。常见两种情况——延迟导入目录DataDirectory第 13 项没处理那些函数对应的 IAT 格子还是原始 RVA第一次调用就跳飞或者绑定了导入bound importIAT 里预置的是「某个特定版本的 DLL 里的地址」实际环境 DLL 版本不同地址全错。解决延迟导入要么在加载时一次性全部解析把延迟描述符当普通导入描述符处理一遍要么确认不会走到那条路径。绑定导入更简单——把 IAT 全部按名字重新解析一遍覆盖掉预置值即可。定位手段是崩溃时的调用栈回溯看返回地址落在哪个 RVA再反查导入表里哪个描述符覆盖了这个范围。5.4 加载成功、功能正常但文件操作和注册表操作全部失败现象程序自己的逻辑跑得通一旦调GetModuleFileName、GetCurrentDirectory、读写相对路径拿到的全是错的或者直接失败。原因PEB 里的进程参数是宿主的。手工映射没有走正常的进程创建流程ProcessParameters里的ImagePathName、CommandLine、CurrentDirectory、Environment全都指向宿主进程的资料。所有依赖这些字段的 API 都会给出「看起来合理但完全不对」的答案。更隐蔽的是GetModuleHandle(NULL)——它返回的是宿主 exe 的基址不是新映像的。解决要么在加载前把 PEB 里相关字段改写成新映像的信息要么在映像层面接受「我是一个寄生的模块」这个事实把所有路径解析改成显式传参。前一种做法改动面大、容易把宿主搞坏后一种更稳但要求源码可控。判断当前是哪种情况在加载后立刻调一次GetModuleFileName(NULL)打日志返回值能立刻说明问题。5.5 一切正常运行几十秒后进程被结束现象功能全对日志全绿然后进程在某次扫描之后被清理掉事件日志里能看到来源不明的终止记录。原因踩到了内存布局层面的检测。私有提交、可执行、大小约等于某个 PE 的SizeOfImage、线程起始地址不在任何已知模块内、页内容带 PE 特征——这几条里凑齐两条就足以被判定。这不是「代码写错了」而是这条路本身的固有特征前面的章节已经解释过为什么它绕不开。解决从原理上说没有普适解法能做的是缩小特征面——按节设置最小必要权限而不是整块留成可读写可执行、加载完成后把不再需要的头部页释放或改成只读、避免线程起始地址落在裸的私有内存里。但要清楚这些只是抬高成本不是消除痕迹。真正该做的判断是这条路在当前环境下是否还值得走还是换成进程内模块化、插件化之类的正规方案更划算。6. 检测与取舍这套机制在真实环境里为什么越来越难藏把前面几章串起来看会得到一个有点反直觉的结论内存加载这条路的技术难度其实没有变高变高的是「不被发现」的难度。原因不在某一项检测变强了而在于观测点整体从「文件」下沉到了「内存布局」而内存布局恰恰是这条路的固有形态改不掉。6.1 三个可以落地的验证角度想自己验证一个进程里有没有手工映射的映像不必上复杂工具从三个角度切入就够观察角度正常加载的进程手工映射的进程页面来源映像页由文件后备可回溯到磁盘路径私有提交无文件关联模块链表PEB 三个链表里都有对应项链表里查不到只能靠扫内存发现线程起始地址落在某个已加载模块的地址范围内落在裸的私有内存里第一个角度靠查询虚拟地址描述符就能做第二个角度遍历PEB-Ldr即可第三个角度在线程快照里取起始地址再反查模块区间。三者交叉验证误报率会低很多。这比单看「有没有MZ头」可靠得多——头部是四个字节擦掉成本几乎为零而页面来源和模块链表的一致性没法靠擦几个字节解决。有一个我自己的血泪经验值得说早期排查时我一度只看页保护属性觉得可执行又私有就是问题。结果在一台装了图形驱动的机器上驱动相关的一批内存全都符合这个特征白忙了两天。从那次以后我每次做这类判断都强制走一遍「页面来源 模块归属 线程起始地址」这三条交叉任何一条单独成立都不下结论。这里面真正的设计取舍在于手工映射用「脱离加载器」换来了「完全控制映像内容」代价是同时也脱离了加载器提供的所有一致性保障——模块登记、页保护、PEB 信息、页来源一个都没有。控制力和可观测性在这里是一对严格的此消彼长。理解了这一点再看这条路上的各种技巧会发现它们几乎全在试图「事后补回一部分一致性」而检测方则在检查这些补丁的接缝。谁掌握的信息更多取决于谁更清楚这套机制的原始形态长什么样。希望帮到你。本文还有配套的精品资源点击获取