栈帧

什么是栈帧

函数栈帧 (stack frame) 就是函数调用过程中程序的调用栈 (call stack) 所开辟的空间,这些空间是用来存放:

  1. 函数参数和函数返回值

  2. 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)

  3. 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)


栈帧结构

从高地址向低地址生长,而在 x86 架构下数据则以小端序写入:高位字节放高地址端,低位字节放低地址端,可以理解为从低地址向高地址生长

所以,栈顶在低地址处,栈基在高地址处。

主调函数进行函数调用时,一般会在紧贴主调函数栈帧下方建立一个新的栈帧。

重要指针寄存器

栈指针: sp ,指向栈顶, push 指令会使 sp 下移, pop 指令会使 sp 上移。

帧指针: bp ,指向当前函数栈帧的基,指向的位置用于保存当前函数的主调函数的帧指针。

指令指针: ip ,是计算机处理器中用于存储下一条待执行指令内存地址的寄存器。

注意,栈指针与帧指针正常情况下均应指向某个单位内存的最低地址处。

32 位栈帧结构

这是什么鸭

64 位栈帧结构

这是什么鸭

64 位下,函数的前 6 个参数会存放在寄存器中,从第一个参数开始依次存入寄存器 rdi , rsi , rdx , rcx , r8 , r9 中。

接下来的讨论默认为 64 位的情形。


栈相关汇编机制

pop 与 push

  1. push x :将 x 压入栈中(本质上是覆盖栈中数据),先使 rsp 下移 8 字节,再将内存单元 x (寄存器或内存地址)的值复制入此时 rsp 所指位置,等价于 sub rsp, 0x8; mov [rsp], x

  2. pop x :弹出栈顶(实际上不会删除原栈顶数据)将栈顶的值,即 rsp 所指位置的值复制入 x (寄存器或内存地址)中,并使 rsp 上移 8 字节,等价于 mov x, [rsp]; add rsp, 0x8

call 与 leave 与 ret

  1. call x :本质上是 jump 指令,但会将本语句的下一条语句的地址 push 到栈中,等价于 push (rip + 指令长度); jump x

  2. leave :清除该函数栈帧(实际上不会处理栈中残余数据),等价于 mov rsp, rbp; pop rbp 执行后

  3. ret :函数返回,等价于 pop rip

函数序言

函数序言 (Function Prologue) 是函数开始时的一段标准汇编代码,用于建立函数的执行环境,通常包含对栈的操作,例:

1
2
3
4
5
; 函数序言
push rbp ; 保存调用者的栈基指针
mov rbp, rsp ; 设置当前函数的栈基指针
sub rsp, 0x20 ; 为局部变量分配栈空间(32字节)
push rbx ; 保存被调用者保存的寄存器

寄存器使用约定

程序寄存器组是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其他函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此, IA32 采用一套统一的寄存器使用约定,所有函数(包括库函数)调用都必须遵守该约定。

根据惯例,寄存器 rax 、 rdx 和 rcx 为主调函数保存寄存器 (caller-saved registers) ,当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。寄存器 rbx 、 rsi 和 rdi 为被调函数保存寄存器 (callee-saved registers) ,即被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。此外,被调函数必须保持寄存器 rbp 和 rsp ,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧。

当然,这些工作都由编译器在幕后进行。不过在编写汇编程序时应注意遵守上述惯例。


栈溢出漏洞

栈溢出介绍

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是:

  1. 程序必须向栈上写入数据。

  2. 写入的数据大小没有被良好地控制。

栈溢出利用思路

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下:

输入

gets : 直接读取一行,忽略’\x00’
scanf : 用于从标准输入(如键盘)读取数据并存储到指定变量

输出

sprintf : 输出格式化字符串到缓冲区

字符串

strcpy : 字符串复制,遇到’\x00’停止
strcat : 字符串拼接,遇到’\x00’停止
bcopy : 内存拷贝

确定填充长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA ,根据其给定的地址计算偏移。一般变量会有以下几种索引模式:

  1. 相对于栈基地址的的索引,可以直接通过查看与 rbp 的相对偏移获得

  2. 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。

  3. 直接地址索引,就相当于直接给定了地址。

一般来说,我们会有如下的覆盖需求:

  1. 覆盖函数返回地址,这时候就是直接看 rbp 即可。

  2. 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。

  3. 覆盖 bss 段某个变量的内容。

  4. 根据现实执行情况,覆盖特定的变量或地址的内容。

之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流。

例题