上一篇博文我介绍了Windbg使用的线性扫描(linear sweep)反汇编算法。本文我将介绍IDA使用的递归下降(recursive descent)反汇编算法。(转载请指明来源于breaksoftware的csdn博客)
递归(recursive)可能大家都很清楚,说白了就是自己调用自己。那么什么是recursive descent呢?似乎很难理解。recursive还是有循环和回归的意思,那么recursive descent就可以理解为“不停减少的循环”和“不停减少的回归”。或许这么说还是不是很好理解,那我们来研究下这个算法的思路的来源,这样可以容易理解这个算法的精髓。
回顾《反汇编算法介绍和应用——线性扫描算法分析》,我们知道线性扫描一个很大的缺点是:因为其不知道程序执行流而导致将数据识别为代码。我们可能会骂这个算法不智能,那么如何才能智能起来呢?想想我们的二进制文件在系统中正常运行时是不会出错的,因为CPU总是可以找到真正的指令起始地址,那么我们反汇编算法只要能模拟CPU执行指令就可以得到正确的反汇编结果了。OK!没错,递归下降算法一个主要的思路就是源于这样的思考结果。但是我们反汇编是静态的,而CPU执行指令是动态的,静态分析无法得知动态执行的结果,这个严重的缺陷会导致我们想完全模拟CPU执行去反汇编的思路变得不现实。但是不要退却,没有完美的方案,只有最可以接受的方案。那我们开始研究下怎么修改我们的思路,让我们的算法变得“最令人可以接受”。
研究修改的方法之前我们要了解CPU执行指令“顺序”的一些基础知识,知己知彼百战百胜。
A 顺序流指令
熟悉汇编的朋友,应该对add、sub、mov、push和pop等指令很熟悉,这类指令执行后,会执行与其地址紧接着的下一条指令。CPU识别这类指令如线性扫描一般简单,那么我们的递归下降算法也就如线性扫描方式去识别这样的指令就行了。
B 无条件跳转指令
jmp是无条件跳转指令。CPU执行这条指令后会跳转到jmp指令参数所指向的地址。这个操作对CPU来说,和顺序流指令没什么区别,只是将EIP改成要跳转的地址。但是这个动态的过程却害惨了静态分析的线性扫描算法,那我们递归下降算法要吸取教训:我们从jmp到的地址开始分析下一条指令。貌似这个想法天衣无缝,但是现实往往是残酷的。请问你一定能得到jmp的地址么?对于jmp 00401010这类的指令我们当然可以得到下条指令的地址,即0x00401010。那么jmp eax呢?eax是多少?CPU知道,我们不知道。这个缺陷我们Mark下。
C 有条件跳转指令
ja和je等是有条件跳转指令,即符合某些要求后才执行跳转,不符合要求则执行其紧接着的那条指令。这些指令的执行顺序如同A、B两种指令的灵魂附体。即条件为真,则走A流程分支;条件为假,则走B流程分支。这么一拆解,我想递归下降算法怎么去分析有条件跳转指令就清楚了。
但是有个问题需要说下,CPU执行这类指令时是知道要走A流程分支还是要走B流程分支的,它不会同时一起走这两条流程。而且可能整个程序运行完了,这个指令的一个分支还没走过(比如if(1){}else{},else永远进不去的)。而我们的递归下降算法是要分析出所有分支的!
那怎么办呢?那我们就将A和B分支的地址中的某一个优先分析,另一个延后分析。可是手心手背都是肉,我们如何取舍?这个时候,我们就要学习国羽和国乒的做法——不惜“让球”,也要选择出最有利于目前流程顺利进行的方法。那么A、B这两个孩子谁有缺陷呢?如上所述,A流程分支没缺陷,而B流程分支存在一定的隐患。那我们就将要执行跳转的B流程分支保存到一个延后分析的列表中。
最后说一句:C有B的灵魂,C有B的缺陷。
D 函数调用指令
call指令是函数调用指令,但是目前,我们可以将其看成B流程。或许有人会说call指令怎么会和jmp混为一谈呢?我们看一个call例子
0x0040177f call 0040209C
0x00401785 mov ecx,eax
其执行等效于
push 00401785 // call指令结束的位置,注意该位置不一定是call完后下条指令开始的位置
jmp 0040177F // 跳转到函数地址
可能有人疑惑为什么push进入堆栈的00401785不一定是call完后下条指令的位置?比如 我们在0040209C的代码如下
pop eax
jmp 00401788
那么,我们程序执行完将会进入00401788,从而过掉了00401785开始的指令。
是不是可以将call简单的看成jmp呢?是吧。
最后说一句:D也有B的缺陷。
E 函数返回指令
ret和retn等是函数返回指令,同call一样,我们可以将其看成是B流程分支。为什么这么说呢?我们接着以D中的例子为例。假如0x0040209C的代码最后是ret,则该ret等效于
pop EIP
因为EIP是下条指令的起始地址,则这步操作可以看成
jmp EIP // 当然不能这么写,这儿只是为了说明这是个跳转的过程
这是动态执行的流程,但是我们是静态分析,怎么知道EIP是啥呢?是的,一般情况下,我们无法知道。那么这个时候,该次递归流程就走完了,我们将会去C流程中产生的延时反汇编队列中取出地址来开始再次的递归操作……这就是递归下降算法名称的由来。
是否还记得我们在B中说的那个场景?如果我们jmp eax了而不知eax是啥时,或者call、ret不知跳转地址时,本次递归下降都会结束,并在延时反汇编列表中寻找新的起始反汇编地址。
貌似我们的递归反汇编思路都讲完了。但是还存在很大的缺陷!为什么?还记得我在《反汇编算法介绍和应用——线性扫描算法分析》所说的递归下降算法缺陷么?它可能无法覆盖全部代码。我们举个例子
0x0040177f call 0040209C
0x00401785 mov ecx,eax
.
.
.
0x0040209C ret
如果依我们之上的流程,那么0x00401785将可能分析不到,因为我们将call看成了jmp,我们该分支分析将在0x0040209C处结束,而0x00401785没出现在延迟反汇编队列中。想想,这是多么可怕!于是比较严谨的将call看成jmp将要做必要修改。
D 函数调用指令(修正后)
我们将call看成C流程,即有条件跳转。那么如上那段汇编,我们将产生两个分支:一个是00401785,一个是0040209C。虽然我们将00401785看成一个分支是非常不严谨的(因为下条指令完全由0040209C里的逻辑决定的),但是为了能尽量多的反汇编出代码,我们还是要做这个妥协!因为这个妥协,也将导致递归下降算法产生一个致命的缺陷——将call指令后数据当成指令去反汇编。
这儿有个小细节需要注意,对于Call指令,我们会将跳转分支地址优先分析,紧跟着call指令的分支延迟分析。因为存在一种可能,即跳转分支中或许可以确定返回的地址。如果返回地址和紧跟着call指令的分支地址相同,则照旧进行;如果不相同,则以返回地址为准。举个例子
void TestFun(void* lpfun)
{
__asm{
mov esp,ebp
pop ebp
pop eax
ret
}
}
int _tmain(int argc, _TCHAR* argv[])
{
__asm{
push xxx
call TestFun
_emit 0xE8
xxx:
}
return 0;
}
在TestFun中,我最后抛出返回地址到eax中。这样堆栈顶部就是lpfun,ret后,EIP变成xxx处地址,并将执行到xxx处,而不是紧跟在call后面的0xE8。我们递归下降算法,优先分析TestFun地址的指令,然后可以通过一些判断,判断出最后返回的地址是我们传入的数据,那么我们传入的数据就是正确的下条指令地址,而0xE8处只是个数据。IDA的反汇编结果是
想想,如果我们将紧跟call指令的分支优先分析,将会出现将0xE8当成call来解析的情况。那么或许之后得靠跳转分支的分析结果再来纠正,这样还不如优先反汇编跳转分支。
说了这么多,再说说上面所说的如何利用call指令分析的缺陷。通过以上例子,我们发现,如果让递归下降算法不知道其call后跳转分支的返回地址,然后在紧跟call指令的位置插入一些废信息,那就造成IDA分析失败了。看例子
void Fun( void* p )
{
__asm
{
add p,3
push p
pop eax
mov esp,ebp
pop ebp
push eax
ret 4
}
}
int _tmain(int argc, _TCHAR* argv[])
{
int i = 0;
__asm
{
push yyy
call Fun
_emit 0xE8
yyy:
_emit 0xE8
mov eax,ebp
}
return 0;
}
我在Fun函数一开始处将地址指向了return 0,然后将这个指针通过push和pop放入eax,让push eax到栈顶,从而在ret时让程序从return 0;开始执行。那么IDA反汇编结果呢?
看!已经错了,当然windbg也是分析错的。
到此,关于反汇编算法的两篇博文写完了。仅供大家参考。
附上测试的工程