
简介本资源是一份面向Windows安全研究者、逆向工程师与系统编程学习者的RunPE进程空心化技术实践代码聚焦于内存中动态加载并执行EXE文件的核心能力解决传统磁盘落地执行易被检测的问题。压缩包为ZIP格式仅含1个关键文件——RunPE.cpp是完整可编译的C实现涵盖承载进程选择、PE映像解析、内存段清空与重写、入口点重定向及线程恢复等全部核心逻辑代码精炼2KB便于逐行理解底层机制。目前已有1210人学习下载适合具备基础Windows API和PE文件格式知识的中高级开发者深入掌握进程注入原理、提升恶意代码分析或防御对抗能力。读者可直接编译运行观察目标EXE在远程进程中‘无痕’启动的全过程并基于此代码拓展Hook、反调试或EDR绕过等进阶实验。1. 进程空中技术在内存中加载exe不是“免杀”也不是“无文件”而是 Windows 用户态 PE 加载器的底层实操边界你有没有遇到过这种场景一个嵌入式设备只开放了极小的存储空间但又要运行某个功能完整的 Windows 工具或者你在做某跨平台系统调试时需要绕过磁盘写入限制在纯内存中启动一个诊断模块又或者你在分析某安全产品行为时发现它根本没落地 exe 文件却实实在在起了一个 svchost.exe 的变体进程——这些都不是玄学而是进程空中技术Process Hollowing / In-Memory PE Loading的真实落点。它不等于“免杀”也不代表“无文件攻击”而是一套基于 Windows PE 结构、Loader 机制与用户态内存管理的可复现工程能力。本质是跳过 CreateProcess 的磁盘路径解析环节手动完成 PE 头解析、节区映射、重定位修正、导入表绑定、TLS 初始化和入口跳转——整套流程完全在内存中完成不依赖磁盘上存在的 .exe 文件。适合嵌入式 Windows 容器、红蓝对抗中的轻量级载荷投递、固件级诊断工具集成以及对 Windows 加载器机制有深度理解诉求的开发者。本文不讲原理图、不画流程框图只拆一个真实可用、带完整错误处理、适配 Win10/Win11 x64 的 C 内存加载器源码包并告诉你每一步为什么这么写、哪个参数改错就直接 crash、哪些字段必须校验否则必翻车。2. PE 文件结构与内存加载四阶段为什么不能直接 memcpy 就 call进程空中技术不是把 exe 二进制读进来VirtualAlloc memcpy ((void(*)())p)()就完事。Windows 的 PE 加载器干了至少四类不可省略的事节区对齐与内存映射、基址重定位、导入地址表IAT解析与绑定、TLS 回调执行。跳过任意一环轻则入口函数访问非法地址崩溃重则触发 CFG 或 CET 异常终止。本节从一份标准 PE 文件头出发逐层说明这四个阶段的技术动因并给出对应代码段的逻辑锚点。2.1 节区对齐与内存映射IMAGE_OPTIONAL_HEADER::SizeOfImage 是唯一可信尺寸PE 文件在磁盘上按FileAlignment对齐通常 512 字节但在内存中按SectionAlignment对齐通常 4096 字节。SizeOfImage字段才是整个映像在内存中应占的总大小——它由所有节区的VirtualSize按SectionAlignment向上取整后累加得出。很多初学者误用FileSize或SizeOfHeaders结果VirtualAlloc分配空间不足后续写入.rdata或.data节时直接触发ACCESS_VIOLATION。// 正确做法以 SizeOfImage 为依据分配内存 PIMAGE_NT_HEADERS ntHeader ImageNtHeader(pRawData); SIZE_T sizeOfImage ntHeader-OptionalHeader.SizeOfImage; LPVOID pBase VirtualAlloc(NULL, sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!pBase) { // 错误处理记录 GetLastError() return false; }提示SizeOfImage是 PE Loader 唯一认可的“合法内存布局尺寸”。它已包含所有节区头、未初始化数据.bss、对齐填充。不要试图自己计算——哪怕你手算结果一致也建议直接读该字段避免因IMAGE_SECTION_HEADER::Misc.VirtualSize为 0 导致漏算。2.2 基址重定位当目标模块非默认基址0x400000时必须手动修正现代编译器默认生成 ASLR 兼容的 PE其IMAGE_OPTIONAL_HEADER::ImageBase往往是0x00000000表示可重定位或设为0x140000000x64 默认基址。但你的VirtualAlloc分配的pBase几乎不可能恰好等于该值。此时PE 中所有含绝对地址引用的位置如全局变量地址、函数指针赋值、跳转指令目标都需被修正。这部分信息保存在.reloc节中格式为IMAGE_BASE_RELOCATION结构链表。// 定位 .reloc 节并遍历重定位块 PIMAGE_SECTION_HEADER relocSec FindSectionByName(pBase, .reloc); if (relocSec relocSec-SizeOfRawData 0) { DWORD_PTR delta (DWORD_PTR)pBase - ntHeader-OptionalHeader.ImageBase; PIMAGE_BASE_RELOCATION pReloc (PIMAGE_BASE_RELOCATION)((BYTE*)pBase relocSec-VirtualAddress); while (pReloc-SizeOfBlock) { WORD* pRelocData (WORD*)((BYTE*)pReloc sizeof(IMAGE_BASE_RELOCATION)); int count (pReloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); for (int i 0; i count; i) { WORD typeOffset pRelocData[i]; WORD type typeOffset 12; WORD offset typeOffset 0x0FFF; if (type IMAGE_REL_BASED_DIR64) { // x64 只需处理 DIR64 ULONGLONG* pAddr (ULONGLONG*)((BYTE*)pBase pReloc-VirtualAddress offset); *pAddr delta; } } pReloc (PIMAGE_BASE_RELOCATION)((BYTE*)pReloc pReloc-SizeOfBlock); } }注意x64 下仅IMAGE_REL_BASED_DIR64值为 10有效x86 还需处理 HIGHLOW 等类型。delta必须是pBase - ImageBase而非pBase - (DWORD_PTR)pRawData——后者是常见翻车点会导致所有重定位偏移错位。2.3 导入表IAT解析与绑定LoadLibraryA GetProcAddress 的组合拳不能少PE 的IMAGE_IMPORT_DESCRIPTOR数组指向一个个 DLL 名称字符串和对应的IMAGE_THUNK_DATA即 IAT数组。每个IMAGE_THUNK_DATA::u1.Function存的是函数名 RVA 或序号。你需要遍历IMAGE_IMPORT_DESCRIPTOR对每个 DLL 调用LoadLibraryA对每个IMAGE_THUNK_DATA若低位为 0 表示序号导入否则为名称导入指向IMAGE_IMPORT_BY_NAME用GetProcAddress获取函数地址填入 IAT 对应位置。PIMAGE_IMPORT_DESCRIPTOR pImportDesc (PIMAGE_IMPORT_DESCRIPTOR)( (BYTE*)pBase ntHeader-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress ); while (pImportDesc-Name) { CHAR* dllName (CHAR*)((BYTE*)pBase pImportDesc-Name); HMODULE hMod LoadLibraryA(dllName); if (!hMod) { // 记录失败 DLL 名便于排错 continue; } PIMAGE_THUNK_DATA pThunkOrig (PIMAGE_THUNK_DATA)((BYTE*)pBase pImportDesc-OriginalFirstThunk); PIMAGE_THUNK_DATA pThunkIAT (PIMAGE_THUNK_DATA)((BYTE*)pBase pImportDesc-FirstThunk); while (pThunkOrig-u1.AddressOfData) { if (IMAGE_SNAP_BY_ORDINAL(pThunkOrig-u1.Ordinal)) { FARPROC proc GetProcAddress(hMod, (LPCSTR)IMAGE_ORDINAL(pThunkOrig-u1.Ordinal)); pThunkIAT-u1.Function (ULONGLONG)proc; } else { PIMAGE_IMPORT_BY_NAME pByName (PIMAGE_IMPORT_BY_NAME)((BYTE*)pBase pThunkOrig-u1.AddressOfData); FARPROC proc GetProcAddress(hMod, (LPCSTR)pByName-Name); pThunkIAT-u1.Function (ULONGLONG)proc; } pThunkOrig; pThunkIAT; } pImportDesc; }提示OriginalFirstThunk指向原始导入名称表用于调试/分析FirstThunk指向运行时 IATloader 实际填充的位置。务必用FirstThunk地址去写入函数指针否则调用时仍跳转到原始地址导致崩溃。2.4 TLS 回调执行忽略 TLS 却调用 DllMain后果很严重如果目标 PE 含.tls节其IMAGE_TLS_DIRECTORY中的AddressOfCallBacks字段会指向一个函数指针数组。Windows Loader 在调用DllMain前会依次调用这些 TLS 回调通常用于线程局部存储初始化。不执行它们可能导致DllMain中访问未初始化的 TLS 变量如__declspec(thread)变量CRT 初始化失败VC 编译的程序普遍依赖 TLS 回调注册堆栈保护某些反调试逻辑误判环境异常。PIMAGE_DATA_DIRECTORY tlsDir ntHeader-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir-VirtualAddress tlsDir-Size) { PIMAGE_TLS_DIRECTORY tls (PIMAGE_TLS_DIRECTORY)((BYTE*)pBase tlsDir-VirtualAddress); if (tls-AddressOfCallBacks) { PIMAGE_TLS_CALLBACK* pCallback (PIMAGE_TLS_CALLBACK*)tls-AddressOfCallBacks; while (*pCallback) { (*pCallback)((HINSTANCE)pBase, DLL_PROCESS_ATTACH, NULL); pCallback; } } }注意TLS 回调签名是void NTAPI Callback(PVOID DllHandle, DWORD Reason, PVOID Reserved)必须用NTAPI调用约定即__stdcall否则栈不平衡直接 crash。x64 下NTAPI等价于__fastcall但显式声明更稳妥。3. 内存加载器核心实现C 模板化封装与关键参数控制本项目提供的源码包是一个轻量、无第三方依赖、支持 x64 的内存加载器实现封装为MemLoader类。它不追求“一键加载”而是暴露关键控制点是否启用重定位、是否强制跳过 TLS、是否允许导入 DLL 白名单、是否启用详细日志。以下为完整可复现的核心类骨架及关键成员函数说明。3.1 MemLoader 类定义与构造参数语义class MemLoader { public: struct Config { bool enableRelocation true; // 是否执行重定位默认 true bool executeTLS true; // 是否执行 TLS 回调默认 true bool allowRemoteDLL true; // 是否允许 LoadLibrary 加载远程 DLL默认 true std::vectorstd::string dllWhitelist; // 若非空则只允许白名单内 DLL如 {kernel32.dll, user32.dll} bool verboseLog false; // 是否输出详细步骤日志调试用 }; MemLoader(const void* pRawPE, size_t size, const Config cfg {}); bool Load(); // 主加载入口 bool Execute(); // 执行入口点OEP void* GetBaseAddress() const { return m_pBase; } DWORD GetExitCode() const { return m_exitCode; } private: const void* m_pRawPE; size_t m_size; Config m_cfg; void* m_pBase nullptr; DWORD m_exitCode 0; bool MapSections(); bool PerformRelocation(); bool BindImports(); bool ExecuteTLS(); bool CallEntryPoint(); };说明Config结构体是控制加载行为的唯一入口。例如若你确定目标 PE 是静态链接且无 TLS如某些 MinGW 编译的小工具可设executeTLS false省去回调开销若在受限沙箱中运行设allowRemoteDLL false并配合dllWhitelist可防止意外加载未知 DLL。3.2 Load() 方法全流程四阶段串联与错误传播Load()不是原子操作而是分阶段执行并检查每步返回值。任一阶段失败立即清理已分配资源并返回false。这是工程健壮性的底线。bool MemLoader::Load() { if (!MapSections()) return false; if (m_cfg.enableRelocation !PerformRelocation()) return false; if (!BindImports()) return false; if (m_cfg.executeTLS !ExecuteTLS()) return false; return true; }MapSections()完成节区复制、属性设置VirtualProtect设置PAGE_EXECUTE_READWRITEPerformRelocation()仅当enableRelocationtrue时调用否则跳过BindImports()自动过滤白名单外 DLL若dllWhitelist非空ExecuteTLS()仅当executeTLStrue时调用。关键设计所有阶段函数均返回bool失败时内部已调用VirtualFree(m_pBase, 0, MEM_RELEASE)。使用者无需手动清理降低误用风险。3.3 Execute() 方法安全跳转与异常捕获Execute()是真正触发 OEP 的函数。它不仅调用入口点还做了两件事使用SetUnhandledExceptionFilter注册顶层异常处理器捕获 OEP 内部崩溃如空指针解引用将DllMain风格的参数传入hInstance m_pBase,dwReason DLL_PROCESS_ATTACH,lpvReserved nullptr。bool MemLoader::Execute() { if (!m_pBase) return false; // 注册异常处理器仅调试用生产环境可移除 SetUnhandledExceptionFilter([](PEXCEPTION_POINTERS pExp) - LONG { // 记录崩溃地址、寄存器状态等 return EXCEPTION_EXECUTE_HANDLER; }); PIMAGE_NT_HEADERS ntHeader ImageNtHeader(m_pBase); DWORD_PTR oepRva ntHeader-OptionalHeader.AddressOfEntryPoint; typedef BOOL(WINAPI* EntryPointFunc)(HINSTANCE, DWORD, LPVOID); EntryPointFunc pOEP (EntryPointFunc)((BYTE*)m_pBase oepRva); // 模拟 DllMain 调用约定 BOOL result pOEP((HINSTANCE)m_pBase, DLL_PROCESS_ATTACH, nullptr); m_exitCode result ? 0 : 1; return result ! FALSE; }注意AddressOfEntryPoint是 RVA相对虚拟地址必须加上m_pBase才得真实地址。若此处写成pOEP (EntryPointFunc)((BYTE*)m_pBase ntHeader-OptionalHeader.ImageBase oepRva)就是典型血泪经验——ImageBase是磁盘建议基址不是运行时基址加它纯属多余且错。3.4 使用示例三行代码加载并运行内存中 EXE假设你已通过网络、资源段、Base64 解码等方式获得某工具的原始 PE 数据// 1. 读取原始 PE 数据示例从文件读取 std::ifstream file(tool.exe, std::ios::binary | std::ios::ate); size_t size file.tellg(); std::vectorBYTE rawPE(size); file.seekg(0); file.read((char*)rawPE.data(), size); file.close(); // 2. 构造配置关闭 TLS启用日志 MemLoader::Config cfg; cfg.executeTLS false; cfg.verboseLog true; // 3. 加载并执行 MemLoader loader(rawPE.data(), rawPE.size(), cfg); if (loader.Load() loader.Execute()) { printf(Success! Exit code: %u\n, loader.GetExitCode()); } else { printf(Failed to load or execute.\n); }提示rawPE.data()必须在整个MemLoader生命周期内有效。若数据来自栈变量或临时 buffer需确保其生命周期覆盖Execute()调用。生产环境建议用std::shared_ptrstd::vectorBYTE管理。4. 避坑 / 常见问题 / 排查五条真实踩过的坑与修复方案进程空中技术看似只有几十行核心逻辑但实际落地时90% 的失败源于对 Windows 加载细节的误判。以下是我在某嵌入式 Windows 容器项目中亲历的五条高频翻车点每条均按「现象 → 原因 → 解决」结构还原附带验证方法。4.1 现象VirtualAlloc成功但memcpy写入.data节时触发ACCESS_VIOLATION原因.data节在 PE 头中Characteristics标志位未设IMAGE_SCN_MEM_WRITE但VirtualAlloc默认分配PAGE_READWRITE而后续VirtualProtect未按节属性重设页面权限。Windows Loader 会根据节头Characteristics设置最终页面属性如.rdata设为PAGE_READONLY若你跳过此步写入只读节必然崩溃。解决遍历所有节头对每个节调用VirtualProtect设置匹配权限PIMAGE_SECTION_HEADER sec IMAGE_FIRST_SECTION(ntHeader); for (int i 0; i ntHeader-FileHeader.NumberOfSections; i, sec) { DWORD protect PAGE_NOACCESS; if (sec-Characteristics IMAGE_SCN_MEM_EXECUTE) protect | PAGE_EXECUTE; if (sec-Characteristics IMAGE_SCN_MEM_READ) protect | PAGE_READWRITE; if (sec-Characteristics IMAGE_SCN_MEM_WRITE) protect | PAGE_READWRITE; VirtualProtect((BYTE*)pBase sec-VirtualAddress, sec-Misc.VirtualSize, protect, oldProtect); }验证用VMMap工具查看目标进程各内存页的Protection列确认.data页为READWRITE.rdata页为READONLY。4.2 现象LoadLibraryA返回NULLGetLastError()为126找不到指定模块原因目标 PE 的导入表中 DLL 名为全路径如C:\Windows\System32\kernel32.dll而非短名kernel32.dll。LoadLibraryA默认不搜索系统路径且路径中含\时会尝试按字面路径加载失败即报 126。解决在BindImports阶段对dllName做路径剥离CHAR* simpleName strrchr(dllName, \\); if (simpleName) simpleName; else simpleName dllName; HMODULE hMod LoadLibraryA(simpleName);验证打印simpleName值确认为kernel32.dll而非C:\Windows\System32\kernel32.dll。4.3 现象CallEntryPoint后进程静默退出GetExitCode为1无任何日志原因目标 PE 是 GUI 子系统subsystem: windows其入口点是WinMain而非main或DllMain。你传入DLL_PROCESS_ATTACH参数但WinMain期望HINSTANCE, HINSTANCE, LPSTR, int参数错位导致栈混乱或直接返回。解决在Load()后、Execute()前检查OptionalHeader::SubsystemWORD subsystem ntHeader-OptionalHeader.Subsystem; if (subsystem IMAGE_SUBSYSTEM_WINDOWS_GUI || subsystem IMAGE_SUBSYSTEM_WINDOWS_CUI) { // 改用 CreateThread 模拟 GUI 线程上下文或强制设为 CONSOLE 子系统 // 更稳妥拒绝加载 GUI PE提示用户需用 console 版本 }验证用dumpbin /headers tool.exe查看subsystem字段确认是否为windows。4.4 现象在 Win11 22H2 系统上加载失败GetLastError()为998访问违规原因启用了CFGControl Flow Guard或CETControl-flow Enforcement Technology的 PE其.cfg节或IMAGE_LOAD_CONFIG_DIRECTORY中的GuardCFCheckFunctionPointer字段非空。内存加载器未初始化 CFG 表导致call指令被硬件拦截。解决检测IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG若存在且GuardFlags IMAGE_GUARD_CF_INSTRUMENTED则需分配GuardCFFunctionTable内存解析.cfg节填充函数表设置NtSetInformationProcess启用 CFG需 SeDebugPrivilege。简化方案推荐编译时禁用/guard:cf或加载前检查并报错PIMAGE_LOAD_CONFIG_DIRECTORY loadCfg (PIMAGE_LOAD_CONFIG_DIRECTORY)( (BYTE*)pBase ntHeader-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG].VirtualAddress ); if (loadCfg (loadCfg-GuardFlags IMAGE_GUARD_CF_INSTRUMENTED)) { // 记录警告CFG enabled, may fail on Win11 }验证用sigcheck -u tool.exe查看CFG列是否为Yes。4.5 现象加载成功但调用printf等 CRT 函数时崩溃原因目标 PE 由 MSVC 编译依赖ucrtbase.dll和vcruntime140.dll但BindImports仅处理kernel32.dll等系统 DLL未加载 CRT DLL。printf的 IAT 条目为空调用即 crash。解决扩展dllWhitelist显式加入 CRT 依赖cfg.dllWhitelist {kernel32.dll, user32.dll, ucrtbase.dll, vcruntime140.dll};验证用Dependencies工具打开原 exe查看Imported Modules列表确认缺失的 DLL 是否在其中。5. 进阶技巧如何验证内存加载是否“干净”三步精准检测法内存加载是否成功不能只看Execute()返回true。真正的“干净”意味着进程行为与磁盘加载完全一致、无额外可疑内存页、无残留文件句柄。我总结了一套三步验证法已在某高校安全实验室的 12 个不同 PE 样本上验证通过覆盖 UPX 壳、MinGW、MSVC、Go 编译产物。5.1 步骤一比对内存镜像哈希 —— 确认节区内容零偏差PE 加载后其内存布局应与磁盘文件经IMAGE_NT_HEADERS::SizeOfImage映射后的理论布局完全一致忽略重定位修正的地址字段。我们提取.text、.rdata、.data三个核心节的原始字节计算 SHA256与磁盘文件对应节的哈希比对。# Python 验证脚本需安装 pefile import pefile import hashlib def calc_section_hash(pe_path, section_name): pe pefile.PE(pe_path) for sec in pe.sections: if sec.Name.rstrip(b\x00).decode() section_name: # 读取磁盘原始节数据FileOffset SizeOfRawData data pe.__data__[sec.PointerToRawData:sec.PointerToRawData sec.SizeOfRawData] return hashlib.sha256(data).hexdigest() return None print(Disk .text hash:, calc_section_hash(tool.exe, .text)) # 运行内存加载后用 ReadProcessMemory 读取目标进程对应节内存再算哈希关键点必须用SizeOfRawData磁盘大小而非VirtualSize内存大小读取否则填充字节导致哈希不等。若哈希一致说明节区复制无损。5.2 步骤二检查内存页属性 —— 确认无 PAGE_EXECUTE_READWRITE 全局页Windows Loader 为每个节设置精确权限.text为PAGE_EXECUTE_READ.rdata为PAGE_READONLY.data为PAGE_READWRITE。若你的加载器统一设为PAGE_EXECUTE_READWRITE则会被 EDR 识别为可疑行为如Sysmon事件 ID 8 “CreateRemoteThread” 的变种检测。使用VMMapSysinternals附加到目标进程导出 HTML 报告筛选Protection列检查所有MEM_IMAGE类型页是否按节名正确设置权限是否存在MEM_PRIVATE且ProtectionPAGE_EXECUTE_READWRITE的大块内存典型 loader 漏洞特征。血泪经验某次因忘记调用VirtualProtect.rdata页保持READWRITE被某国产 EDR 的“内存页权限异常”规则秒杀。从此我每次Load()后必跑 VMMap 快照。5.3 步骤三枚举模块与句柄 —— 确认无磁盘文件句柄残留真正的内存加载进程的HMODULE应指向你VirtualAlloc的地址而非磁盘文件映射。同时不应持有任何对该 exe 文件的HANDLE如CreateFile打开的句柄。用Process Hacker或handle.exeSysinternals检查handle.exe -p pid | findstr .exe若输出为空说明无文件句柄若有说明加载过程中意外打开了磁盘文件如fopen日志、资源加载等。再用EnumProcessModules验证模块基址HMODULE hMods[1024]; DWORD cbNeeded; if (EnumProcessModules(hProcess, hMods, sizeof(hMods), cbNeeded)) { for (int i 0; i cbNeeded / sizeof(HMODULE); i) { MODULEINFO modInfo; if (GetModuleInformation(hProcess, hMods[i], modInfo, sizeof(modInfo))) { printf(Module base: %p, size: %zu\n, modInfo.lpBaseOfDll, modInfo.SizeOfImage); // 应与你分配的 m_pBase 完全一致 } } }从那以后我每次交付内存加载模块都强制走一遍这三步先跑哈希脚本再开 VMMap 截图存档最后用 handle.exe 扫描句柄。不是 paranoid而是知道哪些细节会被 EDR 当成“铁证”。希望帮到你。本文还有配套的精品资源点击获取