许多程序设计语言中,可以将一段经常需要使用的代码封装起来,在需要使用时可以直接调用,这就是程序中的函数(也被称为过程)。其实程序中的函数和数学中所说的函数是很相似的,都是通过输入自变量(有些函数可能不需要自变量),然后经过一系列的运算,最后得出函数的值。
在汇编语言中对于函数的调用一般用一个call指令来完成,当过程返回时用retn指令来完成。call指令有一个操作数,指向被调用过程的地址,当程序需要调用一个过程的时候,程序会跳转到被调用过程的地址处去执行代码。当过程代码执行完毕的时候,程序需要返回到原调用地址处,这个时候就需要使用到retn指令。retn指令是没有操作数的,那么程序怎么知道函数要返回的原调用处的地址?显然,在调用一个过程的时候应该保存返回的原调用处的地址,具体怎么操作,先看一段C语言代码:
#include <stdio.h>
void function()
{
return;
}
int main()
{
function();
return 0;
}
这里定义了一个空函数function(),然后在主函数里调用了这个空函数function(),这里需要修
改一下编译器的选项,在对语言的优化处理中把对内联函数展开的选项修改为“只使用
__inline”,如图2.3.1.1,
否则编译器会把代码比较少的函数当作inline函数处理。然后进行编译,编译完成后使用IDA进行分析,主函数反汇编代码如下:
text:00401010 push ebp
.text:00401011 mov ebp, esp
.text:00401013 call ?function@@YAXXZ ; function(void)
.text:00401018 xor eax, eax
.text:0040101A pop ebp
.text:0040101B retn
这里可以看到0x00401013处使用了一个call指令调用调用过程,call指令的操作数说明了被调用过程,IDA已经分析出这里被调用的过程为function(void)。再看看函数function(void)的代码:
.text:00401000 ; Attributes: bp-based frame
.text:00401000
.text:00401000 ; void __cdecl function()
.text:00401000 ?function@@YAXXZ proc near ; CODE XREF: _main+3 p
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 pop ebp
.text:00401004 retn
.text:00401004 ?function@@YAXXZ endp
.text:00401004
这里是IDA对function(void)的整个分析结果。在C语言中定义的函数function()是一个空函数,什么都没有做就直接返回了,但是在汇编语言中这里除了retn指令还有另外的三句。这三句代码的作用是保护栈桢。ebp是栈基址寄存器,它通常用来对局部变量进行寻址,那么在调用一个过程后,要使用过程内的全局变量,就要重新定义ebp寄存器的值,一般用这两句代码来完成:
push ebp
mov ebp, esp
这两句指令,把原ebp的值压入栈中,然后把ebp的值修改为当前栈顶。一般把这两句指令作为函数开头。那么函数的结尾就是这样的:
pop ebp
retn
一般把这两句指令作为函数的结尾,从栈中弹出原来保存的ebp寄存器的值,恢复ebp后调用retn指令返回原调用处。
用OD加载这个程序,调试看看:
00851010 /$ 55 push ebp
00851011 |. 8BEC mov ebp, esp
按F8步过这两句代码,然后注意OD的寄存器窗口,如图2.3.1.2,可以看到这时ebp寄存哭已经被赋于esp寄存器的值,这时它们都指向0x0029F910。
00851013 |. E8 E8FFFFFF call 00851000
这里使用call指令调用函数function(),OD就没有IDA那么智能了,这里没有分析出被调用过程的名称。按F7跟进这个过程:
00851000 /$ 55 push ebp
00851001 |. 8BEC mov ebp, esp
00851003 |. 5D pop ebp
00851004 \. C3 retn
跟进这个过程后不要执行任何代码,先看看当前的状态。寄存器窗口如图2.3.1.3,很明显这里栈顶esp所指向的地址比刚
才低了4个字节,值为0x0029F90C,那么说明栈里被写入了4字节的数据,看看现在栈里的0x0029F90C处的数据,如图
2.3.1.4。0x0029F90C里的数据为0x00851018,这到底是什
么东西,先待定,按F8步过函数function()中的代码。
00851004 \. C3 retn
执行到这里的时候停住,再看看这时候寄存器的状态,如图2.3.1.5,
这时eip寄存器指向00851004,也就是retn指令的地址,ebp的值为0x0029F910,esp的值为0x0029F90C。看栈里的数据:
0029F90C 00851018 返回到 acm.00851018 来自 acm.00851000
0x0029F90C的值既然为0x00851018。再按一次F8步过
retn指令,再看寄存器信息,如图2.3.1.6,现在eip寄存器指向0x00851018,而esp寄存器的值变回了0x0029F910。栈顶esp抬高了四个字节,原来栈顶0x0029F90C指向的数据0x00851018被弹出,变成了现在eip寄存器的值。看这时的代码:
00851013 |. E8 E8FFFFFF call 00851000
00851018 |. 33C0 xor eax, eax
0085101A |. 5D pop ebp
0085101B \. C3 retn
0x00851018为原调用处的下一句指令的地址。经过这样的分析,过程的调用和返回过程就很明显了,当执行call指令时会把call指令的下一句指令的地址压入栈中当作返回址,然后再执行过程的代码,过程代码执行结束以后使用retn指令返回,当执行retn指令时弹出保存在栈中的原调用指令的下一句指令的地址,把它放到eip中使程序转而执行原调用指令的下一句指令。
再来看看使用指针调用函数的方法:
#include <stdio.h>
void function()
{
return;
}
int main()
{
void (*fn)();
fn = function;
fn();
return 0;
}
在这里function()不变,在主函数main()中定义了一个函数指针fn,把function()函数的地址传给函数指针fn,然后通过函数指针fn调用函数function(),使用IDA分析:
.text:00401010 push ebp
.text:00401011 mov ebp, esp
.text:00401013 push ecx
指针变量里装的是一个32位的地址,它用占用的内存空间为4字节,因此这里向栈中压入一个32位寄存器ecx为指针变量fn开辟空间。
.text:00401014 mov [ebp+fn], offset ?function@@YAXXZ ; function(void)
.text:0040101B call [ebp+fn]
这里获取函数function(void)的地址放到函数指针fn里,然后通过函数指针fn调用函数。这种调用叫做间接调用,通过动态计算函数地址,然后调用。这种调用方式很像C++中的虚函数,关于虚函数的内容,会在后面的章节中讲到。
.text:0040101E xor eax, eax
.text:00401020 mov esp, ebp
.text:00401022 pop ebp
.text:00401023 retn
这种间接调用的方式要比前面看到的直接调用方式麻烦很多。