简单理解函数的调用

函数的调用也是有约定的,比如传递参数的方式,返回值返回的方式等。

一些前提知识

不同的平台编译器用的约定也有可能有不同,详细请参照wiki,x86 Calling Convention。

不同的平台编译器可能对Caller和Callee的职责有区别。详细请参照上述链接,下面主要讨论x86-64(AMD64) 在Linux 下的calling convention。其他平台暂不涉及。

calling_convention.png

System V AMD64 ABI

系统V AMD64的调用约定跟随着Solaris, Linux, FreeBSD, maxOS, 并且实际上已经是那些Unix和类Unix操作系统的标准。

  • 函数参数的前6个整数或者指针参数会被填入RDI, RSI, RDX, RCX, R8, R9(R10被用于作为一个静态的链指针以防有嵌套的调用)
  • 像Microsoft x64的调用约定一样,额外的参数被压入到栈中。
  • 整数返回值在64bits之内都通过RAX返回。如果是128bits,通过RAX和RDX。

Caller 清理Stack 的意思是:
Caller传入栈中的参数,需要Caller自己去清理掉,维持堆栈平衡,这里有个表格:


register list

在 x86-64 Linux, %rbp, %rbx, %r12, %r13, %r14, %r15 and %rsp 是 callee-saved 的寄存器, 其它的称为caller-saved 寄存器。

(图中的caller-owned 即 callee-saved 寄存器,callee-owned 即 caller-saved寄存器)
caller-saved register: 调用者保存寄存器
即如果调用者在调用其他函数之后仍旧想使用调用之前的寄存器的值,那么调用者需要自己保存这些寄存器的值,被调用者无须关心这些,可以直接使用,因为它知道如果调用者还想用这些寄存器里面的值的话,自己已经保存过了。
这一类寄存器又称为 临时的寄存器。在过程调用中,编译器不会自动为你在函数调用之间保存/恢复这些寄存器的值。

callee-saved register: 被调用者保存寄存器
即被调用的函数中,如果想使用这些寄存器,那么必须先将寄存器中的值保存下来后才能使用,并且在返回调用者之前,将寄存器的值恢复。调用者不必担心这些寄存器的值被修改,因为它知道被调用的函数会保护这些寄存器的值。
这一类寄存器又称为 调用保护的寄存器。编译器会帮你保存这些寄存器的值。

cdecl (C declaration):
在C语言中,函数的参数是被从右至左依次按顺序push到栈中的,也就是 最后一个参数先被 push。在Linux,GCC 实际上将其设为标准的调用约定。从GCC 4.5版本起,栈必须按照16字节进行对齐(之前的版本只是需要4字节对齐) 。

RTL(C):
Right to Left 顺序参数入栈。

以上均为翻译wiki,各位看官可以直接看原文哈~

一些简单指令的含义

时刻记住栈的增长方向是从高地址向低地址

  1. push xxx
    将xxx压入到栈中,此时%rsp 的值会增长(这里的增长就是给rsp减掉xxx的大小)对应大小
  2. call xxx
    call 指令做2个事情
  • 将下一条指令的地址push到栈中(即返回地址入栈)
  • jmp到 xxx 地址 执行指令
  1. leave
  • 设置%rsp的值为%rbp,并从栈中pop出来的值赋值给rbp。相当于mov %rbp, %rsp; pop %rbp ;
  1. ret
  • 将程序控制转换到到当前栈顶的地址上。就是pop当前栈顶到%rip。相当于pop %rip;这个地址一般是call指令放置的。即回到了call指令之后的指令上。

这里有个详细的Guide : http://web.stanford.edu/class/cs107/guide/x86-64.html

简单的实践-手动读取函数的参数

有了以上的基础,看一个简单的函数:

#include 
#include 
#include 

void foo3(long a, long b, long c, long d, long e, long f, long g, long h , long i) {
    long j,k,l,m,n,o,p,q,r ;
    // 通过寄存器和栈读取传入参数
    __asm__ ("movq %%rdi, %0\n\t"
                          "movq %%rsi, %1\n\t"
                          "movq %%rdx, %2\n\t"
                          "movq %%rcx, %3\n\t"
                          "movq %%r8, %4\n\t"
                          "movq %%r9, %5\n\t"
                          "movq 0x10(%%rbp), %%rax\n\t"
                          "movq %%rax, %6\n\t"
                          "movq 0x18(%%rbp), %%rax\n\t"
                          "movq %%rax, %7\n\t"
                          "movq 0x20(%%rbp), %%rax\n\t"
                          "movq %%rax, %8\n\t"
                          : "=m"(j), "=m"(k), "=m"(l), "=m"(m),"=m"(n), "=m"(o), "=m"(p), "=m"(q), "=m"(r)
                          ::"%rax") ;
    printf("%ld, %ld, %ld, %ld, %ld, %ld, %ld, %ld, %ld\n", j, k,l,m,n,o,p,q,r) ;
}


int main() {
    // 这里的call指令会将下一条指令的地址入栈
    // jump 到foo3
    foo3(0,1,2,3,4,5,6,7,8) ;

    return 0 ;
}

按照上述的x86_64(AMD64) 在Linux 上的call convention,我们的foo3函数有9个参数,其中6个参数将放置到寄存器中,剩余的3个参数将放到栈中。

注:栈的增长是从高地址向低地址
main中调用foo3的参数传递,依据顺序 RTL,因此依次如下:

pushq 0x8
pushq 0x7
pushq 0x6

三个参数push到栈完成。
剩下的6个,赋值给寄存器:

movq $0x5, %r9
movq $0x4, %r8
movq $0x3, %rcx
movq $0x2, %rdx
movq $0x1, %rsi
movq $0x0, %rdi

之后的操作就是:
call foo3

call foo3 做两件事:

  1. push 下一条指令的地址到栈中
  2. jmp 到foo3的地址开始执行(等同于将%rip的值设置成foo3的地址)

objdump -D a.out > a.s

不习惯看AT&T汇编的,可以
objdump -D a.out -M intel > a.s
用intel语法看

我们可以看看反汇编之后的main函数是什么样:

000000000040121e 
: ; 保存rbp值到栈中 40121e: 55 push %rbp ; 让rbp指向rsp 40121f: 48 89 e5 mov %rsp,%rbp ; 栈增长8个字节, 这个是为了和后续的3个push连起来形成16字节对齐 401222: 48 83 ec 08 sub $0x8,%rsp ; 压参数 401226: 6a 08 pushq $0x8 401228: 6a 07 pushq $0x7 40122a: 6a 06 pushq $0x6 ; %r9d 标志 %r9 的低32位 40122c: 41 b9 05 00 00 00 mov $0x5,%r9d 401232: 41 b8 04 00 00 00 mov $0x4,%r8d 401238: b9 03 00 00 00 mov $0x3,%ecx 40123d: ba 02 00 00 00 mov $0x2,%edx 401242: be 01 00 00 00 mov $0x1,%esi 401247: bf 00 00 00 00 mov $0x0,%edi 40124c: e8 34 ff ff ff callq 401185 ; 这里就体现出了caller清理参数栈空间,总共push了3次8字节,对齐时多分了8字节,总共32字节 ; 所以这里给 %rsp + 0x20 表示栈回退了32字节 401251: 48 83 c4 20 add $0x20,%rsp

栈的布局如下:

main 栈空间

因为是caller清理参数栈,所以图中8,7,6也属于main的栈空间。

在foo3中,foo3 首先要保存当前的栈基指针%rbp,因为此时的%rbp 的值是main函数的栈开始的位置,必须保存起来。在函数中对栈内变量的赋值是通过 %rbp +/- 偏移 来操作的。所以此时应该将 %rsp 的值赋值给 %rbp ,这样就能在该函数中通过对%rbp +/- 偏移,来操作该函数中的局部变量。

可以简单看一下foo3反汇编后的部分代码:

0000000000401185 :
  401185:   55                      push   %rbp
  401186:   48 89 e5                mov    %rsp,%rbp
  401189:   48 83 c4 80             add    $0xffffffffffffff80,%rsp
  ; 以下可以先不看
  40118d:   48 89 7d a8             mov    %rdi,-0x58(%rbp)
  401191:   48 89 75 a0             mov    %rsi,-0x60(%rbp)
  401195:   48 89 55 98             mov    %rdx,-0x68(%rbp)
  401199:   48 89 4d 90             mov    %rcx,-0x70(%rbp)
  40119d:   4c 89 45 88             mov    %r8,-0x78(%rbp)
  4011a1:   4c 89 4d 80             mov    %r9,-0x80(%rbp)
  ... ...

push %rbp // 保存rbp的值到栈中
mov %rsp, %rbp // rbp和rsp指向同一个位置
add $0xffffffffffffff80, %rsp // 其实就是rsp-128,即栈空间扩展128字节,为栈中的变量使用
下面的指令先不看了,此时我们再看看栈空间:

栈空间

那么我们读取6,7,8的值就很直观了,
%rbp-0x10就可以读到6
%rbp-0x18就可以读到7
%rbp-0x20就可以读到8

这样,我们的代码就可以理解了。(因为movq的两个操作数不允许同时为内存,所以通过%rax 转存了一下)。

验证返回值放在哪里些寄存器就留给各位看官了。

既然我们可以通过偏移来直接读取栈中保存的参数,那么当然也可以通过偏移改动栈中的值。接下来我们就通过修改函数的返回地址,来让一个没有被调用的函数,被调用到。

简单的实践-函数返回地址修改

#include 
#include 
#include 

void foo2() {
    printf("wow you got call me ...\n") ;
    // 这里最好直接退出,因为foo2返回时,地址不知道是什么了 ... ...
    exit(-1) ;
}

void foo() {
    long *p = NULL ;
    p = (long*)&p ;
    // 修改caller 保存的返回地址,改成foo2函数的地址
    *(p+2) = (long)foo2 ;
}

int main() {
    // 这里的call指令会将下一条指令的地址入栈
    // jump 到foo
    foo() ;

    return 0 ;
}

有了上述的例子作为基础,就不难理解foo() 中为什么能够成功了。

[tutu@localhost c_test]$ ./a.out 
wow you got call me ...
[tutu@localhost c_test]$ 

同样的,我们借助rbp的值+8也能够实现这样的赋值。

void jump_to_foo2() {
    long *ptr = (long*)&foo2 ;
    __asm__ ("movq %0, %%rax\n\t"
             "movq %%rax, 8(%%rbp)"
             :
             :"m"(ptr)
             :) ;
}

由此,各类缓冲区溢出攻击层出不穷了 ... ... 后续有时间可以写写shellcode ...

感谢各位看官 ~

The End ;

windleaves
2020-04-21 00:51:00

你可能感兴趣的:(简单理解函数的调用)