外观
机器代码:从指令恢复程序的意图
本章使用 Linux x86-64 System V ABI。读反汇编不是逐条翻译成中文,而是建立三个对应:寄存器和内存代表什么数据,分支形成什么控制流,调用边界遵守什么约定。Apple Silicon 本机产生的是另一套指令,应在合适的 x86-64 环境开展原版实验。
数据怎样进入函数
常见整数或指针参数依次放入 rdi、rsi、rdx、rcx、r8、r9,返回值通常在 rax。浮点参数和某些聚合类型另有规则,不能把这个简表当作完整 ABI。32 位寄存器写入会把相应 64 位寄存器的高 32 位清零,例如写 eax 会清零 rax 的高位。
以 long f(long a, long b) { return a + 2*b; } 为例,一个可能的实现是:
asm
leaq (%rdi,%rsi,2), %rax
retAT&T 语法的源在前、目的在后;内存形式为 位移(基址,索引,比例)。lea 在这里计算地址形式的整数表达式,并没有访问该地址的内存。把它当成 load 会误解大量优化代码。读取数组 a[i] 则需要根据元素大小计算地址后真正加载,例如 long 元素常用比例 8。
分支需要同时看比较和跳转
cmp 设置条件码,不保存减法结果;后面的分支才决定如何解释。相同位模式进行有符号比较与无符号比较可能分向不同路径。把 64 位全一看成 −1 时,它小于 1;看成最大无符号数时,它大于 1。因此不能只看到 cmp 就写下“两个数比较大小”,还要记录 jl/jg 或 jb/ja 等跳转类别。
还原循环时先标出基本块、回边与退出条件,再给寄存器命名。一个指针每次加 8、与结束地址比较、累加加载值,往往是遍历 long 数组。只有识别每条路径在何时更新变量,才能区分“先判断循环”与“至少执行一次”。优化器还可能改写成条件移动、向量循环或完全消除循环。
flowchart LR
A[初始化指针与累加器] --> B{指针到达结束地址?}
B -- 是 --> E[返回累加器]
B -- 否 --> C[读取元素并累加]
C --> D[指针前进一个元素]
D --> B
查看流程图文本
flowchart LR
A[初始化指针与累加器] --> B{指针到达结束地址?}
B -- 是 --> E[返回累加器]
B -- 否 --> C[读取元素并累加]
C --> D[指针前进一个元素]
D --> B
栈不是一个固定模板
在 x86-64 中,call 保存返回地址并转移控制,ret 从栈中取回返回地址。栈通常向低地址增长。函数可以为局部变量调整 rsp,但局部变量也可能一直在寄存器里;优化后可能没有传统 rbp 栈帧。调用前的栈对齐、callee-saved 寄存器以及参数区都是 ABI 的约束,不能根据“一个局部数组有 32 字节”就推断返回地址一定距它 32 字节。
观察一段函数时记录四件事:进入时 rsp,分配栈空间后的 rsp,数组起始地址,保存返回地址的位置。调试器中的差值才是当前编译产物的事实。编译优化、栈保护与不同编译器都会影响布局。
Bomb:从约束得到输入
官方 Bomb 的核心是分析给定程序各阶段接受什么输入;自学版本见官方目录。Bomb 说明
建议为每个阶段建立一张表:输入格式、检查条件、失败出口、通过条件、证据地址。用以下命令开始:
sh
file ./bomb
objdump -d ./bomb > bomb.asm
objdump -t ./bomb
# 以下进入 GDB 后执行
gdb ./bombtext
break phase_1
break explode_bomb
run
disassemble phase_1
info registers
x/s $rdi
x/8gx $rsp不要依赖变量一定在某个寄存器:先读调用点如何准备参数。若阶段要求六个整数,看到递推式 a[i] = a[i-1] + i,应从首项检查推导整组数,并在循环比较处逐次核验。若看到链表,应记录节点字段偏移和链接顺序;地址大小不等于节点值大小。若看到查表,应先确定索引如何截断,以及比较发生在字符、整数还是字符串层面。
本站不给一套“通用拆弹字符串”,因为输入依赖具体二进制。你的产物应是可复核的推导笔记与自己 handout 的输入文件。
Attack:把栈协议变成可观察的边界
原版实验限定在提供的 ctarget/rtarget 中,前者用于理解代码注入,后者用于理解返回导向执行;自学调用加 -q 关闭评分服务器通信。只在这些本地教学目标里练习。Attack 官方说明
先画出输入缓冲区、填充、保存状态与返回地址的位置,再看未检查长度的写入为何能够跨越边界。随后区分三种防护:栈 canary 试图检测覆盖,NX 限制数据页执行,ASLR 增加地址不确定性。它们保护的环节不同;例如 NX 并不使越界写变得合法。
实验中的具体字节串应由当前目标反汇编生成:先量出覆盖距离,再核对目标函数参数和返回路径,最后用调试器单步验证每次控制转移。ret 消耗一个栈项,因此把“函数地址放对”与“后续栈状态也对”分开检查。不要把地址、cookie 或偏移从别人的目标复制过来。
自己做一个可解释的反汇编实验
把下面函数存到临时 machine.c,用 cc -O0 -g -S machine.c 和 cc -O2 -S machine.c 比较。C 输入值需保证求和不溢出。
c
long sum(const long *a, long n) {
long total = 0;
for (long i = 0; i < n; ++i) total += a[i];
return total;
}完成标准:能够指出数组地址、循环终止条件、累加器和返回值的对应;能够解释为什么两份汇编不同却保持规定范围内的 C 行为。编译器未生成预想的指令并不意味着实验失败,它正是需要观察的结果。
自测:看到 `lea` 就意味着程序读取了内存吗?
不是。LEA 计算有效地址形式的数值。只有后续实际 load/store 才读取或写入内存。它常用来实现乘以 3、5、9 等整数算术。
自测:为什么函数局部变量未必能在 GDB 中找到?
优化器可能把值保持在寄存器中、合并变量或直接消除计算。调试信息只能描述仍存在且可追踪的值。先用低优化理解控制流,再研究优化产物;不要把调试显示当成 C 语义本身。
下一章:编译、链接与加载。