系统编程安全漏洞防范 - 第2节
一、进程地址空间:程序在内存中的“家”
大家好,我是你们这堂课的老朋友。今天咱们聊聊内存布局,这是系统编程安全的基础。说白了,你要是不懂程序在内存里怎么待着,那后面谈漏洞防范就是空中楼阁。
每个进程启动时,操作系统都会给它分配一块虚拟地址空间。这块空间被划分成几个区域,就像一套房子分成客厅、卧室、厨房一样。我习惯把这几个区域叫做:代码段、数据段、堆、栈。
1.1 四个核心区域
| 区域 | 存放内容 | 生长方向 | 权限 |
|---|---|---|---|
| 代码段(.text) | 程序指令、机器码 | 固定 | 只读+执行 |
| 数据段(.data/.bss) | 全局变量、静态变量 | 固定 | 读写 |
| 堆(Heap) | 动态分配的内存(malloc/new) | 向高地址增长 | 读写 |
| 栈(Stack) | 局部变量、函数调用信息 | 向低地址增长 | 读写 |
嗯,这里要注意:代码段是只读的。为什么?因为如果有人能改代码段,那你的程序就变成别人的傀儡了。我在项目中遇到过一起事故,就是某个驱动模块不小心把代码段映射成了可写,结果被注入了一段恶意代码——从那以后,我每次检查内存映射都会多看一眼权限位。
核心要点:栈和堆的生长方向是相反的。栈从高地址往低地址走,堆从低地址往高地址走。这种设计是为了让它们能共享一块地址空间,不至于互相撞上。
1.2 地址空间布局的SVG示意图
你看这张图,栈在顶部,堆在中间偏下。为什么栈要放在高地址?因为栈是向下生长的,放在顶部可以充分利用地址空间。我刚开始学的时候总觉得这设计反直觉,后来写了一个递归爆栈的程序,亲眼看到栈指针一路往下冲,才真正理解了。
二、函数调用栈原理:程序执行的“记账本”
函数调用栈,说白了就是CPU用来记录“我现在执行到哪了”、“回来之后该干嘛”的一个数据结构。每次调用函数,都会在栈上压入一个栈帧(Stack Frame)。
2.1 栈帧里有什么?
- 返回地址:函数执行完后,回到哪里继续执行
- 局部变量:函数内部定义的变量
- 保存的寄存器:调用者寄存器的现场快照
- 参数:传递给被调用函数的参数(部分平台)
一个小技巧:我个人习惯在调试缓冲区溢出漏洞时,先看栈帧里的返回地址。如果返回地址被改写了,那十有八九是栈溢出了。你可以用GDB的info frame命令查看当前栈帧的详细信息。
2.2 一个简单的例子
void funcB(int x) {
int local = x + 1;
// 断点打在这里
}
void funcA() {
int a = 10;
funcB(a);
}
int main() {
funcA();
return 0;
}
当程序执行到funcB里的断点时,栈上的情况是这样的:
- main的栈帧:包含局部变量(如果有)和返回地址(指向操作系统)
- funcA的栈帧:包含变量
a=10,以及返回地址(指向main中调用funcA的下一行) - funcB的栈帧:包含参数
x=10,局部变量local=11,以及返回地址(指向funcA中调用funcB的下一行)
你想想看,如果funcB里有个缓冲区,你往里面写了超过它大小的数据,会发生什么?没错,返回地址会被覆盖。这就是经典的栈缓冲区溢出漏洞。我曾经在一个嵌入式项目里排查过一个诡异的崩溃问题,查了三天才发现是某个驱动函数里有个strcpy没做长度检查,把返回地址给冲了。
三、寄存器的作用:CPU的“贴身秘书”
寄存器是CPU内部最快的内存,没有之一。它们直接参与指令的执行。对于系统编程安全来说,有几个寄存器你必须烂熟于心。
3.1 关键寄存器一览
| 寄存器 | 全称 | 作用 | 安全相关 |
|---|---|---|---|
| RSP / ESP | 栈指针寄存器 | 指向当前栈顶 | 栈溢出攻击时会被篡改 |
| RBP / EBP | 基址指针寄存器 | 指向当前栈帧的底部 | 用于恢复栈帧,溢出攻击常伪造它 |
| RIP / EIP | 指令指针寄存器 | 指向下一条要执行的指令 | 控制流劫持的目标 |
| RAX / EAX | 累加器 | 存放函数返回值 | ROP攻击中用于传递参数 |
警告:RIP寄存器是攻击者的“圣杯”。如果能控制RIP,就能让CPU执行任意地址的代码。这就是为什么现代操作系统都实现了NX(No-Execute)位和ASLR(地址空间布局随机化)来保护它。
3.2 函数调用时的寄存器约定
在x86-64架构下,有个调用约定(Calling Convention)。我简单说一下:
- 参数传递:前6个整数参数用RDI、RSI、RDX、RCX、R8、R9传递,多余的压栈
- 返回值:放在RAX里
- 被调用者保存的寄存器:RBX、RBP、R12-R15——被调用函数必须恢复它们的原值
- 调用者保存的寄存器:RAX、RCX、RDX、RSI、RDI、R8-R11——调用者自己负责保存
嗯,这里有个坑。我曾经在写一个汇编优化的加密函数时,忘了保存R12的值,结果调用者那边的循环计数器被冲掉了。调试了一整个下午才发现是寄存器保存没做对。所以,写汇编或者内联汇编时,一定要遵守调用约定,不然就会出现诡异的“幽灵bug”。
四、总结:安全视角下的内存布局
好了,咱们把今天的内容串一下。理解内存布局,不是为了写个“Hello World”,而是为了知道攻击者可能从哪里下手:
- 栈:缓冲区溢出、ROP攻击、返回地址篡改
- 堆:堆溢出、Use-After-Free、Double Free
- 数据段:全局变量覆盖、格式化字符串漏洞
- 代码段:如果可写,就是灾难(但现代系统默认只读)
我个人觉得,学安全就像学开车——你得先知道车子的每个部件是干嘛的,才能理解怎么防止出事故。内存布局就是那本“汽车说明书”。下一节课,咱们会深入栈缓冲区溢出,看看攻击者到底是怎么利用这些知识的。
一句话记住:栈向下,堆向上,代码只读,数据可写。寄存器是CPU的贴身秘书,保护好RIP就是保护了程序的“命根子”。