CSAPP - 03.Machine-Level Programming IV

机器级编程 IV:高级 (Advanced Topics)

主要内容
本章探讨内存布局与安全漏洞。

  1. 内存布局:栈、堆、数据段在 x86-64 Linux 中的分布。
  2. 缓冲区溢出:演示了如何利用 C 语言不检查数组边界的特性,通过覆盖栈上的“返回地址”来劫持程序控制流。
  3. 防御机制:介绍了现代系统如何通过栈随机化 (ASLR)、栈金丝雀 (Canary) 和不可执行位 (NX) 来防御攻击。

Motivation
这是一个“黑客与防御者”的章节。它展示了上一章学到的“过程调用机制”如果被滥用会有什么后果。理解这些对编写安全的代码(Secure Coding)和理解系统安全机制至关重要。

5.1 x86-64 Linux 内存布局

一个程序在运行时,其内存被划分为几个关键区域:

  1. 栈 (Stack)
    • 位于内存的最高地址区域(例如 0x00007FFF...)。
    • 向下增长(即新数据放在更低的地址)。
    • 用于存储局部变量、函数参数、返回地址。
    • 每次函数调用都会创建一个新的栈帧 (Stack Frame)
    • 通常有大小限制(例如 8MB)。
    • 为了安全,栈的起始地址是随机化的。
  2. 共享库 (Shared Libraries)
    • 位于栈下方,用于存放像 printf 这样的动态链接库的代码和数据。
    • 其加载地址也是随机化的。
  3. 堆 (Heap)
    • 位于共享库下方。
    • 向上增长(即新数据放在更高的地址)。
    • 用于动态内存分配,即 malloc()calloc()new() 申请的内存。
  4. 数据段 (Data Segment)
    • 用于存放静态分配的数据。
    • 例如:全局变量、静态变量 (static 修饰的变量)、字符串常量(如 “Hello, World”)。
  5. 代码段 (Text Segment)
    • 位于内存的最低地址区域(例如 0x000000000040...)。
    • 存放可执行的机器指令。
    • 通常是只读 (Read-only) 的,防止程序意外修改自身代码。

示例:

  • int local = 0; (函数内的局部变量) -> 栈 (Stack)
  • void *p = malloc(100); (指针 p 在栈上, 它指向的100字节) -> 堆 (Heap)
  • int global = 0; (函数外的全局变量) -> 数据段 (Data Segment)
  • main() 函数本身的代码 -> 代码段 (Text Segment)

5.2 缓冲区溢出 (Buffer Overflow)

缓冲区溢出是 C 语言程序中最主要、最危险的安全漏洞之一。

1. 什么是缓冲区溢出?

当向一个数组(缓冲区)写入的数据超出了该数组分配的内存大小时,多出的数据就会“溢出”,并覆盖掉相邻的内存区域。

这在C语言中尤其危险,因为很多标准库函数不检查边界

  • 危险函数
    • gets(char *dest):无法指定读取长度,已废弃。
    • strcpy(dest, src):无边界检查地复制字符串。
    • strcat(dest, src):无边界检查地拼接字符串。
    • scanf("%s", buf):读取字符串时,不限制长度。

2. 栈破坏 (Stack Smashing) 示例

在栈上发生的缓冲区溢出称为“栈破坏”。

场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void echo() {
char buf[4]; // 缓冲区大小只有 4 字节
gets(buf); // 从用户读取输入
...
}
````

- **编译器的行为**: 编译器在栈上分配空间时,除了 `buf[4]`,还会在其后(更高地址)分配额外的**填充空间 (padding)**,再往后才是保存的**返回地址 (Return Address)**。

```text
内存地址 (Address) 栈内容 (Stack Content)
------------------------------------------------------
High (高地址) | ...父函数的栈帧... |
^ +---------------------------+
| | 返回地址 (Return Addr) | <-- 关键目标!(8字节)
| +---------------------------+
| | 旧 %rbp (Old RBP) | <-- 保存的基址指针 (8字节)
| +---------------------------+ <--- 栈帧分界线
| | (可能存在的填充 Padding) | <-- 编译器为了对齐分配的空隙
| +---------------------------+
| | buf[3] |
| | buf[2] |
| | buf[1] |
Low (低地址) | buf[0] | <-- %rsp (栈顶) 指向这里
------------------------------------------------------
  • Case 1: 输入 “0123456789”
    • 输入字符串的长度超过了 4 字节。
    • 多出的字符会溢出,并覆盖掉 buf 后面的填充空间。
    • 但是,返回地址可能没有被碰到。
    • 结果: 函数可能正常返回,程序_看起来_没问题,但内存已经被破坏。
  • Case 2: 输入 “012345678901234567890123” (24个字符)
    • 输入字符串足够长,它会填满 buf、填满所有填充空间,并最终覆盖掉返回地址
    • 例如,返回地址 0x0000004006c3 被覆盖成了 0x000000400600 (来自 “0123” 的ASCII码和最后的 \0)。
    • 结果: 当 echo 函数执行 ret 指令时,它不会返回到主函数,而是试图跳转到地址 0x400600。这个地址是无效的,导致段错误 (Segmentation Fault),程序崩溃。

1. 核心误区纠正

  • 误区:认为栈上有一个名为 buff 的指针变量(占 8 字节)专门用来存首地址。
  • 真相:对于 char buff[4],栈上只有 4 个字节用来存数据(字符)。根本不存在一个物理上的变量用来存“buff 的地址”。
  • 本质:数组名 buff 只是编译器眼中的符号/标签。编译后,它直接变成了相对于栈指针 (%rsp) 的固定偏移量 (如 0(%rsp)),存在于指令操作数中,不占数据内存。

2. 内存布局对比

  • 局部数组 (char buff[4])
    • 栈占用:仅 4 字节 (存 'A', 'B'...)。
    • 地址存储位置: (直接硬编码在汇编指令里)。
  • 指针变量 (char *ptr)
    • 栈占用:8 字节
    • 地址存储位置:栈上 (确实有一个变量空间用来存地址)。

3. 栈破坏攻击的路径

  • 由于栈上不存在“buff 指针变量”,当写入数据超过 4 字节时:
  • 溢出路径buff 数据区 -> 填充(Padding) -> 旧 %rbp -> 返回地址 (Return Address)
  • 结论:你淹没的是物理上存在的“返回地址”,而不是一个并不存在的“数组指针”。

4. 形象类比

  • 数组 (buff) 是房子:房子本身占地(内存),虽有地址,但房子里不需要专门建个房间存“地址纸条”。
  • 指针变量 (ptr) 是名片:名片本身占地,上面写着地址。

3. 栈破坏攻击 (Stack Smashing Attack)

黑客利用上述原理,不只是让程序崩溃,而是让程序执行恶意代码。

  • 攻击1:跳转到已有代码

    1. 攻击者知道程序中有一个恶意函数(例如 smash())的地址,比如 0x4006c8
    2. 攻击者构造一个特殊的输入字符串,称为攻击载荷 (exploit code)
    3. 这个字符串包含:[任意填充字符 (24字节)] + [恶意函数的地址 (8字节)]
    4. 例如 (Hex): 3031...33 (24字节) + c8 06 40 00 00 00 00 00 (小端序地址)
    5. gets(buf) 将这个字符串读入栈中,buf 和填充区被填满,而返回地址被精确地覆盖为 0x4006c8
    6. 结果: 当 echo 函数 ret 时,它会跳转到 smash() 函数并执行,导致攻击成功。
  • 攻击2:代码注入 (Code Injection)

    1. 这是更致命的攻击。攻击者将可执行的机器码作为字符串的一部分输入。
    2. 攻击者将返回地址覆盖为缓冲区 buf 自己的地址
    3. 结果: 当函数 ret 时,它会跳转到 buf 的起始地址,并开始执行黑客注入的恶意代码。
    4. 这就是1988年互联网蠕虫 (Internet Worm) 和 红色代码 (Code Red) 蠕虫病毒的攻击原理。

5.3 缓冲区溢出保护机制

为了应对这些攻击,现代操作系统和编译器提供了多层防御:

1. 编写安全的代码 (程序员的责任)

  • 永远不要使用 gets()
  • 使用有边界检查的安全函数替代:
    • fgets(buf, size, stdin) 替代 gets()
    • strncpy() 替代 strcpy()
    • snprintf() 替代 sprintf()
  • 使用 scanf() 时,必须指定字段宽度,例如 scanf("%10s", buf),以限制最多读取10个字符。

2. 系统级保护 (操作系统的责任)

  • A. 栈随机化 (ASLR)

    • 全称: 地址空间布局随机化 (Address-Space Layout Randomization)。
    • 原理: 每次程序运行时,操作系统都将栈、堆、共享库的起始地址设置在一个随机的位置。
    • 效果: 攻击者无法预测 buf 的地址(用于代码注入)或 smash() 函数的地址(库函数可能在不同位置)。
    • 局限性: 攻击者可以通过“NOP雪橇”(NOP Sled) 进行暴力破解。即在恶意代码前放置大量空操作指令(nop),只要返回地址猜中了雪橇上的任意一点,程序就会“滑行”到恶意代码并执行。
  • B. 不可执行内存 (Non-Executable / NX bit)

    • 原理: 现代 CPU 硬件允许操作系统将内存页标记为“不可执行”。
    • 效果: 操作系统将栈和堆都标记为“可读/可写”,但 “不可执行”
    • 防御: 这是对抗代码注入攻击最有效的手段。即使攻击者成功将返回地址指向了栈上的 buf,当 CPU 试图执行那里的代码时,会立即触发硬件异常,程序崩溃,攻击失败。

3. 栈金丝雀 (Stack Canaries) (编译器的责任)

  • 原理: 编译器在函数开始时,从一个秘密位置取出一个特殊的随机值,称为 “金丝雀” (Canary),并将其放在栈帧中,位于所有缓冲区和返回地址之间

  • 机制:

    1. 函数开始mov %fs:0x28, %rax (获取金丝雀) -> mov %rax, 0x8(%rsp) (存入栈中)。
    2. 函数返回前mov 0x8(%rsp), %rax (从栈中取回) -> xor %fs:0x28, %rax (与原始值比较)。
    3. 检查:
      • 如果 xor 结果为 0 (值未变),说明没有发生溢出,正常执行 ret
      • 如果 xor 结果非 0 (值被修改),说明缓冲区溢出已经破坏了金丝雀。程序会立即跳转到 __stack_chk_fail直接终止程序,而不会执行 ret
  • 效果: 这种方法可以有效防止任何试图通过溢出缓冲区来覆盖返回地址的攻击。


CSAPP - 03.Machine-Level Programming IV
https://yima-gu.github.io/2026/01/14/CSAPP/03-machine-level-4-advanced/
作者
Yima Gu
发布于
2026年1月15日
许可协议