前言
看CVE-2024-2961的时候卡在了需要二进制基础的部分,于是从这里开始正式接触一下二进制安全的内容
参考原文:
https://www.yuque.com/cyberangel/rg9gdm/gcz7x2
在x86的计算机系统中,内存空间中的栈主要用于保存函数的参数,返回值,返回地址,本地变量等
一切的函数调用都要将不同的数据、地址压入或者弹出栈
栈
LIFO(后进先出),数据结构已经学了这里就不多写了
这种形式的数据结构正好满足我们调用函数的方式: 父函数调用子函数,父函数在前,子函数在后;返回时,子函数先返回,父函数后返回
栈支持两种基本操作:push(入栈)和 pop(出栈,将栈中的数据弹出并存储到指定寄存器或者内存中)
push 0x50 ; 将0x50的压入栈
pop 寄存器名称 ; 将栈中的0x50弹出到某个寄存器中
注意:
上面例子中栈的生长方向是从高地址到低地址的,对应到图片中栈是向下生长的
pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问(但是仍然可以访问)
栈帧
stack frame,本质上就是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)
栈帧有栈顶和栈底之分,其中栈顶的地址最低,栈底的地址最高,SP(栈指针)就是一直指向栈顶的
在 x86-32bit 中,我们用ebp
指向栈底、用esp
指向栈顶
栈帧示意图如下:
一般来说,我们将ebp
到esp
之间区域当做栈帧
整个栈空间不只有一个栈帧,每调用一个函数,就会生成一个新的栈帧
在函数调用过程中,我们将调用函数的函数称为“调用者(caller)”,将被调用的函数称为“被调用者(callee)”:
- “调用者”需要知道在哪里获取“被调用者”返回的值
- “被调用者”需要知道传入的参数在哪里
- 返回的地址在哪里
同时,我们需要保证在“被调用者”返回后,**ebp
,esp
等寄存器的值应该和调用前一致**。因此,我们需要使用栈来保存这些数据。
函数调用实例
函数的调用
简单写一个demo:
#include <stdio.h>
int MyFunction(int x, int y, int z)
{
int a, b, c;
a = 10;
b = 5;
c = 2;
return;
}
int main(){
return 0;
}
在32位环境的linux下编译
gcc -o functest ./functest.c
然后把生成的可执行文件拖进ida反编译,可以看到MyFunction()
的汇编代码
_MyFunction:
push ebp ; 保存ebp的值
mov ebp, esp ; 将esp的值赋给ebp,使新的ebp指向栈顶
sub esp, 0x12 ; 分配额外空间给本地变量
mov qword ptr [ebp-4], 10 ; 对栈中的内存进行赋值操作
mov qword ptr [ebp-8], 5 ; 对栈中的内存进行赋值操作
mov qword ptr [ebp-12], 2 ; 对栈中的内存进行赋值操作
此时的栈:
一开始两个栈帧都视为是空的,在这个过程中,调用者做了两件事情:
- 将被调用函数的参数压入栈中
- 将返回地址压入栈中
这两件事都是调用者负责的,因此压入的栈应该属于调用者的栈帧
然后看被调用者:
- 将旧的(调用者的)
ebp
压入栈,此时esp
指向它 - 将
esp
的值赋给ebp
,ebp
就有了新的值,它也指向存放旧ebp
的栈空间,这时它成了是函数 MyFunction() 栈帧的栈底
这样,我们就保存了“调用者”函数的 ebp,并且建立了一个新的栈帧
接下来,在 ebp 更新后,我们先分配一块0x12字节的空间用于存放本地变量,这步使用sub
实现
通过使用mov
转移指令配合字节数ptr [offset]
我们便可以给 a, b 和 c 赋值了
函数的返回
和调用函数时相反,当函数完成自己的任务后,它会将 esp 移到 ebp 处,然后再弹出旧的 ebp 的值到 ebp 寄存器
这样 ebp 就恢复到了函数调用前的状态了
demo:
int MyFunction( int x, int y, int z )
{
int a, int b, int c;
...
return;
}
汇编大致如下:
_MyFunction:
push ebp
mov ebp, esp
...
mov esp, ebp
pop ebp
ret
最后的ret
指令,相当于pop + jump
,它首先将数据(返回地址)弹出栈并保存到eip
中,然后处理器根据这个地址无条件地跳到相应位置获取新的指令