ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Windows PE内存加载原理与可控实践

Windows PE内存加载原理与可控实践

简介:本资源是一份面向Windows安全研究者、逆向工程师与系统编程学习者的RunPE(进程空心化)技术实践代码,聚焦于内存中动态加载并执行EXE文件的核心能力,解决传统磁盘落地执行易被检测的问题。压缩包为ZIP格式,仅含1个关键文件——C++源码RunPE.cpp,完整实现了承载进程选择、PE映像解析、内存段替换、入口点重定向及线程恢复等全部RunPE核心步骤,代码精炼(2KB),便于逐行分析与调试。目前已有1211人学习下载,适合具备PE结构、Windows API及进程内存管理基础的中高级开发者深入理解恶意软件隐蔽执行机制或构建安全检测原型。读者可直接编译运行,观察目标EXE在宿主进程内存中无文件落地的执行全过程,并基于此拓展对抗分析、行为监控或沙箱逃逸研究。

1. 进程空中技术(在内存中加载exe):不是“无文件攻击”的代名词,而是可控的PE内存映射实践

很多人第一次听说“进程空中技术”,脑子里立刻蹦出红蓝对抗、免杀绕过、无文件落地这些词——但这是个严重误解。所谓“在内存中加载exe”,本质是 Windows PE 文件格式解析 + 内存布局模拟 + 系统调用级重定位的一整套可控工程实践,和恶意行为没有必然关系。它真正解决的是三类硬需求:一是嵌入式/工控场景下禁止写盘的受限环境(如只读固件分区);二是热更新服务模块时避免进程重启导致的业务中断;三是某高校实验室做二进制分析教学时,需要让学生直观观察 PE 加载全过程,而非依赖调试器黑匣子。这份资源不是“一键免杀工具包”,而是一套完整可审计、可单步调试、带符号表支持的 PE 内存加载器源码+配套验证用例。它基于 Windows 原生 API 实现,不依赖任何第三方注入框架,所有重定位、IAT 构建、TLS 回调执行都手动展开,适合想吃透 Windows 加载机制的开发者,也适合需要合规嵌入第三方闭源模块(如某图像处理DLL封装的exe逻辑)的工程团队。


2. PE内存加载的核心原理:为什么不能直接 memcpy 到内存就 call?

2.1 PE文件结构与内存镜像的差异不是“格式不同”,而是“布局契约失效”

Windows 加载器(ntdll!LdrpLoadDll)在磁盘PE加载到内存时,并非简单复制字节流。它严格遵循 IMAGE_OPTIONAL_HEADER 中的ImageBase、SizeOfImage、SectionAlignment等字段,完成四层关键操作:

  • 基址重定位(Relocation):当目标内存地址与ImageBase冲突时,需遍历.reloc节修正所有绝对地址引用(如call 0x401000→call 0x7ff8201000);
  • 节区对齐映射(Section Mapping):磁盘上.text节可能仅占 0x300 字节,但内存中按SectionAlignment=0x1000对齐后实际占用 0x1000 字节,且属性(READ|EXEC)由Characteristics字段控制;
  • 导入表(IAT)动态解析:IMAGE_IMPORT_DESCRIPTOR指向的函数名字符串需逐个GetProcAddress,填入 IAT 表对应槽位;
  • TLS 回调执行:若存在.tls节,需按IMAGE_TLS_DIRECTORY中AddressOfCallBacks指针顺序调用每个 TLS 回调函数。

提示:本资源中的PeLoader::LoadImageInMemory()函数,正是将上述四步拆解为独立可调试的子过程。它不调用LoadLibrary,所有系统调用均显式使用VirtualAlloc/WriteProcessMemory(当前进程) /GetProcAddress,便于你在OutputDebugStringA插桩观察每一步状态。

2.2 为什么必须手动处理重定位?一个真实翻车案例

某开发者曾尝试跳过重定位步骤,仅将 PE 复制到VirtualAlloc(..., PAGE_EXECUTE_READWRITE)分配的内存并jmp入口点,结果在 Windows 10 1904+ 系统上立即触发STATUS_ACCESS_VIOLATION。原因在于:现代 PE 默认启用/DYNAMICBASE,其.reloc节包含大量IMAGE_REL_BASED_DIR64类型重定位项(x64 下占 8 字节)。若忽略此节,所有mov rax, [0x405000]类指令都会访问非法地址——因为.data节在内存中被映射到了0x7ff8205000,而非磁盘偏移0x405000。

本资源提供PeLoader::ApplyRelocations()完整实现,核心逻辑如下:

// 重定位表遍历(简化版,实际含类型判断与地址计算) void PeLoader::ApplyRelocations(BYTE* imageBase, DWORD_PTR delta) { PIMAGE_BASE_RELOCATION reloc = GetFirstRelocDir(imageBase); while (reloc->VirtualAddress != 0) { WORD* relocEntry = (WORD*)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION)); int count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); for (int i = 0; i < count; ++i) { WORD typeOffset = relocEntry[i]; BYTE type = typeOffset >> 12; DWORD offset = (typeOffset & 0x0FFF) + reloc->VirtualAddress; if (type == IMAGE_REL_BASED_DIR64) { // x64 关键类型 ULONGLONG* addr = (ULONGLONG*)(imageBase + offset); *addr += delta; // delta = 实际加载地址 - ImageBase } } reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock); } }
  • delta是核心参数:表示“你把这玩意儿实际放哪了”和“它以为自己该在哪”的差值。例如ImageBase=0x400000,你VirtualAlloc到0x7ff8200000,则delta = 0x7ff8200000 - 0x400000;
  • offset是相对于imageBase的偏移,不是文件偏移,必须用imageBase + offset计算真实内存地址;
  • type == IMAGE_REL_BASED_DIR64必须单独处理,因其修改的是 8 字节地址,而WORD类型重定位项只占 2 字节。

2.3 IAT 构建:不是“填函数指针”,而是模拟 Windows 的符号解析链

PE 的导入表(.idata节)本质是一张“待办清单”:它记录了要从哪些 DLL(如kernel32.dll)加载哪些函数(如CreateFileA),但不存储真实地址。Windows 加载器会:

  1. 遍历IMAGE_IMPORT_DESCRIPTOR数组,对每个 DLL 调用LoadLibraryA;
  2. 对每个OriginalFirstThunk(INT)指向的IMAGE_THUNK_DATA数组,调用GetProcAddress获取函数地址;
  3. 将地址写入FirstThunk(IAT)对应位置。

本资源PeLoader::BuildImportTable()严格复现此流程,关键点在于:

  • 使用GetModuleHandleA(NULL)获取当前进程ntdll句柄,避免重复LoadLibrary导致句柄泄漏;
  • 对IMAGE_THUNK_DATA::u1.Function为 0 的情况(即序号导入),调用GetProcAddress(hMod, MAKEINTRESOURCEA(ordinal));
  • 显式检查GetProcAddress返回NULL并记录失败函数名,便于调试缺失依赖。
// IAT 构建核心片段(错误处理已简化) bool PeLoader::BuildImportTable(BYTE* imageBase) { PIMAGE_NT_HEADERS ntHdr = GetNtHeaders(imageBase); PIMAGE_IMPORT_DESCRIPTOR importDesc = (PIMAGE_IMPORT_DESCRIPTOR)(imageBase + ntHdr->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress); for (; importDesc->Name != 0; importDesc++) { LPCSTR dllName = (LPCSTR)(imageBase + importDesc->Name); HMODULE hMod = GetModuleHandleA(dllName); if (!hMod) hMod = LoadLibraryA(dllName); // 兜底加载 PIMAGE_THUNK_DATA intThunk = (PIMAGE_THUNK_DATA)(imageBase + importDesc->OriginalFirstThunk); PIMAGE_THUNK_DATA iatThunk = (PIMAGE_THUNK_DATA)(imageBase + importDesc->FirstThunk); while (intThunk->u1.AddressOfData != 0) { if (IMAGE_SNAP_BY_ORDINAL(intThunk->u1.Ordinal)) { iatThunk->u1.Function = (DWORD_PTR)GetProcAddress(hMod, MAKEINTRESOURCEA(IMAGE_ORDINAL(intThunk->u1.Ordinal))); } else { PIMAGE_IMPORT_BY_NAME nameRef = (PIMAGE_IMPORT_BY_NAME)(imageBase + intThunk->u1.AddressOfData); iatThunk->u1.Function = (DWORD_PTR)GetProcAddress(hMod, nameRef->Name); } intThunk++; iatThunk++; } } return true; }
  • OriginalFirstThunk(INT)指向函数名/序号,只读,用于调试和二次加载;
  • FirstThunk(IAT)是运行时可写区域,存放真实函数地址,CPU 执行时直接查此表;
  • IMAGE_SNAP_BY_ORDINAL宏用于判断是否为序号导入(高位 bit 为 1),避免nameRef->Name解引用崩溃。

3. 实战:三步完成一个合法exe的内存加载与执行

3.1 准备验证用例:编译一个无依赖、带调试信息的测试exe

为避免环境干扰,我们不拿任意第三方 exe 测试。本资源附带test_loader.exe源码(C++ 控制台程序),其编译要求明确:

  • /SUBSYSTEM:CONSOLE(非 GUI,避免消息循环阻塞);
  • /DYNAMICBASE(强制生成.reloc节,验证重定位);
  • /DEBUG(生成.pdb,便于后续符号调试);
  • 禁用/LARGEADDRESSAWARE(避免在 64 位系统上因地址空间问题导致加载失败);
  • 不链接msvcrt.dll,改用静态链接/MT(消除 CRT 初始化依赖,降低加载复杂度)。

编译命令(VS2019 x64 工具集):

cl /c /Zi /MT /GS- /Gy /EHsc /W4 /WX test_loader.cpp link /OUT:test_loader.exe /SUBSYSTEM:CONSOLE /DYNAMICBASE /DEBUG /ENTRY:mainCRTStartup /NODEFAULTLIB test_loader.obj kernel32.lib user32.lib

注意:/GS-禁用缓冲区安全检查,/Gy启用函数级链接,确保生成紧凑的.text节;/ENTRY:mainCRTStartup指定入口点,绕过 CRT 初始化,让main()成为第一个被调用的 C 函数。

3.2 加载器调用:四行代码启动内存中exe

PeLoader类设计为 RAII 模式,构造时加载,析构时自动清理。主流程仅需四步:

#include "PeLoader.h" #include <iostream> int main() { // 1. 读取PE文件到内存(原始字节流) std::vector<BYTE> peData = ReadFileToVector(L"test_loader.exe"); // 2. 创建加载器实例(自动检测架构、校验PE头) PeLoader loader(peData.data(), peData.size()); // 3. 执行加载(含重定位、IAT、TLS) if (!loader.Load()) { std::wcerr << L"加载失败: " << loader.GetLastError() << std::endl; return -1; } // 4. 获取入口点并调用(等同于 Windows 加载器 jmp OEP) typedef int(__cdecl *EntryPoint)(); EntryPoint oep = (EntryPoint)loader.GetEntryPoint(); int result = oep(); // 此处执行 test_loader.exe 的 main() std::wcout << L"test_loader.exe 执行返回: " << result << std::endl; return 0; }
  • ReadFileToVector()是跨平台文件读取辅助函数,内部使用CreateFileMappingW+MapViewOfFile避免大文件new[]内存碎片;
  • PeLoader构造函数内完成IMAGE_DOS_HEADER/IMAGE_NT_HEADERS校验、节区数量检查、SizeOfImage边界验证,任一失败抛出异常;
  • loader.Load()是核心,依次调用AllocateMemoryForImage()→CopySections()→ApplyRelocations()→BuildImportTable()→ExecuteTLS();
  • loader.GetEntryPoint()返回AddressOfEntryPoint + ImageBase计算后的绝对地址,类型强转为函数指针即可调用。

3.3 验证执行结果:不只是“跑起来”,而是确认内存布局正确

仅看到test_loader.exe控制台输出 “Hello from memory!” 不够。我们必须验证:

  • 加载后的.text节是否具有PAGE_EXECUTE_READ属性?
  • .data节是否可写且内容与原始 PE 一致?
  • IAT 中printf地址是否真实指向msvcrt.dll的导出函数?

本资源提供PeLoader::DumpMemoryLayout()函数,输出关键内存信息:

// 调用示例 loader.DumpMemoryLayout(); // 输出示例: // [+] ImageBase: 0x00007FF6A1200000 // [+] .text: 0x00007FF6A1201000 - 0x00007FF6A1203000 (RWX) // [+] .data: 0x00007FF6A1204000 - 0x00007FF6A1205000 (RW-) // [+] IAT @ 0x00007FF6A1204120: printf=0x00007FFB2E3F1234
  • RWX表示PAGE_EXECUTE_READWRITE,符合.text节加载后需执行的属性;
  • RW-表示PAGE_READWRITE,.data节无需执行权限;
  • IAT 地址0x00007FFB2E3F1234可用x64dbg附加验证,确为msvcrt.printf真实地址。

提示:若DumpMemoryLayout()显示.text权限为PAGE_READONLY,说明VirtualProtect调用失败,需检查SE_DEBUG_PRIVILEGE是否启用(本资源默认不启用,仅用GetCurrentProcess())。


4. 避坑指南:五个血泪经验换来的常见问题排查清单

4.1 现象:加载后立即崩溃,调试器显示Access violation executing location 0x0000000000000000

原因:IAT 构建失败,某个函数地址未填入,导致call [IAT_slot]时跳转到空指针。常见于GetProcAddress返回NULL但代码未检查(如user32.dll在控制台程序中未显式加载)。
解决:在BuildImportTable()中,对每个GetProcAddress调用后添加断言:

FARPROC proc = GetProcAddress(hMod, nameRef->Name); if (!proc) { wchar_t err[256]; swprintf_s(err, L"GetProcAddress failed for %S in %S", nameRef->Name, dllName); OutputDebugStringW(err); // 此处可 throw 或返回 false }

4.2 现象:加载成功,但test_loader.exe的printf输出乱码或不显示

原因:test_loader.exe链接了msvcrt.dll,但加载器未将其加入GetModuleHandle查找链,导致 IAT 填入了错误地址;或stdout句柄未正确继承(控制台程序需AttachConsole(ATTACH_PARENT_PROCESS))。
解决:在BuildImportTable()前,显式加载msvcrt.dll并缓存句柄:

static HMODULE g_hMsvcrt = LoadLibraryA("msvcrt.dll"); // 全局缓存,避免多次加载 // ... 在 IAT 构建循环中,遇到 "msvcrt.dll" 时直接使用 g_hMsvcrt

并在test_loader.exe的main()开头添加:

if (!AttachConsole(ATTACH_PARENT_PROCESS)) { AllocConsole(); // 确保有控制台 } freopen("CONOUT$", "w", stdout); // 重定向 stdout 到控制台

4.3 现象:在 Windows 11 上加载失败,GetLastError()返回ERROR_INVALID_PARAMETER

原因:Windows 11 对VirtualAlloc的lpAddress参数更严格。若指定lpAddress为NULL(让系统选择地址),但SizeOfImage超过 2GB,某些版本会拒绝分配。
解决:PeLoader::AllocateMemoryForImage()中,放弃指定lpAddress,改用MEM_COMMIT | MEM_RESERVE并接受系统分配:

m_imageBase = VirtualAlloc(NULL, m_ntHdr->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!m_imageBase) { // 回退:尝试分块分配(.text/.rdata/.data 分别 VirtualAlloc) }

4.4 现象:加载后 TLS 回调未执行,全局对象构造函数未调用

原因:PE 的.tls节中IMAGE_TLS_DIRECTORY的AddressOfCallBacks字段为NULL,或回调函数数组未以NULL结尾。Windows 加载器要求回调数组以NULL指针结束。
解决:在ExecuteTLS()中,必须校验回调数组:

PVOID* tlsCallbacks = (PVOID*)RVA2VA(m_imageBase, tlsDir->AddressOfCallBacks); if (tlsCallbacks && *tlsCallbacks != nullptr) { // 确保首项非空 for (int i = 0; tlsCallbacks[i] != nullptr; ++i) { typedef VOID (NTAPI *PTLS_CALLBACK)(PVOID, DWORD, PVOID); PTLS_CALLBACK cb = (PTLS_CALLBACK)tlsCallbacks[i]; cb(m_imageBase, DLL_PROCESS_ATTACH, nullptr); } }

4.5 现象:test_loader.exe执行完毕,但PeLoader析构时VirtualFree报ERROR_INVALID_HANDLE

原因:VirtualFree的lpAddress必须与VirtualAlloc返回的地址完全一致。若加载过程中因重定位失败而重新分配内存,但m_imageBase未同步更新,会导致释放错误地址。
解决:PeLoader类中m_imageBase为mutable成员,所有内存分配操作(AllocateMemoryForImage,ReallocateForRelocation)必须原子更新该字段,并在析构函数中仅释放m_imageBase:

PeLoader::~PeLoader() { if (m_imageBase) { VirtualFree(m_imageBase, 0, MEM_RELEASE); // lpAddress 必须原值 m_imageBase = nullptr; } }

5. 进阶技巧:如何让内存加载的exe支持调试与符号匹配?

5.1 符号加载:不是“让WinDbg认出exe”,而是重建PDB路径映射

Visual Studio 和 WinDbg 调试内存加载的模块,关键不在“能不能”,而在“怎么告诉调试器这个内存块对应哪个PDB”。Windows 调试接口IDebugClient的AddSymbolPath并不适用——因为模块未注册到LdrpHashTable。正确做法是:在加载完成后,手动调用SymLoadModuleExW注册符号。

本资源提供PeLoader::RegisterSymbolsForDebugger()函数,其核心是三步:

  1. 从原始 PE 文件中提取IMAGE_DEBUG_DIRECTORY,定位.pdb路径字符串(位于IMAGE_DEBUG_TYPE_CODEVIEW条目中);
  2. 构造完整 PDB 路径(如C:\dev\test_loader.pdb);
  3. 调用SymLoadModuleExW,传入m_imageBase、SizeOfImage、模块名、PDB 路径。
bool PeLoader::RegisterSymbolsForDebugger(LPCWSTR pdbPath) { HANDLE hProcess = GetCurrentProcess(); if (!SymInitializeW(hProcess, NULL, TRUE)) return false; // 获取原始PE中CodeView调试目录 PIMAGE_DEBUG_DIRECTORY dbgDir = FindDebugDirectory(m_peData.data(), IMAGE_DEBUG_TYPE_CODEVIEW); if (!dbgDir) return false; // 解析.pdb路径(CV signature + age + filename) BYTE* cvData = m_peData.data() + dbgDir->AddressOfRawData; DWORD sig = *(DWORD*)cvData; // CV signature if (sig != 0x53445352) return false; // "RSDS" WCHAR pdbName[MAX_PATH]; wcscpy_s(pdbName, (WCHAR*)(cvData + 24)); // offset 24 is filename // 拼接完整路径 WCHAR fullPdbPath[MAX_PATH]; wcscpy_s(fullPdbPath, pdbPath); PathAppendW(fullPdbPath, pdbName); // 注册符号 DWORD64 baseAddr = SymLoadModuleExW(hProcess, NULL, fullPdbPath, NULL, (DWORD64)m_imageBase, m_ntHdr->OptionalHeader.SizeOfImage, NULL, 0); return baseAddr != 0; }
  • SymInitializeW必须在加载前调用一次,初始化符号引擎;
  • SymLoadModuleExW的第五个参数(DWORD64)m_imageBase是关键,它告诉调试器“这段内存就是test_loader.exe”;
  • fullPdbPath必须指向真实存在的.pdb文件,否则符号加载失败但无报错。

5.2 调试实战:在 Visual Studio 中设置断点的完整流程

  1. 编译test_loader.exe时开启/DEBUG,确保生成.pdb;
  2. 在PeLoader调用loader.Load()后、oep()前,插入__debugbreak():
    loader.Load(); __debugbreak(); // 触发 VS 自动附加 EntryPoint oep = (EntryPoint)loader.GetEntryPoint(); oep();
  3. 启动 VS,打开PeLoader项目,按 F5 运行;
  4. 当__debugbreak()触发时,VS 会暂停。此时:
    • 打开调试 → 窗口 → 模块,确认test_loader.exe已列出,且“符号状态”为“已加载”;
    • 打开调试 → 窗口 → 调用堆栈,右键选择“切换到反汇编”,可见oep地址在0x00007FF...范围;
    • 在test_loader.cpp中任意行按 F9 设断点,VS 会自动映射到内存地址。

注意:若断点显示“将不会命中”,说明 PDB 路径错误或SymLoadModuleExW未成功。此时打开调试 → 窗口 → 符号,检查“符号文件路径”是否包含.pdb所在目录。

5.3 内存保护加固:防止被其他进程读取敏感逻辑

内存加载的模块常含密钥、算法等敏感数据。VirtualProtect仅能防用户态读取,无法阻止内核驱动(如 Hypervisor)访问。本资源提供PeLoader::LockMemoryPages()接口,调用VirtualLock锁定物理页,防止被换出到页面文件(Pagefile.sys):

bool PeLoader::LockMemoryPages() { // 锁定整个映像(包括 .text/.data/.rdata) SIZE_T size = m_ntHdr->OptionalHeader.SizeOfImage; if (!VirtualLock(m_imageBase, size)) { DWORD err = GetLastError(); // ERROR_NOT_ENOUGH_MEMORY 表示物理内存不足,可降级为加密内存 return false; } return true; }
  • VirtualLock成功后,该内存页永不写入 pagefile,即使系统内存紧张;
  • 但注意:锁定过多内存会导致系统性能下降,建议仅对.data节(含密钥)调用;
  • 配合CryptProtectMemory对.data节加密,双重防护。

从那以后我每次做内存加载验证,都强制走一遍DumpMemoryLayout()+RegisterSymbolsForDebugger()+LockMemoryPages()三连——不是为了炫技,而是确保每一块内存的权限、符号、保护状态都在掌控之中。这三步做完,你才真正“拥有”了那段在空中运行的代码,而不是赌它刚好没崩。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表