逆向学习(2)---分析实践
刚学习编程时,是从C语言的HelloWorld开始的,那么此处就也从HelloWorld开始分析吧,其中引出了C语言中的调用约定的学习。
还是那个Hello World
使用C编写一个输出HelloWorld的简单程序:
1 |
|
将其打包为一个可执行程序,并使用IDA打开:
可以看到IDA将程序中的机器码转换为了汇编语言的形式。
在IDA显示的内容中,发现除了接触过的汇编语言以外,还存在:
1 | ; int __fastcall main(int argc, const char **argv, const char **envp) |
递归学习一下
C语言的调用约定(Calling Convention)
在C语言中,有不同的调用约定,用来定义函数如何调用、参数如何传递以及栈的清理方式。它们的主要区别如下:
__cdecl(C Declaration)
特点:
- C的默认约定。
- 参数从右到左依次压入栈。
- 返回值通常存储在寄存器(x86:EAX;ARM64: X0)。
- 支持可变参数(如printf,参数数量不固定的函数)。
- 由调用者负责清理栈(由调用函数的一方(Caller)负责将函数参数在栈上的空间清理掉,而不是由被调用函数(Callee)完成)。
__stdcall(Standard Call)
特点:
- 参数由右到左依次压入栈。
- 由被调用者负责清理栈(Callee负责清空栈上的参数)。
- 常用于Windows API函数。
- 不支持可变参数(函数参数需固定)。
__fastcall(Fast Call)
特点:
- 尽量使用寄存器传递函数参数,未用完的参数通过栈传递(前几个参数使用寄存器传递,剩余参数从右到左压栈)。
- 由被调用者负责清理栈(Callee负责清空栈上的参数)。
- 适用于性能敏感的场景,因为寄存器比内存(栈)访问速度快。
- 在x86平台中,ECX和EDX寄存器用于传递前两个参数,其余参数依旧压栈;在ARM64架构下,通常前几个参数使用寄存器(如X0、X1等),后续参数用栈传递。
压栈顺序示例:
1 | int sum(int a, int b); |
从右到左堆栈调用流程:
- b 压栈;
- a 压栈;
- 函数返回后,调用者/被调用者清理栈。
从左到右堆栈调用流程:
- a 压栈;
- b 压栈;
- 函数返回后,调用者/被调用者清理栈。
总结
调用约定 | 参数传递方式 | 栈清理责任 | 支持变长参数 | 用途 |
---|---|---|---|---|
__cdecl | 参数从右到左压栈 | 调用者 | 支持 | 适合跨平台通用性强的C语言代码 |
__stdcall | 参数从右到左压栈 | 被调用者 | 不支持 | 用于特定平台(Windows)上的调用规范 |
__fastcall | 参数通过寄存器+栈传递 | 被调用者 | 不支持 | 优化性能,适合性能敏感的场景 |
回到HelloWorld
基础知识
在ARM64中,函数调用涉及两大关键组件:
- 寄存器:用来存储数据、传递参数或保存返回值。
•X0~X30:通用寄存器,主要用来存储整数、指针、返回值等。
•SP:栈指针,指向当前栈的顶端。
•X29:帧指针,标记栈帧的基址,便于访问局部变量和返回地址。
•X30:链接寄存器,用来保存函数调用的返回地址。
- 栈:用于存储函数的局部变量和寄存器的备份。栈的操作遵循 “向下增长”(ARM64栈的地址递减)。
程序分析
以下代码展示了一个简单的函数调用过程,其中 main 函数负责调用 printf 输出 Hello, World!,并返回 0。
首先从寄存器特征和起始的AREA __test, CODE
可以看出来,我的主机是arm架构的,ORG 0x100003F6C
表示的是指定段的起始位置。
IDA反编译出来的内容:
1 | ; Segment type: Pure code |
函数声明
1 | ; int __fastcall main(int argc, const char **argv, const char **envp) |
此处表明main函数使用了__fastcall调用约定。
内容分解
1.栈帧的创建
1 | STP X29, X30, [SP,#-0x10+var_s0]! |
分解说明:
(1)STP 指令:同时存储两个寄存器的值到内存。
(2)将 X29(帧指针)和 X30(链接寄存器,保存返回地址)压入栈中。
(3)栈指针 SP 递减 0x10(16字节)以分配新的栈空间,同时更新 SP 的值。
作用:
(1)保护当前函数的调用环境,避免寄存器被覆盖。
(2)创建栈帧,用于存储局部变量和保存上下文。
示意图(执行后栈的变化):
1 | 低地址 ┌───────────┐ |
1 | MOV X29, SP |
分解说明:
(1)MOV 指令:将一个值复制到寄存器。
(2)将栈指针 SP 的值复制到帧指针 X29。
作用:更新帧指针 X29,使其指向当前函数的栈帧。现在 X29 指向当前栈帧的顶部,方便访问局部变量和返回地址。
2. 函数调用
1 | ADRL X0, aHelloWorld ; "Hello, World!\n" |
分解说明:
(1)ADRL 指令:加载一个全局变量的地址。
(2)将字符串 “Hello, World!\n” 的地址加载到寄存器 X0 中。
作用:ARM64调用约定规定,第一个参数通过 X0 寄存器传递。此时,printf 函数将接收 X0 作为它的第一个参数。
1 | BL _printf |
分解说明:
(1)BL 指令:跳转到 _printf 的地址,并保存当前返回地址到 X30。
(2)执行 _printf 函数,输出字符串。
作用:将控制权交给 printf 函数,同时保存返回地址以便后续恢复。
过程:printf 会根据寄存器的值(X0),找到 “Hello, World!\n” 并打印。
3. 返回值处理
1 | MOV W0, #0 |
分解说明:
(1)MOV 指令:将立即数 0 加载到 W0。
(2)ARM64调用约定规定,整数返回值存储在 X0(或低32位的 W0)。
作用:准备返回值 0,表示程序成功执行。
4. 栈帧的销毁与返回
1 | LDP X29, X30, [SP+var_s0],#0x10 |
分解说明:
(1)LDP 指令:从栈中加载两个寄存器的值。
(2)恢复 X29(帧指针)和 X30(返回地址)。
(3)同时释放栈空间,更新 SP。
1 | RET |
分解说明:从 X30 中取出返回地址,并跳转到该地址继续执行。
作用:恢复调用者环境,返回到调用 main 的位置。
[!NOTE]
- 在ARM64平台中函数的前几个参数通过寄存器(X0~X7)传递,之后的再通过栈传递,所以此处printf函数的第一个参数也就是X0(此时加载字符串”Hello…”的地址)。
- 在ARM64平台中,当执行跳转指令(如BL、BLR)时,CPU会自动将跳转后应返回的位置(即当前指令的下一条指令地址)保存到X30。
- 在ARM64平台的调用约定中,函数的返回值为整数时默认存储在X0或W0寄存器(取决于返回值是64位(X0)还是32位(W0)),返回值为浮点类型时存储在V0(浮点寄存器),如果返回值体积较大,超出了寄存器的存储能力时,会通过栈传递。
- 当全部内容执行完毕后,使用RET指令,从X30中取出返回地址,恢复到调用main的位置继续执行。
- 开头为什么偏移 -0x10?ARM64 的栈通常以16字节对齐,因此这里分配16字节(0x10),确保对齐的同时提供足够的空间存储两个64位寄存器(X29 和 X30 每个占8字节)。
总结
自从在大学上完汇编的课之后,好像确实没有什么实际的使用和研究,虽然学到了汇编的基本语法和指令集,也大致理解了计算机如何与硬件互动,但随着学业的推进,更多的课程开始专注于高级语言的应用,汇编似乎逐渐被遗忘了。回想起来,曾经有过一段时间觉得这些底层知识不太重要,可能是由于一直在倾向web方面内容的学习,没有太过于注重底层的原理,重新学习后发现,汇编不仅是工具,更是一种深入理解计算机运行的思维方式。
确实这次会感觉重新学习这些知识比较难,不仅是回忆之前学过的,也有一些课程没有接触过的东西,虽然只是过了个最简单的HelloWorld的程序,但那种重新理解底层运作的感觉还是挺震撼的。