
简介这份资源围绕进程空中技术RunPE / Process Hollowing展开面向安全研究人员、逆向工程师以及希望深入理解 Windows 底层机制的学习者帮助解决在不将 exe 写入磁盘的前提下于内存中动态加载并执行代码的问题。压缩包内共 1 个文件为一份 cpp 源码整体约 2KB体量轻巧便于直接阅读与二次修改。内容涉及承载进程选择、目标 exe 节区读取、内存状态保存与清空、入口点重设以及 NtResumeThread 恢复执行等关键环节并延伸至隐蔽执行、逆向工程与动态调试等应用场景同时提及异常进程行为检测等防御思路。目前已有 1206 人学习下载适合具备一定 PE 文件格式与 Windows API 基础、希望动手实践进程注入与内存加载原理的读者参考。1. 进程空中技术把 exe 从磁盘搬到内存里跑到底图什么你有没有遇到过这种场景手头只有一个编译好的 exe源码早丢了但你想在它启动前改掉某个字符串、绕过某个检测或者干脆不想让它以文件形式落在磁盘上被人翻出来。常规做法是直接双击运行但 exe 一旦落到磁盘路径、文件头、导入表全都暴露着随便一个监控工具就能抓到。进程空中技术要解决的就是这个问题——把 exe 的映像从磁盘读进内存在内存里完成映射、重定位、导入表修复然后直接跳过去执行全程不落盘、不注册为常规进程路径。这套东西在安全研究、红队工具开发、恶意样本分析里出现频率很高也是理解 Windows PE 加载机制最直接的方式。适合谁看写过 C/C、懂一点 Win32 API、知道 PE 结构大概长什么样的人。如果你连 VirtualAlloc 和 LoadLibrary 的区别都说不清建议先把 PE 文件格式和进程内存布局补一补再往下看。下面我按实际能跑通的顺序把整个流程拆开讲。2. PE 加载的黑匣子从文件偏移到内存映像的映射逻辑2.1 为什么不能直接把 exe 文件读进内存就跳很多人第一反应是把 exe 当二进制文件读进一块内存然后跳到入口点不就行了不行。磁盘上的 exe 和内存里的 exe 布局是两套东西。磁盘上按文件偏移File Offset排列内存里按相对虚拟地址RVA排列两者之间的换算关系写在 PE 可选头的 SectionAlignment 和 FileAlignment 里。更关键的是磁盘上的节区之间可能有空隙内存里每个节区必须对齐到页边界而且导入表、重定位表这些结构在磁盘上存的是 RVA加载到内存后需要根据实际基址修正。常见做法是分三步先把整个文件读进一块缓冲区解析 DOS 头和 NT 头拿到 SizeOfImage然后按 SizeOfImage 分配一块可读可写可执行的内存接着逐节区把数据从文件偏移拷贝到内存 RVA 处同时按 SectionAlignment 补齐。这一步做完内存里就有一份未修复的映像和 LoadLibrary 加载后的状态还差导入表解析和重定位。// 读取 exe 到缓冲区并解析 PE 头 HANDLE hFile CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); DWORD fileSize GetFileSize(hFile, NULL); BYTE* fileBuf (BYTE*)malloc(fileSize); DWORD read; ReadFile(hFile, fileBuf, fileSize, read, NULL); CloseHandle(hFile); PIMAGE_DOS_HEADER dos (PIMAGE_DOS_HEADER)fileBuf; PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)(fileBuf dos-e_lfanew); DWORD imageSize nt-OptionalHeader.SizeOfImage; BYTE* imageBuf (BYTE*)VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);这段代码里e_lfanew是 DOS 头里指向 NT 头的偏移SizeOfImage是加载后整个映像占用的虚拟内存大小。VirtualAlloc用PAGE_EXECUTE_READWRITE是为了后面能直接执行实际生产环境里应该先 RW 再改 RX这里为了演示简化了。注意fileSize和imageSize通常不相等imageSize 一般更大因为内存对齐粒度是 4KB 而文件对齐可能是 512 字节。2.2 节区拷贝与重定位修正拿到 imageBuf 之后遍历节区表每个节区的 VirtualAddress 是内存里的 RVAPointerToRawData 是文件里的偏移SizeOfRawData 是要拷贝的字节数。拷贝完还要处理重定位——如果实际分配的基址和 OptionalHeader.ImageBase 不一致所有写死的绝对地址都要加上差值。// 逐节区拷贝 PIMAGE_SECTION_HEADER sec IMAGE_FIRST_SECTION(nt); for (int i 0; i nt-FileHeader.NumberOfSections; i) { memcpy(imageBuf sec[i].VirtualAddress, fileBuf sec[i].PointerToRawData, sec[i].SizeOfRawData); } // 重定位修正 ULONG_PTR delta (ULONG_PTR)imageBuf - nt-OptionalHeader.ImageBase; if (delta ! 0) { PIMAGE_DATA_DIRECTORY relocDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir-Size 0) { PIMAGE_BASE_RELOCATION reloc (PIMAGE_BASE_RELOCATION)(imageBuf relocDir-VirtualAddress); while ((BYTE*)reloc imageBuf relocDir-VirtualAddress relocDir-Size) { DWORD count (reloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries (WORD*)((BYTE*)reloc sizeof(IMAGE_BASE_RELOCATION)); for (DWORD j 0; j count; j) { if ((entries[j] 12) IMAGE_REL_BASED_HIGHLOW) { DWORD* patch (DWORD*)(imageBuf reloc-VirtualAddress (entries[j] 0xFFF)); *patch (DWORD)delta; } } reloc (PIMAGE_BASE_RELOCATION)((BYTE*)reloc reloc-SizeOfBlock); } } }重定位这块是翻车高发区。IMAGE_REL_BASED_HIGHLOW只处理 32 位64 位程序要用IMAGE_REL_BASED_DIR64而且 delta 要用 64 位算。如果你加载的是 64 位 exe 却按 32 位逻辑修跳过去必崩。另外有些 exe 编译时开了/FIXED或者去掉了重定位表这种就必须加载到 ImageBase 指定的地址否则没法修。2.3 导入表解析把 API 地址填进去重定位修完映像里的代码还是不能跑因为所有调用外部函数的地方比如MessageBoxA、CreateFileW现在指向的是导入表里的字符串不是真实函数地址。需要遍历导入表对每个 DLL 调LoadLibraryA拿到模块句柄再用GetProcAddress拿函数地址填回 IAT导入地址表。// 解析导入表 PIMAGE_DATA_DIRECTORY importDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir-Size 0) { PIMAGE_IMPORT_DESCRIPTOR imp (PIMAGE_IMPORT_DESCRIPTOR)(imageBuf importDir-VirtualAddress); while (imp-Name ! 0) { char* dllName (char*)(imageBuf imp-Name); HMODULE hDll LoadLibraryA(dllName); PIMAGE_THUNK_DATA origThunk (PIMAGE_THUNK_DATA)(imageBuf imp-OriginalFirstThunk); PIMAGE_THUNK_DATA firstThunk (PIMAGE_THUNK_DATA)(imageBuf imp-FirstThunk); while (origThunk-u1.AddressOfData ! 0) { FARPROC funcAddr; if (origThunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { funcAddr GetProcAddress(hDll, (LPCSTR)(origThunk-u1.Ordinal 0xFFFF)); } else { PIMAGE_IMPORT_BY_NAME byName (PIMAGE_IMPORT_BY_NAME)(imageBuf origThunk-u1.AddressOfData); funcAddr GetProcAddress(hDll, byName-Name); } firstThunk-u1.Function (ULONG_PTR)funcAddr; origThunk; firstThunk; } imp; } }OriginalFirstThunk指向名字表FirstThunk指向地址表两者初始时都指向同一份数据加载后 FirstThunk 被改写成真实地址。如果 OriginalFirstThunk 为 0说明是按序号导入得从 FirstThunk 里读序号。这里有个坑有些加壳或混淆过的 exe 会故意把导入表打乱甚至运行时动态解析 API这种静态填 IAT 的方式就不够用了得配合 API 钩子或者手动模拟加载器行为。3. 从内存跳过去执行入口点、TLS 与线程上下文3.1 入口点不是唯一要处理的东西导入表修完理论上跳到AddressOfEntryPoint就能跑。但实际项目里经常遇到跳过去直接崩的情况原因往往出在 TLS线程本地存储和异常处理表上。TLS 回调在入口点之前执行如果 exe 用了 TLS你得手动遍历 TLS 目录并调用回调。异常处理表.pdata 节在 64 位程序里用于栈展开不注册的话一出异常整个进程就挂。// 处理 TLS 回调简化版 PIMAGE_DATA_DIRECTORY tlsDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir-Size 0) { PIMAGE_TLS_DIRECTORY tls (PIMAGE_TLS_DIRECTORY)(imageBuf tlsDir-VirtualAddress); if (tls-AddressOfCallBacks) { PIMAGE_TLS_CALLBACK* cb (PIMAGE_TLS_CALLBACK*)tls-AddressOfCallBacks; while (*cb) { (*cb)((PVOID)imageBuf, DLL_PROCESS_ATTACH, NULL); cb; } } }TLS 回调的签名是void NTAPI func(PVOID DllHandle, DWORD Reason, PVOID Reserved)Reason 传DLL_PROCESS_ATTACH。注意回调地址在重定位后可能已经修正过如果没修就调会跳到错误地址。另外 64 位程序的 TLS 目录结构和 32 位略有不同字段宽度不一样混用会读错。3.2 创建线程还是直接调用最直接的方式是用CreateThread把入口点当线程函数传进去但这样有个问题入口点的签名是int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)或者DWORD WINAPI mainCRTStartup(void)直接当线程函数传参不对。常见做法是写一个跳板函数在里面按正确的调用约定调入口点。typedef int (WINAPI *EntryPoint)(HINSTANCE, HINSTANCE, LPSTR, int); DWORD WINAPI ThreadProc(LPVOID param) { BYTE* imageBase (BYTE*)param; PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)(imageBase ((PIMAGE_DOS_HEADER)imageBase)-e_lfanew); EntryPoint entry (EntryPoint)(imageBase nt-OptionalHeader.AddressOfEntryPoint); // 对于 GUI 程序第三个参数传 SW_SHOW 之类 entry((HINSTANCE)imageBase, NULL, NULL, SW_SHOW); return 0; } CreateThread(NULL, 0, ThreadProc, imageBuf, 0, NULL);如果 exe 是控制台程序入口点可能是mainCRTStartup它不接受参数直接调就行。判断依据是 OptionalHeader.Subsystem 字段IMAGE_SUBSYSTEM_WINDOWS_GUI对应 WinMainIMAGE_SUBSYSTEM_WINDOWS_CUI对应 main。传错参数会导致栈不平衡程序跑飞。3.3 参数传递与工作目录内存加载的 exe 拿到的命令行参数和正常启动不一样。正常启动时GetCommandLineA返回的是完整命令行内存加载时这个 API 返回的是宿主进程的命令行。如果目标 exe 依赖命令行参数得手动改 PEB 里的 ProcessParameters或者用 API 钩子拦截GetCommandLineA返回伪造的值。工作目录同理GetCurrentDirectory返回的是宿主进程的目录目标 exe 如果按相对路径读文件会找不到。提示调试内存加载的 exe 时建议在跳转前用VirtualProtect把代码节改成PAGE_EXECUTE_READ数据节改成PAGE_READWRITE这样能提前暴露权限问题而不是等到执行时崩在莫名其妙的地方。4. 避坑与排查内存加载 exe 最常见的五类翻车4.1 跳过去直接闪退没有任何报错现象CreateThread返回成功但目标 exe 窗口没出来进程也没了。原因通常是入口点调用约定不对或者导入表没填完。排查方法在跳转前用调试器附加在入口点下断点看栈是否平衡。如果是 64 位程序检查IMAGE_REL_BASED_DIR64是否处理了。另一个常见原因是 exe 依赖的 DLL 在宿主进程里已经加载了不同版本LoadLibraryA返回的是已有模块句柄GetProcAddress拿到的地址和 exe 预期的不一致。4.2 报无法定位程序输入点或找不到 xxx.dll现象跳转后弹窗提示缺少某个 DLL 或函数。原因导入表解析时LoadLibraryA失败或者GetProcAddress返回 NULL 但没检查就填进去了。解决在填 IAT 前加判断如果funcAddr为 NULL记录下是哪个 DLL 的哪个函数然后决定是跳过还是报错。有些 exe 依赖 VC 运行库宿主进程里没有对应版本需要提前把运行库 DLL 放到同目录或者用SetDllDirectory指定搜索路径。4.3 加载 64 位 exe 时重定位算错现象32 位宿主加载 64 位 exe或者反过来跳转后地址全乱。原因重定位块里的类型判断只写了IMAGE_REL_BASED_HIGHLOW没处理IMAGE_REL_BASED_DIR64。解决根据nt-FileHeader.Machine判断是IMAGE_FILE_MACHINE_I386还是IMAGE_FILE_MACHINE_AMD64分别用 32 位和 64 位的 delta 计算方式。另外 64 位 exe 的 ImageBase 默认是0x140000000如果宿主进程已经占用了这个地址范围VirtualAlloc会失败需要换地址并修正重定位。4.4 TLS 回调没执行导致全局变量初始化失败现象exe 跑起来后某些全局变量是 0或者 C 静态对象没构造。原因TLS 回调没调或者调的顺序不对。解决在跳入口点之前手动遍历 TLS 目录并调用回调注意回调地址要经过重定位修正。如果 exe 用了__declspec(thread)变量TLS 目录里会有对应的初始化数据不处理的话线程局部变量全是垃圾值。4.5 杀软拦截或进程被标记现象加载完成后宿主进程被终止或者目标 exe 的行为被拦截。原因VirtualAlloc申请PAGE_EXECUTE_READWRITE内存、手动映射 PE、跳转执行这些行为在杀软眼里就是典型的 shellcode 加载器特征。解决分步申请权限先 RW 再改 RX用NtMapViewOfSection替代VirtualAlloc做映像映射把关键字符串加密存储运行时解密。这些手段能降低被静态查杀的概率但行为监控还是可能触发需要配合更底层的 API 调用。5. 进阶技巧用映射视图替代 VirtualAlloc 并验证加载结果5.1 用 NtMapViewOfSection 做更隐蔽的映像映射VirtualAlloc申请的内存页在进程内存映射里会留下明显的 RWX 记录而用NtCreateSectionNtMapViewOfSection可以把 exe 伪装成一块文件映射权限控制也更细。常见做法是先把 exe 写进一个临时文件或者内存节然后以SEC_IMAGE标志创建节对象再映射到目标进程。这样加载出来的映像和LoadLibrary加载的几乎一样连节区权限都自动按 PE 头设置。// 用节对象映射 exe 映像简化流程 HANDLE hSection; LARGE_INTEGER maxSize { imageSize }; NtCreateSection(hSection, SECTION_ALL_ACCESS, NULL, maxSize, PAGE_EXECUTE_READWRITE, SEC_COMMIT, NULL); PVOID baseAddr NULL; SIZE_T viewSize 0; NtMapViewOfSection(hSection, GetCurrentProcess(), baseAddr, 0, 0, NULL, viewSize, ViewUnmap, 0, PAGE_EXECUTE_READWRITE); // 把 exe 数据写进映射视图然后按前面流程修重定位和导入表 memcpy(baseAddr, fileBuf, fileSize);SEC_IMAGE标志会让内核按 PE 格式解析节区但要求文件内容完整且对齐正确。如果 exe 被修改过或者节区头有问题NtCreateSection会返回STATUS_INVALID_IMAGE_FORMAT。这种方式的优势是内存属性由内核管理不容易被用户态扫描工具直接标记为可疑分配。5.2 验证加载是否成功的三个检查点加载完别急着跳先做三个检查。第一检查imageBuf的前两个字节是不是MZe_lfanew指向的位置是不是PE\0\0确认映像头完整。第二遍历导入表确认每个FirstThunk都被填成了非零值如果有 NULL 说明某个 API 没解析到。第三用VirtualQuery查一下入口点所在内存页的权限确保是可执行的。// 检查入口点内存权限 MEMORY_BASIC_INFORMATION mbi; VirtualQuery(imageBuf nt-OptionalHeader.AddressOfEntryPoint, mbi, sizeof(mbi)); if (!(mbi.Protect (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE))) { // 权限不对用 VirtualProtect 改 DWORD oldProtect; VirtualProtect(mbi.BaseAddress, mbi.RegionSize, PAGE_EXECUTE_READ, oldProtect); }这三个检查做完基本能排除 80% 的加载失败。剩下的 20% 通常是目标 exe 自身有反调试或者依赖特定环境那就得具体样本具体分析了。5.3 一个我踩过的坑忘了处理延迟导入有一次加载一个用了延迟导入Delay Load的 exe导入表里没有LoadLibraryA和GetProcAddress跳过去之后第一次调用延迟导入的函数时崩了。延迟导入的解析逻辑在 exe 自己的__delayLoadHelper2里它依赖LoadLibraryA和GetProcAddress的真实地址而这两个函数在延迟导入表里是单独存的。如果不手动填延迟导入表exe 自己解析时会调到错误地址。解决办法是遍历IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT目录按和普通导入表一样的逻辑填一遍。从那以后我每次加载 exe 前都强制走一遍检查先看有没有延迟导入目录再看 TLS 目录最后确认重定位表类型。这三样过完再跳基本不会出玄学问题。希望帮到你。本文还有配套的精品资源点击获取