简介:这份资源围绕进程空中技术(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 然后 memcpy
PE 文件在磁盘上的布局和加载到内存后的布局不是一回事。磁盘上按 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_lfanew+4] == b"PE\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:base+8].rstrip(b"\x00").decode(errors="ignore") 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_ptr+raw_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_ptr+raw_size], raw_size) return base, size_of_image, image_base0x3000是 MEM_COMMIT | MEM_RESERVE,0x40是 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 位下重定位类型是 10(DIR64),要按 8 字节处理。这里只演示 32 位 HIGHLOW,64 位把类型判断和读写宽度改掉即可。漏掉重定位的典型现象是:程序能跑但一调用全局变量就崩,或者直接 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[val+2:data.index(b"\x00", val+2)].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[val+2:data.index(b"\x00", val+2)].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 位 exe,64 位同理。因为 VirtualAlloc 返回的地址空间和指针宽度不同。你用一个 64 位打包的 loader 去加载 32 位目标,映射阶段可能不报错,但一跑入口点就崩,因为 32 位代码里的指针被当成 64 位解释。
判断方法很简单:看目标 exe 的 PE 头 magic,0x10B 是 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_lfanew+24)读 magic,0x10B 配 32 位 Python,0x20B 配 64 位 Python。打包时也要对应,别用 64 位 PyInstaller 打 32 位目标。
5. 进阶:用内存加载做行为验证与一个实用技巧
跑通最小加载器之后,真正有价值的是用它做行为验证。比如你拿到一个可疑 exe,不想让它落盘执行,可以在加载前 dump 出它的导入表、节表、重定位信息,甚至 hook 几个关键 API 看它想干什么。这比直接双击安全得多,也比静态分析能看到更多运行时行为。
一个具体技巧:在fix_imports里把每个 GetProcAddress 的结果记下来,输出成表。你就能在不执行目标的情况下,知道它调用了哪些敏感 API。
| 监控点 | 输出内容 | 用途 |
|---|---|---|
| LoadLibraryA | DLL 名称 | 判断依赖和可疑模块 |
| GetProcAddress | 函数名 + 地址 | 识别敏感 API 调用 |
| VirtualAlloc | 大小 + 权限 | 发现自修改或 shellcode |
| WriteProcessMemory | 目标进程 + 地址 | 判断是否有注入行为 |
另一个技巧是延迟执行:映射完先不跳入口点,用CreateRemoteThread或直接改入口点前几个字节插一个断点,等确认环境干净再放行。这在分析“exe 卡密破密”类样本时特别有用,因为很多保护会在入口点前做反调试检查。
我自己踩得最狠的一次是:重定位表处理完了,导入表也修了,但忘了 TLS 回调。目标程序是个带 C++ 运行时的 exe,TLS 里初始化了全局对象,结果一跑就崩在某个虚函数调用上。查了两天才发现是 TLS 没执行。从那以后,我养成了一个习惯:任何手动映射的 exe,先看数据目录第 9 项是不是非零,非零就先处理 TLS,再跳入口。这个顺序不能反。
如果你只是想做软件保护验证,内存加载 exe 值得投入;如果是为了绕过某些限制,先想清楚边界,别在主力环境里反复试。希望帮到你。
本文还有配套的精品资源,点击获取