逆核系列No.4--UPack分析

写在前面

运行时压缩器:这类压缩器是针对可执行文件而言的,经处理后的可执行文件内部含有解压缩代码,文件在运行瞬间于内存中解压缩程序对压缩部分进行代码还原。本节将要对经Upack压缩后的可执行文件进行PE格式的分析,以达到更好的理解PE文件头以及Upack压缩的巧妙之处(未违背PE格式)。


概览

Upack修改的部分:

DOS_HEADER.e_lfanew
_IMAGE_NT_HEADERS.FileHeader

(相关字段WORD SizeOfOptionalHeader,该字段指示PE头另一个结构体成员OptionalHeader的大小,Upack增加了该值,由于在设计PE格式之初为了使用不同大小的OptionalHeader增加了该字段,通常PE32文件格式其大小确定为E0,而64位的PE32+则设置为F0,Upack增加该值的目的为了延后节区表的起始,欺骗PE装载器达到扩容的目的,扩容的空间用于在IMAGE_OPTIONAL_HEADER和第一个IMAGE_SECTION_HEADER之间增加空间供写入解压代码需要。)

IMAGE_NT_HEADERS.OptionalHeader

(相关字段DWORD NumberOfRvaAndSizes,该字段用于指示OptionalHeader最后一个成员数组DataDirectory的成员个数,正常是16个,值0x10,Upack修改为0x0A,相对减少,使得余下的几个DataDirectory元素的空间可用于写入解压代码)

IMAGE_SECTION_HEADER中字段

该结构体内的一些字段对于运行并不是必要的。这些字段如下:(DWORD PointerToRelocations ; DWORD PointerToLinenumbers ; WORD NumberOfRelocations ; WORD NumberOfLinenumbers)

节区重叠。
RVA2RAW
导入表

ps:概览列出的独到这里有个印象即可,后面详细阐述


DOS_HEADER相关

upack压缩前的DOS_HEADER用于指示PE头的关键字段e_lfnew值由0xE0变为0x10。正常的部分e_lfnew由文件DOS_HEADER加上可选的DOS存根部分在之后才是IMAGE_NT_HEADERS的起始。

UPack此举的目的在于重叠DOS_HEADER和IMAGE_NT_HEADER

之所以可以进行该操作是由于DOS_HEADER的关键字段只有两个,其余的字段覆盖也不会对运行造成影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct _IMAGE_DOS_HEADER
{
WORD e_magic; //关键字段1⃣️
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew; //关键字段2⃣️
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

UPack压缩后的PE头布局在十六进制下的视图:

e_lfnew = 0x00000010 使得IMAGE_NT_HEADERS在第二行就开始了。


_IMAGE_NT_HEADERS -> FileHeader.SizeOfOptionalHeader

FileHeader.SizeOfOptionalHeader用于指示_IMAGE_NT_HEADERS结构下的第三个结构体成员_IMAGE_OPTIONAL_HEADER所占的大小。

示例文件中的的SizeOfOptionalHeader的值由0x00E0变为0x0148,此举的目的在于拉伸_IMAGE_OPTIONAL_HEADER的大小(实际不需要用那么多,这是为了让过多余的空间可用于存放解压代码,在文件运行的瞬间执行,将压缩的PE头进行还原)。

SizeOfOptionalHeader字段的存在也是在设计之初为了兼容不同大小的SizeOfOptionalHeader而存在的,32位的PE32中-IMAGE_OPTIONAL_HEADER大小为0xE0,64位下PE32中_IMAGE_OPTIONAL_HEADER的大小为0xF0。

此外,压缩后的_IMAGE_FILE_HEADER结束是从0x26+2(最后一个字段Characteristic占2byte)也就是从0x28是为_IMAGE_OPTIONAL_HEADER的起始位置。

_IMAGE_OPTIONAL_HEADER的起始位置0x28+SizeOfOptionalHeader(0x148)=0x170为_IMAGE_SECTION_HEADER的开端。

分析压缩后的OptionalHeader:区域0x28~0x16F,其中0x28~0x108是正常的OptionalHeader,0x108~0x16F是扩容出来的部分,可用于写入解压缩过程需要的资源


IMAGE_OPTIONAL_HEADER.NumberOfRvaAndSizes

NumberOfRvaAndSizes用于指示OptionalHeader最后一个成员数组DataDirectory的成员个数,正常是16个,值0x10,Upack修改为0x0A。

为验证上述结论,对比压缩前后的OptionalHeader.NumberOfRvaAndSizes

压缩前,直接使用工具读取即可:

压缩后的可使用OptionalHeader.NumberOfRvaAndSizes字段在OptionalHeader的相对偏移计算,文首的PE图中得知该字段相对于OptionalHeader的偏移为0x5C,又步骤二分析的值OptionalHeader的起始位置是0x28,因此计算得到压缩后的OptionalHeader.NumberOfRvaAndSizes = 0x0A

UPack此举的目的在于将_IMAGE_OPTIONAL_HEADER最后一个结构体数组成员的元素的不必要元素进行缩减,所节省出来的空间可用于写入解压缩代码。

1
2
3
4
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY; //每个数组成员元素占8byte

至此可以回答步骤二中留的疑问:经扩展后的OptionalHeader(0xE0 -> 0x108)哪个部分开始存放着UPack的代码?

答:_IMAGE_OPTIONAL_HEADER最后一个成员_IMAGE_DATA_DIRECTORY的相对于OptionalHeader的起始位置偏移时0x60(从文首PE结构图得出)并且经过缩减实际交付OptionalHeader的_IMAGE_DATA_DIRECTORY只有10个单元,每个单元占8byte,OptionalHeader起始位置0x28,故UPack的代码区域为0xD8~0x16F:

OllyDbg中选中部分对应的部分代码:


IMAGE_SECTION_HEADER的内容

经过前面的分析,已经知道_IMAGE_SECTION_HEADER的起始位置是0x170,工具分析出压缩后的文件也是三个节区,再根据每个节区的结构体大小0x28可分出三个节区头。

查资料了解到UPack会利用节区头中一些对程序运行无意义的字段用于存放UPack解压缩是需要用到的一些数据,下面就剖析哪些字段被挪用以及都写入了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations; //对程序运行无意义的字段1
DWORD PointerToLinenumbers; //对程序运行无意义的字段2
WORD NumberOfRelocations; //对程序运行无意义的字段3
WORD NumberOfLinenumbers; //对程序运行无意义的字段4,一共12个byte的内容。
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

即三个节区对应的节区头IMAGE_SECTION_HEADER中有12byte的空间可被UPack利用。在由文首的PE结构图中相对位置可以知道:相对每个节区头起始的偏移0x18 - 0x24的数据会是UPack的内容,下面是压缩前后的对比(举例第一个节区即可,其他类似):


节区重叠

使用工具查看经UPack压缩后的文件的节区信息,结果第一、三节区在文件中的偏移地址和所占文件的大小一致(文件起始地址:0x10,大小:0x1F0)

PE格式第一部分是DOS_HEADER的部分,图中的信息说明了经压缩后的文件从0x000~0x1F0这区间内的内容映射到内存会是三个部分(DOS_HEADER(RAW0x10属于该部分)、第一个节区&第三个节区(节区信息决定)),在学习《逆向工程核心原理》一书中提到了第一个节区的内存空间(VirtualSize)实际上是未压缩前文件的ImageSize大小(字段位于OptionalHeader.SizeOfImage),这里做下验证:

回到先前的分析,节区信息中关于第一、三节区的raw size = 0x1F0这实际上很小,而第二个节区的raw size = 0xAE28 非常大。

书中提到的是第二个节区占据文件大部分空间实际上这里存放的是未经压缩的文件信息在这里,然后在压缩文件运行瞬间,借由先前分析的一些用于写入UPack解压代码和数据(主要是OptionalHeader到SectionHeader扩容出来的空间【0x108~0x16F】)进行处理,会将第二节区的内容进行解压到第一个节区与原SizeOfImage相同的内存空间。


RVA2RAW

解压Upack压缩的代码(在第二个节区)到第一节区,需要找到程序的入口以正常运行程序,涉及到RVA2RAW的地址转换

RVA2RAW的变换(注意PointToRawData要遵循值是OptionalHeader.FileAlignment或OptionalHeader.SectionAlignment的整数倍规则)

各种PE分析软件对于经UPack压缩后的文件束手无策的一点原因是无法完成有效的RVA to RAW转换,导致结构错乱,无法正常识别出文件,下边是RVA2RAW的转换方法:
$$
RAW - PointToRawData = RVA - VirtualAddress
$$

其中VirtualAddress、PointToRawData是在节区头中相应字段读取出来的值,是已知值。

以计算文件EntryPoint为例,AddressOfEntryPoint的RVA在OptionalHeader中有字段记录。

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
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint; //EntryPoint的RVA记录字段
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment; //这个字段先记着,后文会使用到。
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

以UPack压缩后的notepad.exe进行分析:

由文首给出的PE结构图可知AddressOfEntryPoint相对于OptionalHeader的偏移是0x10,

由前篇内容已分析出OptionalHeader的起始地址是0x28,可读取EntryPoint的RVA是0x00001018

以节区一为例,经UPack扩展后的OptionalHeader(大小由0xE0扩到0x148)结束位置为0x170(ps:上文分析的结果),结合_IMAGE_SECTION_HEADER结构体以及文首图中SectionHeader中字段的相对偏移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

可读出VitualAddress = 0x00001000、 PointToRawData = 0x00000010

根据上文提到的转换公式,可知RAW = RVA - VirtualAddress + PointToRawData

代入则 RAW= 0x00001018 - 0x00001000 + 0x00000010 = 0x00000028,查看0x00000028:

可以看到此处是字符串LoadLibraryA字符串的位置,并不是节区一代码的开始。

这是为什么呢?

《逆向工程核心技术》书中提到 一般而言,指向节区开始的文件偏移PointToRawData字段的值应该是FileAlignment的整数倍。FileAlignment的值在OptionalHeader中,该字段在结构体中相对偏移查看经UPack压缩后的值:

故先前计算的 RAW = RVA - VirtualAddress + PointToRawData 中PointToRawData 应为0,故实际上的节区一代码区间从

RAW= 0x00001018 - 0x00001000 + 0x00000000 = 0x00000018开始:

在OllyDbg中查看部分代码:


导入表

IMAGR_IMPORT_DESCRIPTOR的信息存放于OptionalHeader.DataDirectory数组中

1
2
3
4
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

每个数组元素占8byte,而导入表的信息是存放于数组的第二个元素

查看UPack处理后的导入表信息:

可知导入表的RVA = 0x000271EE 导入表的Size = 0x00000014.

根据RVA2RAW转换公式计算需要用到节区对应的VirtualAddress 和 PointToRawData,先查看RVA所在的节区

即导入表所在的节区的是第三节区,需要查看第三节区的 VirtualAddress 和 PointToRawData

第三节区的内容:

由此得到第三节区的 VirtualAddress = 0x00027000 和 PointToRawData = 0x00000010(根据OptionalHeader.FileAlignment = 0x200)

故导入表的RAW = 0x000271EE - 0x00027000 + 0 = 0x000001EE。

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
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //指向输入名称表的表(INT)的RVA
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; //指向导入映像文件的名称
DWORD FirstThunk; //指向输入地址表的表(IAT)的RVA
} IMAGE_IMPORT_DESCRIPTOR;
---------------------------------------------------------------------

typedef struct _IMAGE_THUNK_DATA32 {
union {
PBYTE ForwarderString; //指向一个转向者字符串的RVA;
PDWORD Function; //被输入的函数的内存地址;
DWORD Ordinal; //被输入的API的序数值
PIMAGE_IMPORT_BY_NAME AddressOfData; //指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
---------------------------------------------------------------------

typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;//ordinal
BYTE Name[1];//function name string
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
---------------------------------------------------------------------

导入表每个成员都是一个_IMAGE_IMPORT_DESCRIPTOR,占20byte,且结构体数组最后以NULL结构体结束。查看0x000001EE

可以看到末20byte为0,故导入表只有一个成员,即选中的前20byte.

指向INT的字段OriginalFirstThunk = 0x00000000 指向IAT的字段FirstThunk = 0x000011E8,但需要注意的是:

即RAW 0x200起始往后都不是节区的内容。

透过上述的两个关键字段可以看出文件导入了哪些DLL以及导入了哪些API,但指向INT的字段OriginalFirstThunk的RVA = 0(略显奇怪)但引用《逆向工程核心原理》的话:

一般而言,跟踪OriginalFirstThunk(INT)能够发现API名称字符串,但是像UPack这样OriginalFirstThunk的RVA=0时,跟踪FirstThunk (IAT)也无妨,只要INT、IAT其中有一个有API名称字符串即可。

这里跟踪FirstThunk 的RVA(0x11E8),计算RAW的值需要节区三中的VirtualAddress以及PointerToRawData的值。

根据文首的PE结构图中节区的大小以及字段的相对偏移可以看出:VirtualAddress = 0x00001000 PointerToRawData = 0x00000010

注意SectionAlignment

故RAW = 0x11E8 - 0x1000 + 0x0000 = 0x01E8

FirstThunk RAW = 0x01E8 指向的是一个IMAGE_DIRECTORY_ENTRY_IMPORT结构体数组

1
2
3
4
5
IMAGE_DIRECTORY_ENTRY_IMPORT
struct _IMAGE_DIRECTORY_ENTRY_IMPORT{
DWORD VirtualAddress;
DWORD Size;
};

至此,可以解读0x01E8中的内容:以8byte内容为单元

RVA = 0x00000028 Size = 0x000000BE

RVA = 0x00000028不属于节区部分,属于header区域的部分,在header中RVA = RAW,故不需要转化,直接查看即可:

同理,可以查看导入的DLL名称,读取出来的值为0x00000002,这也是header的部分,因此不需要转换,上图也可以看出导入的是KERNEL32.DLL,往后查看size = 0xBE的内容可以查看当前KERNEL32.DLL载入的所有函数。

Author: Victory+
Link: https://cvjark.github.io/2022/04/29/逆核系列No.4--UPack分析/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.