​ 程序都是由具有不同功能的函数组成的,因此在逆向分析中将重点放在函数的识别及参数的传递上是明智的,这样做可以将注意力集中在某一段代码上。函数是一个程序模块,用来实现一个特定的功能。一个函数包括函数名、入口参数、返回值、函数功能等部分。

函数的识别:程序通过调用程序来调用函数,在函数执行后又返回调用程序继续执行。函数如何知道要返回的地址呢?实际上,调用函数的代码中保存了一个返回地址,该地址会与参数一起传递给被调用的函数。有多种方法可以实现这个功能,在绝大多数情况下,编译器都使用call和ret指令来调用函数及返回调用位置。

​ call指令与跳转指令功能类似。不同的是,call指令保存返回信息,即将其之后的指令地址压入栈的顶部,当遇到ret指令时返回这个地址。也就是说,call指令给出的地址就是被调用函数的起始地址。ret指令则用于结束函数的执行(当然,不是所有的ret指令都标志着函数的结束)。通过这一机制可以很容易地把函数调用和其他跳转指令区别开来。
​ 因此,可以通过定位call机器指令或利用ret指令结束的标志来识别函数。call指令的操作数就是所调用函数的首地址。看一个例子,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
int Add(int x, int y);
main( )
{
int a = 5, b = 6;
Add(a, b);
return 0;
}

Add(int x, int y)
{
return(x + y);
}

​ 编译结果如下

image-20240715141146105

​ 这种函数直接调用方式使程序变得很简单一所幸大部分情况都是这样的。但也有例外,程序调用函数是间接调用的,即通过寄存器传递函数地址或动态计算函数地址调用。例如CALL [4*eax+10h]

函数的参数:函数传递参数有3种方式,分别是栈方式、寄存器方式及通过全局变量进行隐含参数传递的方式。如果参数是通过栈传递的,就需要定义参数在栈中的顺序,并约定函数被调用后由谁来平衡栈。如果参数是通过寄存器传递的,就要确定参数存放在哪个寄存器中。每种机制都有其优缺点,且与使用的编译语言有关。

​ (1)利用栈传递参数

​ 栈是一种“后进先出”的存储区,栈顶指针esp指向栈中第1个可用的数据项。在调用函数时,调用者依次把参数压入栈,然后调用函数。函数被调用以后,在栈中取 得数据并进行计算。函数计算结束以后,由调用者或者函数本身修改栈,使栈恢复原样(即平衡栈数据)。
​ 在参数的传递中有两个很重要的问题:当参数个数多于1个时,按照什么顺序把参数压人栈?函数结束后,由谁来平衡栈?这些都必须有约定。这种在程序设计语言 中为了实现函数调用而建立的协议称为调用约定(Calling Convention)。这种协议规定了函数中的参数传送方式、参数是否可变和由谁来处理栈等问题。不同的语言定义 了不同的调用约定,常用的调用约定如下。

image-20240715141839888

​ C规范(即__cdecl)函数的参数按照从右到左的顺序人栈,由调用者负责清除栈。__cdecl是C和C++程序的默认调用约定。C/C+和MFC程序默认使用的调用约定是、 __cdecl,也可以在函数声明时加上__cdecl关键字来手动指定。
​ pascal规范按从左到右的顺序压参数人栈,要求被调用函数负责清除栈。
​ stdcall调用约定是Win32API采用的约定方式,有“标准调用”(Standard CALL)之意,采用C调用约定的入栈顺序和pascal调用约定的调整栈指针方式,即函数入口参数 按从右到左的顺序入栈,并由被调用的函数在返回前清理传送参数的内存栈,函数参数的个数固定。由于函数体本身知道传入的参数个数,被调用的函数可以在返回前 用一条retn指令直接清理传递参数的栈。在Win32API中,也有一些函数是__cdecl调用的,例如wsprintf。

​ 为了了解不同类型约定的处理方式,我们来看一个例子。假设有调用函数test1(Parl,Par2,Par3)按__cdecl、pascal和stdeall的调用约定,其汇编代码如下。

1
2
3
4
5
6
cdecl:
push par3 ;参数从右到左传递
push par2
push par1
call test1
add esp,0c ;平衡栈
1
2
3
4
5
pascal:
push par1 ;参数从左到右传递
push par2
push par3
call test1 ;函数内平衡栈
1
2
3
4
5
stdcall:
push par3 ;参数从右到左传递
push par2
push par1
call test1 ;函数内平衡栈

​ 可以清楚地看到,__cdecl类型和stdcall类型先把右边的参数压人栈,pascal则相反。在栈平衡上,__cdecl类型由调用者用“add esp,0c”指令把12字节的参数空间清 除,pascal和stdcall类型则由子程序负责清除。
​ 函数对参数的存取及局部变量都是通过栈来定义的,非优化编译器用一个专门的寄存器(通常是ebp)对参数进行寻址。C、C+、pascal等高级语言的函数(子程 序)执行过程基本一致,情祝如下。

​ 调用者将函数(子程序)执行完毕时应返回的地址、参数压入栈。

​ 子程序使用“ebp指针+偏移量”对栈中的参数进行寻址并取出,完成操作。
​ 子程序使用ret或retf指令返回。此时,CPU将eip置为栈中保存的地址,并继续执行它。

​ 栈在整个过程中发挥着非常重要的作用。栈是一个先进后出的区域,只有一个出口,即当前栈顶。栈操作的对象只能是双操作数(占4字节)。例如,按stdcall约定 调用函数test2(Parl,Par2)(有2个参数),其汇编代码大致如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
push par2 							;参数2
push par1 ;参数1
call test2 ;调用子程序test2
{
push ebp ;保护现场,原来的ebp指针
mov ebp, esp ;设置新的ebp,使其指向栈顶
mov eax, dword ptr [ebp+0c] ;调用参数2
mov ebx, dword ptr [ebp+08] ;调用参数1
sub esp, 8 ;若函数要使用局部变量,则要再栈中留出一部分空间
......
add esp, 8 ;释放局部变量占用的栈
pop ebp ;恢复现场的ebp
ret 8 ;返回(相当于ret;add esp,8)
;ret后面的值等于参数个数乘4h
}

​ 因为esp是栈指针,所以一般使用ebp来存取栈。其栈建立过程如下。

​ ①此例函数中有2个参数,假设执行函数前栈指针的esp为K。
​ ②根据stdcall调用约定,先将参数Par2压进栈,此时esp为K-04h。
​ ③将参数Par1压人栈,此时esp为K-08h.

​ ④参数入栈后,程序开始执行call指令。cal指令把返回地址压人栈,这时esp为K-0Ch。
​ ⑤现在已经在子程序中了,可以开始使用ebp来存取参数了。但是,为了在返回时恢复ebp的值,需要使用“push ebp”指令来保存它,这时esp为K-10h。
​ ⑥执行“mov ebp,.esp”指令,ebp被用来在栈中寻找调用者压人的参数,这时[ebp+8]就是参数1,[ebp+c]就是参数2。
​ ⑦“sub esp,8”指令表示在栈中定义局部变量。局部变量1和局部变量2对应的地址分别是[ebp-4和[ebp-8]。函数结束时,调用“add esp,8”指令释放局部变量占用 的栈。局部变量的作用域是定义该变量的函数 ,也就是说,当函数调用结束后局部变量便会消失。
​ ⑧调用“ret 8”指令来平衡栈。在ret指令后面加一个操作数,表示在ret指令后给栈指针esp加上操作数,完成同样的功能。
​ 处理完毕,就可以用ebp存取参数和局部变量了,这个过程如图所示。

image-20240715151420270

​ 此外,指令enter和leave可以帮助进行栈的维护。enter语句的作用就是“push ebp”“mov ebp,esp”“sub esp,xxx”,而leave语句则完成“add esp,xxx”“pop ebp”的功能。 所以,上面的程序可以改成如下形式。

1
2
3
4
enter xxxx,0 ;0表示创建xxxx空间来放置局部变量
......
leave ;恢复现场
ret 8 ;返回

​ 在许多时候,编译器会按优化方式来编译程序,栈寻址稍有不同。这时,编译器为了节省ebp寄字器或尽可能减少代码以提高速度,会直接通过esp对参数进行寻 址。esp的值在函数执行期间会发生变化,该变化出现在每次有数据进出栈时。要想确定对哪个变量进行了寻址,就要知道程序当前位置的esp的值,为此必须从函数的 开始部分进行跟踪。
​ 同样,对上例中的test2(Parl,Par2)函数,在VC6.0里将优化选项设置为“Maximize Speed’”。重新编译该函数,其汇编代码可能如下。

1
2
3
4
5
6
7
8
9
push par2 							  ;参数2
push par1 ;参数1
call test2 ;调用子程序test2
{
mov eax, dword ptr [esp+04] ;调用参数1
mov ebx, dword ptr [esp+08] ;调用参数1
......
ret 8 ;返回
}

​ 这时,程序就用esp来传递参数了。其栈建立情况如图所示,过程如下。
​ ①假设执行函数前栈指针esp的值为K。
​ ②根据stdcall调用约定,先将参数Par2压入栈,此时esp为K-04h。
​ ③将Par1压入栈,此时esp为K-08h。
​ ④参数入栈后,程序开始执行call指令。call指令把返回地址压人栈,这时esp为K-OCh。
​ ⑤现在已经在子程序中了,可以使用esp来存取参数了。

image-20240715152157868

​ (2)利用寄存器传递参数

​ 寄存器传递参数的方式没有标准,所有与平台相关的方式都是由编译器开发人员制定的。尽管没有标准,但绝大多数编译器提供商都在不对兼容性进行声明的情况 下遵循相应的规范,即Fastcall规范。Fastcall,顾名思义,特点就是快(因为它是靠寄存器来传递参数的)。

​ 不同编译器实现的Fastcall稍有不同。Microsoft Visual C++编译器在采用Fastcall规范传递参数时,左边的2个不大于4字节(DWORD)的参数分别放在ecx和edx寄存器 中,寄存器用完后就要使用栈,其余参数仍然按从右到左的顺序压入栈,被调用的函数在返回前清理传送参数的栈。浮点值,远指针和int64类型总是通过栈来传递的。 而Borland Delphi/C+编译器在采用Fastcall规范传递参数时,左边的3个不大于4字节(DWORD)的参数分别放在eax、edx和ecx寄存器中,寄存器用完后,其余参数按照从 左至右的PASCAL方式压人栈。

​ 另有一款编译器Watcom C总是通过寄存器来传递参数,它严格为每一个参数分配一个寄存器,默认情况下第1个参数用eax,第2个参数用edx,第3个参数用ebx,第4个 参数用ecx。如果寄存器用完,就会用栈来传递参数。Vatcom C可以由程序员指定任意一个寄存器来传递参数,因此,其参数实际上可能通过任何寄存器进行传递。来看 一个用Microsoft Visual C++6.0编译的Fastcall调用实例,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
int  __fastcall Add(char, long, int, int);

main(void)
{
Add(1, 2, 3, 4);
return 0;
}

int __fastcall Add(char a, long b, int c, int d)

{
return (a + b + c + d);
}

​ 使用Visual C++进行编译,将“Optimizations’”选项设置为“Default’”。编译后查看其反汇编代码,具体如下。

image-20240715160336803

​ Add()函数
image-20240715160347231

​ 另一个调用规范thiscall也用到了寄存器传递参数。thiscall是C++中的非静态类成员函数的默认调用约定,对象的每个函数隐含接收this参数。采用thiscall约定时, 函数的参数按照从右到左的顺序人栈,被调用的函数在返回前清理传送参数的栈,仅通过ecx寄存器传送一个额外的参数——this指针。

​ 定义一个类,并在类中定义一个成员函数,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
class CSum
{
public:
int Add(int a, int b) //实际Add原型具有如下形式:Add(this,int a,int b)
{
return (a + b);
}
};

void main()
{
CSum sum;
sum.Add(1, 2);
}

​ 使用Visual C++进行编译,将“Optimizations”选项设置为“”Default’”。编译后查看其反汇编代码。
image-20240715162537397

image-20240715162640456

​ (3)名称修饰约定

​ 为了允许使用操作符和函数重载,C++编译器往往会按照某种规则改写每一个入口点的符号名,从而允许同一个名字(具有不同的参数类型或者不同的作用域)有 多个用法且不会破坏现有的基于C的链接器。这项技术通常称为名称改编(Name Mangling)或者名称修饰(Name Decoration)。许多C++编译器厂商都制定了自己的名称修 饰方案。
​ 在VC++中,函数修饰名由编译类型(C或C++)、函数名、类名、调用约定、返回类型、参数等因素共同决定。关于名称修饰的内容很多,下面仅简单谈一下常见的C 编译、C++编译函数名的修饰。

​ C编译时函数名修饰约定规则如下。

​ stdcall调用约定在输出函数名前面加一个下画线前缀,在后面加一个“@”符号及其参数的字节数,格式为“functionname(@number”。
​ __cdecl调用约定仅在输出函数名前面加一个下画线前缀,格式为”_functionname”。
​ Fastcall调用约定在输出函数名前面加一个“@”符号,在后面加一个“@”符号及其参数的字节数,格式为“@functionname@number”。

​ 它们均不改变输出函数名中的字符大小写。这和pascall调用约定不同。pascal约定输出的函数名不能有任何修饰且全部为大写。
​ C++编译时函数名修饰约定规则如下。

​ stdcall调用约定以“”标识函数名的开始,后跟函数名;在函数名后面,以“@@YG”标识参数表的开始,后跟参数表;参数表的第1项为该函数的返回值类型,其后依 次为参数的数据类型,指针标识在其所指数据类型前:在参数表后面,以“@Z”标识整个名字的结束(如果该函数没有参数,则以“Z”标识结束)。其格式 为“? functionname@@YC****@Z”或“?functionname@@YG*XZ。
​ __cdecl调用约定规则与上面的stdcall调用约定规则相同,只是参数表的开始标识由“@@YG”变成了“@@YA”。
​ Fastcall调用约定规则与上面的stdcall调用约定规则相同,只是参数表的开始标识由“@@YG”变成了“@@YT”。

函数的返回值:函数被调用执行后,将向调用者返回1个或多个执行结果,称为函数返回值。返回值最常见的形式是return操作符,还有通过参数按传引用方式返回值、通过全局变量返回值等。

​ (1)用return操作符返回值

​ 在一般情况下,函数的返回值放在eax寄存器中返回,如果处理结果的大小超过eax寄存器的容量,其高32位就会放到edx寄存器中,例如下面这段C程序。

1
2
3
4
5
6
MyAdd(int x, int y)
{
int temp;
temp = x + y;
return temp;
}

​ 这是一个普通的函数,它将两个整数相加。这个函数有两个参数,并使用一个局部变量临时保存结果。其汇编实现代码所下。

image-20240715173428513

​ (2)通过参数按传引用方式返回值

​ 给函数传递参数的方式有两种,分别是传值和传引用。进行传值调用时,会建立参数的一份副本,并把它传给调用函数,在调用函数中修改参数值的副本不会影响 原始的变量值。传引用调用允许调用函数修改原始变量的值。调用某个函数,皆把变量的地址传递给函数时,可以在函数中用间接引用运算符修改调用函数内存单元中 该变量的值。例如,在调用函数max时,需要用两个地址(或者两个指向整数的指针)作为参数,函数会将结果较大的数放到参数a所在的内存单元地址中返回,代码如 下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void max(int *a, int *b);
main( )
{
int a = 5, b = 6;
max(&a, &b);
printf("a、b中较大的数是%d", a); //将最大的数显示出来
return 0;
}

void max( int *a, int *b)
{
if(*a < *b)
*a = *b; //经比较后,将较大的数放到a变量之中
}

​ 其汇编代码如下

image-20240725180625834