函数的初始化
在逆向工程中,函数的初始化操作是函数在开始执行时,为正确运行而进行的准备工作。通常,这些操作发生在函数的序言(Prologue)阶段,具体的内容和顺序会因编译器、调用约定和目标平台(如x64
或x86
)不同而有所差异。函数的序言(Prologue)阶段标志着函数的开始,并且包含了设置栈帧、保存上下文等关键操作。尽管在不同的体系结构和编译器优化情况下可能会有所变化,但总体上有一些通用的方式可以帮助识别它。下面分别介绍x86和x64架构下的典型序言。
序言阶段的典型操作:
①保存调用者的栈帧:通过push ebp将调用者的栈帧基址寄存器ebp压入栈中。 ②建立新的栈帧:通过mov ebp, esp将当前的栈指针esp赋值给栈帧基址寄存器ebp,从而在新函数中创建一个新的栈帧。 ③开辟栈空间:通过sub esp, X来减少栈指针esp的值,从而为局部变量或保存的数据开辟空间。X代表所需栈空间的大小。 ④保存失性寄存器:非易失性寄存器如ebx、esi、edi等通常也在序言阶段被保存到栈上(如push ebx等操作)。
1. 标准序言的结构
在大多数情况下,函数的序言遵循一定的结构模式,特别是在有栈帧的情况下。下面分别介绍x86
和x64
架构下的典型序言。
x86架构中的序言
在x86
下,使用基址指针寄存器(ebp
)和栈指针寄存器(esp
)来管理栈帧。典型的函数序言包括以下几个步骤:
push ebp ; 保存前一个栈帧的基址
mov ebp, esp ; 设置新的栈帧基址
sub esp, 0xXX ; 为局部变量分配空间
push ebp
:将调用者的栈帧基址保存到栈中,以便函数返回时可以恢复。
mov ebp, esp
:将当前的栈指针(esp
)复制到基址指针(ebp
)中,建立新的栈帧。
sub esp, XX
:为函数的局部变量在栈上分配空间,XX
是分配的字节数,可能会因函数复杂度而异。
esp和ebp是用于栈操作的两个重要寄存器,esp指向栈顶,栈中的每一次压栈(push)和弹栈(pop)操作都会通过esp来进行;ebp通常被用作栈帧基址寄存器,指向函数调用时栈帧的起始位置,也就是函数进入时的栈顶位置。
x64架构中的序言:
类似于x86
,但x64
中的寄存器名称不同,例如使用rbp
和rsp
。
push rbp ; 保存调用者的栈帧基址
mov rbp, rsp ; 设置当前函数的栈帧基址
sub rsp, 0xXX ; 为局部变量分配空间
2.保存非易失性寄存器
在某些调用约定(如stdcall
、fastcall
)下,函数在进入时需要保存非易失性寄存器,以便在函数结束时能够恢复调用者的上下文。非易失性寄存器(Callee-saved registers)是指在函数调用过程中,如果被调用的函数修改了这些寄存器,它需要在函数结束时将其恢复到原来的状态。这样可以保证调用者在调用函数后,这些寄存器的值不会被破坏。
x86保存寄存器的典型序言:
push ebx
push esi
push edi
x64保存寄存器的典型序言:
push rbx
push r12
push r13
这些保存寄存器的指令通常出现在函数的序言阶段,他们是函数初始化的一部分,标志着对调用者上下文的保护。接着我们通过一个实际例子来进行具体分析。下面是一个 C 代码的示例:
#include <stdio.h>
// 传递多个整型参数
int sumIntegers(int a, int b, int c, int d, int e) {
return a + b + c + d + e;
}
// 传递多个整型指针
void modifyIntegers(int *p1, int *p2, int *p3, int *p4, int *p5) {
*p1 = 10;
*p2 = 20;
*p3 = 30;
*p4 = 40;
*p5 = 50;
}
int main() {
int x = 1, y = 2, z = 3, w = 4, v = 5;
int sum = sumIntegers(x, y, z, w, v);
printf("Sum: %d\n", sum);
modifyIntegers(&x, &y, &z, &w, &v);
printf("Modified values: %d, %d, %d, %d, %d\n", x, y, z, w, v);
return 0;
}
使用VS
对上述代码进行编译,生成x86
和x64
架构的exe
程序。接着将这两个程序放入x96dbg
中进行分析:
①x86架构
将x86架构程序载入x32dbg中
定位main
函数,得到如下代码:
在图中红色方框中的代码就是main
函数的序言部分:
push ebp
mov ebp,esp
sub esp,10C
push ebx
push esi
push edi
push ebp
:保存调用者的栈帧基址,将调用者的ebp
寄存器的值压入栈中,目的是在函数返回时恢复调用者的栈帧。
mov ebp, esp
:设置当前函数的栈帧基址,将当前栈指针esp
的值赋给ebp
,这意味着ebp
现在是当前函数的栈帧基址,用于访问局部变量和参数。
sub esp, 10C
:为局部变量分配栈空间,减少栈指针esp
的值,开辟0x10C(即268字节)的栈空间。这部分空间通常用于存储局部变量或其他需要在栈上分配的临时数据。
后面的三个指令则是对非易失性寄存器(ebx
、esi
、edi
)进行保存。
再接下去就是初始化局部变量区域:通过rep stosd
指令将0xCCCCCCCC
填充到局部变量区域。这通常用于调试时帮助检测未初始化的变量或栈溢出。
lea edi, [ebp-10C] ; 获取局部变量空间的起始地址并存入edi
mov ecx, 43 ; 设置计数器,表示要写入的次数为67(43h)
mov eax, CCCCCCCC ; 设置要写入的值为0xCCCCCCCC
rep stosd ; 将eax的值(0xCCCCCCCC)重复写入到edi指向的内存区域
lea edi, [ebp-10C]
:lea
指令(Load Effective Address)将[ebp-10C]
(局部变量空间的起始地址)加载到edi
寄存器中。此时edi
指向局部变量的起始位置。
mov ecx, 43
:将0x43
(67)存入ecx
,作为rep stosd
指令的计数器,表示接下来要重复执行stosd
指令67次。
mov eax, CCCCCCCC
:将0xCCCCCCCC
存入eax
寄存器,这个值将被写入到栈空间中。0xCCCCCCCC
在调试环境中通常用于填充未初始化的内存或局部变量,用于帮助识别未使用或非法使用的内存区域。
rep stosd
:rep
是一个前缀,用于重复执行后面的stosd
指令,直到ecx
为0。stosd
将eax
中的值(0xCCCCCCCC
)写入edi
指向的内存地址,每次写入4字节,ecx
自动减1。因为ecx
初始值为67,所以这个操作将0xCCCCCCCC
写入[ebp-10C]
到[ebp-4]
的内存区域(268字节)。
再往后的代码中则表示函数执行过程中堆栈保护的设置,以及准备进行一次函数调用的过程。
mov eax,dword ptr ds:[<___security_cookie>]
xor eax,ebp
mov dword ptr ss:[ebp-4],eax
mov eax, dword ptr ds:[<___security_cookie>]
:
作用:从数据段(ds)中的全局变量<___security_cookie>读取一个值到eax寄存器中,___security_cookie 是堆栈保护机制中常用的安全Cookie。这个值通常是一个随机生成的值,用于检测栈溢出攻击。在程序启动时,___security_cookie 会被初始化为一个随机数。它在函数开始和结束时被用来验证栈的完整性,确保没有被非法修改。
xor eax, ebp
:将安全Cookie
与ebp
寄存器中的值进行异或操作(XOR
),结果保存在eax
中。ebp
是当前函数的栈帧基址。通过异或ebp
,它使得安全Cookie与当前函数的栈帧相关联,从而进一步加强了堆栈保护机制。
mov dword ptr ss:[ebp-4], eax
:将eax
(即异或后的安全Cookie值)存储到栈帧中[ebp-4]
的位置。
后面的两行代码也则是在进行各种函数初始化时的检查,因为涉及到跳转,这边就不做过多赘述了。
再往下运行的这部分代码就开始进行参数传递,这些指令将数值压入栈中后进行函数调用,且因为在函数调用后可以看到有平栈的操作,所以基本上可以确定该函数的调用约定为cdecl
。
在函数的所有参数压入栈中后,此时ESP
与EBP
寄存器的指向如下:
ESP指向栈顶,且ESP指向的地址值比EBP来的小,所以在参数压栈的过程中地址实际上是减少的。接着当我们进入函数后,程序会自动将函数的返回地址压入栈中;
此时栈中的情况为
进入函数后程序做的第一件事就是将原来的ebp压入栈中,用于保存调用者栈空间。
此时栈中的内容为:
紧接着就是mov ebp,esp
将栈顶寄存器的值赋给基址寄存器,然后sub esp,C0
开辟新的栈帧空间。
此时栈中的内容如下:
后续的代码就是在进行函数的相关初始化,与上述main
函数的初始化相同,这边就不再赘述了。接着我们来说一下在函数内的参数引用,也就是函数如何使用栈中的参数值:
mov eax,dword ptr ss:[ebp+8]
add eax,dword ptr ss:[ebp+C]
add eax,dword ptr ss:[ebp+10]
add eax,dword ptr ss:[ebp+14]
add eax,dword ptr ss:[ebp+18]
这个时候dword ptr ss:[ebp+8]
实际上就表示栈中的第一个参数,dword ptr ss:[ebp+C]
就是第二个参数以此类推。
最后得到5个参数的和放在eax
寄存器中。
再往后就是各种还原操作如还原非易失性寄存器、还原开辟的栈帧空间、还原ESP
和ESP
原来的位置等。
最后ret
,根据栈中的返回地址回到原本的执行地址中。
由于是cdecl
调用约定,所以需要进行平栈操作(add esp,14
这边的14为16进制,转化为十进制为20,在32位架构中正好就是代码中5个参数占用的空间),至此函数执行完毕。
参数传递-指针
上面的例子是所有参数传递方式为整数的情况,那么如果传入的参数为指针呢?我们就接着看下面的代码。
可以看到如果传入的参数为指针的情况下,这个时候压入栈中的就是参数的地址,这边使用lea
命令进行取地址,接着我们进入函数中进行查看;
可以看到此时函数中的初始化操作和还原操作都是一样的,这里我们来关注一下不同点:
mov eax,dword ptr ss:[ebp+8]
mov dword ptr ds:[eax],A
mov eax,dword ptr ss:[ebp+C]
mov dword ptr ds:[eax],14
mov eax,dword ptr ss:[ebp+10]
mov dword ptr ds:[eax],1E
mov eax,dword ptr ss:[ebp+14]
mov dword ptr ds:[eax],28
mov eax,dword ptr ss:[ebp+18]
mov dword ptr ds:[eax],32
mov eax, dword ptr ss:[ebp+8]
将第一个参数的值(即一个指针)从栈中加载到 eax
寄存器。
mov dword ptr ds:[eax], A
将常量 A
(十六进制数,等于 10)存储到 eax
指向的地址。这意味着将值 10 存入该指针指向的内存位置。
后面的情况也是类似的,总结一下该代码实际上就是根据传入的指针参数,将一系列特定值(10、20、30、40、50)写入这些指针所指向的内存地址。
执行完毕后也是一样根据栈中的返回地址回到原来的指令执行地址处,进行平栈操作。
至此函数执行完毕。后续的printf
函数的调用这边就不做过多赘述了,如果对这个比较不熟悉的话,可以看看笔者前面的文章。
至此x86
架构的函数执行的全部过程刨析完了,接着我们来看一下x64
架构中函数执行的全部过程。
②x64架构
首先我们将x64
架构的程序载入x64dbg
中进行分析调试。
紧接着定位到main
函数得到如下代码:
我们先来看一下
push rbp
push rdi
sub rsp,1B8
lea rbp,qword ptr ss:[rsp+30] ;与x86不同,x64将rbp指向了rsp+30的地址
mov rdi,rsp
mov ecx,6E
mov eax,CCCCCCCC
rep stosd
这段代码可以被视为一个序言阶段(prologue)。具体来说,这段代码所做的事情符合序言的特征:
①保存寄存器值:push rbp
和 push rdi
保存了旧的基址指针和 rdi
寄存器的值。
②为局部变量分配空间:sub rsp, 1B8
在栈上分配了 440 字节的空间用于局部变量和临时数据。
③设置栈帧基址:lea rbp, [rsp+30]
为 rbp
设置了新的基址,便于函数访问栈中的局部变量。
④初始化内存:通过 mov eax, CCCCCCCC
和 rep stosd
初始化栈上分配的内存空间。这在某些情况下是为了确保局部变量的内存被预先设置,避免使用未初始化的数据。
再往下与x86
架构程序一样是做一个堆栈保护的设置,以及函数运行的各种检查操作:
接着往下就是正式开始进行函数的参数传递
初始化参数值
mov dword ptr ss:[rbp+4], 1
mov dword ptr ss:[rbp+24], 2
mov dword ptr ss:[rbp+44], 3
mov dword ptr ss:[rbp+64], 4
mov dword ptr ss:[rbp+84], 5
这些指令将常量值 1
、2
、3
、4
和 5
存入栈中相对于 rbp
的不同偏移位置(rbp+4
、rbp+24
等)。这些地址似乎是用于存储函数的局部变量或传入参数的。
准备参数进行函数调用
mov eax, dword ptr ss:[rbp+84]
mov dword ptr ss:[rsp+20], eax
mov eax, [rbp+84]
将 rbp+84
处的值(即之前存储的 5
)加载到 eax
寄存器。
然后,mov [rsp+20], eax
将 eax
的值(5
)存入栈中 rsp+20
处。这是通过栈传递的参数之一,通常用于超过寄存器限制的额外参数。
此时栈内的内容为:
接着通过寄存器传递参数
在 x64 调用约定(Windows 下的 Microsoft x64 Calling Convention
或 SysV ABI
)中,前四个整数参数是通过寄存器 rcx
、rdx
、r8
、r9
传递的:
mov r9d, dword ptr ss:[rbp+64]
mov r8d, dword ptr ss:[rbp+44]
mov edx, dword ptr ss:[rbp+24]
mov ecx, dword ptr ss:[rbp+4]
r9d
被赋值为 [rbp+64]
处的值(即之前存储的 4
)。
r8d
被赋值为 [rbp+44]
处的值(即之前存储的 3
)。
edx
被赋值为 [rbp+24]
处的值(即之前存储的 2
)。
ecx
被赋值为 [rbp+4]
处的值(即之前存储的 1
)。
这些指令表明,函数 parameterpass-x64.7FF6E34C139D
将通过寄存器传递这四个参数,接着调用函数
call parameterpass-x64.7FF6E34C139D
接着我们进入函数中进行查看,因为此时我们进入了函数中,所以这个时候需要将函数的返回地址压入栈中,此时栈中的内容为:
进入函数后,直接将四个寄存器中的参数值压入栈中(可以看到x64架构的程序虽然是使用寄存器进行参数传递,但是在函数中还是需要将这些寄存器中的值压入栈中)
此时栈中的内容如下:
接着就是序言阶段,这边就不做过多赘述了。
后续就是通过ebp
去取出刚刚压入栈中的几个数据进行相加运算,并把最后的和放在rax
寄存器中;
具体的ebp+E8
这些值可以直接右击地址,然后转到内存中进行查看即可,实际上就是刚刚压入栈中的值:
最后就是函数执行完毕后执行的还原操作即还原非易失性寄存器(rdi)中的值,以及rsp和rbp的值。
最后ret
返回,至此函数运行结束。
后续做的printf
操作这边就不做过多赘述,具体可以根据动态调试时窗口的变化进行判断。
参数传递-指针
接着我们来看一下关于x64架构的指针参数传递
事实上指针的传递过程与整数差不多,是将最后一个指针指向的先放入rsp+20
处,接着将其他的,其他的指针参数从左往右依次放入r9-rcx
中,接着调用函数。由于调用了函数,程序自动向栈中压入的函数的返回地址,原来的rsp+20
处此时就是rsp+28
,接着再将r9-rcx
存放中的地址值分别压入rsp20-rsp+8
中。
接着就是做初始化与检查:
接着就是通过如下形式,提取处栈中的地址,对地址上的值重新赋值:
mov rax,qword ptr ss:[rbp+E0] ;从栈上 rbp+E0 的位置读取一个 64 位的值(假设是一个指针),并将其存入 rax 寄存器。
mov dword ptr ds:[rax],A ;将常量 A(十六进制,等于 10)存储到 rax 指向的内存地址中。意味着将值 10 写入该指针所指的内存位置。
mov rax,qword ptr ss:[rbp+E8]
mov dword ptr ds:[rax],14
mov rax,qword ptr ss:[rbp+F0]
mov dword ptr ds:[rax],1E
mov rax,qword ptr ss:[rbp+F8]
mov dword ptr ds:[rax],28
mov rax,qword ptr ss:[rbp+100]
mov dword ptr ds:[rax],32
这段代码从栈中读取几个指针,并向这些指针所指的内存位置分别写入值 10
, 20
, 30
, 40
, 和 50
。函数执行的结果直接就体现在指针指向的地址中了。
接着将各种栈指针、寄存器恢复到调用之前的状态,同上。
最后ret
返回到原来的执行地址上,函数结束。
在本文中,我们深入探讨了函数逆向工程的整体流程,通过对函数的结构、调用约定及其参数传递方式的详细分析,能够有效地识别和理解目标程序的行为,为后续的漏洞分析和安全研究奠定基础。