August 9, 2022

PE & UPX壳

本来是想学习魔改壳和脱壳后修正IAT的,发现涉及到很多PE的东西,以前也大概学过,这次就全都总结一下。

参考链接:
https://docs.microsoft.com/en-us/windows/win32/debug/pe-format
https://www.52pojie.cn/thread-326995-1-1.html
https://blog.csdn.net/whatday/article/details/99709317
https://bbs.pediy.com/thread-181433-1.htm
https://www.cnblogs.com/LyShark/p/13731329.html
https://blog.csdn.net/apollon_krj/category_7066517.html
https://www.cnblogs.com/LyShark/p/13731329.html
https://www.cnblogs.com/zpchcbd/p/13071219.html

PE

几个常用概念:

测试代码:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main()
{
char input[100];
printf("This is a test.\nInput:");
scanf_s("%s", input, 100);

printf("hello, %s!\n", input);
return 0;
}

PE头

DOS头

40h,64字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct _IMAGE_DOS_HEADER{
0X00 WORD e_magic; //Magic DOS signature MZ(4Dh 5Ah):DOS签名("MZ"),用于标记是否是可执行文件
0X02 WORD e_cblp; //Bytes on last page of file
0X04 WORD e_cp; //Pages in file
0X06 WORD e_crlc; //Relocations
0X08 WORD e_cparhdr; //Size of header in paragraphs
0X0A WORD e_minalloc; //Minimun extra paragraphs needs
0X0C WORD e_maxalloc; //Maximun extra paragraphs needs
0X0E WORD e_ss; //intial(relative)SS value
0X10 WORD e_sp; //intial SP value
0X12 WORD e_csum; //Checksum
0X14 WORD e_ip; //intial IP value
0X16 WORD e_cs; //intial(relative)CS value
0X18 WORD e_lfarlc; //File Address of relocation table
0X1A WORD e_ovno; //Overlay number
0x1C WORD e_res[4]; //Reserved words
0x24 WORD e_oemid; //OEM identifier(for e_oeminfo)
0x26 WORD e_oeminfo; //OEM information;e_oemid specific
0x28 WORD e_res2[10]; //Reserved words
0x3C DWORD e_lfanew; //Offset to start of PE header:指向NT头
};

DOS头除了开头一个字和结束的一个双字,中间都可以换成没用的数据。

DOS存根

DOS头和NT头中间,没什么用可以扬了。
这是为了兼容MS-DOS,作用就在DOS环境下打一个This program cannot be run in DOS mode.然后退出。

NT头

1
2
3
4
5
6
7
//NT头
//pNTHeader = dosHeader + dosHeader->e_lfanew;
struct _IMAGE_NT_HEADERS{
0x00 DWORD Signature; //PE文件标识:ASCII的"PE\0\0"
0x04 _IMAGE_FILE_HEADER FileHeader; //标准文件头
0x18 _IMAGE_OPTIONAL_HEADER OptionalHeader; //可选文件头
};
标准文件头
1
2
3
4
5
6
7
8
9
10
11
12
//标准PE头:最基础的文件信息,共20字节
struct _IMAGE_FILE_HEADER{
0x00 WORD Machine; //※程序执行的CPU平台:0X0-任何平台;0X14C-x86;0x8664-x64;0x0200-Intel Itanium
0x02 WORD NumberOfSections; //※PE文件中区块数量
0x04 DWORD TimeDateStamp; //时间戳:连接器产生此文件的时间距1969/12/31-16:00P:00的总秒数
//0x08 DWORD PointerToSymbolTable; //COFF符号表格的偏移位置。此字段只对COFF除错信息有用
//0x0c DWORD NumberOfSymbols; //COFF符号表格中的符号个数。该值和上一个值在release版本的程序里为0
//0x10 WORD SizeOfOptionalHeader; //IMAGE_OPTIONAL_HEADER结构的大小(字节数):32位默认E0H,64位默认F0H(可修改)
0x12 WORD Characteristics; //※描述文件属性,eg:
//单属性(只有1bit为1):#define IMAGE_FILE_DLL 0x2000 //File is a DLL.
//组合属性(多个bit为1,单属性或运算):0X010F 可执行文件
};

文件属性Characteristics各bit的信息:

可选文件头
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//可选PE头
struct _IMAGE_OPTIONAL_HEADER{
0x00 WORD Magic; //※幻数(魔数),0x0107-ROM image,0x010B-32位PE,0X020B-64位PE
//0x02 BYTE MajorLinkerVersion; //连接器主版本号
//0x03 BYTE MinorLinkerVersion; //连接器副版本号
0x04 DWORD SizeOfCode; //所有代码段的总和大小,注意:必须是FileAlignment的整数倍,存在但没用
0x08 DWORD SizeOfInitializedData; //已经初始化数据的大小,注意:必须是FileAlignment的整数倍,存在但没用
0x0c DWORD SizeOfUninitializedData; //未经初始化数据的大小,注意:必须是FileAlignment的整数倍,存在但没用
0x10 DWORD AddressOfEntryPoint; //※程序入口地址OEP,这是一个RVA(Relative Virtual Address),通常会落在.textsection,此字段对于DLLs/EXEs都适用。
0x14 DWORD BaseOfCode; //代码段起始地址(代码基址),(代码的开始和程序无必然联系)
0x18 DWORD BaseOfData; //数据段起始地址(数据基址)
0x1c DWORD ImageBase; //※内存镜像基址(默认装入起始地址),默认为4000H
0x20 DWORD SectionAlignment; //※内存对齐:一旦映像到内存中,每一个section保证从一个「此值之倍数」的虚拟地址开始
0x24 DWORD FileAlignment; //※文件对齐:最初是200H,现在是1000H
//0x28 WORD MajorOperatingSystemVersion; //所需操作系统主版本号
//0x2a WORD MinorOperatingSystemVersion; //所需操作系统副版本号
//0x2c WORD MajorImageVersion; //自定义主版本号,使用连接器的参数设置,eg:LINK /VERSION:2.0 myobj.obj
//0x2e WORD MinorImageVersion; //自定义副版本号,使用连接器的参数设置
//0x30 WORD MajorSubsystemVersion; //所需子系统主版本号,典型数值4.0(Windows 4.0/即Windows 95)
//0x32 WORD MinorSubsystemVersion; //所需子系统副版本号
//0x34 DWORD Win32VersionValue; //总是0
0x38 DWORD SizeOfImage; //※PE文件在内存中映像总大小,sizeof(ImageBuffer),SectionAlignment的倍数
0x3c DWORD SizeOfHeaders; //※DOS头(64B)+PE标记(4B)+标准PE头(20B)+可选PE头+节表的总大小,按照文件对齐(FileAlignment的倍数)
0x40 DWORD CheckSum; //PE文件CRC校验和,判断文件是否被修改
0x44 WORD Subsystem; //用户界面使用的子系统类型:1-系统驱动;2-窗口应用程序;3-控制台应用程序
//0x46 WORD DllCharacteristics; //总是0
0x48 DWORD SizeOfStackReserve; //默认线程初始化栈的保留大小
0x4c DWORD SizeOfStackCommit; //初始化时实际提交的线程栈大小
0x50 DWORD SizeOfHeapReserve; //默认保留给初始化的process heap的虚拟内存大小
0x54 DWORD SizeOfHeapCommit; //初始化时实际提交的process heap大小
//0x58 DWORD LoaderFlags; //总是0
0x5c DWORD NumberOfRvaAndSizes; //DataDirectory数目:总为10H(16)
0x60 _IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];//#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
};

FileBuffer是磁盘上.exe文件在内存中的一份拷贝,但是FileBuffer无法直接在内存中运行,必须经过PE loader(装载器)装载以后成为ImageBufferImageBufferFileBuffer的”拉伸”。即exe–>FileBuffer–>ImageBuffer

丢进IDA里看一下,找1530这个地址,可以发现是mainCRTStartup的地址,即入口点函数。

1
2
3
4
5
struct _IMAGE_DATA_DIRECTORY{
DWORD VirtualAddress;
DWORD Size;
};
//占用16*8 = 128Byte = 80H = E0H(可选PE头默认大小) - 60H(前面所有成员固定占用大小)

16个_IMAGE_DATA_DIRECTORY分别存储了以下信息:
导入表导出表、资源、异常信息、安全证书、重定位表、调试信息、版权所有、全局指针、TLS、加载配置、绑定导入IAT、延迟导入、COM信息、最后一个保留未使用。

节表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define IMAGE_SIZEOF_SHORT_NAME              8
typedef struct _IMAGE_SECTION_HEADER{
0X00 BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //节(段)的名字.text/.data/.rdata/.cmd等。
//由于长度固定8字节,所以可以没有\0结束符,因此不能用char *直接打印
0X08 union{
DWORD PhysicalAddress; //物理地址
DWORD VirtualSize; //虚拟大小
}Misc;//存储的是该节在没有对齐前的真实尺寸,可改,不一定准确(可干掉)
0X0C DWORD VirtualAddress; //块的RVA,相对虚拟地址
0X10 DWORD SizeOfRawData; //该节在文件对齐后的尺寸大小(FileAlignment的整数倍)
0X14 DWORD PointerToRawData; //节区在文件中的偏移量
//0X18 DWORD PointerToRelocations; //重定位偏移(obj中使用)
//0X1C DWORD PointerToLinenumbers; //行号表偏移(调试用)
//0X20 WORD NumberOfRelocations; //重定位项目数(obj中使用)
//0X22 WORD NumberOfLinenumbers; //行号表中行号的数目
0X24 DWORD Characteristics; //节属性(按bit位设置属性)
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
Flag Value Description
IMAGE_SCN_CNT_CODE 0x00000020 The section contains executable code.
IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 The section contains initialized data.
IMAGE_SCN_CNT_UNINITIALIZED_ DATA 0x00000080 The section contains uninitialized data.
IMAGE_SCN_MEM_EXECUTE 0x20000000 The section can be executed as code.
IMAGE_SCN_MEM_READ 0x40000000 The section can be read.
IMAGE_SCN_MEM_WRITE 0x80000000 The section can be written to.

PE体

IID、IAT、绑定导入表

可选头的DataDirectory数组指明了IID和IAT的地址。

IID(导入表)

IID结构体记录了PE文件要导入哪些库文件。

1
2
3
4
5
6
7
8
9
10
typedef struct IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; //导入表结束标志
DWORD OriginalFirstThunk; //RVA指向一个结构体数组(INT表)
};
DWORD TimeDateStamp; //时间戳
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name; //RVA指向dll名字,以0结尾
DWORD FirstThunk; //RVA指向一个结构体数组(IAT表)
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
IAT与INT

IAT(Import Address Table)、INT(import Name Table)

PE装载器把导入函数输入至IAT的顺序:

  1. 读取IID的Name成员,获取库名称字符串。
  2. 装载相应库。-> LoadLibrary("dll_name")
  3. 读取IID的OriginalFirstThunk,获取INT地址。
  4. 逐一读取INT中数组的值,获取相应IMAGE_IMPORT_BY_NAME地址。
  5. 使用IMAGE_IMPORT_BY_NAME获取相应函数的起始地址。
    -> GetProcAddress("fun_name")
  6. 读取IID的FirstThunk(IAT)成员,获得IAT地址
  7. 将获得的函数地址填入IAT相应的数组值
  8. 重复4-7,直到INT结束。
  1. 加载到内存前
    两者结构相同,IAT和INT都指向一个结构体数组,此数组存储要导入的函数的序号和函数名。
    IAT和INT的元素为IMAGE_THUNK_DATA结构,而其指向为IMAGE_IMPORT_BY_NAME结构。
1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal;
DWORD AddressOfData; //RVA 指向_IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

4字节最高位如果为0,则这4字节值为IMAGE_IMPORT_BY_NAME的RVA;
4字节最高位如果为1,则去掉最高位,剩下的31bit值时dll函数在导出表中的导出序号。

1
2
3
4
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; //可能为0,编译器决定,如果不为0,是函数在导出表中的索引
BYTE Name[1]; //函数名称,以0结尾,由于不知道到底多长,所以干脆只给出第一个字符,找到0结束
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
  1. 加载到内存后
    INT保持不变,IAT根据导入表INT(IAT加载前)的内容和导出表信息,修改为对应的函数的地址信息。

绑定导入表

依据导入表中的时间戳来判断IAT是否进行了绑定导入。

当DLL地址重定位时,则绑定导入表和重定位表需要修改。

1
2
3
4
5
6
7
//最后一个结构全0表示绑定导入表结束
typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
DWORD TimeDateStamp; //表示绑定的时间戳,如果和PE头中的TimeDateStamp不同则可能被修改过
WORD OffsetModuleName; //dll名称地址
WORD NumberOfModuleForwarderRefs; //依赖dll个数
// Array of zero or more IMAGE_BOUND_FORWARDER_REF follows
} IMAGE_BOUND_IMPORT_DESCRIPTOR, *PIMAGE_BOUND_IMPORT_DESCRIPTOR;

NumberOfModuleForwarderRefs是指该dll自身依赖的dll的个数。
值为n代表该结构后面紧跟了n个IMAGE_BOUND_FORWARDER_REF结构。

1
2
3
4
5
typedef struct _IMAGE_BOUND_FORWARDER_REF {
DWORD TimeDateStamp; //时间戳,同样的作用检查更新情况
WORD OffsetModuleName; //dll名称地址
WORD Reserved; //保留
} IMAGE_BOUND_FORWARDER_REF, *PIMAGE_BOUND_FORWARDER_REF;

注意:这两个结构体中所有的OffsetModuleName都是相对于绑定导入表首地址的偏移地址
即:绑定导入表首地址 + OffsetModuleName = RVA

EAT

导出表(Export Table)。
这玩意一般是给DLL用的,别的exe可以用DLL导出的函数,exe一般没有导出表。

DataDirectory[0]中的VA指向导出表。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; //未使用
DWORD TimeDateStamp; //时间戳
WORD MajorVersion; //未使用
WORD MinorVersion; //未使用
DWORD Name; //指向改导出表文件名字符串
DWORD Base; //导出表的起始序号
DWORD NumberOfFunctions; //导出函数的个数(更准确来说是AddressOfFunctions的元素数,而不是函数个数)
DWORD NumberOfNames; //以函数名字导出的函数个数
DWORD AddressOfFunctions; //导出函数地址表RVA:存储所有导出函数地址(表元素宽度为4,总大小NumberOfFunctions * 4)
DWORD AddressOfNames; //导出函数名称表RVA:存储函数名字符串所在的地址(表元素宽度为4,总大小为NumberOfNames * 4)
DWORD AddressOfNameOrdinals; //导出函数序号表RVA:存储函数序号(表元素宽度为2,总大小为NumberOfNames * 2)
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
  • 每个dll都有一个导出表,每个导出表有三个子导出表(地址AddressOfFunctions、名字AddressOfNames、序号AddressOfOrdinals)。

从库中获取函数地址的API称为GetProcAddress()函数。该API引用EAT来获取指定API的地址。
下面说明它如何获取函数地址。

  1. 用AddressOfNames成员转到函数名称数组
  2. 通过比较数组中字符串,查找指定函数名
  3. 用AddressOfNameOrdinals成员转到函数序号数组
  4. 在序号数组中查找相应的序号值
  5. 用AddressOfFunctions转到函数地址数组
  6. 在地址数组中用序号作为索引,获得指定函数的起始地址

去除UPX特征

修改区段名

十六进制编辑器打开,可以看到UPX0、UPX1、UPX2三个区段名,随便改成其他的什么名字,再试试upx的-d命令

可以看到已经不能脱壳了。

去除UPX头

0x200到0x224处的数据是UPX头,供UPX脱壳识别的一些信息,不影响程序运行,只与解压缩壳有关,全部给他扬了。

程序还可以正常运行,不过一些UPX版本信息之类的已经是无了。

特征码

不过就算扬了UPX头,exeinfo之类的查壳程序也还是能检测出来程序有UPX壳,因为检测UPX是通过一些特征码来检测的。
UPX解压缩时必然会用到一些汇编指令,这些指令的机器码就成为了用来检测UPX的特征码。

UPX特征码:

1
2
3
特征码1:60 BE ?? ?? ?? 00 8D BE ?? ?? ?? FF
特征码2:60 BE ?? ?? ?? ?? 8D BE ?? ?? ?? ?? 57 EB 0B 90 8A 06 46 88 07 47 01 DB 75 ?? 8B 1E 83 ?? ?? 11 DB 72 ?? B8 01 00 00 00 01 DB 75
特征码3:55 FF 96 ?? ?? ?? ?? 09 C0 74 07 89 03 83 C3 04 EB ?? FF 96 ?? ?? ?? ?? 8B AE ?? ?? ?? ?? 8D BE 00 F0 FF FF BB 00 10 00 00 50 54 6A 04 53 57 FF D5 8D 87 ?? ?? 00 00 80 20 7F 80 60 28 7F 58 50 54 50 53 57 FF D5 58 61 8D 44 24 80 6A 00 39 C4 75 FA 83 EC 80

[De1CTF2019]Re_Sign

题目地址

为什么用这个题,因为写这玩意的时候手头没有现成的32位程序,我也懒得去编译一个了

不能直接upx -d,拖进winhex看看,发现是UPX头被扬了

第一次接触这个题是大概半年之前了,当时手脱壳脱不出来,搜了很多wp,要么跳过脱壳的过程直接开始分析,要么就是用吾爱破解的静态脱壳机(这玩意确实好用,不过现在新版UPX的壳已经脱不了了),又看到有人说是导入表乱了,反正是一堆问题 =_=

最近重新开始学PE文件格式,顺带把壳和傀儡进程也学习一下,想到这个题又鼓捣了一通。刚开始以为是ASLR的问题,查了发现这个文件没开ASLR……然后又试着脱了一下,当场脱出来了,运行也一切正常。本来想详细写一下手脱壳过程,但是复现的时候又双叒叕gg了,死活复现不出来,干,只能放弃了。真是玄学。

修改特征码

试一下改特征码1,把入口的pushad扬了。
顺手改一下区段名。

程序还能正常运行,但是脱壳机已经gg了。

DIE扫一下,已经识别不出来UPX壳了。

Exeinfo看一下

识别出来壳,不过已经识别不出来是UPX壳了。

换NFD看看

疑似UPX壳,看来还是识别出来了。

再继续瞎改一通,思路就是保持汇编指令的含义不变,但是使用别的指令。

特征码2:

特征码3:

其实特征码3这里没看懂原文short改long怎么改的(wtcl wtcl
于是我就使劲盯着这段代码看,后来发现最后的指令是popad恢复现场,那么前面不管pop到哪个寄存器里应该就都无所谓了,只要保证堆栈平衡就行,于是把eax改成ebx。

然后再加2个垃圾区段

这个启发式搜索害挺🐂🍺的,不开的话扫不出来壳,开的话扬了特征码加了垃圾区段还是能扫出来壳,以后一定要学学这是个啥东西(坑++

移动PE头

这个用的还是我自己写的64位程序。

选中的部分是可以扬了的DOS头,不影响程序运行。
扬了DOS头要修改PE头的入口地址,并且保证PE头的长度等于原来的DOS头加PE头长度,不然后面的地址都会出问题。
DOS头的长度是0x40,扬了DOS头之后在PE头后面贴0x40个无用数据。当然DOS头也可以只扬一部分 怎么高兴怎么来

修改后

测试可以正常运行

这里我一定要吐槽一下,刚开始在winhex里贴数据的时候我一直贴的是40个,然后每次都失败。刚开始猜想是不是64位和32位文件格式不同之类的问题,然后用32位程序试了一下。32位的DOS头长度是0xc0,所以要贴0xc0个数据,输数据的时候我才恍然大悟,c0是无效数据,那么其实我输入40他给我贴的是40个数据而不是0x40个数据

哈哈,有傻子,我不说是谁 :)

呃呃呃,隔的时间太久,之前修改过特征码的文件已经被我扬了,就不测exeinfo和DIE了,先记这么多(逃

IAT修复

🕊️🕊️🕊️学了再来补(逃

关于本文

本文作者 云之君, 许可由 CC BY-NC 4.0.