UPack PE文件头详细描述
UPack说明
UPack是一种运行时压缩器,其特点是用一种非常独特的形式对PE头进行变形。
使用UPack压缩notepad.exe。先将两文件复制到合适的文件夹中:
进行压缩
Upack会压缩源文件本身且不会另外备份。压缩重要文件前一定要先备份。
比较PE文件头
下载Stud_PE工具:http://www.cgsoftlabs.ro/
重叠文件头
该方法把DOS头与NT头巧妙地重叠在一起:
查看DOS头,可以看到两个重要成员e_magic与e_lfanew。后者表示NT头偏移,此处为00000010。
由此可知DOS存根被省略,NT头与DOS头进行了重叠。
IMAGE_FILE_HEADER.SizeOfOptionalHeader
对比源文件中上述四项,发现可选头长度SizeOfOptionalHeader改变为148,它比正常值E0或F0更大一些,因此节区头从偏移170(28+148)开始。
可选头大小增加,也就意味着在可选头与节区头之间增加了额外的空间,UPack就是在这个区域添加解码代码。
IMAGE_OPTIONAL_HEADER.NumberOfRvaAndSizes
查看可选头:
NumberOfRvaAndSizes由原本的10h变为0Ah,也就是说DATA_DIRECTORY的后六个元素被忽略(文件偏移D8后面)。在这之后的区域被添加了UPack解码代码。
使用调制器查看后面的汇编代码:
IMAGE_SECTION_HEADER
查看节区头,前面可知节区头从偏移170开始:
节区头中未框选部分对程序运行没有任何意义。
重叠节区
使用Stud_PE查看文件节区信息:
这里可以看到,第一节区和第三节区的文件偏移均为10,偏移10是PE头区域,但在UPack中该位置起就是节区部分。也就是说UPack对PE头、第一节区、第三节区进行重叠。
PE装载器会将文件偏移0~1FF的区域分别映射到3个不同的内存位置。
文件的头区域大小为200,而第二节区大小为AE28,占据了后面的全部区域。原文件notepad.exe即压缩于此。另外,第一节区区域内存尺寸为14000,与原文件的SizeOfImage(PE文件在内存中的大小)具有相同的值。也就是说,压缩在第二节区中的文件映像会被原样解压缩到第一节区。
解压缩后第一节区:
RVA to RAW
各种PE实用程序对UPack束手无策的原因就是无法正确进行RVA→RAW的变换。
UPack文件的EP的RVA为1018。
若按以往的变换方法:RAW=1018-1000+10=28。
显然RAW 28处并不是代码区域,也就不可能是EP。
出现上面这样的问题就在于第一节区的PointerToRawData值10。一般而言,指向节区中的文件偏移应该是FileAlignment的整数倍,也就是0、200、400、600等值。PE装载器发现第一个节区的PointerToRawData值不是FileAlignment的整数倍时,会将其强制转化为整数倍(该情况下为0),使得UPack文件能够正常运行。
因此正确的RAW应该是1018-1000+10=18。
导入表(IMAGE_IMPORT_DESCRIPTOR array)
UPack的导入表结构也很独特。
RVA=000271EE,RAW=271EE-27000+0=1EE。
选中区域即为IMAGE_IMPORT_DESCRIPTOR结构体,导入表是由一系列该结构体组成的数组。
这里需要注意的是,偏移200上方的粗线表示文件中第三个节区的结束,因此运行时偏移200以后的部分不会被映射到第三节区内存。仅0~1FF被映射,后面用NULL填充。
查看调试器就可以看到:
因此这里其实被隔断了,最终只用了2字节表示了FirstThunk,并且后面也没有IMAGE_IMPORT_DESCRIPTOR结构体了。
导入地址表
由上可知INT=0,Name=2,FirstThunk(IAT)=11E8(均为RVA)。
可以看到Name为Kernel32.dll,接下来再看一看导入了哪些API函数。
一般跟踪INT,此处INT=0,跟踪IAT也有同样结果,IAT对应RAW=11E8-1000+0=1E8。
上图框选部分即为IAT域,也是INT,即Name Pointer数组。可以看到由两个API,查看对应位置:
对应位置存在着导入API的[ordinary+名称字符串],也就是说分别为LoadLibraryA与GetProcAddress。(它们在形成原文件IAT时非常方便,所以普通压缩器也常常导入使用。)
INT与IAT到底有什么区别呢?
引用博客https://www.cnblogs.com/Rev-omi/p/13308430.html的解释:
程序加载前,IAT和INT指向同一结构,而加载后INT不变依旧保存dll函数名与函数序号的地址信息。而IAT则根据导入表INT(IAT加载前)的内容和库文件导出表信息,修改为对应的函数的地址信息。这也是为什么INT被称为OriginalFirstThunk的原因。
UPack调试——查找OEP
解码循环
UPack把压缩后的数据放在第二节区,再运行解码循环将这些数据解压缩后放到第一个节区。
接下来开始调试程序:
框选部分实现的操作是:将ESI(010270F0)所指的区域中的27h个Dword大小的数据移动到EDI(0101FE28)所指的地址中。
01020F0对应RAW=F0,即将F0~18C复制到对应区域,即将解码代码释放出来,以供后面执行:
而EDI=0101FE28,对应RAW=B028,恰好是PE文件末尾处。
复制完成后:
继续调试:
后面的指令将接下来的区域进行填充,一次填充一个FFFFFFFF,一个00000,4个01,1C00个400,一直填充到01026EDC处,接近第二节区结尾处。
REP STOS DWORD PTR ES:[EDI]
用EAX的值,填充EDI,填充ECX个DWORD。
接下来会进入一个循环:
分析循环
首先这里反复调用的函数地址101FCCB即为decode()函数地址,查看函数:
这里注意在这个函数中edx的值始终不变,最开始指向001FEC4,即前面复制的结尾
当不发生跳转时:
当发生跳转时:
分支结束后:
[EBX] , [EBX-4] , [EBX+4]这三个变量分别命名为A,B,C
1.将A与[EDX]相乘然后除以1000H,放在EAX中。
2.[B]按字节逆序再减去C,放在EDX中。
3. EAX的值与EDX进行比较
(1)若大于则不跳转 :A = EAX,[EDX] += (800 - [EDX]) / 20H) , 通常[EDX] <= 400H 。
(2)若小于等于则跳转:C += EAX , B -= EAX , [EDX] -= [EDX] / 20H .然后置CF位为1。
4.对比[EBX+3]字节是否为0,若为0,则 B++, C /= 3000H , A /= 3000H 。
执行返回。
函数返回后继续调试:
可以看到紧接着就是jb语句,这里CF=1跳转,CF=0顺序执行。
首先看不发生跳转的分支,对应上面函数的分支(1):CF=0。
整个跳转过程如下图所示:
总而言之经过一系列变换,最终到达FE5D并重新跳转至FD13开始新的循环。
接下来看不发生跳转的分支,对应上面函数的分支(2):CF=1。
同样的经过一些列变换,最终到达FE5D处并返回最初的循环。
两种加密方式最终都执行上图指令再返回,显然上面指令是将EAX的内容写入ES:[EDI]中。也就是说前面的命令先执行解压缩的操作,然后将结果写入实际内存。
在FE5D处下断点,F9调试,查看对应区域:
可以看到对应区域不断被填充。
最后可以看到:直到填充至0104B5A位置,接近整个内存区域的末尾(01014FFF)。
设置IAT
一般而言,压缩器执行完解码循环后会根据原文件重新组织IAT。(前面UPX也是这样:第二个循环解压缩,第三个循环恢复CALL/JMP指令,第四个循环恢复IAT),接下来分析IAT设置过程。
循环1:需要读取[edi]+18后结果为0X00或0X01才可以继续执行,也就是说要查找存储E9或E8的字节。至01001810第一次获得符合要求字节。
循环2:需要读取紧接着的一个字,且该字al为1。至01001829获得第一个符合循环1,2数据。
上述循环结束后,继续调试:
逐个读取名称字符,直到0结尾处:
获取紧跟着的API函数的地址:GetProcAddress()函数依次将获得到的函数地址写入EDI所指向的地址。
直到当前库中的API函数全部获取完成,此时会遇到两个0:
字符串结束后,下一个字符仍为0,说明当前库中API函数全部huo’q获取地址。跳转获取下一个库:
最终执行结束,RETN返回,此时程序就到达了OEP:0100139D处。
书中还提到了设置硬件断点查找OEP的方法:
F9直接运行到OEP处,但这种方法的前提是事先知道该值时OEP。