CMU 15-213 Part4 Machine Programming II

函数/过程调用

调用栈

x86-64架构的Linux系统为程序提供一片独占的虚拟内存空间,其布局如下图所示,分为栈,堆,共享库,数据段,代码段,地址依次从高到低(只有47位可作为地址)。位于内存最高的地址区域的栈,用于处理函数调用过程中的数据存储。每次函数调用,系统会为当前调用分配一个栈帧,用于存储函数调用的参数,本地变量和代码指针。x86-64架构中的%rsp和%rbp寄存器用于保存栈信息,%rsp指向栈顶,%rbp指向当前栈帧的底部,%rbp是可选的。由于栈向低地址方向扩展,内存空间中是一个倒挂的栈。运行callq指令进行调用时,自动递减%rsp并将%rip压栈,而调用retq时,出栈一个元素作为%rip并递增%rsp,完成返回操作。

内存的布局

调用函数时,寄存器最多只能传递6个参数,从第7个参数开始就要通过栈传递:在调用callq前,先将第7个及以后的参数压入栈中。调用函数返回时可能会修改寄存器的值,有些寄存器的值是由调用者负责保存(caller save),如%rdi、%rsi,有些值是被调用者保存(callee save),如%rbp、%r12。调用者保存是指调用前调用者压栈保存%rdi、%rsi等寄存器,调用返回后再出栈恢复;而被调用者保存是指调用函数在进入时先压栈%rbp等,返回前出栈恢复。调用函数的返回值则保存在%rax中。

寄存器使用说明

调用函数的本地变量(local variable)除了使用寄存器,也可以存储在栈中(如数组,使用内存引用的变量等)。如果使用栈来存储本地变量,在进入函数时通过subq指令来操作%rsp,比如下列函数运行时内存[%rsp-16, %rsp]可用作本地变量:

call_incr:
  pushq   %rbx
  subq    $16, %rsp
  ...
  addq    $16, %rsp
  popq    %rbx
  ret

所以,经过多级函数调用后,栈中会存在一系列的栈帧,分别保存了多余的参数,函数的返回地址,待恢复的寄存器值,本地变量等数据。而一个正在递归调用的函数,虽然只有一份代码,但内存中存在它的多个栈帧,相当于函数代码的实例化。

栈帧的结构

利用栈攻击与防范

  • 缓冲溢出(Buffer Overflow),指往栈中缓冲写入超量的数据,甚至覆盖了原有用来控制调用过程的数据,比如覆盖返回地址导致函数返回时跳转到黑客注入的代码位置。C语言库中的一些函数存在缓冲溢出的隐患,如gets, strcpy, strcat, 以及使用%sscanf等,这些函数对应的安全版本分别是fgets, strncpy, strncat, 以及使用%nsscanf

  • 代码注入攻击(Code Injection),指利用缓冲溢出,在栈中写入自定义的代码,再通过覆盖返回地址跳转到该代码的位置来执行这些代码。

  • 面向返回(Return-oriented Programming)攻击,指利用程序现存的机器码片段(称为gadget),片段以c3(即retq)结尾,只要在栈上写入一系列gadget的地址,就可以在这些片段中跳转完成一些工作。

面向返回攻击

下面的措施可以防范上述的攻击:

  • 好的编程实践:避免使用有安全隐患的函数,总是对缓冲进行边界检查

  • 系统初始化程序时为栈生成一个随机的偏移(每次运行都会改变),使黑客无法确定注入代码地址。

  • 栈哨兵(Stack Canary):每次调用函数时,在栈中插入一个哨兵值,返回时检查哨兵是否发生更改。

C语言的高级数据结构

数组(Array)

C语言中数组与指针的本质是一样的,只不过声明数组会自动在内存中开辟一片空间。而访问数组元素a[i]可以转化为*(a+i)指针解引用操作。

  • int A[m][n],声明了一个m行n列的二维数组,也按照一维数组的方式存储,一字排开。访问元素A[i][j]的方法为Mem[A+sizeof(int)*(i*n+j)](一次内存访问);

二维数组的内存结构

  • int *A[m],声明了一个m行的元素为指针的数组,访问元素A[i][j]的方法为Mem\[Mem[A+i*n*sizeof(int*)]+j*sizeof(int)](两次内存访问);

指针数组的内存结构

  • int (*A)[n],声明了一个指向n维数组的指针,默认为空。

结构体(Struct)

结构体用一块内存来存储,不同的字段存在不同的偏移量上。处于处理性能考虑,数据存储时有对齐的要求:

  • 块内对齐,数据存储的地址必须是其类型大小的倍数,如4字节长的int的地址以00结尾,结构体在内部使用填充字节来满足该要求

块内对齐

  • 块间对齐,为了使结构体之间每个字段都能满足对齐要求,结构体在尾部增加填充,使得结构体整体长度为最长类型长度的倍数

块间对齐

联合(Union)

与结构体不同,联合用同一片内存来表示不同的数据类型。联合的大小由最大的组成类型决定。利用这个特点,联合可以实现不改变比特序列的类型转换,比如获取浮点数(float)比特序列所对应的无符号整型(unsigned int)。

联合的内存结构

但在大端和小端(Big/Little Endian)的机器上访问相同的值,使用联合得到字节顺序会有所不同。

联合与大端小端