最近大三学长告诉我研究生学长那边有个嵌入式恶意代码检测的项目在招人,可能也问到我们本科部这边有没有人了,然后我就说有兴趣去了,虽然不知道是不是当牛马干苦力,但是没有过类似的经历,所以还是想硬着头皮去体验一下。当然,我对逆向的各个方向都抱有兴趣。然后没想到学长还要腾讯会议面试一下,有点没想到,毕竟我们学院大二这边不知道学逆向工程的是不是只有我一个🤣,如果只招大二的话好像也没有其他人了。
有点尴尬,我的逆向经历?我好像啥逆向经历也没有吧,打过比赛,但也没拿什么有含金量的奖项😭
第一个问题谈谈学习过比较擅长的技术点,这个我的初步想法是谈谈Python逆向。
第二个问题是输出自己对于栈的理解。这个其实我很早之前就想再看一遍《逆向工程核心原理》中对栈的介绍了。记得学汇编指令的时候,对栈也只是Push,Pop和栈的特征的理解。感觉大二学数据结构也只是仅限于这些理解。但是读了《逆向工程核心原理》之后,才知道栈如何在程序中发挥用处,书里面还结合了程序在OD中调试。只看了一次读书的内容感觉自己记得还是不牢,今天就来温习一下栈。栈感觉在二进制方向当中存在感还是很高的,记得之前还遇到基于栈的虚拟机保护,难度挺高。
第三个问题我倒是不知道我应该要了解啥,我只是个牛马,学长老师说啥我都可以干👌。
本篇文章完全参考《逆向工程核心原理》。
栈的特征
栈是一种数据结构,它按照FILO(First In Last Out,即后进先出)的原则存储数据。
栈有一个栈顶指针ESP(Extended Stack Pointer),初始状态指向栈的底部。
执行Push命令可以将数据压入栈中,栈顶指针会移动到栈的顶部。
执行Pop命令可以将栈中数据弹出,如果栈为空,则栈顶指针重新移动到栈底部。
如图是一个示例程序
右上记录了初始情况下栈顶指针ESP的值,为0019FF78,右下图则是栈中的情况。
当我们调试程序,按F8执行第一条汇编指令,即push 0x100时,数据0x100便被压入栈中,而ESP的值则减少了四个字节,向上移动。
这里有个细节需要提及,我们发现压入栈时,ESP的值减小,即栈顶部在低地址处,而栈底部在高地址处,也就是说,栈是一种由高地址向低地址扩展的数据结构。这也是栈的一大特征。
当我们再执行下一条汇编指令pop eax,就会恢复初始的栈的状态,数据0x100被弹出栈,并且ESP的值加上四个字节。
总结一下,向栈压入数据时,栈顶指针减小,向低地址移动,而从栈中弹出数据时,栈顶指针增加,向高地址移动。
栈帧
栈帧还是很重要的,因为在IDA看汇编指令的时候,会经常出现EBP(栈帧指针,即Extended Base Pointer)
栈帧在函数中用于声明局部变量、调用函数。栈帧就是利用EBP寄存器访问栈内的局部变量、参数、函数返回地址等的手段。ESP寄存器承担着栈顶指针的作业,而EBP寄存器则负责行使栈帧指针的功能。因为在运行程序时,我们的ESP寄存器的值是随时变化的,如果通过ESP访问栈中函数的局部变量、参数时,这个基准就一直在变,不太方便进行访问。
所以我们在调用某个函数时,会先把用作基准点(函数起始地址)的ESP值保存到EBP,并维持在函数内部,这样,我们可以不管ESP,就以EBP的值为基准(base)能够安全访问到相关函数的局部变量、参数、返回地址。
栈帧对应得汇编代码通常如下
push ebp
mov ebp,esp
...
mov esp,ebp
pop ebp
retn
在分析32位程序的时候,我们会经常看到这样的汇编代码,他们象征一个函数的入口。
64位程序倒是稍有不同
接下来调试一个程序来了解栈帧,程序源代码如下
#include "stdio.h"
long add(long a, long b)
{
long x = a, y = b;
return (x + y);
}
int main(int argc, char* argv[])
{
long a = 1, b = 2;
printf("%d\n", add(a, b));
return 0;
}
main()
用OD打开示例程序,代码的main函数是程序入口,我们首先分析,如图中地址401020处即为main函数的汇编指令,图为程序运行时的初始状态。
我们可以看到ESP的值为19FF30,EBP的值为19FF74,地址401250现在在栈顶的位置,即保存在ESP(19FF30)当中,这个地址是main函数执行完毕之后需要返回的地址。
push ebp
main()函数首先把ebp的值压入到栈中,这是一次备份,当main()函数执行完毕之后,会进行Pop,恢复ebp的原始值,然后retn
mov ebp,esp
接着用ebp寄存器存储esp的值,此时的ebp就指向了栈顶的位置,ebp就持有与当前esp相同的值,并且直到main()函数执行完毕,它都不会被修改,我们就可以通过ebp安全访问到存储在栈中的函数参数与局部变量。执行好这两条指令之后,main()函数的栈帧就生成了,也就是设置好ebp了。所以设置ebp的过程就是栈帧生成的过程?
根据书中指引,在栈窗口右键,然后选择地址->相对于ebp,方便我们后面观察,我用的吾爱破解的中文版。
栈窗口就变得很直观了,再看主函数代码。
long a = 1, b = 2;
上述代码用于在栈中为局部变量(a,b)分配空间,并赋初始值,下一条汇编指令如下
sub esp,0x8
sub在汇编指令当中是一条减法指令,这条指令将esp的值减去8字节,而减去的8字节,实质是为函数的局部变量a与b开辟空间,以便让它们保存在栈中,long类型的数据在32位的操作系统当中占据4个字节,所以两个long,也就刚好是8个字节。
开辟好栈空间后,在main()函数的内部,无论esp的值如何变化,变量a和b的栈空间都不会受到破坏,ebp是固定不变的,我们就可以以它为基准来访问函数的局部变量。看下两条指令
mov dword ptr ss:[ebp-0x4],0x1
mov dword ptr ss:[ebp-0x8],0x2
我们重点分析一下这两句汇编指令,ss是stack segment的缩写,也就是栈段,ss:[ebp-0x4]这种就是表示栈段中ebp-0x4地址处的内存空间,很前面的dword,则是说明这段内存空间是4个字节,这里汇编指令的作用很明显就是对这两段内存空间赋值,把局部变量1放到ebp-0x4,2放到ebp-0x8。
执行完两条汇编指令之后就如上图所示,1和2都被压入栈内。
接下来的五条汇编指令
mov eax,dword ptr ss:[ebp-0x8]
push eax
mov ecx,dword ptr ss:[ebp-0x4]
push ecx
call 00401000
这五条指令描述了调用add()函数的整个过程,call指令的作用就是调用函数,401000处的函数就是我们的add()函数,而在调用函数前,我们需要将函数的参数push入栈,可以看到这里用了两条push指令将ecx寄存器中的参数push入栈。
这里有一个点需要注意的就是参数入栈的顺序与C语言源码当中的参数顺序恰好相反(函数参数的逆行存储),源码是add(a,b),这里的push是先将参数2入栈,执行汇编指令后栈内情况如图。
接下来我们进入add()函数内部,来分析401000处的汇编指令.
这里还有一个重点,执行call命令进入被调用的函数之前,CPU会先吧函数的返回地址压入栈,用作函数执行完毕后的返回地址,比如我们是在地址40103C处调用的函数,下一条汇编指令的地址为401041,我们在执行完add()函数之后,程序执行流就该返回到401041地址处,然后执行main()函数的剩下代码,该地址也被称为add()函数的返回地址,如图,很直观,这里已经进入了add()函数了
add()
long add(long a, long b)
{
long x = a, y = b;
return (x + y);
}
函数开始执行,和main()函数一样生成栈帧
push ebp
mov ebp,esp
这里我们可以看到先把main()函数的基址指针(ebp)保存一份在栈中,即19FF2C,然后呢再将esp的值赋值给ebp,这样就能保持add()函数的ebp值不变了。这时候ebp的新值就是19FF14。
然后是声明变量的代码
long x = a, y = b;
对应汇编指令
mov eax,dword ptr ss:[ebp+0x8]
mov dword ptr ss:[ebp-0x8],eax
mov ecx,dword ptr ss:[ebp+0xC]
mov dword ptr ss:[ebp-0x4],ecx
这里还是很清晰的,这里是找ebp+0x8和ebp+0xC的值,也就是我们push进去的函数参数,先把1放入地址ebp-0x8处,再把2放入地址ebp-0x4处。也就是说低地址先放进去,高地址再放进去。
接着add()函数return两者之和,汇编指令如下
mov eax,dword ptr ss:[ebp-0x8]
add eax,dword ptr ss:[ebp-0x4]
这里的指令也很好理解,需要注意的是,eax寄存器在算术运算中存储输入输出数据,函数的返回值通常也都存储再eax寄存器。执行这两句汇编指令的过程中,栈内的情况并没有变化。
栈帧删除
至此,我们的add()函数就执行完毕了,在retn前我们需要删除栈帧,汇编代码如下
mov ebp,esp
pop ebp
这里删除的过程和前面生成栈帧的过程其实是相对应的,把ebp的值赋值给esp
执行完mov命令之后,sub esp,8 的命令就会失效,函数add()中的两个局部变量x,y不再有效。
接着将add()函数开始执行时备份到栈中的ebp的值恢复为原来的19FF2C,此时的栈和寄存器的情况如下
其中esp的值指向的地址存储了另外一个地址,就是call命令执行时CPU存储到栈中的返回地址。接着retn就回到main()函数的指令处。此时调用栈就返回到调用add()函数时的状态了。
应用程序以这样的方式管理栈,可以使得不管有多少函数嵌套调用,栈都能得到较好的维护,不会崩溃,书中特别说
但是由于函数的局部变量、参数、返回地址等是一次性保存到栈中的,利用字符串函数的漏洞等很容易引起栈缓冲区溢出,最终导致程序或者系统崩溃。
这一句话我倒是不是很理解。
回到main函数当中
add esp,8
在执行完add()函数之后,就不再需要函数a和b了,所以把esp的值加上8,将两个参数从从栈当中清理掉。
接着调用printf()函数,汇编指令如下
push eax
push StackFra.0040B384 ; ASCII "%d\n"
call StackFra.00401067
add esp,0x8
eax寄存器当中存储add()函数的返回值,即结果3,指令将其推入栈中,再将参数”%d\n”也推入栈中。符合函数参数的逆行存储,这两个参数总共8字节,推入栈中后调用401067地址处的函数,很明显是printf()函数,然后再用add的汇编指令,将两个函数参数从栈中清理掉。
xor eax,eax
mov esp,ebp
pop ebp
retn
然后用xor指令将eax寄存器的值置为0,用这个指令而不用mov指令是因为,xor指令比mov eax,0的指令执行速度快,所以我们常用这个指令来初始化寄存器。
然后再和add()函数中删除栈帧一样的操作,我们执行完retn之后可以看到也返回到地址401250处
还有一点需要注意,执行pop指令,还有当我们用add指令清理栈时,其中的数据并没有删除,可能只是等待下一次的被覆盖,这些指令只是让esp指针往栈底移动。
完结撒花💕