首页
社区
课程
招聘
[原创]从GDB中观察x86-64函数调用约定的完整过程
发表于: 4天前 860

[原创]从GDB中观察x86-64函数调用约定的完整过程

4天前
860

测试代码:

#include <stdio.h>

// 测试参数传递:6个参数(刚好全用寄存器)
int six_args(int a, int b, int c, int d, int e, int f) {
    return a + b + c + d + e + f;
}

// 测试参数传递:8个参数(超出6个,后两个走栈)
int eight_args(int a, int b, int c, int d, int e, int f, int g, int h) {
    return a + b + c + d + e + f + g + h;
}

// 测试callee-saved寄存器
int use_callee_saved(int x) {
    int arr[3] = {100, 200, 300};
    return arr[0] + arr[1] + arr[2] + x;
}

int main() {
    printf("six: %d\n", six_args(1, 2, 3, 4, 5, 6));
    printf("eight: %d\n", eight_args(1, 2, 3, 4, 5, 6, 7, 8));
    printf("saved: %d\n", use_callee_saved(42));
    return 0;
}

运行环境:

    Ubuntu 20.04.6 LTS

编译指令:

    gcc -g -O0 -o main main.c

gcc版本

    gcc version 9.4.0

调试工具:

    GDB


开始执行:

进入main 函数,查看反汇编代码:

endbr64 

push   %rbp

mov    %rsp,%rbp

mov    $0x6,%r9d

mov    $0x5,%r8d

mov    $0x4,%ecx

mov    $0x3,%edx

mov    $0x2,%esi

mov    $0x1,%edi

callq  1169 <six_args>

mov    %eax,%esi

lea    0xd8c(%rip),%rdi

mov    $0x0,%eax

callq  1070 <printf@plt>

pushq  $0x8

pushq  $0x7

mov    $0x6,%r9d

mov    $0x5,%r8d

mov    $0x4,%ecx

mov    $0x3,%edx

mov    $0x2,%esi

mov    $0x1,%edi

callq  11a3 <eight_args>

add    $0x10,%rsp

mov    %eax,%esi

lea    0xd55(%rip),%rdi

mov    $0x0,%eax

callq  1070 <printf@plt>

mov    $0x2a,%edi

callq  11e7 <use_callee_saved>

mov    %eax,%esi

lea    0xd43(%rip),%rdi

mov    $0x0,%eax

callq  1070 <printf@plt>

mov    $0x0,%eax

leaveq 

retq   

验证函数在小于等于6个参数时,函数传参的方式是按照 rdi(函数的第一个参数),rsi,rdx,rcx,r8,r9(函数的第六个参数)顺序进行传参的

反汇编代码如下:

mov    $0x6,%r9d

mov    $0x5,%r8d

mov    $0x4,%ecx

mov    $0x3,%edx

mov    $0x2,%esi

mov    $0x1,%edi

进入six_args函数: 在执行call的过程中观察栈中的信息发现,在执行call命令后会自动的将call命令的下一跳指令的rip压入栈中(return address),保证函数在返回后可以回到当前执行流中继续执行 

callq  1169 <six_args> 

; gdb查看rsp中的值  x /10x $rsp


反汇编代码如下;

endbr64 

push   %rbp

mov    %rsp,%rbp

mov    %edi,-0x4(%rbp)

mov    %esi,-0x8(%rbp)

mov    %edx,-0xc(%rbp)

mov    %ecx,-0x10(%rbp)

mov    %r8d,-0x14(%rbp)

mov    %r9d,-0x18(%rbp)

mov    -0x4(%rbp),%edx

mov    -0x8(%rbp),%eax

add    %eax,%edx

mov    -0xc(%rbp),%eax

add    %eax,%edx

mov    -0x10(%rbp),%eax

add    %eax,%edx

mov    -0x14(%rbp),%eax

add    %eax,%edx

mov    -0x18(%rbp),%eax

add    %edx,%eax

pop    %rbp

retq   


在函数调用时,会保存调用方的栈帧,为了防止被调用函数污染了调用方的栈帧,也是为了保证堆栈平衡

即:

push   %rbp

mov    %rsp,%rbp

继续向下执行:

mov    %edi,-0x4(%rbp)

mov    %esi,-0x8(%rbp)

mov    %edx,-0xc(%rbp)

mov    %ecx,-0x10(%rbp)

mov    %r8d,-0x14(%rbp)

mov    %r9d,-0x18(%rbp)

发现函数在保存调用方的传参时,并没有为当前函数开辟一块栈空间来储存这些参数的值,而是直接使用了rbp向下增长来保存参数

经过查阅发现 x86-64的System V ABI(Linux/macOS使用的调用约定)定义了一个叫做Red Zone红区的概念:

大概就是在函数的调用过程中,rsp-128字节的空间被编译器都认为是安全的,可以直接使用,并不需要sub rsp, x


继续向下执行:

mov    -0x4(%rbp),%edx

mov    -0x8(%rbp),%eax

add    %eax,%edx

mov    -0xc(%rbp),%eax

add    %eax,%edx

mov    -0x10(%rbp),%eax

add    %eax,%edx

mov    -0x14(%rbp),%eax

add    %eax,%edx

mov    -0x18(%rbp),%eax

add    %edx,%eax

计算6个参数的值,最后结果保存在eax中作为函数的返回值,在x86-64 System V ABI规则中,函数的返回值:整数/指针的返回式是保存在rax(小的值可能保存在eax,ax, al)中的,而浮点数是保存在xmm0中


继续向下执行: 恢复栈帧,将栈帧恢复调用前的状态,retq后会取出压入的return address,修改rip的值,恢复调用方执行流

pop    %rbp

retq   


继续向下执行: 根据函数的调用约定,分别将"six: %d\n"字符指针以及 six_args的返回值分别放入了 rdi以及 rsi中

mov    %eax,%esi

lea    0xd8c(%rip),%rdi

mov    $0x0,%eax

; 通过x/10s $rdi 验证字符指针中的内容


在放入对应的寄存器后,还需要将al寄存器的值赋值为0:

因为在x86-64 ABI对不定数量的参数有要求,在传递可变参数函数的时候需要判断是否使用了浮点数寄存器xmm,而al中存放的值就为使用了xmm寄存器的数量

ps:当前函数并没有给al赋值,

即反汇编代码:

mov    $0x0,%eax


进入printf函数,输出six: 21


继续向下执行:

pushq  $0x8

pushq  $0x7

mov    $0x6,%r9d

mov    $0x5,%r8d

mov    $0x4,%ecx

mov    $0x3,%edx

mov    $0x2,%esi

mov    $0x1,%edi

发现并验证,在函数参数超过6个的时候,后面的参数是通过压栈去传递的,根据函数调用约定cdecl,参数从右到左压入栈中


进入eight_args函数:

endbr64 

push   %rbp

mov    %rsp,%rbp

mov    %edi,-0x4(%rbp)

mov    %esi,-0x8(%rbp)

mov    %edx,-0xc(%rbp)

mov    %ecx,-0x10(%rbp)

mov    %r8d,-0x14(%rbp)

mov    %r9d,-0x18(%rbp)

mov    -0x4(%rbp),%edx

mov    -0x8(%rbp),%eax

add    %eax,%edx

mov    -0xc(%rbp),%eax

add    %eax,%edx

mov    -0x10(%rbp),%eax

add    %eax,%edx

mov    -0x14(%rbp),%eax

add    %eax,%edx

mov    -0x18(%rbp),%eax

add    %eax,%edx

mov    0x10(%rbp),%eax

add    %eax,%edx

mov    0x18(%rbp),%eax

add    %edx,%eax

pop    %rbp

retq   

验证rsp栈中内容: x/10w $rsp

栈底部16个字节分别储存的值为:0x555552ab 0x00005555 0x00000007 0x00000000 0x00000008 0x00000000

0x555552ab 0x00005555 return address

int g的值 0x00000007 0x00000000  

int h的值 0x00000008 0x00000000  小端排序,所以值为0x08


继续执行,保存返回值到eax中,恢复栈帧,返回到调用方函数内部;发现

反汇编代码:

add    $0x10,%rsp

根据函数调用约定cdecl,由调用方进行平栈,恢复栈帧。


继续执行:再次调用printf函数

mov    %eax,%esi

lea    0xd55(%rip),%rdi

mov    $0x0,%eax

callq  0x555555555070 <printf@plt>

调用结束后输出:eight: 36


继续执行: 将0x2a放入edi中调用use_callee_saved函数

mov    $0x2a,%edi

callq  0x5555555551e7 <use_callee_saved>


并没有发现caller-saved: push rax push rcx等类似代码,因为代码实现的过于简单

GCC 在 -O0 下没有需要用到需要保存的寄存器RAX, RCX, RDX, RSI, RDI, R8-R11


进入函数内部:

endbr64 

push   %rbp

mov    %rsp,%rbp

sub    $0x30,%rsp

mov    %edi,-0x24(%rbp)

mov    %fs:0x28,%rax

mov    %rax,-0x8(%rbp)

xor    %eax,%eax

movl   $0x64,-0x14(%rbp)

movl   $0xc8,-0x10(%rbp)

movl   $0x12c,-0xc(%rbp)

mov    -0x14(%rbp),%edx

mov    -0x10(%rbp),%eax

add    %eax,%edx

mov    -0xc(%rbp),%eax

add    %eax,%edx

mov    -0x24(%rbp),%eax

add    %edx,%eax

mov    -0x8(%rbp),%rcx

xor    %fs:0x28,%rcx

je     1240 <use_callee_saved+0x59>

callq  1060 <__stack_chk_fail@plt>

leaveq 

retq   


发现代码:

endbr64 

push   %rbp

mov    %rsp,%rbp

sub    $0x30,%rsp  ; 再次印证sub rsp, x ; 计算结果rsp的值永远是16的倍数。


继续向下,并没有发现期望看到的callee-saved:即push rbx 以及push r12等类似代码,同理也是因为太简单,所以并没有拥到对应的RBX, R12-R15寄存器,只用到了rbp寄存器


但是发现了:

mov    %edi,-0x24(%rbp)

mov    %fs:0x28,%rax

mov    %rax,-0x8(%rbp)

栈溢出保护的代码,他在当前函数栈帧中,保存的旧RBP值的下方存入了一个金丝雀的值(8字节),当此处的值被覆盖,或者修改时,会触发栈空间保护,程序异常终止,对应函数末尾:

xor    %fs:0x28,%rcx

je     1240 <use_callee_saved+0x59>  ; 如果值不相等则调用__stack_chk_fail函数

callq  1060 <__stack_chk_fail@plt> 


继续执行:初始化数组

movl   $0x64,-0x14(%rbp)

movl   $0xc8,-0x10(%rbp)

movl   $0x12c,-0xc(%rbp)


继续执行到程序结束....


发现:在传入函数参数时,函数还要在函数栈内部保存一次传参例如use_callee_saved中%edi,-0x24(%rbp)


尝试思考:

会不会是为了保证传入的参数栈上的值或者寄存器的值不会被修改,也对应了c语言中的值传递和指针传递的特性。


查阅后发现:

寄存器会被复用。函数执行过程中会用 RDI、ESI 等寄存器做计算,

原始的参数值会被覆盖。所以 -O0 编译器在函数入口就把它们存到栈上,

后续都从栈上读取。

这不是"值传递"的特性,而是 -O0 编译器的策略:

把所有变量都放到栈上,方便 GDB 调试。


后续验证-O2情况下编译器的处理



总结:

1. 验证在64位linux系统中,如果函数参数小于等于6,函数传参是通过寄存器传参的

   rdi,rsi,rdx,rcx,r8,r9来进行传参的,而超过6个则根据栈来传参,遵守cdecl调用约定

2. 可变参数函数如果传递了浮点数时,则需要告诉该函数处理浮点数的数量保存在al中

3. 在连续movl在连续地址中保存4字节数据,那他可能是在初始化一个数组(特征发现)

        4. 函数返回值如果为指针或者整数时,用rax寄存区传递,如果为浮点数则用xmm0寄存器传递

5. 在函数栈向下128字符的空间为红区,编译器保证了函数在执行过程中可以自由的使用这块空间(红区)

6. 接触到了线程局部存储(TLS)以及“金丝雀”的作用;

7. 发现了栈保护机制Stack Canary


six_args函数栈帧布局对比eight_args函数栈帧布局




传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 3天前 被liu_scar编辑 ,原因: 有错误需要更改
收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回