X86汇编调用框架浅析与CFI简介

xiaoxiao2021-02-28  72

[阅读本文仅需要一点x86汇编的知识。另, 本文的汇编使用AT&T语法]

在内核代码调试中, 或是漏洞分析中, 学会看懂backtrace或是熟悉汇编, 都是基础之功。这中间都牵涉到一个叫调用框架(call frame)的概念, 这个名词也叫栈帧(stack frame)或活动过程记录(activation record)。所谓调用框架就是指称一个函数(或过程,或方法)被调用时位于内存中的一块区域, 其中保存着该函数体运行所需要的信息。这其中涉及到几点重要的考量:

函数拥有明显的生存周期界限。在被调用进入执行第一条函数体指令时开始存在, 在返回调用者时消亡。函数可以输入参数以改变具体行为, 而具体参数值在运行时确定。函数可以被多次调用, 比如以循环或递归方式。

综合这些考量, 现代的硬件与操作系统一齐合作, 提供了一种方案, 就是调用框架:

操作系统在进程的地址空间中提供一块区域, 每当一个函数被调用, 就在其中开辟一段区域, 这段区域存放着函数活动过程的重要信息的记录, 当其返回时, 这块区域就被销毁, 这是`活动过程记录`得名的缘由。函数调用链是一个`先入后出`的结构: A调用B, A的生命流程总比B长, 这块区域也体现了这种结构: 调用链上每个函数的活动记录都以先入后出的方式组织在这个区域中, 所以这块区域被叫做`栈`, 每个活动记录被叫做`栈帧`现代的CPU, 几乎都提供了实现这种栈的硬件支持: 有一个`寄存器SP`(stack pointer), 指向当前活动记录的顶端, 还有一个`寄存器BP`(base pointer),指向栈底。

下面就x86架构的调用框架进行分析。

x86的调用框架

下面是一个函数示例代码:

/* demo.c */ int demo(int a, int b) { long l1 = 1; int l2 = 2; return l1 + l2 + a + b; } int main(void) { demo(37, 42); return 0; }

编译成汇编代码, 用-O0选项, 表示不优化, 以生成可以和原始代码逐条对应的目标代码:

$ gcc --version gcc (Ubuntu/Linaro 4.7.2-2ubuntu1) 4.7.2$ gcc -O0 -o demo.s -S demo.c

结果如下, main函数只取一部分:

原始版 简化版 demo: demo: .LFB0: pushl %ebp .cfi_startproc movl %esp, %ebp pushl %ebp .cfi_def_cfa_offset 8 subl $16, %esp .cfi_offset 5, -8 movl $1, -8(%ebp) movl %esp, %ebp movl $2, -4(%ebp) .cfi_def_cfa_register 5 subl $16, %esp movl -4(%ebp), %eax movl $1, -8(%ebp) movl -8(%ebp), %edx movl $2, -4(%ebp) addl %eax, %edx movl -4(%ebp), %eax ----- > movl 8(%ebp), %eax movl -8(%ebp), %edx addl %eax, %edx addl %eax, %edx movl 8(%ebp), %eax movl 8(%ebp), %eax addl %eax, %edx addl %eax, %edx movl 12(%ebp), %eax movl 12(%ebp), %eax addl %edx, %eax addl %edx, %eax leave .cfi_restore 5 leave .cfi_def_cfa 4, 4 ret ret .cfi_endprocmain: ... movl $42, 4(%esp) // 把42赋给%esp指向地址再加4字节的位置 movl $37, (%esp) call demo

左边为原始版本, 掺杂了许多包含.cfi_*的指令, 这部分放在本文最后讲述。 所以, 现在就上图右边的简化版本来进行讨论。

传参

对于x86架构, 前述的SP与BP寄存器分别是esp和ebp。包括x86在内的几乎所有现代的机器, 栈都是从高地址向低地址生长的, 一个例外是HP的PA-RISC机器[1]。

在main函数中, 有两条mov指令, 明显是执行传参的动作。在调用demo函数的指令call demo执行前, 栈是如此的形态:

高地址 : : | | 42 | | +----------+ <--- %esp + 4 | | 37 | 低地址 +----------+ <--- %esp

注意参数是以从右到左的方式传入的, 至于其原因, 后文再解释。

前序

在call demo指令执行后, 就进入了demo函数的活动范围, 在其运行结束后, 控制流程又会返回到main函数的活动范围。这种嵌套结构, 本质地要求要记录下两层结构交汇点的信息, 以便在底一层结构消除后, 返回到上一层。

从CPU的观点来看, 由于它的指令寄存器eip存放的是下一条指令的地址, 这就要求: 在函数最后一条指令执行后, eip中能正确存放函数之后的下一条指令的位置, 术语叫返回地址, 这意味着这个返回地址必须存在某处以方便获取。由于一个CPU执行单元只有一个eip, 而在函数执行过程中, eip会被不断改变, 所以, 返回地址显然不能放在其中。通用的解决方案是: 把返回地址放在调用框架中, 作为其保存信息的一部分。

所以, call demo的效果是: 返回地址会被CPU自动压到栈上, 然后, eip中被装入demo函数第一条指令地址, 由此, 实现了函数调用。此时, 栈的形态是:

高地址 : : | | 42 | | +----------+ <--- %esp + 8 | | 37 | | +----------+ <--- %esp + 4 | | 返回地址 | 低地址 +----------+ <--- %esp

在demo函数的前两条指令, 执行了很奇怪的操作: 先把旧的ebp压栈, 再把当前的esp赋给ebp, 此时两个寄存器都指向同一位置。 栈形态如下:

高地址 : : | | 42 | | +----------+ <--- %esp + 12 | | 37 | | +----------+ <--- %esp + 8 | | 返回地址 | | +----------+ <--- %esp + 4 | | 旧的ebp | 低地址 +----------+ <--- %esp(%ebp)

这两步操作是个规范化步骤, 叫做前序(prologue), 它有两个作用 :

标记一个新的调用框架。保存前一个函数的调用框架的基址(旧的ebp), 使ebp指向当前函数的调用框架基址。

在函数的执行过程中, 函数的局部变量将会是在返回地址之下的区域开辟空间来存放, 由于ebp是固定的, 可以用它作标杆, 标示参数与局部变量的位置。比如第一个参数位于

转载请注明原文地址: https://www.6miu.com/read-96530.html

最新回复(0)