战前准备

要制作最小PE文件,那么需要知道哪些字段可以删,哪些字段不能删,而哪些字段需要修改

首先,需要知道哪些字段可以删除/修改,即对这个字段进行任何操作都不会影响程序的运行

下图将所有可删的字段全部改为了1,由于010editor的特性,这里将会显示为橙色

当然,要缩小PE文件绝不止是将这些橙色部分进行修改,下面的数据节、引入函数节等都需要修改

值得注意的是,由于修改PE文件会涉及RVA和FA的修改,如果每一步都进行这些修改,虽然会使得更改过程更加清晰,但是效率将会大大降低,所以作为马后炮,我在这里先将RVA和FA的修改按下不表,将大致过程结束之后在进行RVA和FA的统一更改

第一步

删除DOS桩和MZ签名部分

MZ签名最重要的就是前两个字节,其余都可以删除,但是需要注意的是,003CH处用于定位NT映像头开始的位置,当NT映像头的位置改变时,这个地方也需要做出变化

如下图所示,是删掉上图所示一些部分的结果(注意观察左边的地址)

为什么不删除MZ签名后的两个字节呢?这就需要注意观察刚才所说的003CH位置了,删去部分MZ文件头和DOS桩后,003CH的位置将会被NT映像头中的可选映像头中的SectionAlignment(节的对齐粒度)替代,而在给到的模板中,这个对齐粒度为4字节,而这个数转换为地址则为00000004H,恰好是删除操作之后的NT映像头的准确位置,因此,MZ签名后的两个字节并不删除,方便寻址与粒度的配合

第二步

删除可选映像头的Directory域部分(这里放没有删除DOS-MZ文件头+DOS桩的图以方便理解)

这里分为两部分,一个是NumberOfRvaAndSizes,一个是Directory域

蓝色部分的第一个四字节为NumberOfRvaAndSizes,指明了数据目录有多少个项,此处的值为00000010H,即16个项,由于Directory域中只有输入表是必须的,而输入表在数据目录的第二项(如图所示)

因此只需要留下两项,即把NumberOfRvaAndSizes改为00000002H

后面的更改便是把Directory域中的除了输出表和输入表的部分给删除就行了,由于一个项在域中占8个字节,因此在NumberOfRvaAndSizes后面的16个字节不删除,其余的都可以删

这里放删除DOS-MZ文件头+DOS桩之后需要删的部分(蓝色部分)

删除后

记得改NumberOfRvaAndSizes

第三步

删除.rdata节在节表中的位置

让我们从模板来看这个位置

如图所示,这个地方就是节表,紧跟在Directory域后面,节表中的每一个项都占40字节,即28H

节表最重要的是代码节,即.text节的部分,其余都可以进行删除

此处,我们需要删除.rdata节的部分,即下图蓝色部分

而在删除之后,需要更改NumberOfSections这一项为1

(吐槽一下,就算不删.rdata部分,直接改这个为01程序也能继续运行)

而除了.rdata项之外,.text项是否也有可以删除的地方呢

答案是肯定的

节表中的.text项后面的16个字节是可以被删除的

这个可以被删除的部分为“本节属性”,可能被后面的东西取代了,但不造成影响

于是,在上一步的结果后面,我们继续做减法

这一段可以删除

然后记得把NumberOfSections这一项改一改

在这一步我们可以得到这个结果

第四步

由于要制造最小PE文件,所以后面汇编有得你写的需要重写,因此原先代码节的部分就可以删除了

根据可选映像头中的AddressOfEntryPoint可以定位代码节的开始

那怎么定位结束呢?

这就需要知道IAT表的位置了,由于.text节和.rdata节紧密相连,而.rdata节中的第一个部分就是IAT,因此找到IAT的开始就找到了.text节的结束了

那么如何找到IAT的位置呢?

这就需要看我们的Directory域了(虽然上面删掉了,但是也可以看模板找字节进行搜寻对吧?),Directory域中有IAT这一项,这一项和其他项一样,有RVA和大小,有了RVA就可以定位了

下图为模板中Directory域中IAT的RVA

对应的位置

根据OEP和IAT的RVA,我们就可以确定要删除的代码段了

于是这一步我们可以得到这个结果

第五步

见缝插针

在这一步,我们需要把.rdata中需要的函数名和dll文件的名字放在上面那些可以被更改但不会影响程序运行的地方

首先明确,在模板给出的函数中,实际真的被用到的只有MessageBoxA函数和其对应的dll,即use32.dll

因此我们需要把这两个放到适当的位置

此处略过对比,直接上结果

把user32放到这里

把MessageBoxA放到这里

这样下面那些名字就可以删掉了

但是有个小问题,应该删到哪呢?

这里我们需要知道.rdata节的最后一部分,即IMPORT Hints/Names&DLL Names的结构了,在一个部分里面,分为两个小部分,两个小部分通过一个字节的00隔开,部分与部分之间用两个字节00隔开

下面简要说明这两个小部分:

一个是IMAGE_IMPORT_BY_NAME,还有一个是DLL names字符串,第一个是一个结构由两项组成:Hint和Name,Hint占两个字节,Name为一个字符串

通过这个,我们可以推断,如果要知道删到哪,那么需要知道整个部分的第一个函数,然后往前找两个字节就可以了

于是我们可以知道要删的就是下图这个部分(以上一步的结果为例)

那么我们就删除了后面的IMPORT Hints/Names&DLL部分了

第六步

这一步,我们需要重构原先的IAT和IDT

可能有人会问,那INT(IMPORT Name Table)呢?

这个可以直接删掉的,只要让IDT指向IAT或DLL文件名,IAT再指向函数名就好了,INT?真不熟!

为此,我们先删除整个.rdata节,然后根据IAT和IDT的结构进行复原

删去之后,我们需要明确,由于我们设置的粒度为4,所以最后的结果必须是4字节的整数倍,在这个地方,我们删除.rdata节的所有数据后,可以在文件末尾补两个字节的0,后面就不用再补了

image-20230926100423971

现在,让我们开始复原IAT和IDT吧

首先我们先认识IAT的结构

1
2
3
4
5
6
7
8
IAT是一个指针数组
数组内存储着函数名前两个字节的地址(即上文所述的Hint)
一个元素四个字节
相同DLL内的函数名地址相连
如这里两个函数名地址
F8 02 00 00 EC 02 00 00
不同DLL中的函数用四字节0隔开
D0 02 00 00 00 00 00 00 F8 02 00 00

然后再让我们看看IDT的结构

1
2
3
IDT由一系列的 IMAGE_IMPORT_DESCRIPTOR结构组成
结构的数量取决于程序要使用的DLL文件的数量,每一个IMAGE_IMPORT_DESCRIPTOR 结构对应一个DLL文件
在所有这些结构的最后,由一个内容全为0的 IMAGE_IMPORT_DESCRIPTOR 结构作为结束

下面重点介绍一下IMAGE_IMPORT_DESCRIPTOR 的数据结构

1
2
3
4
5
6
7
8
9
10
11
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; //0 for terminating null import descriptor
DWORD OriginalFirstThunk; //指向INT内的地址
}; //此处共占4字节
DWORD TimeDateStamp; //当可执行文件不与被导入的DLL进行绑定时,此字段为0
DWORD ForwarderChain; //第一个被转向的API索引
DWORD Name; //指向被导入的DLL名称
DWORD FirstThunk; //指向IAT内的地址
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

由结构可知最重要的是OriginalFirstThunk、Name和FirstThunk这三个部分,由于我们已经删除了INT表,因此需要让OriginalFirstThunk和FirstThunk的值都指向IAT表

对于IAT表,我们需要让它指向MessageBoxA这个函数,因此可以重构成这样

1
1C 00 00 00 00 00 00 00

对于IDT表,我们可以重构成这样

1
2
3
4
5
6
7
E8 00 00 00
00 00 00 00
00 00 00 00
0C 00 00 00
E8 00 00 00
00 00 00 00
00 00 00 00

最后结果如下(注意IAT指向的地址和IDT指向的地址)

第七步

让我们最后整点汇编吧

首先把能进行修改的字节标1(橙色)

OD,启动!

我们以模板的汇编代码为准进行修改

由于上面已经分析过代码段的位置,这里不再赘述,在OD中直接找到这个位置

先结合模板分析一下

(模板中的代码段)

(模板中的数据)

这里需要一点汇编知识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
push 0x1040
// 68 40 10 00 00 压栈

push 0x4001F8
// 68 F8 01 40 00 标题

push 0x40020C
// 68 0C 02 40 00 文本框内容

push 0x0
// 6A 00 某个参数,不过这里没有

call 00400264
// E8 14 00 00 00 CALL进MessageBoxA中,这里的地址需要计算

jmp dword ptr ds:[0x400274]
// FF 25 74 02 40 00 400274H(little-endian)是IAT地址

由上文所述,如果需要程序正常运行,那么需要将数据段的数据准确压入栈中,为此我们需要在地址中下一点功夫

根据数据段的位置,以及可以被修改的字节位置,我们可以写出以下汇编代码

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
push 0x1040
// 68 40 10 00 00 压栈(与上面相同)

jmp
// EB 0D 间接跳转,后面会大量使用,用于在不连续内存块中保证运行完整

push 0x4000A4
// 68 A4 00 40 00 标题

jmp
// EB 1A 间接跳转

push 0x4000B8
// 68 B8 00 40 00 文本框内容

push 0x0
// 6A 00 某个参数,不过这里没有

call 00400264
// E8 0B 00 00 00 CALL进MessageBoxA中,这里的地址需要计算

jmp
// EB 09 间接跳转

jmp dword ptr ds:[0x4000E8]
// FF 25 E8 00 40 00 4000E8H(little-endian)是IAT地址

然后就可以插入了

这里重点提一下中间的call后面地址的计算

1
X=真正要跳转的地址-(E8这条指令的地址+5)

根据在OD中对模板文件的查看,容易知道E8跳转的地址实际上就是汇编代码最后一段的地址(FF25),因此我们可以得出

1
X=00400070H-(00400060H+5H)=0BH

这样我们的汇编部分就结束了

第八步

最后做一点小修改

首先是SizeOfOptionalHeader

这个是整个可选映像头的大小,鉴于我们对可选映像头的修改只有删除Directory域,因此这里做个简单的加减法(E0-70=70)就可以得出结果了

然后是OEP

鉴于我们把汇编代码乱放,因此我们需要找到第一条指令的执行位置并修改OEP

接下来是SizeOfHeaders

别看这有个Size,实际上指向的应该是第一个节开始的位置,这里放OEP就好

之后是Directory域的修改

即输入表的RVA和大小的修改,不多赘述

最后是代码节的修改

将地址改为数据的起点即可

重点要更改的是节表的VirtualAddress和PointerToRawData这两项

当然,不能忘记改自己的名字和学号,这个就不放了(GBK编码)

至此,所有工作已经结束,我们已经获得了一个Win11下可以正常运行的最小PE文件(以模板给的字符串是268B,如果删掉数据节一点东西的话应该能更小)