前面都是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:\Windows\SysWOW64\calc.exe
系列汇总
- 写一个PE的壳_Part 1:加载PE文件到内存
- 写一个PE的壳_Part 2:ASLR+修复输入表(IAT)+重定位表支持(.reloc)
- 写一个PE的壳_Part 3:Section里实现PE装载器
- 写一个PE的壳_Part 4:修复对ASLR支持+lief构建新PE
- 写一个PE的壳_Part 5:PE格式修复+lief源码修改
- 写一个PE的壳_Part 6:简单的混淆
文章目录
Part 1:加载PE文件到内存
目的:写一个读取PE文件,分析它的头部信息、将它的节区信息映射到内存的程序(
loader.exe
)
1.main函数框架
main函数实现的主要功能如下:
- 1.读取一个文件,申请一块能放下文件大小的内存
- 2.调用load_PE函数将PE文件读取到上面申请的内存中,然后返回PE文件的EP(暂时先不关心具体的实现)
- 3.从PE文件EP处开始执行
#include <stdio.h>
#include <stdlib.h>
// 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 argument\n");
return 1;
}
FILE* exe_file = fopen(argv[1], "rb"); //读取一个PE文件
if(!exe_file) {
printf("error opening file\n");
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 file\n");
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:\Windows\SysWOW64\calc.exe
-
结果:有
[+] Loading PE file
输出说明框架暂时操作都是OK的
上面框架中的load_PE函数没有实现,下面介绍load_PE需要做的工作:将PE头信息和section部分加入我们自己申请的内存
2.加载PE Header
PE文件里能看到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 <winnt.h>
#include <windows.h>
- 编程处理Dos Header
NT Headers
从上到下,按照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的地方
[Optional Header]
“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
:内存中的偏移,是RVARawSize
:磁盘中占用的大小(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:\Windows\SysWOW64\
里面放的文件,就是一个静态的二进制文件 - 内存:当执行可执行文件时,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; i<p_NT_HDR->FileHeader.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; i<p_NT_HDR->FileHeader.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:\Windows\SysWOW64\calc.exe
,计算器没有出现,没有达到预期效果
原因是2个非常重要的表我们还没有处理:添加输入表和管理重定位表,这将在Part 2部分介绍