前面都是PE的零散的知识,可以通过给PE文件写一个壳将前面的PE相关知识进行汇总
网上看到了一个英文教程(详细看参考),里面包含了给PE加壳和调用LIEF库的操作(这个库很简单);按照教程实现一遍(错误都进行了修正,主要是Part 4),对理解PE文件格式和脱壳很有帮助
下面是结合自己的实践写的笔记,不是教程的原版翻译
教程主要实现的壳的整体功能:
- 1.A PE File will be copied as-is (at first) in a custom section.
- 2.The unpacker will load this PE in memory and jump on it.
源码:https://github.com/jeremybeaume/packer-tutorial
编译器选取
可以使用Visual Studio或者MinGW,本教程使用MinGW64
MinGW编译器安装教程:MinGW-w64安装教程——著名C/C++编译器GCC的Windows版本_无知人生 (ivu4e.com)
-
小插曲:由于实验的机器使用的是win10,64位;导致开始使用MinGW64编译时总是达不到预期效果,后来发现MinGW64默认生成的是64位程序,尝试使用-m32参数显示缺库,还是不生效(生效是要32位的库是全的才可以,自己电脑不想安装那么多库,不处理了)
-
解决:重新下载i686-8.1.0-release-win32-sjlj-rt_v6-rev0版本,使用i686-w64-mingw32-gcc.exe 就可以了
教程使用的测试程序:以系统自带的32位计算器进行测试,路径:C:WindowsSysWOW64calc.exe
- Part 1:加载PE文件到内存
- 1.main函数框架
- 2.加载PE Header
- Dos Header
- NT Headers
- CFF查看
- [signature]
- [File Header]
- [Optional Header]
- 代码处理
- 3.加载Sections部分
- CFF查看
- 代码处理
- 4.验证结果
- 5.参考
1.main函数框架目的:写一个读取PE文件,分析它的头部信息、将它的节区信息映射到内存的程序(loader.exe)
main函数实现的主要功能如下:
- 1.读取一个文件,申请一块能放下文件大小的内存
- 2.调用load_PE函数将PE文件读取到上面申请的内存中,然后返回PE文件的EP(暂时先不关心具体的实现)
- 3.从PE文件EP处开始执行
#include#include // loads a PE in memory, returns the entry point address void* load_PE (char* PE_data); int main(int argc, char** argv) { if(argc<2) { printf("missing path argumentn"); return 1; } FILE* exe_file = fopen(argv[1], "rb"); //读取一个PE文件 if(!exe_file) { printf("error opening filen"); return 1; } fseek(exe_file, 0L, SEEK_END); // Get file size: put pointer at the end long int file_size = ftell(exe_file); // and read its position fseek(exe_file, 0L, SEEK_SET); // put the pointer back at the beginning char* exe_file_data = malloc(file_size+1); // allocate memory and read the whole file // read whole file size_t n_read = fread(exe_file_data, 1, file_size, exe_file); if(n_read != file_size) { printf("reading error (%d)n", n_read); return 1; } // load the PE in memory printf("[+] Loading PE filen"); void* start_address = load_PE(exe_file_data); if(start_address) { // call its entry point ((void (*)(void)) start_address)(); //注意:强制将void*转换成一个函数指针 } return 0; } void* load_PE (char* PE_data) { //TODO return NULL; }
-
编译命令:i686-w64-mingw32-gcc.exe main.c -o loader.exe,生成一个名称为loader的PE文件
-
运行:loader.exe C:WindowsSysWOW64calc.exe
-
结果:有[+] Loading PE file输出说明框架暂时操作都是OK的
上面框架中的load_PE函数没有实现,下面介绍load_PE需要做的工作:将PE头信息和section部分加入我们自己申请的内存
2.加载PE HeaderPE文件里能看到3种类型地址,要熟悉他们的含义:
- RAW:一个磁盘上PE文件的偏移地址
- VA(Virtual Address):内存里的绝对虚拟地址,比如程序里打印一个指针就是这个地址
- RVA(Relative Virtual Addresses):相对偏移地址,相对指的是相对于PE文件被加载到内存后的基址(ImageBase)
CFF查看calc.exe,显示了头部信息,是否是32位等基本的汇总信息
这里只是简单过一遍PE文件的头部知识
Dos Header- CFF查看Dos Header
每个PE文件都是以Dos Header开始的,签名(e_magic)总是“MZ” (0x5A4D),e_lfanew是NT Headers的RAW地址(0xE8)
- 疑惑:e_lfanew(0x3C)和NT header(0xE8)之间存放的是什么?
是DOS stub,里面有一个固定的字符串(This program cannot run in DOS mode),这里基本不用关心(CFF里也没有专门呈现)
- 头文件:PE文件的数据结构定义在winnt.h,为了使用标准的windows函数,也要包含头文件windows.h
#include#include
- 编程处理Dos Header
从上到下,按照CFF里展示的顺序介绍NT Headers
CFF查看 [signature]NT Headers里面只有一个signature (“PE”),另外2个其他头结构都在NT Headers的signature后面
[File Header]signature后面是“File Header”,下面CFF查看“File Header”的信息
几个重要成员:
- NumberOfSectons:区段的个数,后面会用到,要留意
- SizeOfOptionalHeader:“Optional Header”的大小,这个信息在Part 4中错误导致程序没有被识别为可执行程序
- Characteristics:里面的“File is a executable”和“File is a DLL”是唯一区分文件是EXE还是DLL的地方
“File Header”后面是“Optional Header”,“Optional Header”是PE文件中核心部分
重要成员如下:
- Magic:另一个签名,区分PE文件是PE32还是PE64
- AddressOfEntryPoint: 程序入口地址,即加载PE到内存后,运行时第一条执行语句的RVA地址,注意是相对地址,很重要
- ImageBase:二进制文件优先加载进内存的地址,所有RVA计算的参考基准
- SizeOfimage:模块被加载到内存后的虚拟大小,也可以理解成为了加载模块,自己的PE加载器最少要申请的空间大小
- SizeofHeaders:所有头文件总大小
- DLLCharacteristics:有很多标志,最有用的是“Dll can move”,标识模块的ASLR是否使能 (或者他能否被移动)
注意:ASLR( Address Space Layout Randomization,地址空间布局随机化)是一种通过增加攻击者预测目的地址难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目一种技术;由于篇幅的原因,将ASLR的简单介绍放在Part 2中
下面是代码需要处理的部分
代码处理winnt.h中“NT Headers” 结构定义如下
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
可以看到结构里没有指针,代码处理如下
DWORD hdr_image_base = p_NT_HDR->OptionalHeader.ImageBase; DWORD size_of_image = p_NT_HDR->OptionalHeader.SizeOfImage; DWORD entry_point_RVA = p_NT_HDR->OptionalHeader.AddressOfEntryPoint; DWORD size_of_headers = p_NT_HDR->OptionalHeader.SizeOfHeaders;
有了模块被加载到内存后的虚拟大小(SizeOfimage)后,需要申请一块内存;下满是按照被加载的二进制文件的ASLR是使能为前提的(参数为NULL的VirtualAlloc申请一块任意内存的地址来支持的)
开始加载PE内容进入内存,大体上有2种方式:
- 一种是直接一次拷贝进内存,然后在按照PE文件格式解析(下面采用的方式)
- 另一种是直接按照PE格式,分部分的加入内存
首先将PE的头加入内存,即将PE header复制到ImageBase(上面我们在heap上申请的一块内存)处
memcpy(ImageBase, PE_data, size_of_headers);
现状:现在我们已经有一块内存,里面有PE header了,但是没有代码和数据,因此需要引出Section的话题
3.加载Sections部分PE的二进制文件一旦被加载进入内存,相似属性的部分都被分割成不同部分,成为section;头信息中是有这些section的描述或者叫引导信息的,被成为section table或者Section Headers
CFF查看其中,有5个区段(section),每个区段都有的重要数据成员:
- Name:区段的名字,比如 .text 区段通常用来存代码, .data 通常用来存数据
- VirtualSize:内存中占用的大小(the size it occupies in memory)
- VirtualAddress:内存中的偏移,是RVA
- RawSize:磁盘中占用的大小(the size of the section data in the PE file)
- RawAddress:磁盘中,相对于PE文件开始处的偏移(the offset of the beginning of the data in the PE file)
- Characteristics:权限,读/写、执行等权限
注意:一定要区分好磁盘和内存
- 磁盘:是一个可执行文件放在我们电脑上的位置,比如:C:WindowsSysWOW64里面放的文件,就是一个静态的二进制文件
- 内存:当执行可执行文件时,PE装载器会将磁盘上静态的二进制文件,按照固定格式加载进入内存
上面介绍完,现在可以将PE文件的扇区部分也加载进入内存
- 加载section进入内存
// Section headers starts right after the IMAGE_NT_HEADERS, so we do some pointer arithmetic-fu here. IMAGE_SECTION_HEADER* sections = (IMAGE_SECTION_HEADER*) (p_NT_HDR + 1); // For each sections for(int i=0; iFileHeader.NumberOfSections; ++i) { // calculate the VA we need to copy the content, from the RVA // section[i].VirtualAddress is a RVA, mind it char* dest = ImageBase + sections[i].VirtualAddress; //ImageBase是我们自己申请的一块heap上内存 // check if there is Raw data to copy if(sections[i].SizeOfRawData > 0) { // We copy SizeOfRaw data bytes, from the offset PointertoRawData in the file memcpy(dest, PE_data + sections[i].PointerToRawData, sections[i].SizeOfRawData); } else { memset(dest, 0, sections[i].Misc.VirtualSize); } }
目前为止,已经将所有区段都加载到我们自己申请的内存中了,区段是不是就处理完了呢?
没有,因为现在所有区段的属性都是read/write,还要将每个区段的权限按照section table的原始Characteristics进行一下设置
- 设置section的属性
// Set permission for the PE hader to read only DWORD oldProtect; VirtualProtect(ImageBase, p_NT_HDR->OptionalHeader.SizeOfHeaders, PAGE_READONLY, &oldProtect); // 设置每个section的权限 for(int i=0; iFileHeader.NumberOfSections; ++i) { char* dest = ImageBase + sections[i].VirtualAddress; DWORD s_perm = sections[i].Characteristics; DWORD v_perm = 0; //flags are not the same between virtal protect and the section header if(s_perm & IMAGE_SCN_MEM_EXECUTE) { v_perm = (s_perm & IMAGE_SCN_MEM_WRITE) ? PAGE_EXECUTE_READWRITE: PAGE_EXECUTE_READ; } else { v_perm = (s_perm & IMAGE_SCN_MEM_WRITE) ? PAGE_READWRITE: PAGE_READONLY; } VirtualProtect(dest, sections[i].Misc.VirtualSize, v_perm, &oldProtect); }
- 返回PE文件入口
加载一个PE文件进入内存暂时告一段落,现在将PE文件的入口地址(VA)返回
return (void*) (ImageBase + entry_point_RVA);4.验证结果
上面都做完了,再编译(i686-w64-mingw32-gcc.exe main.c -o loader.exe)一下,运行:loader.exe C:WindowsSysWOW64calc.exe,计算器没有出现,没有达到预期效果
原因是2个非常重要的表我们还没有处理:添加输入表和管理重定位表,这将在Part 2部分介绍
5.参考- 1.Writing a PE packer – Part 1 : load a PE in memory
- 2.PE格式系列0x02 PE头部信息(WinDbg查看)



