
简介围绕进程空中技术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可选头 24PE32首选加载基址0x140000000SectionAlignment可选头 32内存对齐粒度0x1000FileAlignment可选头 36文件对齐粒度0x200SizeOfImage可选头 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指向名字表INTFirstThunk指向地址表IAT。加载时按 INT 遍历遇到最高位为 1 的项说明是按序号导入否则按名字导入。每个名字走一次LoadLibraryGetProcAddress把结果写回 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_PRIVATEMEM_COMMIT两者在系统眼里完全是两种东西。这个差异不只是检测层面的抓手也会影响行为比如某些 API 在MEM_IMAGE页面上有特殊处理代码完整性检查、控制流保护CFG对非映像页面的处理也不一样。很多人写完之后发现“功能都实现了但就是跑不稳”根子在这。第二个坑是 exe 和 dll 不一样。dll 通常有导出、有重定位、入口是DllMainexe 可能根本没有重定位表链接时用了固定基址入口是 CRT 启动代码里面可能包含安全 Cookie 初始化、堆初始化、全局构造。这些代码默认自己运行在一个“由加载器正确初始化过”的进程里你手工把它搬过去加载器那一层是空的。所以 exe 的内存加载比 dll 更容易踩到“看起来正常、实际状态缺失”的坑。理解这几层比背 API 名字重要得多。API 就那么几个参数含义翻文档就有但知道“为什么这一步非做不可”才能在失败的时候定位到是哪一层没补上。3. 进程镂空全流程六个动作拆到 API 参数级进程镂空Process Hollowing是这套思路里最经典的一种落地方式先创建一个正常进程并让它停住把它原本的映像从内存里挖掉再把自己的 exe 填进去最后让主线程从新的入口点跑起来。整个动作可以拆成六步每步都有明确的参数含义和失败模式下面逐个过一遍。3.1 第一步先找一个合法的外壳进程外壳进程的选择标准很实际位数必须和目标 exe 一致32 位宿主跑不了 64 位代码反之亦然、路径最好在系统目录、启动快、体积小、不弹窗、不联网。位数不一致是最常见的事故来源跨位数写入会直接返回ERROR_PARTIAL_COPY299。创建时用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 0x10x86 是 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 下线程入口的第一个参数放在RCXx86 放在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 WorldRelease 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] bPE\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) # 0x10BPE32, 0x20BPE32 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(fSectionAlignment{sect_align:#x} FileAlignment{file_align:#x} fSizeOfHeaders{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返回 FALSEGetLastError()是 299ERROR_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_PRIVATEMEM_COMMIT而且这个私有区域里会出现完整的 MZ/PE 头。扫描规则很简单遍历目标进程的所有私有可提交区域检查起始处是不是MZ且偏移 0x3C 处的e_lfanew指向PE\0\0。这个检查成本很低误报主要来自 JIT 和某些解压代码配合区域大小和权限可以降下来。第二个位置是映像路径不一致。PEB 里的ImageBaseAddress指向的内存区域其对应文件路径通过QueryVirtualMemory的MemoryMappedFilenameInformation查和进程的可执行文件路径对不上。正常的进程这两个值是一致的对不上就说明内存里的东西来路不明。第三个位置是线程起始地址。主线程恢复后它的起始地址应该落在某个已映射映像的范围内。如果起始地址落在私有可执行页里且这个页不属于任何已加载模块这就是一个非常强的信号。检测方法是在进程创建时挂内核回调拿到线程的起始地址再和模块列表做区间比对。第四个位置是调用链。CreateProcess带CREATE_SUSPENDED、紧接着一轮跨进程内存写入、然后SetThreadContext改上下文、最后ResumeThread这四个动作在时间上挨得太近在同一个父进程上反复出现就更可疑。ETW 的 Threat-Intelligence 提供程序、内核的进程/线程通知回调都能拿到这条链。单看每一个调用都合法串起来看意图就很清楚。提示做检测验证的时候先用自己写的样本跑一遍确认规则能命中再拿正常软件做误报测试。只测命中不测误报的规则上线就是灾难。回过头看我在这条路上最大的收获不是“会写了”而是知道了每一层抽象是干什么用的。加载器不是黑匣子它做的每一件事都能被拆开、被观察、被检测。从那以后我每次分析这类样本都会强制走一遍“内存类型 → 映像路径 → 线程起始地址 → 调用链”这四步哪怕前面三步都正常也要看第四步。这份流程和我整理的偏移对照表、PE 解析脚本放在一起需要的话可以拿来对着自己的实验环境走一遍。希望帮到你。本文还有配套的精品资源点击获取