C++逆向之函数工作的原理

先思考两个问题

  • 当函数执行时,程序流程会转到函数体内的实现地址,只有遇到return语句或者“}”符号才返回到下一条语句的地址处,请问编译器是如何确定应该回到什么地址处的?
  • 为什么很多高级语言在传递参数时会将实参复制给形参这一操作呢?

栈帧的形成和关闭

栈在内存中是一块特殊的存储空间。它的存储原则是“先进后出”,即最先被存储的数据最后被释放。汇编过程通常使用push指令和pop指令对栈空间执行数据压入和数据弹出操作。栈结构在内存中占用一段连续的存储空间,通过esp与ebp这两个栈指针寄存器来保存当前栈的起始地址与结束地址(又称为栈顶与栈底)。在栈空间中,每4字节的栈空间保存一个数据,像这样的栈顶到栈底之间的存储空间被称为栈帧。
栈帧是如何形成的呢?当栈顶指针esp小于栈底指针ebp时,就形成了栈帧。通常,在C++中,栈帧中可以寻址的数据有局部变量、函数返回地址、函数参数等。
不同的两次函数调用,所形成的栈帧也不相同。当由一个函数进入到另一个函数中时,就会针对调用的函数开辟出其所需的栈空间,形成此函数的栈帧。当这个函数结束调用时,需要清除掉它所使用的空间,关闭栈帧,我们把这一过程称为栈平衡。
为什么要进行栈平衡呢?这就像借钱一样,“有借有还,再借不难”。如果某一函数在开辟了栈空间后没有进行恢复,或者过度恢复,那么将会造成栈空间的上溢或下溢,极有可能给程序带来致命性错误。
在进入某个函数的具体实现代码之前,一般会预先保存栈底指针,以便退出函数时还原以前的栈底。在退出函数时,会将栈底指针ebp与栈顶指针esp进行对比,检测当前栈帧是否被正确关闭,以及栈顶与栈底是否平衡。如果不平衡,则调用函数__cheesp,弹出栈平衡错误提示对话框。

1
2
3
4
5
6
#include<iostream>
using namespace std;
int main(int argc,char * argv[])
{
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
00401030 >  55              push ebp                                 ; 进入函数后的第一件事,保存栈底指针ebp
00401031 8BEC mov ebp,esp ; 调整当前栈底指针位置到栈顶
00401033 83EC 40 sub esp,0x40 ; 抬高栈顶esp,此时开辟栈空间0x40,作为局部变量的存储空间
00401036 53 push ebx ; 保存寄存器ebx
00401037 56 push esi ; 保存寄存器esi
00401038 57 push edi ; 保存寄存器edi
00401039 8D7D C0 lea edi,dword ptr ss:[ebp-0x40] ; 取出此函数可以栈空间的首地址
0040103C B9 10000000 mov ecx,0x10 ; 设置ecx为0x10
00401041 B8 CCCCCCCC mov eax,0xCCCCCCCC ; 将局部变量初始化为0xCCCCCCCC
00401046 F3:AB rep stos dword ptr es:[edi] ; 根据ecx的值,将eax中的内容,以4字节为单位写到edi指向的内存中
00401048 33C0 xor eax,eax ; 设置返回值为0
0040104A 5F pop edi ; 还原寄存器edi
0040104B 5E pop esi ; 还原寄存器esi
0040104C 5B pop ebx ; 还原寄存器ebx
0040104D 8BE5 mov esp,ebp ; 还原esp
0040104F 5D pop ebp ; 还原ebp
00401050 C3 retn ; 返回

进入函数后,先保存原来的ebp,然后调整ebp的位置到esp,接下来通过“sub esp,0x40”这句指令打开了0x40字节大小的栈空间,这是留给局部变量使用的。如果编译选先祖为debug,则为了调试方便将局部变量初始化为CCCCCCCCh。
由于进入函数前打开了一定大小的栈空间,在函数调用结束后需要将这些栈空间释放,因此需要还原环境pop与“mov esp,ebp”,以降低栈顶这样的指令。

各种调用方式的考察

进入函数时会打开栈空间,退出函数时会还原栈空间。在C++中,通常使用栈来传递函数参数,因此传递函数的栈也属于被调用函数栈空间中的一部分。那么它又是如何平衡的呢?汇编过程中通常使用“ret XXXX”来平衡参数所使用的栈空间,当函数的参数为不定参数时,函数自身无法确定参数所使用的栈空间大小,因此无法由函数自身执行平衡操作,需要此函数的调用者执行平衡操作。为了确定参数的平衡者,以及参数的传递方式,于是有了函数的调用约定。C++环境下的调用约定有三种:_cdecl、_stdcall、_fastcall。这3种调用约定的解释如下:

  • _cdecl:C\C++默认的调用方式,调用方平衡栈,不定参数的函数可以使用。
  • _stdcall:被调用方平衡栈,不定参数的函数无法使用
  • _fastcall:寄存器方式传参,被调方平衡栈,不定参数的函数无法使用。

当函数参数个数为0时,无需区分调用方式,使用_cdecl和_stdcall都一样。而大部分函数都是有参数的,那么如何分析出它们的调用方式呢?通过查看平衡栈即可还原对应的调用方式。那么_cdecl和_stdcall这两种调用方式又有什么区别呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<iostream>
using namespace std;
void _stdcall ShowStd(int number)
{
printf("%d\n",number);
}
void _cdecl ShowCde(int number)
{
printf("%d\n",number);
}
int main(int argc,char * argv[])
{
ShowStd(5);
ShowCde(5);
return 0;
}

void _stdcall ShowStd(int number)

1
00401079  C2 0400       retn 0x4                                 ;  结束后平衡栈顶4,等价esp+=4

void _cdecl ShowCde(int number)

1
004010C9    C3            retn                                     ;  没有平衡操作

1
2
3
4
5
6
7
//使用_stdcall方式调用函数ShowStd
004010F8 |. 6A 05 push 0x5 ; 函数传参,使用push指令,esp-4
004010FA |. E8 06FFFFFF call test.00401005 ; 没有对esp操作的指令
//使用_cdecl方式调用函数ShowCde
004010FF |. 6A 05 push 0x5 ; 函数传参,使用push指令esp-4
00401101 |. E8 04FFFFFF call test.0040100A
00401106 |. 83C4 04 add esp,0x4 ; esp+=4,平衡栈顶

_cdecl调用方式在函数内没有任何平衡参数操作,而是在退出函数后对esp执行了加4操作,从而实现栈平衡,_stdcall调用方式则与之相反。那么,是不是只要检查ret处是否有平衡操作即可得知函数的调用方式呢?由于汇编语言灵活多变,这种方法也不能保证分析结构的正确性。在函数结尾处,很有可能会有其他汇编指令间接地对esp做加法,如pop这样的指令也可达到栈平衡效果,而且指令周期较短。因此还需要结合函数在执行过程中使用的栈空间,与函数调用结束时的栈平衡数进行对比,以判断是否实现参数平衡。
C语言中经常使用的printf函数就是典型的_cdecl调用方式,由于printf的参数可以有多个,所以只能以_cdecl方式调用。那么,当printf函数被多次使用后,会在每次调用结束后进行栈平衡操作吗?在debug版下,为了匹配源码会这样做。而经过O2选项的优化后,会采用复写传播优化,将每次参数平衡的操作进行归并,一次性平衡栈顶指针esp。

使用esp或ebp寻址

将高级语言转换成汇编代码后,很多高级语言中的变量访问,就变成了对ebp或esp的加减法操作(寄存器相对间接寻址方式)来获取变量在内存中的数据。局部变量是通过栈空间来保存的。在内存中,局部变量是以连续排列的方式存储在栈内的。
由于局部变量使用栈空间进行存储,因此进入函数后的第一件事就是开辟函数中局部变量所需的栈空间大小。这时函数中的局部变量就有了各自的内存空间。在函数结尾处执行释放栈空间的操作。因此局部变量是有生命周期的,它的生命周期在进入函数体的时候开始,在函数执行结束的时候结束。
在大多数情况下,使用ebp寻址局部变量只能在非O2选项中产生,这样做是为了方便调试和检测栈平衡,使目标代码可读性更高。而在O2编译选项中,为了提升程序的效率,省去了这些检测工作,在用户编写的代码中,只要栈顶是稳定的,就可以不再使用ebp,利用esp直接访问局部变量,可以节省一个寄存器资源。

1
2
3
4
5
6
7
8
9
10
#include<iostream>
using namespace std;
void InNumber()
{
int nInt=1;
scanf("%d",&nInt);
char cChar=2;
scanf("%c",&cChar);
printf("%d %c",nInt,cChar);
}

在release版下,用ida查看反汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var_5= byte ptr -5       ; IDA定义的局部变量标号,IDA环境下局部变量用var_开头
var_4= dword ptr -4 ; IDA定义的局部变量标号
; 为局部变量开辟8字节栈空间,这里没有了debug版下的繁琐操作
sub esp, 8
; 这句指令等价于:esp+8-4,标号var_4等于-4,IDA自动识别出访问的变量地址,并调整显示方式,省去了计算偏移量这个过程,类似于高级语言中的为变量命名,使代码显示起来更具可读性
lea eax, [esp+8+var_4]
mov [esp+8+var_4], 1 ; 初始化var_4变量为1
push eax ; eax中保存[esp+8-4]的值,将eax作为参数入栈
push offset aD ; "%d"
call _scanf ; 调用函数_scanf
add esp, 8
; 在分析指令的时候,IDA会根据代码上下文归纳出影响栈顶的指令,以确定esp相对寻址所访问的目标。
; 于是IDA识别出以下相对寻址指令的目标是该函数中的局部变量var_5,之前执行了两次push指令,
; 所以esp指向的栈顶地址存在-8的差值,而且本函数第一条指令 sub esp, 8 也影响栈顶。综合以上
; 信息,IDA为了表达出此时访问的局部变量为var_5,并且将var_5定义为-5,需要对esp相对寻址进行调整
; ,以加强代码的可读性。
lea ecx, [esp+8+var_5]
mov [esp+8+var_5], 2 ; 为var_5处的局部变量赋值2
push ecx ; 功能同上,esp-=4
push offset aC ; "%c"
call _scanf
; 由于又执行了两次push指令,并且没有平衡栈,所以需要调整esp的相对偏移值,这里的调整值为
; 10h。这里的movsx指令显示var_5的类型为有符号类型,byte ptr说明长度为单字节,对应C语言
; 中的定义应该是char。
movsx edx, [esp+10h+var_5]
mov eax, [esp+10h+var_4]
add esp, 8
push edx
push eax
push offset aDC ; "%d %c"
call sub_403AD1
; 经过优化后,一次性平衡栈顶esp,esp+=14h
add esp, 14h
retn ; 执行ret指令结束函数调用

通过IDA的标识,可以轻松地知道函数实现中的两个局部变量。
使用了esp寻址后,不必在每次进入函数后都调整栈底ebp,这样即减少了ebp的使用,又省去了维护ebp的相关指令,因此可以有效提升程序的执行效率。
每次访问变量都需要计算,如果在函数执行过程中esp发生了改变,再次访问变量就需要重新计算偏移。为了省去对偏移量的计算,方便分析,IDA在分析过程中事先将函数中的每个变量的偏移值计算出来,得出了一个固定偏移值,使用标号将其记录。IDA是如何计算出这个固定偏移值的呢?这个偏移值可以为正,也可以为负,因此有两种计算偏移值的方案:正数标号法和负数标号法。
显然IDA选择了用负数作为偏移值,将其作为标号,参与变量寻址计算。IDA为什么要选择后者呢?这是为了把函数的参数与局部变量进行区分。

函数的参数

函数参数通过栈结构进行传递,在C++代码中,其传参顺序从右向左一次入栈,最先定义的参数最后入栈。参数也是函数中的一个变量,采用正数标号法来表示局部变量偏移标号时,函数的参数标号和局部变量的标号值都是正数,无法区分,不利于分析。如果使用负数标号法表示,则可以将两者区分,正数表示参数,而负数则表示局部变量,0值表示返回地址(call指令会将下一条指令地址作为函数的返回地址)。这样,用户在对反汇编代码进行分析时就省去了计算偏移量的工作,只需查看标号名称就可得知在访问某一变量。根据寻址过程中的计算方式得知访问的变量是局部变量还是参数。
因为函数的传参是通过栈方式传递的,使用push指令将数据压入到栈中,而push指令将操作数复制到栈顶,所以这是压入栈中的数据和原数据在两个不同地址处,是独立存在的,因此对函数参数的修改,实际上是对当前函数栈内的参数中保存的值进行修改,与原数据没有任何关系。正因如此,在C\C++中,形参是实参的副本,对形参的修改不影响其实参。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<iostream>
using namespace std;
void AddNumber(int nOne)
{
nOne+=1;
printf("%d",nOne);
}
int main(int argc,char * argv[])
{
int nNumber=0;
scanf("%d",&nNumber);
AddNumber(nNumber);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
; int __cdecl main(int argc, const char **argv, const char **envp)
_main proc near

var_4= dword ptr -4 ; 局部变量标号定义,main函数中只有一个局部变量
argc= dword ptr 4
argv= dword ptr 8
envp= dword ptr 0Ch
; 注意这里的push ecx,定位到函数末尾,并没有发现pop ecx。因此这里并不是保存寄存器环境,而是使用低周期的push ecx代替高周期的sub esp,4,强度削弱。这样就有了局部变量的空间
push ecx
lea eax, [esp+4+var_4] ; 取出局部变量地址到eax中
mov [esp+4+var_4], 0 ; 将局部变量赋值为0
push eax ; 压入eax作为参数,在eax中保存局部变量地址
push offset aD ; "%d"
call _scanf ; 调用_scanf函数
mov ecx, [esp+0Ch+var_4] ; 取局部变量内容放入ecx中
push ecx ; 结合函数sub_401000分析此处是否为参数压栈,考察函数内有没有对其引用,有没有使用ret指令平衡参数
call sub_401000 ; 调用函数sub_401000
xor eax, eax
add esp, 10h ; 退出前平衡栈顶esp,共使用4此push指令,由此得出函数sub_401000为_cdecl调用方式,使用了1个参数
retn
_main endp


sub_401000 proc near

arg_0= dword ptr 4 ; 正数,为参数标号。在IDA下参数以arg_为前缀

mov eax, [esp+arg_0] ; 访问第一个参数,取出数据到eax中
inc eax ; 对参数内容加1
push eax ; 将加1后的eax压入栈中,作为printf函数参数
push offset aD ; "%d"
call sub_403AC1 ; 调用printf函数
add esp, 8 ; 平衡printf函数使用的两个参数
retn ; 此函数内没有平衡参数,可见此函数为_cdecl调用方式
sub_401000 endp

C\C++将不定长参数的函数定义为:

  • 至少要有一个参数;
  • 所有不定长的参数类型传入时都是dword类型;
  • 需要在某一个参数中描述参数总个数或将最后一个参数赋值为结尾标记。

有了这三个特性,就可以实现不定参数的函数。根据参数的传递特性,只要确定第一个参数的地址,对其地址值做加法,就可访问到此参数的下一个参数所在的地址。获取参数的类型是为了解释地址中的数据。上面提到的第三点是为了获取参数的个数,其目的是正确访问到最后一个参数的地址,以防止访问参数空间越界。
printf函数就是利用第一个参数来获取参数总个数的。只需检测printf函数中的第一个参数指向的字符串中包含几个“%”就可以确定其后的参数个数(“%%”形成的转义字符除外)。

函数的返回值

函数调用结束后,ret指令执行后为什么可以返回到函数调用处的下一条指令呢?call指令被执行后,该指令同时还会做另一件事,那就是将下一条指令所在的地址压入栈中。函数退出前,会执行ret指令,这个指令取得esp所指向的4字节内容作为函数的返回地址值更新eip,程序的流程回到返回地址处,同时执行esp加4的操作,以释放返回的地址空间,平衡栈顶。
C\C++中使用寄存器eax来保存返回值,由于32位的eax寄存器只能保存4字节数据,因此大于4字节的数据将使用其他方法保存。通常,eax作为返回值,只有基本数据类型与size(type)小于等于4的自定义数据(浮点类型数据除外)。在debug版下,如果函数有返回值,那么最后的操作通常对eax赋值后执行ret指令。
那么如果返回值类型为自定义类型-结构体,其大小超过4字节,编译器会如何处理呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
using namespace std;
struct tagTEST
{
int m_nOne;
int m_nTwo;
};
tagTEST RetStruct()
{
tagTEST testRet;
testRet.m_nOne=1;
testRet.m_nTwo=2;
return testRet;
}
int main(int argc,char * argv[])
{
tagTEST test;
test=RetStruct();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
sub_401030 proc near

var_48= byte ptr -48h
var_8= dword ptr -8
var_4= dword ptr -4

push ebp
mov ebp, esp
sub esp, 48h
push ebx
push esi
push edi
lea edi, [ebp+var_48]
mov ecx, 12h
mov eax, 0CCCCCCCCh
rep stosd ; 环境保存,初始化
mov [ebp+var_8], 1 ; 对结构体成员变量赋值
mov [ebp+var_4], 2 ; 对结构体成员变量赋值
mov eax, [ebp+var_8] ; 取结构体成员变量数据传入eax中
mov edx, [ebp+var_4] ; 取结构体成员变量数据传入edx中
pop edi ; 环境恢复
pop esi
pop ebx
mov esp, ebp
pop ebp
retn ; 执行ret指令结束函数调用
sub_401030 endp

由于只有两个成员变量,因此编译器使用了eax和edx来传递返回值。更多关于结构体知识以后再讨论。

回顾

函数调用的一般工作流程

  1. 参数传递
    通过栈或寄存器方式传递参数。
  2. 函数调用,将返回地址压栈
    使用call指令调用参数,并将返回地址压入栈中。
  3. 保存栈底
    使用栈空间保存调用方的栈底寄存器ebp
  4. 申请栈空间和保存寄存器环境
    根据函数内局部变量的大小抬高栈顶让出对应的栈空间,并且将即将修改的寄存器保存在栈内。
  5. 函数实现代码
    函数实现过程的代码。
  6. 还原环境
    还原栈中保存的寄存器信息。
  7. 平衡栈空间
    平衡局部变量使用的栈空间
  8. ret返回,结束函数调用
    从栈顶取出第2步保存的返回地址,更新eip。
    在非_cdecl调用方式下,平衡参数占用栈空间。
  9. 调用esp,平衡栈顶
    此处为_cdecl特有的方式,用于平衡参数占用的栈顶。

两种编译选项下的函数识别

Debug编译选项组下的函数识别非常简单,由于其注重调试的特性,其汇编代码基本上就是函数的原貌,只需对照汇编代码逐条分析即可将其还原成高级代码。其识别见下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
push reg/mem/imm      ; 根据调用函数查看参数的使用,可确定是否为参数
......
call reg/mem/imm ; 调用函数
add esp,xxxx ; 如果Debug编译选项组下是_cdecl调用方式,由调用方平衡栈顶

jmp FUN_ADDR ; call指令调用处,可能存在使用跳转指令执行到函数
FUN_ADDR:
push ebp ; 保存栈底
......
mov eax,CCCCCCCCh
rep stos dword ptr [edi] ; 初始化局部变量
......
pop ebp ; 还原栈底
ret ; 查看ret是否平衡栈

在O2选项下,_cdecl调用方式的函数调用结束后,并不一定会马上平衡栈顶,极有可能会复写传播并与其他函数一起平衡栈。由于函数实现改用了esp寻址,因此需要注意函数执行过程是否对esp进行了修改,如进行了修改,在平衡栈顶esp时,考察是否有对esp进行平衡恢复。当函数有参数时,检查参数是否在函数实现中被平衡,以确定其调用方式。_cdecl调用方式的函数识别要点。

1
2
3
4
5
6
7
push reg/mem/imm    ; 根据调用函数查看参数使用,可确定是否为参数
......
call reg/mem/imm ; 调用函数
add esp,xxxx ; 在Release版下调用_cdecl方式的函数,栈平衡可能会复写传播

; 函数实现内没有将局部变量初始化为 CCCCCCCCh
; 若在函数体内不存在内联汇编或异常处理等代码,则使用esp寻址

在软件开发过程中,通常以面向对象的方式设计结构,然后由程序员实现每个对象的每个成员函数。编译器产生二进制代码后,面向对象变成了模块化代码,因此在分析人员的眼里,看到的都是以函数为单位的代码块,至于对C++中的成员函数、虚函数等的分析,在以后进行讨论。

文章目录
  1. 1. 栈帧的形成和关闭
  2. 2. 各种调用方式的考察
  3. 3. 使用esp或ebp寻址
  4. 4. 函数的参数
  5. 5. 函数的返回值
  6. 6. 回顾
    1. 6.1. 函数调用的一般工作流程
    2. 6.2. 两种编译选项下的函数识别