昨天,我们的 CSAPP 课程开启了Lab1,也就是大名鼎鼎的 Binary Bomb Lab(二进制炸弹实验)。这个实验旨在通过逆向工程一个没有源码的可执行文件(“炸弹”),来锻炼我们对汇编语言、调试工具(静态分析和GDB动态调试)以及底层系统运作的理解。
站长在奋战六小时后,终于解决了全部七个phase以及隐藏关卡,收获颇多。在这篇博客中,我将结合通用的拆弹策略和我自己在这个特定“炸弹”上遇到的具体挑战与解决方案,分享我的完整拆解过程。希望能为同样在挑战 Bomb Lab 的同学提供一些参考和启发。

一、 准备工作:工欲善其事,必先利其器
在开始拆弹之前,我们需要准备好必要的工具和环境。
首先,必须用属于你自己的 bomb
可执行文件,每个人的炸弹通常是独一无二的,直接使用他人的答案是行不通的。
其次是环境选择。虽然可以在本地 Linux 系统或虚拟机中进行,但我发现 GitHub Codespaces 是一个非常方便的选择。它提供了一个云端的、预配置好的 Linux 开发环境,可以直接在浏览器或 VS Code 中使用,避免了本地配置的麻烦。注册GitHub后无需认证即可获得每月90小时的CPU时间,也就是45小时的常见双核8g RAM配置可用时间。PS:学生认证后每月180小时。我的实验就是在GitHub Codespaces完成的。
最基础的一些工具,指令:
objdump -d bomb > bomb.asm | 将 bomb 文件的反汇编结果输出到 bomb.asm 文件中。后续分析时,对照此文件会非常有帮助 |
./bomb < solutions.txt (gdb) run < solutions.txt | 随着你解开越来越多的阶段,每次调试都要重新输入之前的答案会很繁琐。建议将已找到的正确答案按顺序保存在一个文本文件中(例如 solutions.txt ),每行一个答案。然后,你可以使用输入重定向来运行别忘了添加后面几行答案的占位符:) |
break explode_bomb break phase_x | 均为gdb命令,前者防止意外“引爆”,方便在此时检查失败原因;后者在每个阶段入口设置断点,方便独立分析。 |
run , c , si | 运行,继续,单步。 启动程序,让它运行到断点处,然后可用使用 stepi (si) 逐条指令执行,观察状态变化。 |
p , x , info registers | 检查状态:在断点处,使用 print (p) 查看寄存器或表达式的值(如 p $eax , p/x $ebp-0x10 ),使用 examine (x) 查看内存内容(如 x/s <地址> 看字符串, x/dw <地址> 看十进制整数, x/xw <地址> 看十六进制字) |
disas | 反汇编,可以随时使用 disassemble 查看当前位置或某个函数的汇编代码。 |
strings bomb | 在初始阶段查找可执行文件中的字符串常量。这对于找到 Phase 1 的密码、sscanf 的格式字符串、或者某些提示信息非常有帮助 |
x/dw | 例如在gdb的phase_1中,在断点的位置,x/dw $ebp-0x18 -> 得到第一个个整数 |
一个通用的迭代与记录方式: 分析asm的对应代码段 -> 假设 -> GDB 验证 -> 修正假设 -> 记录与得到结果。做的过程中,同学们可以在 bomb.asm
文件中添加注释,记录你用到的指令、寄存器的含义、尝试过的输入等。站长在phase_3左右渐渐习得了拆弹方式,虽然后续phase挑战性更大,但速度反而快了许多,有种柳暗花明的感觉。
二、各阶段详解:我的拆解实录
这是我结合对Bomb Lab的调查研究,和我的实际完成过程,总结的基本方法。由于每个同学的bomb不完全相同,这里无法清晰的定义一个标准答案,但常见的逻辑和分析方法是基本确定的。
Phase 0: 热身 – 字符串比较
- 常见逻辑: 这是最简单的阶段,通常要求你输入一个特定的字符串,程序会将其与一个硬编码的字符串进行比较。
- 分析方法:
- 使用
strings bomb
查找可能的密码字符串。 - 在 GDB 中
break phase_0
。 disas phase_0
,找到调用strings_not_equal
或类似比较函数的指令。- 在调用比较函数之前设置断点。
run
程序,当停在断点时,检查传入比较函数的两个参数(通常在%rdi
,%rsi
或栈上),其中一个应该指向你的输入,另一个指向硬编码的目标字符串。- 使用
x/s <目标字符串地址>
查看需要输入的字符串内容。
- 使用
Phase 1: 数据类型与内存表示
- 常见逻辑: 这个阶段通常测试对不同数据类型(如整数、浮点数)在内存中如何表示的理解。可能会涉及类型转换和对内存特定字节的比较。
- 分析方法 (以我遇到的整数转浮点为例):
disas phase_1
发现有加载整数常量、使用fildl
/fstpl
进行转换和存储、调用sscanf
读取两个整数、以及两次cmp
比较的模式。- 理解关键在于
fstpl
指令将一个整数转换成了 8 字节的双精度浮点数存放在内存(例如-0x18(%ebp)
处)。 - 后续的
cmp
指令分别将你输入的两个整数与这个 8 字节浮点数的前 4 字节(低位)和后 4 字节(高位)进行比较。 - GDB 操作:
- 在
fstpl
指令之后设置断点 (例如break *0x08048a2d
)。 run
(输入 Phase 0 答案)。- 当断点命中时,使用
x/dw $ebp-0x18
查看浮点数的低 32 位对应的整数值。 - 使用
x/dw $ebp-0x14
查看浮点数的高 32 位对应的整数值。 - 这两个值就是你需要按顺序输入的答案。
- 在
Phase 2: 循环与算术序列
- 常见逻辑: 通常要求输入一系列数字(例如 6 个),并通过循环检查这些数字是否满足某种算术关系或序列模式。
- 分析方法:
disas phase_2
找到读取输入的函数(如read_six_numbers
)和存储输入的位置(通常在栈上)。- 识别循环结构:找到初始化、条件检查、循环体、更新和跳转回起点的指令。
- 重点分析循环体内的
cmp
指令:- 它比较的是什么?通常是当前数字
n[i]
和一个基于前面数字(如n[i-1]
)或循环变量i
计算出来的值。 - 在
cmp
指令之前设置断点。 run
(输入 Phase 0-1 答案),输入一组猜测的数字(比如1 1 1 1 1 1
)。- 当断点命中时,检查参与比较的两个值(通常在寄存器中)。
- 通过观察比较失败的原因(哪个值应该等于哪个值),推导出数字间需要满足的数学关系(例如
n[i] == n[i-1] + i
)。
- 它比较的是什么?通常是当前数字
- 根据推导出的关系,选择一个合法的初始值(如
n[0]=0
),计算出整个序列。
Phase 3: Switch 与跳转表
- 常见逻辑: 通常使用
sscanf
读取多个不同类型的输入(如整数和字符),然后使用第一个输入(通常是整数)作为switch
语句的条件,执行不同的检查逻辑。switch
在汇编中常通过跳转表实现。 - 分析方法:
disas phase_3
找到sscanf
调用。使用 GDBx/s <格式字符串地址>
确认需要输入的类型和数量(例如"%d %d"
或"%d %c %d"
)。- 找到对第一个整数输入的范围检查(通常有
cmp
和条件跳转)。 - 识别跳转表: 寻找类似
jmp *<基地址>(,%索引寄存器,<比例因子>)
的间接跳转指令。索引寄存器通常保存着第一个输入(可能经过加减处理)。 - 分析 Case:
- 确定第一个输入的有效范围。
- 选择一个有效的第一个输入值(例如 0 或 1,如果范围允许)。
- 在间接跳转指令
jmp *...
之后设置断点。 run
(输入 Phase 0-2 答案),输入你选择的第一个值和任意猜测的后续值。- 当断点命中时,程序会进入对应的 case 代码块。
disas
查看这个代码块的逻辑。 - 通常 case 代码块会包含
cmp
指令,检查你输入的第二个(或第三个)值是否等于某个硬编码的常量。 - 在这些
cmp
指令前设断点,再次运行,检查需要匹配的常量值。
- 组合答案:将你选择的第一个输入和在对应 case 中找到的需要匹配的后续值组合起来。
Phase 4: 递归函数
- 常见逻辑: 通常会定义一个递归函数(如
func4
),phase_4
会读取一个或两个输入,然后调用这个递归函数,并检查其返回值是否等于某个期望值。 - 分析方法:
disas phase_4
找到读取输入的代码和对输入的初步检查(例如范围限制)。- 找到调用递归函数
func4
的地方,记下传递给它的参数。 - 找到
func4
返回后,对其返回值 (%eax
) 进行比较的代码。记下期望的返回值。 disas func4
分析递归函数的逻辑:- 找到基例 (Base Case): 函数在什么条件下不再递归调用自身而直接返回?返回值是什么?
- 找到递归步骤 (Recursive Step): 函数如何调用自身?参数是如何变化的?递归调用的结果如何组合成最终返回值?
- 推导输入: 理解了递归逻辑和期望的最终返回值后,反向推导出需要给
func4
传递什么样的初始参数才能得到期望结果。结合phase_4
对输入的限制,确定最终答案。 - GDB 验证 (如果手动推导困难):
- 选择一个符合
phase_4
输入限制的值(例如in2=4
)。 - 在 GDB 中直接调用函数进行测试:
(gdb) call func4(<in2>, <常量参数>)
(例如call func4(4, 6)
)。 - 查看 GDB 显示的实际返回值。这个返回值就是
phase_4
要求的in1
。
- 选择一个符合
Phase 5: 字符串处理与查找表
- 常见逻辑: 通常要求输入一个特定长度的字符串。程序会遍历输入字符串,对每个字符进行某种转换(常见的是位操作,如取低 4 位
& 0xf
),然后用转换后的结果作为索引去一个查找表(硬编码的字符数组)中查找字符,最后将查找到的字符组成一个新的字符串,与一个目标字符串比较。 - 分析方法:
disas phase_5
找到长度检查逻辑(如调用string_length
并比较结果)。- 识别处理输入字符串的循环。
- 重点分析循环内部: 找到取输入字符、进行位操作(如
and $0xf
)、使用结果作为地址偏移量访问内存(查找表,如movzbl <表基地址>(%eax), %eax
)、存储查找到的字符到缓冲区的指令序列。 - 找到调用
strings_not_equal
比较最终生成的字符串和目标字符串的地方。 - GDB 获取关键信息:
x/s <目标字符串地址>
查看需要匹配的目标字符串。x/<N>c <查找表基地址>
查看查找表的内容(N 通常是 16)。
- 反向推导:
- 对于目标字符串的第一个字符,在查找表中找到它,记下其索引
idx
。 - 寻找一个输入字符,使得该字符经过循环中的转换操作(如
& 0xf
)后,结果等于idx
。 - 对目标字符串的每个字符重复此过程,找到所有输入字符。
- 对于目标字符串的第一个字符,在查找表中找到它,记下其索引
Phase 6: 复杂数据结构与排序
- 常见逻辑: 通常涉及更复杂的数据结构,最常见的是链表。程序会读取一系列数字(通常是 1 到 N 的排列),验证输入的有效性(范围、唯一性),然后根据输入排列来重新组织内存中预设的一组节点(链表)的链接顺序,最后检查新形成的链表是否满足某种排序要求(通常是按节点内存储的值升序或降序)。
- 分析方法:
disas phase_6
找到读取输入(如read_six_numbers
)和验证输入的代码。- 识别原始数据结构: 找到代码中加载的链表头节点地址(例如
movl $0x804b174,-0x40(%ebp)
)。使用 GDBx/xw <地址>
仔细追踪这个原始链表的链接顺序 (next
指针通常在节点偏移+8 或 +16 的位置) 和每个节点存储的关键值(通常在偏移 0 的位置)。精确记录原始链表中节点的顺序和它们的值至关重要。 - 分析重排序逻辑: 理解输入排列是如何控制新链表形成的。常见的模式是:输入数字
n[i]
表示原始链表中的第n[i]
个节点(按原始链接顺序计数)应该成为新链表中的第i
个节点。仔细分析汇编代码中构建新链表指针数组或直接修改next
指针的部分。 - 识别最终检查: 找到遍历新链表并比较相邻节点值的代码。确定比较的是哪个字段的值,以及要求的顺序是升序还是降序 (
jge
或jle
跳转方向是关键)。 - 推导输入排列:
- 确定原始链表中各节点的值及其原始顺序(索引)。
- 根据最终检查的要求,确定节点值需要按什么顺序排列(例如降序)。
- 找出要达到目标顺序,新链表的节点应该按什么原始索引顺序排列。
- 将这个原始索引序列转换成输入排列(通常是
索引 + 1
)。
Secret Phase: 隐藏的挑战
- 存在性: 通过
strings bomb
查找特殊提示,或objdump -t bomb
查找名为secret_phase
的函数来判断。 - 触发: 触发方式各异,常见的是在某个常规阶段(如 Phase 4)答案后附加特定字符串。需要分析
main
函数或phase_defused
函数寻找线索,或者直接在secret_phase
入口设置断点,尝试正常通关看是否会意外进入。找到phase_defused
中用于比较的字符串地址 (x/s <地址>
) 可能会发现触发密码。 - 分析: 一旦触发,分析方法与常规阶段类似,但逻辑可能更复杂,例如涉及二叉树搜索 (
fun7
函数很常见)、递归或其他算法。需要仔细分析其输入要求、执行流程和最终检查条件。
祝同学们拨开迷雾,聚焦精要,顺利完成这个有挑战性的Lab!
Leave a Reply to 苏心贤 Cancel reply