Lec 22:底层攻击 (Low-level Exploits)
主题:砸栈、熔断、信任信任 —— smashing stacks, melting down, trusting trust 课程:MIT 6.1800 Computer Systems Engineering 结构依据
s22.pdf,技术细节补充自 Aleph One《Smashing the Stack for Fun and Profit》、Pincus & Baker《Beyond Stack Smashing》、Ken Thompson《Reflections on Trusting Trust》。
0. 本讲的威胁模型 (threat model) 与定位
上一讲的对手是有权访问机器上敏感数据的人(典型如系统管理员,他本就被允许接触口令表)。本讲转换视角:
三条主线分别对应三种"信任被打破"的层次:
| 层次 | 攻击 | 打破的信任 |
|---|---|---|
| 软件层(程序边界) | 砸栈 (stack smashing) | 程序不会越界写自己的内存 |
| 硬件层(微架构) | 熔断 (Meltdown) | CPU 的隔离边界(用户态 / 内核态) |
| 工具链层(编译器) | 信任信任 (trusting trust) | 我们能通过读源码判断程序是否被植入后门 |
课堂"in the news"举例:Apple 修复了一个被执法部门用来从 iPhone 提取已删除聊天记录的漏洞(2026-04);以及 2024 年 Linux
xz/liblzma供应链后门事件 —— 后者正是 Thompson 论点的现实回响。
1. 砸栈 (Stack Smashing)
1.1 程序栈如何工作
栈 (stack) 是后进先出 (LIFO) 结构,由 PUSH/POP 操作维护。函数调用以栈帧 (stack frame) 为单位组织。需要三个寄存器:
- IP(*instruction pointer*,指令指针):指向下一条要执行的指令。
- SP(*stack pointer*,栈指针):指向栈顶(最低地址,因为本讲的栈向下增长)。
- BP / FP(*base pointer* / *frame pointer*,基址指针 / 帧指针,x86 上即
EBP):指向当前栈帧的固定位置,使局部变量和参数的偏移量不随 PUSH/POP 变化。
栈需要保证两件事:
- 每个函数能访问自己的局部变量与参数;
- 函数返回后,调用者的下一行继续执行。
以课件代码为例:main() 在第 8 行调用 function(7),function 返回后应回到第 9 行 x = 5。
函数调用时压栈的内容(地址从高到低,即压入顺序):
高地址 ┌─────────────────────┐
│ 调用者(main)的局部变量 │
│ 传给 function 的参数 │ ← 参数反序压入
│ saved IP (返回地址 RET)│ ← call 指令自动压入
│ saved BP (SFP) │ ← 函数序言压入
│ function 的局部变量 │ ← buffer 等在这里
低地址 └─────────────────────┘ ← SP(栈顶)push %ebp(保存旧 BP,即 SFP)→ mov %esp,%ebp(用当前 SP 建立新 BP)→ sub $N,%esp(为局部变量预留空间)。返回时的尾声:通过 BP 定位当前栈帧起点,从固定偏移处恢复 saved BP 与 saved IP,于是 IP 指回
main() 的下一条指令、BP 指回 main() 的栈帧起点,继续执行。x86 用 ENTER/LEAVE 高效完成序言尾声。注意字对齐:内存按字长(这里 4 字节 / 32 位)寻址。所以 5 字节缓冲区实际占 8 字节,10 字节缓冲区实际占 12 字节 —— 这一点在算 payload 长度时很关键。
1.2 砸栈的核心机制
gets()、strcpy() 这类不限长度的函数。经典直觉(Aleph One):若用 'A'(0x41)填满 256 字节去覆盖一个 16 字节缓冲区,返回地址会被写成 0x41414141,函数返回时跳到非法地址 → 段错误 (segmentation violation)。段错误说明我们已经控制了返回地址——下一步只是把它改成"有意义"的地址。
1.3 三个递进的攻击 demo
课件用三段结构几乎相同的程序,目标层层递进。共同前提是用 gets(buffer) 读入对手可控的字符串,buffer 为 64 字节。
Demo 1:覆盖普通变量 modified
栈布局(高→低): [args] [saved IP/BP] [modified (4B)] [buffer (64B)]对手目标:输入一个字符串,溢出 buffer 把相邻的 modified 从 0 改成非 0。
Demo 2:覆盖函数指针 fp,跳进 win()
栈布局(高→低): [args] [saved IP/BP] [fp (4B)] [buffer (64B)]对手目标:溢出后把 fp 覆盖为 win() 的地址,使 if(fp) fp(); 跳进本不该被调用的 win。
① 多长? 至少要填满
buffer(64B)再加上 buffer 与 fp 之间可能的填充,直到正好覆盖到 fp 所在的 4 字节。② 内容是否任意? 不是任意的。覆盖
buffer 与填充区的部分可以是任意字节(如全 'a'),但落在 fp 位置的 4 字节必须精确等于 win() 的地址。③ 关键内容是什么?
win() 的入口地址,按目标架构的字节序(x86 小端)写入。Demo 3:直接覆盖返回地址 saved IP
栈布局(高→低): [args] [saved IP/BP] [buffer (64B)]最纯粹的形式:没有 modified 也没有 fp,对手直接溢出 buffer 覆盖 saved IP,让函数返回时跳到 win()。
课件提示:demo 里
buffer与 saved IP 之间还有一点额外空间(编译器对齐 / 其他保存的寄存器),所以实战中偏移量要靠gdb实测,而非纸面推算。(本讲不要求你会用 gdb。)
1.4 注入任意代码:Shellcode 与 NOP 滑板
如果程序里压根没有 win() 这种现成代码呢?Aleph One 的答案:把要执行的机器码放进溢出的缓冲区里,再把返回地址改成指向缓冲区。
execve("/bin/sh", ...) 启动一个 shell,从而获得交互式命令执行能力。NOP(空操作 0x90)。只要返回地址落在这片"滑板"中任意一点,CPU 就会一路滑行 (*slide*) 到 shellcode 入口。这把"命中一个精确地址"放宽成"命中一个地址区间",大幅提高成功率。1.5 基本砸栈攻击依赖的关键假设
1. 栈是可执行的——注入到栈上的字节能被当作指令运行;
2. 地址是可预测的——攻击者能(通过实验)算出缓冲区/返回地址的位置;
3. 无边界检查——语言/运行时不会在越界写时报错。
打破其中任何一条,基本攻击就会失效——这正是现代防御的切入点。
1.6 现代防御与对应的反制攻击
- 不可执行栈 (*non-executable stack* / NX / DEP / W^X):把栈页标记为不可执行,注入的 shellcode 无法运行。
- 地址空间布局随机化 (*ASLR*,address space layout randomization):随机化栈、堆、库的基址,使攻击者无法预测跳转目标。
- 栈金丝雀 (*stack canary*):在返回地址前放一个随机哨兵值,返回前校验;溢出若改写返回地址必先破坏 canary,从而被检测。(课件未点名,但属同族防御。)
- 边界检查 (*bounds checking*):根治越界写,但破坏 C 写紧凑代码的能力(见 §1.7)。
防御并非终点。Pincus & Baker《Beyond Stack Smashing》总结了绕过上述防御的反制攻击 (counter-attacks):
- 弧注入 / 返回到 libc (*arc-injection* / *return-to-libc*):不注入新代码,而是把返回地址指向程序里已有的合法代码(如 libc 的
system()),并在栈上摆好参数。因为不在栈上执行新指令,直接绕过不可执行栈。这是后来 ROP(*return-oriented programming*)的雏形。 - 堆溢出 (*heap smashing*):攻击发生在堆而非栈上,常通过破坏堆块的元数据(如空闲链表指针)在
malloc/free时实现任意地址写。 - 指针篡改 (*pointer subterfuge*):覆盖的不是返回地址,而是程序中的数据指针或函数指针(Demo 2 即其雏形),间接劫持控制流或读写。
1.7 安全 vs. 性能的权衡
边界检查能根治砸栈,但代价是牺牲 C 写紧凑代码的能力。课件用一段网络 I/O 代码举例:
struct record { int age; int sal; char name[1]; };
struct record *r;
char buf[100];
read(socket, buf, 100);
r = (struct record *)buf; // 直接把字节流强转为结构体
printf("%d,%d,%s\n", r->age, r->sal, r->name);这段在 C 里编译成极紧凑的汇编(直接指针转换、零拷贝),但在 Java 里要几百行(需逐字段安全解析)。
(工程联想:这正是 Go / Rust 等现代系统语言的取舍立场——Go 用 GC + 边界检查换安全,Rust 用所有权在编译期消除越界而不付运行时代价。)
2. 熔断 (Meltdown):从用户态读取内核内存
2.1 攻击目标与原理
两个关键硬件机制:
- 乱序 / 瞬态执行 (*out-of-order / transient execution*):CPU 为提速会提前推测执行后续指令。即便某条指令最终会触发异常(如非法访问内核地址),其后的指令可能已被瞬态执行;异常发生时这些瞬态结果会被架构层回滚,但对缓存的副作用不会被回滚。
- Flush+Reload 缓存侧信道 (*cache side channel*):被访问过的内存会进缓存。攻击者先清空 (*flush*) 缓存,触发瞬态访问后再逐项计时重载 (*reload*):命中缓存的那一项明显更快,从而泄露"哪个地址被瞬态访问过"。
2.2 核心技巧:probe_array[data * 4096]
攻击流程(对应论文 Listing 1):
1. 瞬态地把一个内核字节读进寄存器,记为 data(0–255)
2. 用 data 索引一个探测数组:访问 probe_array[data * 4096]
→ 这次访问把"第 data 个页"载入缓存(即便该行代码"永不被真正执行到")
3. 异常被处理后,对 probe_array 的 256 个页逐一 Flush+Reload 计时
→ 访问最快的那个页 = data 的值 → 泄露出这个内核字节
逐字节重复,即可读出整段内核内存。probe_array[data] 会怎样?)4096 = 一个内存页 (*page*) 的大小。乘以页大小让 0–255 的每个可能值都落到不同的页,原因有二:① 缓存行粒度:缓存以 64 字节的缓存行 (*cache line*) 为单位。若用
probe_array[data],相邻的 data 值落在同一缓存行,无法区分。② 硬件预取器 (*prefetcher*):CPU 会预取相邻缓存行。整页步长(4096 ≫ 64)能让目标页与相邻页拉开足够距离,避免预取污染测量结果。
所以不乘任何数的话,缓存行混叠 + 预取会让侧信道完全失效。
2.3 看懂汇编(论文 Listing 2)
无需背汇编,但给定定义应能逐行读懂。核心片段语义:
; rcx = 目标内核地址, rbx = probe_array 基址
xor rax, rax ; rax 清零
retry:
mov al, byte [rcx] ; 把内核地址处的 1 字节读入 al(会触发异常,但瞬态先执行)
shl rax, 0xc ; rax <<= 12,即 rax = data * 4096 ← 乘法在这里发生
jz retry ; 若 data==0 则重试,提升信噪比
mov rbx, qword [rbx + rax] ; 触碰 probe_array[data*4096] 对应的页 → 进缓存rax:64 位通用累加寄存器;al是它的最低 1 字节。shl rax, 0xc:逻辑左移 12 位 = ×$2^{12}$ = ×4096。"乘 4096"正是这一行(clicker 题答案)。qword:quad word,8 字节(64 位)操作数。byte [rcx]:从地址rcx取 1 字节。
2.4 为什么虚拟化挡不住,KAISER 能挡住
KAISER / KPTI 为何有效:KAISER(即后来的 *Kernel Page-Table Isolation*)在用户态运行时把内核内存从用户态页表中解除映射。地址空间里压根没有内核地址的映射,瞬态加载也就无从读起——没有可泄露的东西。这是釜底抽薪,而非堵侧信道。
2.5 更广的教训
- 抽象会泄漏:为性能而生的微架构优化(乱序执行、缓存)破坏了用户态/内核态这一逻辑隔离边界——性能优化可以成为安全漏洞。
- 侧信道是一等公民:即便架构层语义"正确回滚",时序等物理副作用仍能泄露信息。安全分析必须考虑实现层而非仅规范层。
- 硬件 bug 难修:只能靠软件缓解(KPTI)兜底,且往往带来性能损失——又一次安全 vs. 性能的权衡。
3. 编译器:我们能信任它们吗?(Reflections on Trusting Trust)
3.1 编译器做什么
3.2 Thompson 攻击的两个阶段
阶段一:直接植入后门(可被读源码发现)
设想一个被改过的 UNIX 源码,在 login 里藏了后门。
进一步:把后门逻辑从 UNIX 源码移进被改过的 C 编译器里——编译器一旦发现自己在编译 UNIX,就自动注入后门。此时 UNIX 源码是干净的(后门不在其中),但后门仍存在于编译器源码里——还是能靠读编译器源码发现。
阶段一的被改编译器只会给 UNIX 插后门、不会感染新编译器,所以用干净源码编出的新编译器是干净的 → 它编出的 UNIX 没有后门 → 两份二进制不同 → 后门被检测到。
阶段二:自我复制的 hack(读源码无法发现)
① 编译 UNIX 时 → 注入登录后门;
② 编译"C 编译器自身"时 → 把上述①②两段恶意逻辑重新注入新编译器二进制。
完成自举后,把恶意源码从编译器源码中删除。此后编译器源码完全干净,但恶意行为靠二进制代代相传(类似一个 *quine* 自我复制)。
源码里看不到任何恶意代码,比对也看不出差异——后门彻底隐形。
3.3 更广的教训
- 核心论点:你无法完全信任不是自己从零写的代码——这层不信任会沿工具链一路下推:编译器、汇编器、加载器、微码、乃至硬件。读源码不足以建立信任。
- 历史:该思想早于 Thompson 1984 图灵奖演讲——Karger & Schell 在 1974 年的 Multics 安全评估报告中已预见(课件引用 ESD-TR-74-193)。
- 解法倾向:Thompson 暗示这类信任问题更应靠政策 / 制度 (*policy-based*) 而非纯技术手段解决——靠对人和流程的信任与问责,而非寄望某个完美技术。
(现代回响:可复现构建 *reproducible builds*、Diverse Double-Compiling 是对此 hack 的技术性回击;而 2024 年 xz 后门则证明"供应链信任"至今仍是软肋。)
4. 全讲总结
- 攻防是螺旋升级的:每实现一个防御(NX、ASLR、KPTI)就会出现反制(return-to-libc、堆溢出、其它瞬态执行攻击)。底层攻击尤其阴险 (*insidious*)。
- 安全常以性能为代价:边界检查、KPTI、内存安全语言都付出运行时或表达力成本。这是工程决策而非纯技术问题。
- 无法做到完美安全 ≠ 不能进步:更复杂的攻击对对手而言成本更高、有时不值得发动。提高攻击门槛本身就是有效防御。
- 有些信任问题超出技术范畴:Thompson 的 hack 说明纯技术无法根除信任问题,需辅以政策与流程。
附:考试自测清单(据 l22.pdf 大纲)
- [ ] 砸栈的威胁模型是什么?
- [ ] 函数调用时栈如何变化?开头压入了哪些东西?
- [ ] 栈如何支持函数返回后回到调用者下一行?(BP/saved IP/saved BP 的作用)
- [ ] 基本砸栈攻击依赖什么假设?
- [ ] 给定一个 demo,能否说清:栈布局、对手意图(覆盖哪个变量、想达成什么)、payload 长度与内容/位置?
- [ ] Meltdown 的目标?乱序执行与缓存如何配合?
- [ ] Listing 1 中为何乘 4096?只用
probe_array[data]会怎样? - [ ] 能逐行读懂 Listing 2 的汇编吗?(
rax/al/shl/qword) - [ ] 为何虚拟化挡不住 Meltdown?为何 KAISER 能挡住?
- [ ] 编译器做什么?如何检测阶段一的 Thompson hack?为何该法对 v2.0 失效?