赞
踩
目录
本示例运行环境为Windows 10平台,所示例的程序或动态库为VS2022平台下编写并编译的X64程序。
本示例使用IDA Pro版,版本号7.7.220118 Win x64。
理解本示例需要具务如下知识:
关于栈桢的约定的相关知识,详情请参见Windows平台的相关约定:
X64汇编语言寄存器结构及其与X86架构编程区别_ComputerInBook的博客-CSDN博客_x64寄存器
而关于Linux或Unix平台的栈帧的约定稍有不同,请参见:
Linux/Unix平台X64函数调用约定_ComputerInBook的博客-CSDN博客_linux x64调用约定
以上两者虽然有一些差异,但它们又有共同之处,即保有影子内存,序言和结语,以方便对于调用异常的处理。
与X86调用约定不同,C/C++编译器在64位平台上仅支持一种调用约定。这种调用约定利用了64位平台上可获得的新增寄存器数量:
(1)前4个整数或者指针参数依次通过rcx,rdx,r8和r9传递。
(2)前4个浮点参数通过通过前4个SSE寄存器xmm0-xmm3传递。
(3)由调用者为寄存器中的参数传递保留栈上的空间(至少在运行栈上分配32字节的阴影空间(shadow space))。被调用函数可以访问这个栈空间来,将寄存器中的内容写回栈空间。
(4) 任何多余4个参数的其它参数都使用栈来传递,并按照从左到右的次序(即,从第5个参数开始,使用栈传递参数)。
(5) 任何调用返回的整数或者指针值都放在rax寄存器中(调用完成执行返回动作时放在rax寄存器中,例如,调用ret指令时),而浮点数的返回值放在寄存器xmmO中。
(6) rax,rcx,rdx,r8-r11寄存器是易失性的(volatile)。
(7) rbx,rbp,rdi,rsi,r12-r15 寄存器是非易失性的(nonvolatile)。
这些调用约定与C++非类似:指针默认作为第一个参数传递,其它三个参数利用余下的3个寄存器,多出4个的参数则使用栈传递。
(8) call指令从rsp(堆栈指针)寄存器中减去 8,表示空出8字节的栈空间用于存放返回值,因为地址是64位长(8字节)。
(9) 当调用一个子过程(subroutine)的时候,规定指令指针(rip)必须在一个16字节的边界对齐(也就是128位,即16的倍数,这可能是在设计CPU时综合性能考量)。call指令将一个8字节的返回地址压入堆栈中,因此,调用程序必须从堆栈指针中减去8,除了32,它已经减去了阴影空间。
以下示例中,我们在单独的dll文件中定义如下形式的原型函数,因为只展示函数参数传递,我们不写函数体:
void _cdecl demo(int v1, int v2, int v3, int v4,int v5);
实现为空函数体:
void demo(int v1, int v2, int v3, int v4, int v5)
{
}
调用函数:
void Test()
{
demo(1, 2, 3, 4,5);
}
生成可执行文件后,用IDA打开,定位到Test函数的反汇编:
(1) 切换到Imports窗口,找到调用函数demo:
(2) 切换到Imports窗口,双击显示demo函数名的这一行,进入到IDA View-A窗口:
在图中高亮行显示extrn __imp_demo:qword这一行右键,再选择交叉引用列表,出现如下交叉引用界面:
我们选中第一行,双击第一行跳转到调用函数的汇编代码(图示模式):
右键选对【文本模式】进入文本模式(或按【空格键】)在图形模式或文本模式之间切换:
(3) 我们从调用函数开始分析,即下面的反汇编代码:
在这个调用函数前后,标有---SUBROUTINE----字样,以区分调用函数体的反汇编代码起止。
下面分别分析这一段反汇编代码:
var_18= dword ptr -18h
这是IDA为方便描述指针偏移而定义的局部变量,只起辅助分析作用,不是反汇编的一部分。其命名规则为var_offset的形式,其offset为相对于基址指针的偏移量;dword ptr表示这是一个双字整数,-18表示具体的值因为后面用加的形式,因为这里用负号;h后缀表示十六进制数。
sub rsp, 38h
这一行表示在调用函数的栈空间内分配38h(56字节的栈空间),因为栈空间的分配方向是由高地址向低地,栈顶指针指向低地址,分配空间即相减。为什么全本56字节呢?从前面的预备知识我们可以了解到,四个参数的影子内存占用8x4=32个字节(注意,一个X64影子内存的大小是8字节,不管这个参数是多大),参数数量小于等于4时,默认分配28h(40字节的影子内存。参数量多于4个字节时,按每16字节递增,这里5个参数,本来再分配8字节就可以存下,由于最小分配16字节,因此分配56字节。
mov [rsp+38h+var_18], 5
按从右向左的次序将参数入栈,因此先将5这个参数压入当前栈的偏移rsp-18h处,示意图如下:
rsp+56((=rsp+38h) | |
rsp+48 | |
5 | rsp+32(=rsp+38h-18h) |
rsp+24 | |
rsp+16 | |
rsp+8 | |
rsp |
mov r9d, 4
mov r8d, 3
mov edx, 2
mov ecx, 1
按从右向左的次序将参数移入对应的寄存器参数(32位模式下是使用栈存储参数,注意与X64模式的区别),我们可以将这四个参数保存到影子内存,如果这样做了,则rsp栈指针恰好指向函数的第一个参数。
call cs:__imp_demo_cdecl
系统开始调用子函数,注意,在call命令还做了一件事,即在进入demo_cdec函数之前,将rsp的指针减8,分配8字节的空间用于存放返回地址,调用子函数完成后,从此处取出返回地址,并跳转到进入函数之前的地址,同时rsp指针加8。(注意:X64模式会忽略前缀cs:)
add rsp, 38h
这里与分配栈空间的地方对应,前面分配了38h字节内存,为里还原回去,从而使用得堆栈正确。
与函数参数传递具有约定的规则不同的是,没有约定的规则指导局部变量的应该如何布局。当编译器在编译一个函数的时候,它必须面对的一个任务是计算出函数的局部变量所需要占用的空间数量。另一个任务是确定是否这些变量可以位于CPU的寄存器中,或者确定是否它们都应该位于程序栈帧中。决定将局部变量存于何处与调用函数或者任何被调函数都没有任何关系。基于检查函数源代码去确定局部变量的存储布局显然是不可能的。
定义两个函数,被调函数bar,为了演示,我们先置其实现为空;调用函数demo_stackframe,如下所示:
//callee
void bar(int j, int k)
{
}
//caller
void demo_stackframe(int a, int b, int c)
{
int x = 0;
char buffer[64];
int y = 0;
int z = 0;
bar(z, y);
}
外层调用:
void Test()
{
demo_stackframe(1,2,3);
}
生成可执行文件,然后执行后续操作。
(1) IDA加载可执行文件后,进步菜单【跳转】->【跳转到函数】,查找函数demo_stackframe:
(2) 反汇编代码分析
frame= byte ptr -1B0h
var_190= byte ptr -190h
var_18C= dword ptr -18Ch
k= dword ptr -10Ch
j= dword ptr -0ECh
arg_0= dword ptr 10h
arg_8= dword ptr 18h
arg_10= dword ptr 20h
下面分别说明:
frame= byte ptr -1B0h
var_190= byte ptr -190h
这两句是IDA定义的辅助变量,表示指针偏移值,byte ptr表示指针类型,后面的值表示具体偏移值。
var_18C= dword ptr -18Ch
这一句定义一个双字类型辅助变量var_18C,值为-18Ch
k= dword ptr -10Ch
j= dword ptr -0ECh
这两句定两个双字类型常量j,k。
arg_0= dword ptr 10h
arg_8= dword ptr 18h
arg_10= dword ptr 20h
这三句定义三个双字类型参数常量。
mov [rsp-8+arg_10], r8d ;将函数的第三个参数移入栈内存rsp-8+arg_10位置。
mov [rsp-8+arg_8], edx ;将函数的第二个参数移入栈内存rsp-8+arg_8位置。
mov [rsp-8+arg_0], ecx ;将函数的第一个参数移入栈内存rsp-8+arg_0位置。
push rbp ; rbp 指针入栈
push rdi; rdi指针入栈
sub rsp, 1A8h ;分配1A8h大小的栈存储空间
lea rbp, [rsp+20h] ;将rsp+20h处的地址移入rbp寄存器作为栈基址
其它编译器生成的语句暂且跳过
mov [rbp+190h+k], 0
mov [rbp+190h+j], 0
mov edx, [rbp+190h+k] ; k
mov ecx, [rbp+190h+j] ; j
call bar
以上语句将i,j局部变量赋值0后移入参数寄存器ecx,edx,再调用函数bar。
前面使用sub rsp, 1A8h分配了栈内存,我们看看后面释放栈内存的语句:
lea rsp, [rbp+188h]
pop rdi
pop rbp
retn
本来与lea rsp, [rbp+188h]等效的语句是add rsp, 188h,根前面的关系可以看出,
rsp = rbp-20h,因此,还原回去的需要加上1A8h ,即rsp= rbp-20h+1A8h=rbp+188h,这正是上面lea rsp, [rbp+188h]的由来。
很显然,栈帧是一个运行时概念;没有栈和没有一个正在运行的程序,栈帧是不能存在的。但是,这并不意味着你在执行诸如使用IDA等执行静态分析的时候,可以忽略栈帧的概念。为每个函数建立栈帧的所有代码都存在于二进制文件中。通过存细分析这些代码,即使程序没有运行,我们也可以获得任何函数栈帧的详细信息。事实上,IDA 的一些最复杂的分析功能是专门用于确定 IDA 反汇编的每个函数的栈帧布局。在初始分析阶段,IDA 通过记录每个push或pop操作以及任何可能改变堆栈指针的算术运算(例如添加或减去常量值),竭尽全力监控堆栈指针在函数过程中的行为。另外的目标包括确定是否一个给定函数使用了一个专用的栈帧指针(例如,通过识别一个push ebp/mov ebp,esp序列),以及识别所有引用函数栈帧局部变量的内存。
IDA栈帧视图有两种,一种是概要栈帧视图,另一种是详细栈帧视图。
查看概要栈帧视图,在引用的变量之上单击右键,比如:
mov [rsp-8+arg_10], r8d
mov [rsp-8+arg_8], edx
mov [rsp-8+arg_0], ecx
在arg_10上单击右键,出现上下文菜单,有诸多操作可以在这里选择。如下图:
要查看详细栈帧视图,同样,选中对应的变量,双击,可弹出如下栈帧视图:
上图中两个特别的值需要注意:“s”和“s”(每个变量以前导空格开始),这些伪变量是 IDA 对保存的返回地址(“r”)和保存的寄存器值(“s”在本例中仅表示 EBP)的特殊表示。 为了完整性,这些值包含在栈帧视图中,因为栈帧中的每个字节都被考虑在内。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。