大数跨境
0
0

Windows 手动加载器(Manual PE Loader)深度剖析 c2开发核心功能

Windows 手动加载器(Manual PE Loader)深度剖析 c2开发核心功能 湛蓝空间
2025-05-12
2
导读:背景介绍Windows 系统下的可执行文件(PE 文件)通常通过操作系统提供的 CreateProcess、

背景介绍

Windows 系统下的可执行文件(PE 文件)通常通过操作系统提供的 CreateProcess、 LoadLibrary 等 API 由内核加载器完成映射与初始化。而在某些场景中,例如免杀执行、内存注入、反分析、恶意载荷执行等,使用标准加载流程会触发防护机制,因此开发者需要绕过系统加载器,采用“手动加载(Manual Mapping)”方式将 PE 文件直接注入内存并执行。

本文分析的是一个完整的 64 位兼容型内存加载器实现,它不依赖操作系统默认的映像加载机制,而是:

  • 手动读取磁盘 PE 文件;
  • 解析其结构;
  • 分配映像内存;
  • 拷贝节区;
  • 修复 IAT、重定位、TLS、异常处理等;
  • 执行入口点或导出函数。

本文将对其中的每一步操作和原理做深入且完整的分析。


1. 加载 PE 文件到内存: ReadFileFromDisk

 
 
 
  1. BOOL ReadFileFromDisk(IN LPCSTR cFileName, OUT PBYTE* ppBuffer, OUT PDWORD pdwFileSize)

实现思路:

  • 调用 CreateFileA 函数打开目标文件(可执行文件或 DLL);
  • 使用 GetFileSize 函数获取文件大小;
  • 使用 HeapAlloc函数分配合适的内存空间;
  • 使用 ReadFile 将整个文件内容读取至内存。

原理分析:

这一步只是将磁盘上的文件读入内存,不涉及任何 PE 映射。


2. 解析 PE 结构体: InitializePeStruct

 
 
 
  1. 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],表示所有重要表的虚拟地址和大小:

索引
说明
0
导出表(Export)
1
导入表(Import)
5
基址重定位(Reloc)
3
异常处理表(Exception)
9
TLS 回调表(TLS)

解释:

这些字段将被用在后续阶段的模块修复过程中:

  • FixImportAddressTable 使用 pEntryImportDataDir
  • FixReloc 使用 pEntryBaseRelocDataDir
  • FixTLSFixSEH 等使用 TLS / Exception 数据目录;
  • 若要调用导出函数,则需 pEntryExportDataDir

原理:

将从磁盘读取到的 PE 文件缓冲区( pFileBuffer)解析为一个包含关键 PE 元数据的结构体( PE_HDRS),供后续内存加载使用。

第一步:保存原始文件信息

 
 
 
  1. pPeHdrs->pFileBuffer = pFileBuffer;
  2. pPeHdrs->dwFileSize = dwFileSize;

解释:

  • 将读取的 PE 文件内容和文件大小保存在结构体中。
  • 后续如访问节表(PointerToRawData)或进行重定位等操作,都需要原始文件内容。

第二步:定位 NT 头

 
 
 
  1. 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 头合法性

 
 
 
  1. if(pPeHdrs->pImgNtHdrs->Signature!= IMAGE_NT_SIGNATURE)
  2. return FALSE;

说明:

  • IMAGE_NT_SIGNATURE 是常量 0x00004550,ASCII 即 "PE\0\0";
  • 如果文件格式非法,或遭篡改,会导致 signature 不匹配;
  • 此处是安全校验,失败直接返回。

第四步:判断是否是 DLL 类型

 
 
 
  1. pPeHdrs->bIsDLLFile =(pPeHdrs->pImgNtHdrs->FileHeader.Characteristics& IMAGE_FILE_DLL)? TRUE : FALSE;

解释:

  • FileHeader.Characteristics 是一个位掩码字段,描述文件属性;
  • IMAGE_FILE_DLL(0x2000) 表示该文件是 DLL;
  • 加载 DLL 与 EXE 时逻辑不同(例如是否调用 DllMain),因此需要提前标记。

第五步:获取节表地址

 
 
 
  1. pPeHdrs->pImgSecHdr = IMAGE_FIRST_SECTION(pPeHdrs->pImgNtHdrs);

解释:

  • IMAGE_FIRST_SECTION 是一个宏,表示节表的起始地址;
  • 节表是紧跟在 IMAGE_NT_HEADERS 后的一组 IMAGE_SECTION_HEADER 数组;
  • 每个节(.text、.data、.rdata 等)都描述了自己的 VA、Raw 数据偏移和大小等;
  • 用于后续映像映射和权限设置。

第六步:获取各个数据目录表指针

 
 
 
  1. pPeHdrs->pEntryImportDataDir    =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
  2. pPeHdrs->pEntryBaseRelocDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
  3. pPeHdrs->pEntryTLSDataDir       =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS];
  4. pPeHdrs->pEntryExceptionDataDir =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
  5. pPeHdrs->pEntryExportDataDir    =&pPeHdrs->pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];

注意点:

  • 验证 NT 头签名是否为 "PE\0\0"
  • 不合法的结构直接中断。

3. 手动分配映像空间 + 节区映射: LocalPeExec

 
 
 
  1. pPeBaseAddress =VirtualAlloc(NULL,SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

为什么不是分配 RawSize?

因为节区加载到内存中,是以 RVA 的形式进行偏移布局的,不是简单的文件顺序。 SizeOfImage 是 PE 文件被加载后完整的虚拟地址空间尺寸,包括所有节区之间的空洞(页对齐)。

节区写入流程:

 
 
 
  1. for(int i =0; i <NumberOfSections; i++){
  2.     memcpy(
  3.         pPeBase +VirtualAddress,
  4.         pFileBuffer +PointerToRawData,
  5. SizeOfRawData
  6. );
  7. }

这个过程模仿了 Windows 加载器在加载 PE 文件时所做的节区映射过程。

注意:这段代码不会映射 DOS 头或 NT 头(可选),也不会做页保护,全部写入的是节区数据。


4. 修复导入表 IAT: FixImportAddressTable

导入表结构理解:

  • IMAGE_IMPORT_DESCRIPTOR[]:每个结构描述一个 DLL;
  • OriginalFirstThunk:指向函数名数组(IMAGEIMPORTBY_NAME);
  • FirstThunk:是 IAT,需要将函数地址写入此处。

加载流程:

遍历 Import Descriptor 列表

 
 
 
  1. PIMAGE_IMPORT_DESCRIPTOR pImgDescriptor = NULL;
  2. for(SIZE_T i =0; i < pEntryImportDataDir->Size; i +=sizeof(IMAGE_IMPORT_DESCRIPTOR)){
  3.     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 的导入描述符。

停止条件:遇到末尾描述符

 
 
 
  1. if(pImgDescriptor->OriginalFirstThunk==0&& pImgDescriptor->FirstThunk==0)
  2. break;
  • IAT 的最后一项通常全为 0;
  • 如果 OriginalFirstThunk 和 FirstThunk 都为 0,说明我们已经遍历到结尾,直接退出。

提取 DLL 名称及 Thunk 表指针

 
 
 
  1. LPSTR cDllName =(LPSTR)(pPeBaseAddress + pImgDescriptor->Name);
  2. ULONG_PTR uOriginalFirstThunkRVA = pImgDescriptor->OriginalFirstThunk;
  3. ULONG_PTR uFirstThunkRVA = pImgDescriptor->FirstThunk;

解读:

  • Name 是 DLL 文件名(如 "kernel32.dll")的 RVA,需要加上映像基地址转换为 VA;
  • OriginalFirstThunk: 指向函数名表(INT);
  • FirstThunk: 指向 Import Address Table(IAT);

IAT 是我们真正要写入函数地址的位置。


加载 DLL:LoadLibraryA

 
 
 
  1. if(!(hModule =LoadLibraryA(cDllName))){
  2.     PRINT_WINAPI_ERR("LoadLibraryA");
  3. return FALSE;
  4. }

加载当前导入描述符对应的 DLL。

如果该 DLL 无法加载(路径错误、系统缺失),则无法修复对应的函数地址,因此返回失败。


遍历函数导入项

 
 
 
  1. while(TRUE){
  2.     PIMAGE_THUNK_DATA pOriginalFirstThunk =(PIMAGE_THUNK_DATA)(pPeBaseAddress + uOriginalFirstThunkRVA +ImgThunkSize);
  3.     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):运行时会被替换为函数地址,我们要填的就是这个。

停止函数导入项遍历

 
 
 
  1. if(pOriginalFirstThunk->u1.Function==0&& pFirstThunk->u1.Function==0)
  2. break;

说明该 DLL 的导入函数项遍历完毕。


区分按序号和按名称导入

 
 
 
  1. if(IMAGE_SNAP_BY_ORDINAL(pOriginalFirstThunk->u1.Ordinal)){
  2. GetProcAddress(hModule, IMAGE_ORDINAL(...));
  3. }else{
  4.     pImgImportByName =(PIMAGE_IMPORT_BY_NAME)(pPeBaseAddress + pOriginalFirstThunk->u1.AddressOfData);
  5. GetProcAddress(hModule, pImgImportByName->Name);
  6. }

按序号导入(Ordinal):

  • 有些 DLL(尤其是系统级 DLL)导出函数时不包含名称,只能通过编号导入;
  • IMAGE_SNAP_BY_ORDINAL() 判断导入项是否以序号方式编码;
  • IMAGE_ORDINAL() 提取实际序号。

按名称导入:

  • AddressOfData 是一个 RVA,指向 IMAGE_IMPORT_BY_NAME 结构;
  • 结构中包含函数名,传给 GetProcAddress(hModule,Name)

写入真实函数地址

 
 
 
  1. 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 位表示该块内偏移量。

修复过程:

解析重定位表起始地址

 
 
 
  1. PIMAGE_BASE_RELOCATION pImgBaseRelocation =(PIMAGE_BASE_RELOCATION)(pPeBaseAddress + pEntryBaseRelocDataDir->VirtualAddress);

pEntryBaseRelocDataDir 是 .reloc 表的 IMAGE_DATA_DIRECTORY

VirtualAddress 是 RVA,需要加上 pPeBaseAddress 得到真实地址;

IMAGE_BASE_RELOCATION 是重定位表块的结构体,每个块对应某一页(4KB)中多个重定位项。


计算实际加载地址与理想地址的偏差值(Delta)

 
 
 
  1. ULONG_PTR uDeltaOffset = pPeBaseAddress - pPreferableAddress;

所有需要修正的地址,都来自于 PE 文件“预期的 ImageBase”(即 OptionalHeader.ImageBase);

pPeBaseAddress 是实际加载地址(用 VirtualAlloc 得到);

二者差值 = 所有绝对地址需调整的偏移量( delta)。


初始化重定位条目指针

 
 
 
  1. PBASE_RELOCATION_ENTRY pBaseRelocEntry = NULL;

这个结构是自定义的:

 
 
 
  1. typedefstruct _BASE_RELOCATION_ENTRY {
  2.     WORD Offset:12;
  3.     WORD Type:4;
  4. } BASE_RELOCATION_ENTRY,*PBASE_RELOCATION_ENTRY;

它将每个 16-bit WORD 拆分为 4-bit 类型 和 12-bit 偏移量;

这是标准的 Windows 重定位项格式。


遍历所有重定位块

 
 
 
  1. while(pImgBaseRelocation->VirtualAddress){

每个 IMAGE_BASE_RELOCATION 结构表示一个重定位块;

结构字段:

  • VirtualAddress: 此块对应的页内 RVA 起始地址;
  • SizeOfBlock: 包含当前块的所有重定位项的大小(包含头部);

块数组也以空结构( VirtualAddress==0)结束。


获取重定位项数组首地址

 
 
 
  1. pBaseRelocEntry =(PBASE_RELOCATION_ENTRY)(pImgBaseRelocation +1);
  • IMAGE_BASE_RELOCATION 是块头结构;
  • 重定位项紧跟在结构体后面;
  • 所以直接 +1 即指向首个 relocation entry。

遍历当前块中的所有条目

 
 
 
  1. while((PBYTE)pBaseRelocEntry !=(PBYTE)pImgBaseRelocation + pImgBaseRelocation->SizeOfBlock){
  • 每个 entry 是 2 字节(一个 WORD);
  • 我们通过比较字节偏移判断是否到达该块末尾。

按类型修复对应地址

支持的重定位类型(Windows 定义):

类型宏
含义
IMAGE_REL_BASED_ABSOLUTE
0
无需修复,填充对齐用
IMAGE_REL_BASED_HIGHLOW
3
32 位绝对地址(x86)
IMAGE_REL_BASED_DIR64
10
64 位地址(x64)
IMAGE_REL_BASED_HIGH
1
修复高 16 位(老架构)
IMAGE_REL_BASED_LOW
2
修复低 16 位(老架构)

举例说明: IMAGE_REL_BASED_DIR64

 
 
 
  1. *((ULONG_PTR*)(pPeBaseAddress + VA +Offset))+= uDeltaOffset;
  • pPeBaseAddress+VirtualAddress+Offset: 是原始值存储的位置;
  • 取出这个值(地址)加上 delta,即完成修正。

处理未知类型

 
 
 
  1. default:
  2.     printf("[!] Unknown relocation type: %d | Offset: 0x%08X \n", pBaseRelocEntry->Type, pBaseRelocEntry->Offset);
  3. return FALSE;

遇到无法识别或未处理的重定位类型则立即失败返回。


移动到下一个 relocation entry

 
 
 
  1. pBaseRelocEntry++;

每个 entry 是 WORD 类型,步进 2 字节。


移动到下一个 block

 
 
 
  1. 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

 
 
 
  1. 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 区别处理

 
 
 
  1. if(isDLL){
  2. 调用DllMain(hInst, DLL_PROCESS_ATTACH)
  3. 可选调用导出函数(若提供)
  4. }else{
  5. 调用主入口函数(MAIN =EntryPoint)
  6. }

注意点

  • DLL 入口是 DllMain(HINSTANCE,Reason,LPVOID)
  • EXE 则是 mainCRTStartup,没有参数;
  • 此处不调用 CRT 初始化,若目标代码依赖 CRT(如 printf),行为不可预测。

总结

本文展示了一个完整的 Windows PE 内存加载器的实现,涵盖从文件读取、映像构建、导入表修复、重定位到入口点执行的全过程。这种手动加载方式不仅可以用于学习 PE 格式,还在安全研究和工程实践中具有重要价值。

【声明】内容源于网络
0
0
湛蓝空间
分享互联网以及网络安全技术相关信息
内容 2
粉丝 0
湛蓝空间 分享互联网以及网络安全技术相关信息
总阅读0
粉丝0
内容2