
简介这份资源围绕进程空中技术RunPE / Process Hollowing展开面向安全研究人员、逆向工程师以及希望深入理解 Windows 底层机制的学习者帮助读者掌握在不将 exe 写入磁盘的前提下于内存中动态加载并执行可执行代码的核心思路。内容涵盖承载进程选择、目标 exe 节区读取、内存状态保存与清空、入口点重设以及 NtResumeThread 恢复执行等关键环节并延伸至隐蔽执行、逆向工程与动态调试等典型应用场景。压缩包内共 1 个文件为一份 cpp 源码整体约 2KB体量轻巧便于直接阅读与二次实验。目前已有 1206 人学习下载适合具备一定 PE 文件格式与 Windows API 基础、希望从代码层面理解进程注入原理与防御检测思路的读者参考。1. 进程空中技术把 exe 从磁盘搬进内存再跑起来你手上有一个 exe但不想让它以文件形式躺在磁盘上被扫描、被误删、被“另一个进程锁定”。进程空中技术在内存中加载 exe解决的就是这件事把 PE 文件读进内存手动完成映射、重定位、导入表修复再跳转到入口点执行。它适合做安全研究、红队工具开发、软件保护验证以及排查“exe 卡密破密”“exe 文件用 AI 分析”这类场景下的加载行为。核心难点不在“读文件”而在“让 Windows 加载器以为这个模块是正常加载的”。下面按最小可跑通路径拆开讲参数和坑都给到能直接抄的程度。2. 内存加载 exe 的 PE 映射原理与最小验证2.1 为什么不能直接 VirtualAlloc 然后 memcpyPE 文件在磁盘上的布局和加载到内存后的布局不是一回事。磁盘上按 Section 对齐FileAlignment常见 0x200内存里按页对齐SectionAlignment常见 0x1000。如果你把整个文件原样拷到一块内存里就跳过去第一条指令就可能落在错误的偏移上。常见做法是分三步先读文件头拿到 SizeOfImage用 VirtualAlloc 预留一块 RWX 或 RX 内存再按每个 Section 的 VirtualAddress 把数据搬到对应偏移最后处理重定位表和导入表。少了任何一步轻则崩溃重则静默退出连报错都没有这就是很多人说的“玄学”。验证阶段我一般用一个自己编译的、只弹 MessageBox 的 exe 做靶子因为它不依赖复杂导入失败时容易定位是映射问题还是导入问题。import struct def parse_pe_sections(data: bytes): 解析 PE 节表返回 (size_of_image, image_base, sections) e_lfanew struct.unpack_from(I, data, 0x3C)[0] assert data[e_lfanew:e_lfanew4] bPE\x00\x00, 不是有效 PE coff e_lfanew 4 num_sections struct.unpack_from(H, data, coff 2)[0] opt_size struct.unpack_from(H, data, coff 16)[0] opt coff 20 magic struct.unpack_from(H, data, opt)[0] if magic 0x10B: # PE32 image_base struct.unpack_from(I, data, opt 28)[0] size_of_image struct.unpack_from(I, data, opt 56)[0] else: # PE32 image_base struct.unpack_from(Q, data, opt 24)[0] size_of_image struct.unpack_from(I, data, opt 56)[0] sec_off opt opt_size sections [] for i in range(num_sections): base sec_off i * 40 name data[base:base8].rstrip(b\x00).decode(errorsignore) vaddr, vsize struct.unpack_from(II, data, base 12) raw_size, raw_ptr struct.unpack_from(II, data, base 16) sections.append((name, vaddr, vsize, raw_ptr, raw_size)) return size_of_image, image_base, sections这段代码只做解析不执行。size_of_image决定你要分配多大内存image_base是重定位的基准sections里vaddr是内存偏移、raw_ptr是文件偏移。参数上注意PE32 和 PE32 的 optional header 偏移不同用 magic 判断别写死。2.2 手动映射的最小步骤与参数拿到节表后映射逻辑是对每个节把data[raw_ptr : raw_ptrraw_size]写到base vaddr。如果raw_size为 0比如 .bss跳过拷贝但保留内存。映射完还要把 PE 头也拷进去因为加载器后续可能读它。import ctypes def map_image(data: bytes): size_of_image, image_base, sections parse_pe_sections(data) # 预留内存先给 RWX 方便调试稳定后改 RX base ctypes.windll.kernel32.VirtualAlloc( None, size_of_image, 0x3000, 0x40) if not base: raise OSError(VirtualAlloc 失败) # 拷贝 PE 头 ctypes.memmove(base, data, 0x1000) for name, vaddr, vsize, raw_ptr, raw_size in sections: if raw_size 0: continue ctypes.memmove(base vaddr, data[raw_ptr:raw_ptrraw_size], raw_size) return base, size_of_image, image_base0x3000是 MEM_COMMIT | MEM_RESERVE0x40是 PAGE_EXECUTE_READWRITE。调试阶段用 RWX跑通后改成 RX 再单独处理需要写的页否则杀软和系统缓解措施会盯上你。0x1000是 PE 头常见大小稳妥做法是按SizeOfHeaders字段来这里为了最小化先写死。2.3 重定位为什么换块内存就崩如果实际分配到的base和 PE 里记录的image_base不一致所有写死的绝对地址都会错。重定位表.reloc就是干这个的。没有重定位表的 exe 只能加载到首选基址否则直接失败。def apply_relocations(base, data, image_base, delta): delta 实际基址 - 首选基址 e_lfanew struct.unpack_from(I, data, 0x3C)[0] opt e_lfanew 24 magic struct.unpack_from(H, data, opt)[0] # 数据目录第 5 项是重定位表 dd_off opt (96 if magic 0x10B else 112) reloc_rva, reloc_size struct.unpack_from(II, data, dd_off 5*8) if reloc_rva 0: return end reloc_rva reloc_size off reloc_rva while off end: page_rva, block_size struct.unpack_from(II, data, off) if block_size 0: break entries (block_size - 8) // 2 for i in range(entries): entry struct.unpack_from(H, data, off 8 i*2)[0] if entry 12 3: # IMAGE_REL_BASED_HIGHLOW patch_at base page_rva (entry 0xFFF) val ctypes.c_uint32.from_address(patch_at).value ctypes.c_uint32.from_address(patch_at).value (val delta) 0xFFFFFFFF off block_sizedelta是实际基址减首选基址。64 位下重定位类型是 10DIR64要按 8 字节处理。这里只演示 32 位 HIGHLOW64 位把类型判断和读写宽度改掉即可。漏掉重定位的典型现象是程序能跑但一调用全局变量就崩或者直接 0xC0000005。2.4 导入表修复不修就是空指针exe 里调用MessageBoxA、CreateFileW这些靠的是导入表。手动映射不会自动帮你填 IAT必须自己 LoadLibrary GetProcAddress。def fix_imports(base, data): e_lfanew struct.unpack_from(I, data, 0x3C)[0] opt e_lfanew 24 magic struct.unpack_from(H, data, opt)[0] dd_off opt (96 if magic 0x10B else 112) imp_rva, imp_size struct.unpack_from(II, data, dd_off 1*8) if imp_rva 0: return desc imp_rva while True: oft, name_rva, first_thunk struct.unpack_from(III, data, desc) if name_rva 0: break dll_name data[name_rva:data.index(b\x00, name_rva)].decode() hmod ctypes.windll.kernel32.LoadLibraryA(dll_name.encode()) thunk oft if oft else first_thunk i 0 while True: if magic 0x10B: val struct.unpack_from(I, data, thunk i*4)[0] if val 0: break if val 0x80000000: func ctypes.windll.kernel32.GetProcAddress(hmod, val 0xFFFF) else: fn data[val2:data.index(b\x00, val2)].decode() func ctypes.windll.kernel32.GetProcAddress(hmod, fn.encode()) ctypes.c_uint32.from_address(base first_thunk i*4).value func i 1 else: val struct.unpack_from(Q, data, thunk i*8)[0] if val 0: break if val 0x8000000000000000: func ctypes.windll.kernel32.GetProcAddress(hmod, val 0xFFFF) else: fn data[val2:data.index(b\x00, val2)].decode() func ctypes.windll.kernel32.GetProcAddress(hmod, fn.encode()) ctypes.c_uint64.from_address(base first_thunk i*8).value func i 1 desc 20关键点OriginalFirstThunk为 0 时要用FirstThunk当名字表。序号导入的最高位是 1取低 16 位当序号。IAT 写入的地址是base first_thunk不是文件偏移。这一步错了程序会在第一次调用 API 时跳到垃圾地址。2.5 跳转执行与 TLS 回调映射、重定位、导入都修好后入口点是base AddressOfEntryPoint。但 exe 可能有 TLS 回调必须在入口点之前执行否则某些运行时初始化会失败。def run_image(base, data): e_lfanew struct.unpack_from(I, data, 0x3C)[0] opt e_lfanew 24 entry struct.unpack_from(I, data, opt 16)[0] # 先跑 TLS 回调略按数据目录第 9 项遍历 func ctypes.CFUNCTYPE(ctypes.c_int)(base entry) func()CFUNCTYPE把地址转成可调用对象。注意调用约定exe 入口通常是__stdcall或系统默认Python 的 CFUNCTYPE 默认 cdecl简单靶子能跑复杂程序要换 WINFUNCTYPE。跑完不返回是正常的因为 exe 入口不会 ret 到你的 Python 里这也是为什么很多人说“一调用就卡死”——其实是控制权交出去了。3. 从 Python 到 exe把加载器本身也做成可执行文件3.1 用 PyInstaller 打包加载器的参数取舍你写好的内存加载器如果以 .py 形式跑目标机器上没 Python 就废了。常见做法是 PyInstaller 打包成单文件 exe。参数上--onefile体积大、启动慢但分发方便--onedir启动快适合调试。pyinstaller --onefile --noconsole --name loader ^ --hidden-import ctypes ^ loader.py--noconsole去掉黑框但调试阶段别加否则你看不到任何报错。--hidden-import在用到动态导入时补ctypes 一般能自动收。打包后如果报“failed to execute script”先换--onedir看控制台输出十有八九是某个模块没打进去。3.2 加载器读取目标 exe 的三种方式第一种是内嵌字节把目标 exe 用 base64 或直接 bytes 写进 loader.py适合小文件。第二种是读同目录文件简单但目标 exe 还是落盘了。第三种是从网络或资源段读取不落盘但复杂度高。import base64 # 内嵌方式把 target.exe 转成 base64 字符串 TARGET_B64 TVqQAAMAAAAEAAAA... data base64.b64decode(TARGET_B64) base, size, image_base map_image(data) delta base - image_base apply_relocations(base, data, image_base, delta) fix_imports(base, data) run_image(base, data)内嵌方式要注意 Python 字符串长度限制和内存占用。一个 5MB 的 exe 转 base64 后约 6.7MB源码文件会很大编辑器可能卡。我一般超过 2MB 就改用资源段或外部读取。3.3 32 位与 64 位加载器的匹配问题这是血泪经验32 位 Python 只能加载 32 位 exe64 位同理。因为 VirtualAlloc 返回的地址空间和指针宽度不同。你用一个 64 位打包的 loader 去加载 32 位目标映射阶段可能不报错但一跑入口点就崩因为 32 位代码里的指针被当成 64 位解释。判断方法很简单看目标 exe 的 PE 头 magic0x10B 是 32 位0x20B 是 64 位。loader 本身也要用对应位数的 Python 打包。很多人卡在这里以为是重定位写错了其实是位数不匹配。3.4 加载后内存属性的收尾调试跑通后把 RWX 改成 RX 是基本操作。但要注意有些节本身需要写比如 .data不能一刀切。def protect_sections(base, data): size_of_image, image_base, sections parse_pe_sections(data) for name, vaddr, vsize, raw_ptr, raw_size in sections: if name in (.data, .bss): prot 0x04 # PAGE_READWRITE else: prot 0x20 # PAGE_EXECUTE_READ old ctypes.c_ulong(0) ctypes.windll.kernel32.VirtualProtect( base vaddr, vsize, prot, ctypes.byref(old))0x04是读写0x20是执行读。按节名判断是简化做法严谨做法是按节 Characteristics 字段的 IMAGE_SCN_MEM_WRITE 和 IMAGE_SCN_MEM_EXECUTE 位来判断。收尾没做好程序可能在某些 API 写入自己代码段时崩掉。4. 避坑与排查内存加载 exe 最常见的 5 个翻车点4.1 现象映射后一执行就 0xC0000005没有任何输出原因重定位没做或做错。手动映射拿到的基址几乎不可能等于 PE 里的首选基址所有绝对地址都偏了。解决在跳转前打印base和image_base确认 delta 不为 0 时重定位表被正确遍历。64 位下检查重定位类型是不是 10别用 3。4.2 现象程序能跑但一调用 MessageBox 就崩原因导入表没修IAT 里还是文件偏移或 RVA不是真实函数地址。解决在fix_imports里打印每个 DLL 名和函数名确认 LoadLibrary 和 GetProcAddress 都返回非零。序号导入要取低 16 位别把最高位当函数名。4.3 现象加载器本身被杀软秒删原因VirtualAlloc 用 RWX、手动映射 PE、跳转执行这三个行为组合起来就是恶意软件特征。解决调试阶段接受被删稳定后改 RX、分页设置权限、避免在入口点前做可疑 API 调用。如果只是做研究在隔离环境里跑别在主力机上反复试。4.4 现象目标 exe 依赖的 DLL 找不到原因手动映射不会自动处理依赖链。目标 exe 导入的 DLL 如果不在系统目录或当前目录LoadLibrary 会失败。解决在fix_imports前把目标 exe 所在目录加入 DLL 搜索路径或者提前 LoadLibrary 所有依赖。注意顺序依赖的依赖也要先加载。4.5 现象32 位 loader 加载 64 位 exe映射成功但入口点崩原因位数不匹配指针宽度和调用约定都错。解决用struct.unpack_from(H, data, e_lfanew24)读 magic0x10B 配 32 位 Python0x20B 配 64 位 Python。打包时也要对应别用 64 位 PyInstaller 打 32 位目标。5. 进阶用内存加载做行为验证与一个实用技巧跑通最小加载器之后真正有价值的是用它做行为验证。比如你拿到一个可疑 exe不想让它落盘执行可以在加载前 dump 出它的导入表、节表、重定位信息甚至 hook 几个关键 API 看它想干什么。这比直接双击安全得多也比静态分析能看到更多运行时行为。一个具体技巧在fix_imports里把每个 GetProcAddress 的结果记下来输出成表。你就能在不执行目标的情况下知道它调用了哪些敏感 API。监控点输出内容用途LoadLibraryADLL 名称判断依赖和可疑模块GetProcAddress函数名 地址识别敏感 API 调用VirtualAlloc大小 权限发现自修改或 shellcodeWriteProcessMemory目标进程 地址判断是否有注入行为另一个技巧是延迟执行映射完先不跳入口点用CreateRemoteThread或直接改入口点前几个字节插一个断点等确认环境干净再放行。这在分析“exe 卡密破密”类样本时特别有用因为很多保护会在入口点前做反调试检查。我自己踩得最狠的一次是重定位表处理完了导入表也修了但忘了 TLS 回调。目标程序是个带 C 运行时的 exeTLS 里初始化了全局对象结果一跑就崩在某个虚函数调用上。查了两天才发现是 TLS 没执行。从那以后我养成了一个习惯任何手动映射的 exe先看数据目录第 9 项是不是非零非零就先处理 TLS再跳入口。这个顺序不能反。如果你只是想做软件保护验证内存加载 exe 值得投入如果是为了绕过某些限制先想清楚边界别在主力环境里反复试。希望帮到你。本文还有配套的精品资源点击获取