对exe文件脱壳之后的重建输入表以及函数的转发

作者:tgrddf55

一.问题引入

在b站视频(BV1LL4y1n7Af)crackme 第十个视频ceycey中,笔者按照视频所演示的进行脱壳,却发现dump之后的exe文件无法打开,在师傅的指引下,知晓了这可能是本机的windows系统版本太高的问题,并且这个问题可以通过重建输入表来解决,于是笔者开始了重建输入表的学习。


本篇内容包括PE文件中的输入表结构、壳的功能及简单原理、何为dump,
如何重建输入表、函数的转发。

如果读者遇到了在64位系统中脱32位程序的upx壳失败的问题,并且上述知识没有任何储备的话,建议从头认真阅读本文章。

本文章是笔者边学边写,因此可能存在部分问题,望读者指正。


二.为什么要重建输入表

由于这个问题涉及到较多的前置知识,本篇文章致力于让没有学习这些知识的同学也能理解内容,但笔者能力有限,如果想对该篇内容进行更深刻的理解的话,建议学习PE文件结构

(1)输入表结构

exe文件即为PE文件中的一种,这个文件中存有许多结构,类似于一座房子,有窗户,有门……其中输入表就是其中的一种结构。

输入表的作用就是引用外部的函数,比如C++中我们可以引用sqrt()函数用来求根号,而sqrt()函数就是存放在cmath头文件中。输入表就是存放程序所引用的外部函数的信息的。外界函数肯定是好多个函数存放在某一个文件中的,这个文件的后缀是.dll,因此输入表中的信息是包含好多个.dll和它所存放的函数的。

接下来我们来看一张图,这张图展示了PE文件中的输入表结构。

输入表

图片左边的那一列是输入表最外层的结构,,我们应该注意的是,一个dll文件就对应一个这样的结构,因此文件中实际存在的是很多个这样的结构。我们重点关注其中第1、4、5个元素,我们将其简称为OFTnameFT

其中name这个元素指向的.dll文件的名字,也就是说,这一个结构所包含的信息是关于这一个.dll文件的。这张图中就是“Kernel32.dll”。

文件未载入内存时,也就是文件在磁盘中存放时,OFTFT分别指向了一组关于dll文件包含函数的信息,通过这些信息就可以找到程序所要引用的函数,这两组信息我们分别称为 INTIAT,如图所示。

这两张表的数据会因为程序载入内存而改变。接下来我们介绍一下在程序载入内存时这两张表的信息是如何改变的。

在此之前我们需要知道的是,程序载入内存时,因为程序需要调用外部来自dll的函数,所以dll文件就会在程序分配的地址中展开,这时候dll文件中的函数就会存在一个地址,程序里面只要引用这个地址就可以调用这个函数了。这类似于我们自己在程序中写了一个函数,函数就会在程序中存在一个地址,调用这个函数的时候只需要引用这个地址就可以,dll文件只是帮我们把函数提前写好,dll在程序被分配的地址中展开就相当于dll文件在程序中写入这个函数。这告诉我们我们要想快速的引用这个函数,只需要引用这个地址就可以了。

当程序被载入内存时,windows会首先读取 OFT的信息,然后找到 INT,通过INT找到函数的名字,然后通过这个名字索引到这个函数,这时已经得到了这个函数的地址,接下来将这个真实地址填入IAT表中对应的位置,比如函数1的真实地址是 7715A8,那么IAT表中第一个元素就会被填入7715A8,接下来,程序要调用函数1的时候只需要引用在IAT表中第一个元素的值,也就是7715A8就可以了。

输入表

也就是说,INT表只有在最开始被windows使用来找到函数的具体位置,之后INT就没有作用了,只需要用IAT就可以了,但是需要注意的是,INT的信息仍会被保留,而不会被清除。

(2)壳的功能及原理

壳主要分为两大类:压缩壳和加密壳。由于笔者遇到的upx壳为压缩壳,在此主要讲解压缩壳。

我们首先假设一个exe程序的步骤是这样的 1->2->3->4->5

加了壳之后,其实是将其中的某些代码压缩了,同时在程序头加上壳的代码用来解压缩。那么加上壳之后的程序就是这样:

11(壳的代码)-> 1.2 -> 2.2 -> 3.2 -> 4-> 5

程序加载到内存时,首先11(壳的代码)运行 ,运行之后 1.2就解压缩成了1,2.2就解压缩成了2, 3.2就解压缩成了3,11(壳的代码)运行完后,这三个部分也就解压缩完成,这时,程序也就运行到了1,之后就和没有加壳的程序一样了。

上面的知识告诉我们,要想脱壳,我们只需要运行完11(壳的代码)找到1的入口点就可以了,我们将1的入口点称之为OEP(程序的原始入口点 Original Enter Point)。

这其实也告诉我们,要想找到OEP,一定需要运行完壳的代码,否则,即使你事先知道OEP的地址,但没有运行壳代码,直接跳转到这一个地址也是失败的。(静态分析)

(3)dump

接下来,我们再了解一个概念 dump 。我们通过前面的知识可以知道,一个程序在磁盘中和程序在内存中的情况是不同的,至少它的IAT信息是不同的,那么我们为了研究程序在内存中的情况,就很自然地想到希望一个东西能够将内存中程序的情况复刻出来,将其保存在磁盘中。dump就是指的这个过程,通过 dump我们可以得到程序在内存中的信息。在脱壳过程中,当我们已经找到了OEP(程序的原始入口点 Original Enter Point),我们就希望能够将这个状态保存下来,这时也需 dump 这个过程,OD中就可以实现这个功能。

一个正常的没有加过壳的程序,将其在OD中 dump 出来,通过上面的知识我们可以推断出,这个被 dump 出来的程序应该仍然可以运行。虽然它的 IAT表中被更改了,但是windows仍然可以通过INT得到函数名进而使得程序正常运行。

但是加过壳的程序,它的INTIAT都可能存在问题,因此如果我们在OEP处直接 dump,这样出来的程序可能是无法运行的。

在此笔者做了大量的实验,验证了 INTIAT不同情况下程序的运行情况。
未加壳的程序,当它OFT错误(全为零),也即通过INT这条路径索引到函数名失败,IAT正确(指向函数名,而不是函数的真实地址)时,函数能够正常运行。笔者推测可能是由于windows通过IAT表来找到函数名,进而找到函数的真正地址,再填充IAT表相应位置,从而使得函数正常运行。(见下3图)

(应当注意的是,这里的RVA并非在文件中的偏移地址,但是可以通过一定的公式进行换算,Stud_PE中存在换算器)

(在此解释一下下面三张图的含义,第一张图即为通过工具找到的kernel32.dll的输入表信息,可以看到OFT为零,也就是说这里无法通过OFT找到INT,但是 FT 所指向的IAT信息是正确的)

(第二张图通过FT中的309C换算成E6C,再找到E6C这个位置,这个位置显示的是函数名的地址)

(第三张图也即通过上图找的地址索引到函数名 GetMoudleHandleA )
yiat

yiat

yiat

(可以将上面三图比照最上面第一张图输入表结构来看,进而更好地理解输入表结构)

未加壳的程序,当它的OFT错误(全为零),IAT不正确(存放函数的真正地址)时,函数不能正常运行。我都有函数的正确地址了,为什么函数还不能正常运行呢?因为windows是通过函数名来找到函数的真正地址,这一个步骤是windows能够正确识别函数的关键步骤,然而当我们的INT错误,windows仍然会进行上一段的步骤,然而正确的IAT存放的是函数名的地址,windows会通过IAT中的地址索引到函数名,现在windows根据IAT索引是找不到函数名的(因为IAT存放的是在内存中函数的真正地址,而不是函数名的地址)

(见下2图)

(第一二张图所展示的步骤和正常程序所演示的步骤相同,但注意到这里的IAT表所填入的是函数的真实地址7636DAF3(小端序存储,也即逆序存储))

(此二图同样可以比照输入表在内存中的结构来看)

yiat

yiat

接下来我们来看一下upx壳会对程序的输入表进行何种的改变。

(第一张图为没有加壳的程序输入表)
(第二章图为加壳之后的程序输入表)

yiat

yiat

可以看到加壳之后,程序的函数数量不仅发生了改变( 10->5 ),输入表中的 OFT信息也改变了,这样 dump 出来的程序中,INT /IAT也存在问题,函数数量也存在问题,如果不重建输入表的话(在OD中没有勾选重建输入表),程序一定是无法运行的。(之后会对这个问题进行分析)

二、重建输入表的原理

加壳之后,会发现这时候输入表和原来的程序输入表大不相同,甚至函数数量也不一样,按照道理来说函数数量减少了那么程序就肯定运行不起来了,为什么还能运行?

其实这时候的输入表不是真正的输入表,而应该是壳自己的输入表,通过以上学习我们可以知道,程序在内存中只需要有所有函数的真正地址就可以了,壳的代码在运行完后,肯定会将函数的真正地址展现出来,并不影响程序的运行。但是程序在OEP处 dump 出来(如果没有重建输入表)后输入表仍然是壳的输入表,并不是真正的 IAT(存放函数真正地址的表),因此这样的程序在不重建输入表是无法运行的。

但是 找到程序的OEP时,所有函数的真正地址应该都已经出现了,存放函数地址的是 IAT,我们可以通过找到这个 IAT,进而还原真个输入表的结构。通过这个原理,我们已经可以手动修复输入表了,也就是利用十六进制编辑器将函数的名字、OFTFT重新写入就可以了。

接下来我们讲解一下如何利用OD找到真正的 IAT(存放函数真正地址)。

OD中在找到OEP后,以下的代码就是原程序了,找到任意一个调用API函数的语句,然后找到在哪个地址存放了函数的地址就可以了。这些地址就是IAT。(如图)

yiat

(表中标出来的77开头的和75开头的地址就是函数的真正地址)(注意小端序存储)

三、如何重建输入表

其实OD中就存在重建输入表这个选项,但是OD不太智能,有些时候重建输入表之后仍然有问题。

其实对于upx壳来说,OD重建输入表已经完全可以了,但是笔者在64位系统中重建的32位程序,这样导致重建的输入表仍然出现问题,因此如果是upx等简单壳,不想学习较为复杂的重建输入表步骤的话,可以选择将其拖入win7 32位虚拟机中运行,笔者也对这一步进行了操作。

接下来我们开始讲解一下,使用importREC重建输入表。

(其实完全可以使用16进制的编辑器手动重建输入表,但是笔者懒)

由于笔者在群中已经发布过视频,建议跟着视频来进行学习使用ImportREC重建输入表。

视频为本文件同目录下的“ceycey 脱壳 重建输入表.mkv”

这里应该注意的是,笔者在视频中的演示存在一定的问题,在填写 IAT 地址信息的时候,笔者并没有学习到这一方面的知识,实际填写的是错误的,但是importREC可能比较智能,总之笔者误打误撞地完成了。在此笔者重新录制一个视频。读者可以选择只看这一个视频。

视频为本文件同目录下的“importrec.mkv”

四、函数的转发

接下来将解释一下笔者遇到的根本问题。

在64位程序中脱32位程序,使用OD重建输入表,此时OD会将某些函数识别错误。(见图)

yiat

图中标出来的函数是来自ntdll.dll,但是这个库却是user32.dll,那么这个程序肯定就无法运行了。

我们看一下正确的情况是怎么样的。

yiat

正确的函数是DefWindowProcA,错误的函数却是NtdllDefWindowProcA。
我们很容易就可以想到这两个函数肯定存在某种关系。
实际上确实这两个函数存在关系,这里涉及到函数的转发。

dll文件中存在很多函数,而dll文件之间也可以引用,或者说是转发。
在32位系统的user32.dll中,DefWindowProcA函数是不存在转发的,也就是正常的。但是64位的user32.dll有一些不同。我们知道dll文件中存储的是真正的函数,可是,现在user32.dll(64 位)中是没有DefWindowProcA真正函数的,只有DefWindowProcA这个名字,它将这个真正函数给了ntdll.dll,在ntdll.dll中这个函数叫做NtdllDefWindowProcA,也就是我们上两张图看到的。
其中这个NtdllDefWindowProcA就叫做DefWindowProcA的转发函数。

接下来我们讲解一下dll文件导出表的结构。

yiat

有三个表,一个存储函数的名字,一个存储函数的地址,一个索引表用来连接这两个表。dll文件在我们的程序中展开之后,windows通过程序的导入表找到所要使用函数的名字,然后拿着这个名字在展开后的dll中找,没错,就是在导出名称表中找。找到之后根据对应的索引表再找到函数的地址,然后再利用这个地址填充程序的IAT。这样就将我们之前学到的导入表的知识关联起来了。

但是现在user32.dll(64)中DefWindowProcA对应的导出地址表中存放的不是这个函数的地址(因为在user32.dll不存在这个函数了,也就没有这个地址了,只是还残留着这个函数的名字),存放的其实是其导出函数的名字的地址,我们通过这个地址找到的其实是NtdllDefWindowProcA这个字符串。
yiat

yiat

(从图一找到图二时同样存在文件偏移地址和RVA的转换,在此只是演示一下上一段所讲解的内容)

windows在程序中载入user32.dll后,知道其导出函数有转发函数了,然后就会展开ntdll.dll,然后将NtdllDefWindowProcA的地址给填入到user32.dll中的DefWindowProcA函数的地址,也就是原本存放NtdllDefWindowProcA字符串地址的位置。这样user32.dll中的导出地址表就都是函数的真正地址了,尽管有些函数是通过导出函数来找到这个地址的。最后windows就会将user32.dll的导出地址表挨着填入IAT
(见下图)
1

这样其实就已经解释了笔者遇到的问题了。

在64位系统中,OD(即使是importREC)是根据IAT表中函数的真正地址来找到这个函数名,进而重建整个导入表的,现在DefWindowProcA的真正地址填入的却是NtdllDefWindowProcA的地址,当然重建之后还是NtdllDefWindowProcA这个函数。

而32位系统中DefWindowProcA并没有转发,user32.dll中存在着这个函数,自然使用OD重建输入表是正确的。

以上关于函数转发的内容主要来源于下面这个链接

https://www.cnblogs.com/revercc/p/16703647.html

学到这里,我们就会发现,在第三部分中笔者进行的重建输入表是存在问题的。我们只是将无效的函数剪掉,也就是把原本应该存在的函数给扔掉了,刚好这个程序好像没有调用到这个函数,所以我们剪掉这个函数之后程序仍然能够运行。但是如果这个函数比较重要的话,笔者重建输入表之后就仍然不会正常运行。正确的做法就是在importREC中,不是剪掉这个函数,而是更改函数名,将ntdll前缀去掉,这样才是正确的做法(笔者的importREC似乎没有这个功能,我看网络上有这个功能的版本是v2.0.5)。

本篇内容到这里就结束了,感谢您的阅读!

如存在问题,恳请指正!