背景介绍
Windows 系统下的可执行文件(PE 文件)通常通过操作系统提供的 CreateProcess、 LoadLibrary 等 API 由内核加载器完成映射与初始化。而在某些场景中,例如免杀执行、内存注入、反分析、恶意载荷执行等,使用标准加载流程会触发防护机制,因此开发者需要绕过系统加载器,采用“手动加载(Manual Mapping)”方式将 PE 文件直接注入内存并执行。
本文分析的是一个完整的 64 位兼容型内存加载器实现,它不依赖操作系统默认的映像加载机制,而是:
- 手动读取磁盘 PE 文件;
- 解析其结构;
- 分配映像内存;
- 拷贝节区;
- 修复 IAT、重定位、TLS、异常处理等;
- 执行入口点或导出函数。
本文将对其中的每一步操作和原理做深入且完整的分析。
1. 加载 PE 文件到内存: ReadFileFromDisk
BOOL ReadFileFromDisk(IN LPCSTR cFileName, OUT PBYTE* ppBuffer, OUT PDWORD pdwFileSize)
实现思路:
- 调用
CreateFileA函数打开目标文件(可执行文件或 DLL); - 使用
GetFileSize函数获取文件大小; - 使用
HeapAlloc函数分配合适的内存空间; - 使用
ReadFile将整个文件内容读取至内存。
原理分析:
这一步只是将磁盘上的文件读入内存,不涉及任何 PE 映射。
2. 解析 PE 结构体: InitializePeStruct
BOOL InitializePeStruct(OUT PPE_HDRS pPeHdrs, IN PBYTE pFileBuffer, IN DWORD dwFileSize)
核心结构:
IMAGE_DOS_HEADER: DOS MZ 头部,包含e_lfanew指向 NT 头偏移;IMAGE_NT_HEADERS: PE 头,包含 OptionalHeader 和 FileHeader;IMAGE_SECTION_HEADER: 节表(节的数量在FileHeader.NumberOfSections中);IMAGE_DATA_DIRECTORY: 包含导入表、重定位表、TLS、异常处理表等信息;bIsDLLFile: 判断当前是 DLL 还是 EXE,影响执行逻辑。
背景知识: IMAGE_OPTIONAL_HEADER 的 DataDirectory
PE 文件的 Optional Header 中包含一个数组 IMAGE_DATA_DIRECTORYDataDirectory[16],表示所有重要表的虚拟地址和大小:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
解释:
这些字段将被用在后续阶段的模块修复过程中:
FixImportAddressTable使用pEntryImportDataDir;FixReloc使用pEntryBaseRelocDataDir;FixTLS,FixSEH等使用 TLS / Exception 数据目录;- 若要调用导出函数,则需
pEntryExportDataDir。
原理:
将从磁盘读取到的 PE 文件缓冲区( pFileBuffer)解析为一个包含关键 PE 元数据的结构体( PE_HDRS),供后续内存加载使用。
第一步:保存原始文件信息
pPeHdrs->pFileBuffer = pFileBuffer;pPeHdrs->dwFileSize = dwFileSize;
解释:
- 将读取的 PE 文件内容和文件大小保存在结构体中。
- 后续如访问节表(PointerToRawData)或进行重定位等操作,都需要原始文件内容。
第二步:定位 NT 头
pPeHdrs->pImgNtHdrs =(PIMAGE_NT_HEADERS)(pFileBuffer +((PIMAGE_DOS_HEADER)pFileBuffer)->e_lfanew);
PE 文件结构回顾:
- 所有 PE 文件都以
IMAGE_DOS_HEADER开始(MZ魔数); e_lfanew是IMAGE_DOS_HEADER中的一个字段,指向 NT 头(偏移量);- NT 头起始为
"PE\0\0"签名,然后是IMAGE_FILE_HEADER+IMAGE_OPTIONAL_HEADER。
解释:
(PIMAGE_DOS_HEADER)pFileBuffer: 将文件开头解释为 DOS 头;.e_lfanew: 是一个DWORD,告诉我们 NT 头在哪;- 最终通过
(PIMAGE_NT_HEADERS)(pFileBuffer+e_lfanew)定位 NT 头。
第三步:校验 NT 头合法性
if(pPeHdrs->pImgNtHdrs->Signature!= IMAGE_NT_SIGNATURE)return FALSE;
说明:
IMAGE_NT_SIGNATURE是常量0x00004550,ASCII 即 "PE\0\0";- 如果文件格式非法,或遭篡改,会导致 signature 不匹配;
- 此处是安全校验,失败直接返回。
第四步:判断是否是 DLL 类型
pPeHdrs->bIsDLLFile =(pPeHdrs->pImgNtHdrs->FileHeader.Characteristics& IMAGE_FILE_DLL)? TRUE : FALSE;
解释:
FileHeader.Characteristics是一个位掩码字段,描述文件属性;IMAGE_FILE_DLL(0x2000)表示该文件是 DLL;- 加载 DLL 与 EXE 时逻辑不同(例如是否调用 DllMain),因此需要提前标记。
第五步:获取节表地址
pPeHdrs->pImgSecHdr = IMAGE_FIRST_SECTION(pPeHdrs->pImgNtHdrs);
解释:
IMAGE_FIRST_SECTION是一个宏,表示节表的起始地址;- 节表是紧跟在
IMAGE_NT_HEADERS后的一组IMAGE_SECTION_HEADER数组; - 每个节(.text、.data、.rdata 等)都描述了自己的 VA、Raw 数据偏移和大小等;
- 用于后续映像映射和权限设置。
第六步:获取各个数据目录表指针
pPeHdrs->pEntryImportDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];pPeHdrs->pEntryBaseRelocDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];pPeHdrs->pEntryTLSDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS];pPeHdrs->pEntryExceptionDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];pPeHdrs->pEntryExportDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
注意点:
- 验证 NT 头签名是否为
"PE\0\0"; - 不合法的结构直接中断。
3. 手动分配映像空间 + 节区映射: LocalPeExec
pPeBaseAddress =VirtualAlloc(NULL,SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
为什么不是分配 RawSize?
因为节区加载到内存中,是以 RVA 的形式进行偏移布局的,不是简单的文件顺序。 SizeOfImage 是 PE 文件被加载后完整的虚拟地址空间尺寸,包括所有节区之间的空洞(页对齐)。
节区写入流程:
for(int i =0; i <NumberOfSections; i++){memcpy(pPeBase +VirtualAddress,pFileBuffer +PointerToRawData,SizeOfRawData);}
这个过程模仿了 Windows 加载器在加载 PE 文件时所做的节区映射过程。
注意:这段代码不会映射 DOS 头或 NT 头(可选),也不会做页保护,全部写入的是节区数据。
4. 修复导入表 IAT: FixImportAddressTable
导入表结构理解:
IMAGE_IMPORT_DESCRIPTOR[]:每个结构描述一个 DLL;OriginalFirstThunk:指向函数名数组(IMAGEIMPORTBY_NAME);FirstThunk:是 IAT,需要将函数地址写入此处。
加载流程:
遍历 Import Descriptor 列表
PIMAGE_IMPORT_DESCRIPTOR pImgDescriptor = NULL;for(SIZE_T i =0; i < pEntryImportDataDir->Size; i +=sizeof(IMAGE_IMPORT_DESCRIPTOR)){pImgDescriptor =(PIMAGE_IMPORT_DESCRIPTOR)(pPeBaseAddress + pEntryImportDataDir->VirtualAddress+ i);
PE 背景知识:
IMAGE_IMPORT_DESCRIPTOR是一个数组,每项描述一个导入 DLL。- 数组以全 0 项结束(不是通过计数)。
解释:
pEntryImportDataDir->VirtualAddress是 Import Table RVA,转换为 VA:pPeBaseAddress+RVA;- 每次偏移一个结构体大小(20 字节);
pImgDescriptor就是当前 DLL 的导入描述符。
停止条件:遇到末尾描述符
if(pImgDescriptor->OriginalFirstThunk==0&& pImgDescriptor->FirstThunk==0)break;
- IAT 的最后一项通常全为 0;
- 如果 OriginalFirstThunk 和 FirstThunk 都为 0,说明我们已经遍历到结尾,直接退出。
提取 DLL 名称及 Thunk 表指针
LPSTR cDllName =(LPSTR)(pPeBaseAddress + pImgDescriptor->Name);ULONG_PTR uOriginalFirstThunkRVA = pImgDescriptor->OriginalFirstThunk;ULONG_PTR uFirstThunkRVA = pImgDescriptor->FirstThunk;
解读:
Name是 DLL 文件名(如 "kernel32.dll")的 RVA,需要加上映像基地址转换为 VA;OriginalFirstThunk: 指向函数名表(INT);FirstThunk: 指向 Import Address Table(IAT);
IAT 是我们真正要写入函数地址的位置。
加载 DLL:LoadLibraryA
if(!(hModule =LoadLibraryA(cDllName))){PRINT_WINAPI_ERR("LoadLibraryA");return FALSE;}
加载当前导入描述符对应的 DLL。
如果该 DLL 无法加载(路径错误、系统缺失),则无法修复对应的函数地址,因此返回失败。
遍历函数导入项
while(TRUE){PIMAGE_THUNK_DATA pOriginalFirstThunk =(PIMAGE_THUNK_DATA)(pPeBaseAddress + uOriginalFirstThunkRVA +ImgThunkSize);PIMAGE_THUNK_DATA pFirstThunk =(PIMAGE_THUNK_DATA)(pPeBaseAddress + uFirstThunkRVA +ImgThunkSize);
PIMAGE_THUNK_DATA是一个 union 结构体,表示函数导入项;- 每个 thunk 表项是 8 字节(x64)或 4 字节(x86);
ImgThunkSize是当前在数组中的偏移,用于逐项向后遍历。
OriginalFirstThunk vs FirstThunk:
- OriginalFirstThunk(INT):保存函数名或序号,是导入表;
- FirstThunk(IAT):运行时会被替换为函数地址,我们要填的就是这个。
停止函数导入项遍历
if(pOriginalFirstThunk->u1.Function==0&& pFirstThunk->u1.Function==0)break;
说明该 DLL 的导入函数项遍历完毕。
区分按序号和按名称导入
if(IMAGE_SNAP_BY_ORDINAL(pOriginalFirstThunk->u1.Ordinal)){GetProcAddress(hModule, IMAGE_ORDINAL(...));}else{pImgImportByName =(PIMAGE_IMPORT_BY_NAME)(pPeBaseAddress + pOriginalFirstThunk->u1.AddressOfData);GetProcAddress(hModule, pImgImportByName->Name);}
按序号导入(Ordinal):
- 有些 DLL(尤其是系统级 DLL)导出函数时不包含名称,只能通过编号导入;
IMAGE_SNAP_BY_ORDINAL()判断导入项是否以序号方式编码;IMAGE_ORDINAL()提取实际序号。
按名称导入:
AddressOfData是一个 RVA,指向IMAGE_IMPORT_BY_NAME结构;- 结构中包含函数名,传给
GetProcAddress(hModule,Name)。
写入真实函数地址
pFirstThunk->u1.Function=(ULONGLONG)pFuncAddress;
- 将通过
GetProcAddress得到的真实地址写入 IAT; - 覆盖原始占位值(如
0x00000000)。
注意事项:
- 有些 DLL 导入可能是延迟绑定,在此会强行提前绑定;
- 若 IAT 与 INT 不一致(非法构造),此处行为可能出错;
- 若导入函数失败,当前实现直接终止加载流程。
5. 重定位修复(必须): FixReloc
背景:
PE 文件编译时,默认希望加载到某个地址(OptionalHeader.ImageBase);
如果该地址被占用,系统或加载器需将它加载到其他位置(非 ImageBase);
所有硬编码为 ImageBase+x 的指针都变得无效;
重定位表 .reloc 就是专门列出“哪些地址要修正”的数据结构;
这称为重定位(Relocation)或 Rebase。
重定位块结构:
IMAGE_BASE_RELOCATION:包含 VirtualAddress 和 SizeOfBlock;
紧跟着一系列 WORD 类型的 relocation entry;
高 4 位表示类型,如 IMAGE_REL_BASED_DIR64;
低 12 位表示该块内偏移量。
修复过程:
解析重定位表起始地址
PIMAGE_BASE_RELOCATION pImgBaseRelocation =(PIMAGE_BASE_RELOCATION)(pPeBaseAddress + pEntryBaseRelocDataDir->VirtualAddress);
pEntryBaseRelocDataDir 是 .reloc 表的 IMAGE_DATA_DIRECTORY;
VirtualAddress 是 RVA,需要加上 pPeBaseAddress 得到真实地址;
IMAGE_BASE_RELOCATION 是重定位表块的结构体,每个块对应某一页(4KB)中多个重定位项。
计算实际加载地址与理想地址的偏差值(Delta)
ULONG_PTR uDeltaOffset = pPeBaseAddress - pPreferableAddress;
所有需要修正的地址,都来自于 PE 文件“预期的 ImageBase”(即 OptionalHeader.ImageBase);
pPeBaseAddress 是实际加载地址(用 VirtualAlloc 得到);
二者差值 = 所有绝对地址需调整的偏移量( delta)。
初始化重定位条目指针
PBASE_RELOCATION_ENTRY pBaseRelocEntry = NULL;
这个结构是自定义的:
typedefstruct _BASE_RELOCATION_ENTRY {WORD Offset:12;WORD Type:4;} BASE_RELOCATION_ENTRY,*PBASE_RELOCATION_ENTRY;
它将每个 16-bit WORD 拆分为 4-bit 类型 和 12-bit 偏移量;
这是标准的 Windows 重定位项格式。
遍历所有重定位块
while(pImgBaseRelocation->VirtualAddress){
每个 IMAGE_BASE_RELOCATION 结构表示一个重定位块;
结构字段:
VirtualAddress: 此块对应的页内 RVA 起始地址;SizeOfBlock: 包含当前块的所有重定位项的大小(包含头部);
块数组也以空结构( VirtualAddress==0)结束。
获取重定位项数组首地址
pBaseRelocEntry =(PBASE_RELOCATION_ENTRY)(pImgBaseRelocation +1);
IMAGE_BASE_RELOCATION是块头结构;- 重定位项紧跟在结构体后面;
- 所以直接
+1即指向首个 relocation entry。
遍历当前块中的所有条目
while((PBYTE)pBaseRelocEntry !=(PBYTE)pImgBaseRelocation + pImgBaseRelocation->SizeOfBlock){
- 每个 entry 是 2 字节(一个
WORD); - 我们通过比较字节偏移判断是否到达该块末尾。
按类型修复对应地址
支持的重定位类型(Windows 定义):
|
|
|
|
|---|---|---|
IMAGE_REL_BASED_ABSOLUTE |
|
|
IMAGE_REL_BASED_HIGHLOW |
|
|
IMAGE_REL_BASED_DIR64 |
|
|
IMAGE_REL_BASED_HIGH |
|
|
IMAGE_REL_BASED_LOW |
|
|
举例说明: IMAGE_REL_BASED_DIR64
*((ULONG_PTR*)(pPeBaseAddress + VA +Offset))+= uDeltaOffset;
pPeBaseAddress+VirtualAddress+Offset: 是原始值存储的位置;- 取出这个值(地址)加上 delta,即完成修正。
处理未知类型
default:printf("[!] Unknown relocation type: %d | Offset: 0x%08X \n", pBaseRelocEntry->Type, pBaseRelocEntry->Offset);return FALSE;
遇到无法识别或未处理的重定位类型则立即失败返回。
移动到下一个 relocation entry
pBaseRelocEntry++;
每个 entry 是 WORD 类型,步进 2 字节。
移动到下一个 block
pImgBaseRelocation =(PIMAGE_BASE_RELOCATION)pBaseRelocEntry;
这个操作通过指针强转实现“跳过当前块”,进入下一个块。
注意:这在语义上是:
当前 block 的结尾地址 = 当前 entry 遍历结束后的位置。
类型说明:
DIR64: 64 位绝对地址(最常见于 x64);HIGHLOW: 32 位地址(x86);ABSOLUTE: 忽略;HIGH/LOW: 低/高 16 位地址(已极少使用);
为什么必须做这一步?
如果你没有执行 FixReloc,而你的 PE 被加载到了与 ImageBase 不同的地址,则程序运行时会访问错误地址,最终导致崩溃或逻辑异常。
这一步是“手动加载器”能运行非 ASLR-free 程序的核心逻辑。
6. 设置节区权限: FixMemPermissions
节区加载完成后,初始都是 PAGE_READWRITE,必须根据其 Characteristics 字段设置为实际的权限:
|
|
|
|---|---|
IMAGE_SCN_MEM_EXECUTE |
PAGE_EXECUTE |
+MEM_READ |
PAGE_EXECUTE_READ |
+MEM_WRITE |
PAGE_EXECUTE_READWRITE |
MEM_WRITE |
PAGE_READWRITE |
MEM_READ |
PAGE_READONLY |
使用 VirtualProtect 对每个节进行单独权限设置是还原真实映射结构的关键步骤。
7. 修复 TLS 回调:TLS Directory
PIMAGE_TLS_DIRECTORY =>AddressOfCallbacks=>(TLS_CALLBACK*)[]
TLS 机制:
- 每个线程启动前,系统会调用 DLL 中注册的 TLS callback;
- 如果是自解密或反调试逻辑,它们通常埋藏在 TLS 中;
- 若不主动调用这些回调,将造成行为异常或崩溃。
本实现:
- 遍历 TLS 回调列表;
- 对每个回调函数调用
(BaseAddress,DLL_PROCESS_ATTACH,&Context)。
8. 异常表注册: RtlAddFunctionTable
仅适用于 x64,因为:
- 64 位结构不使用 SEH 链表,而是基于 Exception Directory +
RUNTIME_FUNCTION_ENTRY; - 如果该表未注册,当异常触发时会发生崩溃;
RtlAddFunctionTable告诉操作系统新增了一个映像映射区含有异常处理表。
9. 命令行参数修复: FixArguments
修改当前进程的 PEB->ProcessParameters->CommandLine 缓冲区,构造一个新的命令行字符串。
实际作用:
- 支持被加载的 EXE 文件通过
GetCommandLine()获取启动参数; - 便于模拟正常启动环境,如支持
main(argc,argv)中参数解析。
10. 启动方式:DLL 与 EXE 区别处理
if(isDLL){调用DllMain(hInst, DLL_PROCESS_ATTACH)可选调用导出函数(若提供)}else{调用主入口函数(MAIN =EntryPoint)}
注意点:
- DLL 入口是
DllMain(HINSTANCE,Reason,LPVOID); - EXE 则是
mainCRTStartup,没有参数; - 此处不调用 CRT 初始化,若目标代码依赖 CRT(如
printf),行为不可预测。
总结
本文展示了一个完整的 Windows PE 内存加载器的实现,涵盖从文件读取、映像构建、导入表修复、重定位到入口点执行的全过程。这种手动加载方式不仅可以用于学习 PE 格式,还在安全研究和工程实践中具有重要价值。

