简介:面向Windows平台开发者的内存执行技术示例包,演示如何将EXE文件作为资源嵌入自身程序,并在运行时加载到内存直接执行,避免磁盘落地,常用于程序防护、启动加速及特殊场景下的防篡改需求。包内提供完整的Visual C++工程,包含C++源代码、头文件、资源脚本、工程配置与调试选项等10个文件,压缩包仅108KB,结构精简,便于直接打开编译和跟踪调试。程序实现涵盖资源定位、内存分配、节区映射、权限设置到线程与进程启动的完整链条,代码中对FindResource、VirtualAlloc、CreateSection、VirtualProtect、CreateThread等关键API的用法均有直观体现。已有2777人学习下载,适合对PE文件格式、进程创建原理和内存加载机制感兴趣的中高级开发者研究参考,也可作为系统级软件设计与反篡改方案的入门样例。
1. exe从资源加载到内存运行:不落盘先把程序跑起来
所谓“exe在内存中运行”,是指把目标程序作为二进制资源打包进另一个宿主程序里,运行时先从资源段读出来,再按 PE 格式手动映射到内存并跳转入口执行,整个过程中磁盘上不会出现第二个 exe 文件。很多做了多年开发的同行第一次听到会觉得是玄学,实际上它就是把 PE 加载器的原理自己实现了一遍,常用于安全分析、样本复现、绿色工具的加载场景。这个方案适合已经写过基础 Windows 程序、想深入 PE 结构的人,也适合需要在受控目录禁止写入的环境里运行工具的从业者。
2. 资源段嵌入 exe:先解决“文件从哪来”的问题
2.1 为什么用资源段而不是附赠文件
最简单的思路自然是:安装目录放一个 exe,宿主启动时把它读进来再运行。但实际项目里会遇到三个常见问题。第一,多文件发布容易丢失,用户把主程序复制走,漏掉旁边那个 exe,整个功能就没了。第二,很多受管环境会拦截子目录下的可执行文件创建,日志里全是权限拒绝。第三,如果你做的是一个自解压类工具,目标 exe 本身还可能被杀毒软件单独扫描,直接报毒。
把 exe 放进宿主程序的资源段,至少能规避前两个问题。PE 资源段是跟着主程序走的,用户拿到的是单个文件,不存在“旁边那个文件没带上”的场景;资源在内存里是一段只读数据,不需要创建额外文件。第三个问题只能说缓解,因为有些杀软会对“宿主进程释放内存代码”的行为做启发式检测,这个在第五章再展开。
2.2 用 rc 脚本把目标 exe 编入资源
最常见的做法是用 RCDATA 自定义资源类型,把目标 exe 原样塞进去。这里以 C++ 工程为例,先准备一个 resource.h 定义资源 ID,再写一个 .rc 脚本:
// resource.h #define IDR_TARGET_EXE 101// app.rc #include "resource.h" IDR_TARGET_EXE RCDATA "target.exe"这段脚本的意思是:把当前目录下的 target.exe 作为 RCDATA 类型资源、资源 ID 为 101 编入最终生成的 exe。参数上注意一点,文件名如果带空格或中文,建议用双引号包起来,路径用相对路径时,编译脚本的目录就是查找目录,我习惯在 rc 文件里写".\\bin\\target.exe"这类显式相对路径,避免在不同 IDE 配置下找不到文件。
2.3 用资源 API 把目标 exe 读成连续内存
资源编译进去之后,运行时就通过 FindResource、SizeofResource、LoadResource、LockResource 这一套 API 取出来。这里我一般不会直接返回 LockResource 的指针,而是复制一份到 malloc 出来的新缓冲里,因为资源句柄的生命周期和模块生命周期绑定,后续 PE 映射处理时还要反复读写这块数据,复制出来更安全。
#include <windows.h> #include <cstdlib> #include <cstring> // 从当前模块资源中读取 exe 数据,返回 malloc 分配的内存,大小由 outSize 返回 BYTE* LoadExeFromResource(WORD resId, DWORD* outSize) { HRSRC hRes = FindResource(NULL, MAKEINTRESOURCE(resId), RT_RCDATA); if (hRes == NULL) return NULL; DWORD size = SizeofResource(NULL, hRes); if (size == 0) return NULL; HGLOBAL hGlobal = LoadResource(NULL, hRes); if (hGlobal == NULL) return NULL; const BYTE* pRes = (const BYTE*)LockResource(hGlobal); if (pRes == NULL) return NULL; BYTE* pCopy = (BYTE*)malloc(size); if (pCopy != NULL) { memcpy(pCopy, pRes, size); *outSize = size; } return pCopy; }几个参数值得说清楚:FindResource 的第二个参数 MAKEINTRESOURCE(resId) 是资源 ID,第三个参数 RT_RCDATA 代表自定义二进制资源;第一个参数传 NULL 表示从当前 exe 模块取资源,如果你把这段代码放进 DLL,就要传 DLL 的模块句柄。SizeofResource 返回的是字节数,但某些加壳工具会篡改这个值,所以后面解析 PE 头时一定要校验数据开头是不是 “MZ” 两个字符,如果校验失败,多半是资源被压缩或者资源类型选错了。
2.4 读出来的数据还不能直接执行
拿到这段资源数据后,直接把它当函数指针跳转是不行的。磁盘上的 exe 文件是“文件对齐”状态,节区数据按 FileAlignment 存放,而进程加载器要求的是“内存对齐”状态,节区按 SectionAlignment 对齐,两者经常不一致。另外,exe 里的地址是按它自己声明的 ImageBase 计算的,运行时它不一定能落在那个地址上;导入表也还是空壳,系统加载器没有帮它填好函数地址。这些工作都要我们自己完成,这就是第三章和第四章的核心内容。
提示:在调试阶段,可以先在 LoadExeFromResource 里加一个临时文件写出操作,把读到的资源数据保存成 target_dump.exe,然后用 PE 查看工具比对原始文件,确认资源嵌入步骤没问题,再继续往下写映射逻辑。
3. 手动映射 PE:把“文件态”变成“内存态”
3.1 为什么要自己搭一套加载逻辑
系统加载器在 CreateProcess 时做了三件核心事:分配大小为 SizeOfImage 的虚拟内存、按节表把文件数据复制到对应虚拟地址、解析重定位表和导入表。我们要做的就是从资源数据里重建这个过程。如果只是简单地调 VirtualAlloc 然后把整个文件一次性复制过去,遇到节区 VirtualAddress 和文件偏移对不齐的程序,入口代码一执行就崩,因为跨节区的数据引用全部错位。
正确顺序是:读 DOS 头定位 NT 头,拿 SizeOfImage 分配内存,先复制头部区域,再逐个节区把原始数据搬运到内存虚拟地址,最后做重定位和导入表修复。
3.2 解析 MZ 头和节表,复制节区数据
先实现一个最基础的映射函数,只做“分配内存 + 复制头 + 复制节区”。这里为方便演示,直接使用 IMAGE_NT_HEADERS,它会在 32/64 位编译环境下自动选择对应结构体;如果你的加载器需要跨位数加载,就要手动判断 OptionalHeader.Magic 再选择 IMAGE_NT_HEADERS32 或 IMAGE_NT_HEADERS64。
BYTE* MapPeToMemory(BYTE* pFileData, DWORD fileSize) { // 1. 校验 DOS 头 IMAGE_DOS_HEADER* pDos = (IMAGE_DOS_HEADER*)pFileData; if (pDos->e_magic != IMAGE_DOS_SIGNATURE) // 'MZ' return NULL; // 2. 校验 NT 头 IMAGE_NT_HEADERS* pNt = (IMAGE_NT_HEADERS*)(pFileData + pDos->e_lfanew); if (pNt->Signature != IMAGE_NT_SIGNATURE) // 'PE' return NULL; DWORD sizeOfImage = pNt->OptionalHeader.SizeOfImage; DWORD sizeOfHeaders = pNt->OptionalHeader.SizeOfHeaders; // 3. 分配可读可写可执行的内存,简化权限管理 BYTE* pImage = (BYTE*)VirtualAlloc(NULL, sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pImage == NULL) return NULL; // 4. 复制文件头到内存头 memcpy(pImage, pFileData, sizeOfHeaders); // 5. 遍历节表,按内存虚拟地址复制节数据 IMAGE_SECTION_HEADER* pSec = IMAGE_FIRST_SECTION(pNt); for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) { DWORD rawAddr = pSec[i].PointerToRawData; DWORD rawSize = pSec[i].SizeOfRawData; DWORD virtAddr = pSec[i].VirtualAddress; DWORD virtSize = pSec[i].Misc.VirtualSize; // 文件中的原始数据可能比内存中的 VirtualSize 小 DWORD copySize = (rawSize < virtSize) ? rawSize : virtSize; memcpy(pImage + virtAddr, pFileData + rawAddr, copySize); } return pImage; }这段代码里最容易理解错的是 rawSize 和 virtSize 的关系。SizeOfRawData 是文件里对齐后占用的字节数,Misc.VirtualSize 是节区在内存中实际需要的大小,两者并不一定相等。比如 .bss 节在文件里几乎不占空间,但 VirtualSize 可能很大,这时就不能按 rawSize 复制,否则未初始化数据区没有清零,后面程序跑起来会读到脏数据。这里为了篇幅没有做显式清零,实际做加载器时,应该在复制完 rawSize 后再把剩下的虚拟区间用 memset 清零。
3.3 节区权限:别急着全部给 RWX
上面代码用 PAGE_EXECUTE_READWRITE 分配整块镜像,是为了让 demo 先跑通。把这段代码用在正式项目里是不合格的,因为节区原本声明的权限被抹平了:代码节应该是可读可执行,数据节应该是可读可写,只读常量节不该有写权限。正确的做法是先按 RWX 分配,等复制完节区、修复完重定位和导入表之后,再按节区 Characteristics 逐节设置保护属性。
还有一个隐藏坑:VirtualAlloc 是以 64KB 为粒度对齐的,而 SizeOfImage 通常也是按 64KB 对齐的,所以正常分配没问题。但如果某个 PE 的 SizeOfImage 不是 SectionAlignment 的整数倍,VirtualAlloc 分配出的区域末尾可能会出现跨页重叠,这时候要做一次对齐修正。我一般会在分配前把 sizeOfImage 向上取整到 0x1000 的倍数,省得后面越界。
4. 重定位与导入表修复:让内存中的程序真正“有地址可用”
4.1 重定位:为什么运行时地址不等于 ImageBase
PE 文件编译时会假设自己加载在 ImageBase(通常是 0x00400000),代码里的绝对地址都是基于这个基址算出来的。但我们的 VirtualAlloc 并不保证给你这个地址,甚至在开启了 ASLR 的系统上,即使你强行指定固定地址也未必成功。当运行地址和 ImageBase 不一致时,所有写死的绝对地址就都错了。
重定位表里记录的不是“哪些地址需要改”,而是“哪些位置存放了指向绝对地址的指针”。处理逻辑是:拿到重定位表块,遍历每个条目,根据条目类型把对应位置的指针值加上基址差值。
void ApplyRelocations(BYTE* pImage) { IMAGE_DOS_HEADER* pDos = (IMAGE_DOS_HEADER*)pImage; IMAGE_NT_HEADERS* pNt = (IMAGE_NT_HEADERS*)(pImage + pDos->e_lfanew); DWORD_PTR baseDelta = (DWORD_PTR)pImage - (DWORD_PTR)pNt->OptionalHeader.ImageBase; if (baseDelta == 0) return; // 恰好加载在声明的 ImageBase,无需重定位 IMAGE_DATA_DIRECTORY* pReloc = &pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (pReloc->VirtualAddress == 0 || pReloc->Size == 0) return; DWORD offset = 0; while (offset < pReloc->Size) { IMAGE_BASE_RELOCATION* pBlock = (IMAGE_BASE_RELOCATION*)(pImage + pReloc->VirtualAddress + offset); WORD* pEntries = (WORD*)((BYTE*)pBlock + sizeof(IMAGE_BASE_RELOCATION)); DWORD count = (pBlock->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); for (DWORD i = 0; i < count; i++) { WORD type = pEntries[i] >> 12; WORD lowOffset = pEntries[i] & 0x0FFF; if (type == IMAGE_REL_BASED_HIGHLOW) { DWORD* pAddr = (DWORD*)(pImage + pBlock->VirtualAddress + lowOffset); *pAddr += (DWORD)baseDelta; } else if (type == IMAGE_REL_BASED_DIR64) { DWORD_PTR* pAddr = (DWORD_PTR*)(pImage + pBlock->VirtualAddress + lowOffset); *pAddr += baseDelta; } // IMAGE_REL_BASED_ABSOLUTE 是占位类型,直接跳过 } offset += pBlock->SizeOfBlock; } }这里要注意类型判断:x86 程序常见的是 HIGHLOW,x64 程序几乎都是 DIR64。如果你在 32 位加载器里加载 64位目标程序,不能把 IMAGE_NT_HEADERS 直接套在 64 位数据上,必须切到 IMAGE_NT_HEADERS64 解析,否则后面的数据目录位置全部偏移,重定位表读到的就是错误数据。
4.2 导入表修复:把 IAT 从“占位符”变成“真实地址”
资源里的 exe 在文件态时,导入表项已经存在,但里面的函数地址是编译器留的空位,只有在进程加载时才会被系统加载器填上。我们自己加载,就得手动解析导入描述符,对每个 DLL 调用 LoadLibraryA,再对每个导入函数调用 GetProcAddress,最后写回到 FirstThunk 指向的地址处。
void FixImports(BYTE* pImage) { IMAGE_DOS_HEADER* pDos = (IMAGE_DOS_HEADER*)pImage; IMAGE_NT_HEADERS* pNt = (IMAGE_NT_HEADERS*)(pImage + pDos->e_lfanew); IMAGE_DATA_DIRECTORY* pImp = &pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (pImp->VirtualAddress == 0 || pImp->Size == 0) return; IMAGE_IMPORT_DESCRIPTOR* pDesc = (IMAGE_IMPORT_DESCRIPTOR*)(pImage + pImp->VirtualAddress); for (; pDesc->Name != 0; pDesc++) { const char* dllName = (const char*)(pImage + pDesc->Name); HMODULE hDll = LoadLibraryA(dllName); if (hDll == NULL) continue; // 这个 DLL 没找到,一般说明目标程序依赖关系不全 // OriginalFirstThunk 保存的是导入名称/序号表 // FirstThunk 保存的是实际 IAT,两者在文件态内容相同,运行时要改后者 DWORD_PTR* pOrigThunk = pDesc->OriginalFirstThunk ? (DWORD_PTR*)(pImage + pDesc->OriginalFirstThunk) : (DWORD_PTR*)(pImage + pDesc->FirstThunk); DWORD_PTR* pIat = (DWORD_PTR*)(pImage + pDesc->FirstThunk); for (DWORD_PTR i = 0; pOrigThunk[i] != 0; i++) { FARPROC pFn = NULL; // 高位为 1 表示按序号导入 if (pOrigThunk[i] & IMAGE_ORDINAL_FLAG) { WORD ordinal = (WORD)(pOrigThunk[i] & 0xFFFF); pFn = GetProcAddress(hDll, (LPCSTR)ordinal); } else { IMAGE_IMPORT_BY_NAME* pName = (IMAGE_IMPORT_BY_NAME*)(pImage + pOrigThunk[i]); pFn = GetProcAddress(hDll, pName->Name); } if (pFn != NULL) pIat[i] = (DWORD_PTR)pFn; } } }这段代码的核心是区分 OriginalFirstThunk 和 FirstThunk。文件在磁盘上时两者指向同一片数据,但运行时前者是只读的描述信息,后者才是系统加载器写入实际函数地址的位置。很多第一次写加载器的人会把地址写回 OriginalFirstThunk,结果目标程序运行时读取 IAT 拿到的还是 0,直接触发访问违规。
4.3 从入口点把 exe 跑起来
映射、重定位、导入修复都完成之后,就可以调用入口点了。exe 的入口点不是 main 函数,而是 CRT 启动函数,它会初始化堆、全局对象,再调用 main。我们手动调用时,因为宿主环境的 CRT 已经初始化过,目标程序的初始化代码再跑一遍很容易出问题,所以这里要区分场景:如果目标程序只是调用 WinAPI 的工具,直接跳转入口基本能跑;如果它依赖 C++ 全局对象和标准库,就得做好心理准备,后面很可能在静态初始化阶段崩掉。
typedef DWORD (WINAPI *fnEntryPoint)(void); DWORD RunMappedExe(BYTE* pImage) { IMAGE_DOS_HEADER* pDos = (IMAGE_DOS_HEADER*)pImage; IMAGE_NT_HEADERS* pNt = (IMAGE_NT_HEADERS*)(pImage + pDos->e_lfanew); DWORD addrOfEntry = pNt->OptionalHeader.AddressOfEntryPoint; if (addrOfEntry == 0) return 0; fnEntryPoint pfn = (fnEntryPoint)(pImage + addrOfEntry); return pfn(); }实际项目中我会在调用入口前多做一个事:遍历 TLS 目录,如果有 TLS 回调函数,就先按数组顺序执行一遍,因为某些加壳工具会把自校验逻辑放在 TLS 回调里,跳过会导致程序启动后立刻退出或被反调试检测到。TLS 回调的机制不复杂,它本身就是一组函数指针,遍历调用即可,但很多加载器示例都忽略了它。
5. 避坑与排查:内存加载常见的五个翻车点
5.1 现象:入口点还没执行,进程直接崩
这是最常见的翻车场景,代码走到 RunMappedExe 那一步就 0xC0000005。原因通常是重定位没做或者重定位类型判断写错了。比如目标是 x64 程序,加载器却按 x86 的 HIGHLOW 类型去解析,指针只改了低 32 位,高 32 位保留原值,跳转地址直接变成垃圾地址。解决方法是先用 PE 查看工具确认目标程序的 Machine 字段和 OptionalHeader.Magic,再决定用哪一套结构体解析,并在 ApplyRelocations 里把 HIGHLOW 和 DIR64 两类都处理掉,不要只写一个。
5.2 现象:程序能起窗口,一调系统 API 就报无效地址
这种问题往往不是立即崩溃,而是点击某个按钮或执行某个功能时才崩,排查起来更耗时。原因基本是导入表修复时写错了位置:要么把地址写进了 OriginalFirstThunk,导致 IAT 还是全 0;要么只是给第一个描述符做了修复,后面的 DLL 描述符因为 Name 字段解析偏移出错而被跳过。我的排查习惯是,在 FixImports 的循环里加断点,每次 LoadLibraryA 之后看返回句柄是不是有效,再看 pIat[i] 写入的地址是否落在对应系统 DLL 的模块范围内,哪一项不对,就顺着哪个 DLL 名倒查解析偏移。
5.3 现象:声明需要管理员权限的程序跑起来后权限不对
直接加载运行 exe 不会触发 UAC 提权,因为进程还是宿主进程,令牌没有提升。目标程序如果用 requireAdministrator 编译,它内部调用某些需要管理员权限的 API 时就会失败,表现是程序还在运行但功能不可用。解决方式有两个方向:一是宿主进程启动时就带上管理员权限,让令牌满足要求;二是放弃加载这类程序,该落盘时还是通过系统机制拉起子进程,不要为了强行不落盘而无视系统安全边界。
5.4 现象:带 TLS 回调的程序没有执行初始化
有些程序和壳会把关键的初始化逻辑放在 TLS 回调里,尤其是加了反调试或虚拟机保护的样本。如果加载器只调用入口点,TLS 回调永远不会触发,程序可能在入口点开始处就检测到异常状态并退出。解决方式是在调用入口点前,先读取 TLS 目录的 AddressOfCallBacks,找到回调数组,逐个用函数指针方式调用。我见过某加固工具把自校验放到回调数组的第三个位置,跳过之后每次跑到固定偏移就退出,加上回调调用后问题消失。
5.5 现象:杀软把加载器整个当成风险工具处理
内存加载 exe 在行为上很像恶意软件常用的“无文件落地”技术,杀软查杀时不会区分你是做安全研究还是做恶意功能,启发式引擎只要看到 VirtualAlloc 分配 RWX 内存、写入 PE 数据、跳转执行这套组合,就很可能报警。这个坑没有纯技术解法,我一般的处理是分两步:先用 VirtualProtect 按节区收紧权限,避免整块内存长时间 RWX;再在正式项目中加数字签名和厂商白名单。如果是个人学习用途,建议在隔离环境里测试,别在主力开发机上跑一堆免杀思路,很容易把自己机器搞出误报。
提示:遇到“能运行但结果不对”的问题,优先怀疑重定位和导入表;遇到“根本跑不起来”的问题,优先怀疑 PE 头解析和节区复制。
6. 验证加载结果:把“跑起来”变成“可确认跑对了”
判断一个内存加载器是否真的成功,不是看目标程序弹不弹窗,而是要确认三件事:目标数据确实是从资源段读出来的、内存布局符合 PE 规范、执行入口后行为与磁盘版本一致。
我常用一个很直接的验证方法:在目标程序入口点第一行放一个“写标记文件”的动作,加载完成后去检查这个文件是否生成。如果生成,说明代码确实执行了。然后对比进程内存和磁盘原文件,用某内存查看工具把 VirtualAlloc 分配的区域整体 dump 出来,和原始 exe 文件的节区做哈希比对,重点看 .text 节是否一致。这里能顺便发现一个隐蔽问题:如果节区复制时用的是 SizeOfRawData 而不是 VirtualSize,dump 出来的 .data 节会比原文件短一块,某些全局变量没被复制完整,程序平时跑着没问题,一到特定数据分支就异常。
另一个验证技巧是分步观察。写加载器不要一次写完直接跑,我会分成三个检查点:第一,资源读取后先检查数据头前两个字节是不是 MZ;第二,映射完成后检查每个节区的 VirtualAddress 处前四个字节是否与文件对应偏移一致;第三,FixImports 完成后随机挑一个导入函数,对比它写入的地址和目标 DLL 的导出地址。这三个检查点都通过,才调用入口点。这样一旦后面崩了,可以很快定位是映射阶段还是修复阶段的问题。
还有一个容易忽略的验证项是入口点返回值。exe 的入口点返回值通常是进程退出码,但在内存加载场景下,如果入口点内部直接调用了 ExitProcess,宿主程序也会跟着退掉。为了避免这个问题,我一般在 RunMappedExe 里做一层保护:调用入口前 hook 掉 ExitProcess,让它改为返回一个特殊值,等入口返回后再按需恢复。这样目标程序里的退出操作不会把整个宿主带崩。
如果目标是验证“不落盘”,可以在宿主里监控文件目录,确认除了宿主自身没有任何 exe 被创建。配合前面的内存 dump 哈希比对,基本就能说服自己和同事:这东西确实是从资源段读到内存里跑起来的。
从那以后,我每次动手写加载器,都会强制自己先走一遍“MZ 校验、节表复制校验、IAT 抽查、入口点分隔”这四步,不在没有验证完整映射的情况下直接蹦入口。这个习惯救了我好几次,希望帮到你。
本文还有配套的精品资源,点击获取