Bomb Lab——柳暗花明

昨天,我们的 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.asmbomb 文件的反汇编结果输出到 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: 热身 – 字符串比较

  • 常见逻辑: 这是最简单的阶段,通常要求你输入一个特定的字符串,程序会将其与一个硬编码的字符串进行比较。
  • 分析方法:
    1. 使用 strings bomb 查找可能的密码字符串。
    2. 在 GDB 中 break phase_0
    3. disas phase_0,找到调用 strings_not_equal 或类似比较函数的指令。
    4. 在调用比较函数之前设置断点。
    5. run 程序,当停在断点时,检查传入比较函数的两个参数(通常在 %rdi, %rsi 或栈上),其中一个应该指向你的输入,另一个指向硬编码的目标字符串。
    6. 使用 x/s <目标字符串地址> 查看需要输入的字符串内容。

Phase 1: 数据类型与内存表示

  • 常见逻辑: 这个阶段通常测试对不同数据类型(如整数、浮点数)在内存中如何表示的理解。可能会涉及类型转换和对内存特定字节的比较。
  • 分析方法 (以我遇到的整数转浮点为例):
    1. disas phase_1 发现有加载整数常量、使用 fildl/fstpl 进行转换和存储、调用 sscanf 读取两个整数、以及两次 cmp 比较的模式。
    2. 理解关键在于 fstpl 指令将一个整数转换成了 8 字节的双精度浮点数存放在内存(例如 -0x18(%ebp) 处)。
    3. 后续的 cmp 指令分别将你输入的两个整数与这个 8 字节浮点数的前 4 字节(低位)和后 4 字节(高位)进行比较。
    4. GDB 操作:
      • fstpl 指令之后设置断点 (例如 break *0x08048a2d)。
      • run (输入 Phase 0 答案)。
      • 当断点命中时,使用 x/dw $ebp-0x18 查看浮点数的低 32 位对应的整数值。
      • 使用 x/dw $ebp-0x14 查看浮点数的高 32 位对应的整数值。
      • 这两个值就是你需要按顺序输入的答案。

Phase 2: 循环与算术序列

  • 常见逻辑: 通常要求输入一系列数字(例如 6 个),并通过循环检查这些数字是否满足某种算术关系或序列模式。
  • 分析方法:
    1. disas phase_2 找到读取输入的函数(如 read_six_numbers)和存储输入的位置(通常在栈上)。
    2. 识别循环结构:找到初始化、条件检查、循环体、更新和跳转回起点的指令。
    3. 重点分析循环体内的 cmp 指令:
      • 它比较的是什么?通常是当前数字 n[i] 和一个基于前面数字(如 n[i-1])或循环变量 i 计算出来的值。
      • cmp 指令之前设置断点。
      • run (输入 Phase 0-1 答案),输入一组猜测的数字(比如 1 1 1 1 1 1)。
      • 当断点命中时,检查参与比较的两个值(通常在寄存器中)。
      • 通过观察比较失败的原因(哪个值应该等于哪个值),推导出数字间需要满足的数学关系(例如 n[i] == n[i-1] + i)。
    4. 根据推导出的关系,选择一个合法的初始值(如 n[0]=0 ),计算出整个序列。

Phase 3: Switch 与跳转表

  • 常见逻辑: 通常使用 sscanf 读取多个不同类型的输入(如整数和字符),然后使用第一个输入(通常是整数)作为 switch 语句的条件,执行不同的检查逻辑。switch 在汇编中常通过跳转表实现。
  • 分析方法:
    1. disas phase_3 找到 sscanf 调用。使用 GDB x/s <格式字符串地址> 确认需要输入的类型和数量(例如 "%d %d""%d %c %d")。
    2. 找到对第一个整数输入的范围检查(通常有 cmp 和条件跳转)。
    3. 识别跳转表: 寻找类似 jmp *<基地址>(,%索引寄存器,<比例因子>) 的间接跳转指令。索引寄存器通常保存着第一个输入(可能经过加减处理)。
    4. 分析 Case:
      • 确定第一个输入的有效范围。
      • 选择一个有效的第一个输入值(例如 0 或 1,如果范围允许)。
      • 在间接跳转指令 jmp *... 之后设置断点。
      • run (输入 Phase 0-2 答案),输入你选择的第一个值和任意猜测的后续值。
      • 当断点命中时,程序会进入对应的 case 代码块。disas 查看这个代码块的逻辑。
      • 通常 case 代码块会包含 cmp 指令,检查你输入的第二个(或第三个)值是否等于某个硬编码的常量。
      • 在这些 cmp 指令前设断点,再次运行,检查需要匹配的常量值。
    5. 组合答案:将你选择的第一个输入和在对应 case 中找到的需要匹配的后续值组合起来。

Phase 4: 递归函数

  • 常见逻辑: 通常会定义一个递归函数(如 func4),phase_4 会读取一个或两个输入,然后调用这个递归函数,并检查其返回值是否等于某个期望值。
  • 分析方法:
    1. disas phase_4 找到读取输入的代码和对输入的初步检查(例如范围限制)。
    2. 找到调用递归函数 func4 的地方,记下传递给它的参数。
    3. 找到 func4 返回后,对其返回值 (%eax) 进行比较的代码。记下期望的返回值。
    4. disas func4 分析递归函数的逻辑:
      • 找到基例 (Base Case): 函数在什么条件下不再递归调用自身而直接返回?返回值是什么?
      • 找到递归步骤 (Recursive Step): 函数如何调用自身?参数是如何变化的?递归调用的结果如何组合成最终返回值?
    5. 推导输入: 理解了递归逻辑和期望的最终返回值后,反向推导出需要给 func4 传递什么样的初始参数才能得到期望结果。结合 phase_4 对输入的限制,确定最终答案。
    6. GDB 验证 (如果手动推导困难):
      • 选择一个符合 phase_4 输入限制的值(例如 in2=4)。
      • 在 GDB 中直接调用函数进行测试:(gdb) call func4(<in2>, <常量参数>) (例如 call func4(4, 6))。
      • 查看 GDB 显示的实际返回值。这个返回值就是 phase_4 要求的 in1

Phase 5: 字符串处理与查找表

  • 常见逻辑: 通常要求输入一个特定长度的字符串。程序会遍历输入字符串,对每个字符进行某种转换(常见的是位操作,如取低 4 位 & 0xf),然后用转换后的结果作为索引去一个查找表(硬编码的字符数组)中查找字符,最后将查找到的字符组成一个新的字符串,与一个目标字符串比较。
  • 分析方法:
    1. disas phase_5 找到长度检查逻辑(如调用 string_length 并比较结果)。
    2. 识别处理输入字符串的循环。
    3. 重点分析循环内部: 找到取输入字符、进行位操作(如 and $0xf)、使用结果作为地址偏移量访问内存(查找表,如 movzbl <表基地址>(%eax), %eax)、存储查找到的字符到缓冲区的指令序列。
    4. 找到调用 strings_not_equal 比较最终生成的字符串和目标字符串的地方。
    5. GDB 获取关键信息:
      • x/s <目标字符串地址> 查看需要匹配的目标字符串。
      • x/<N>c <查找表基地址> 查看查找表的内容(N 通常是 16)。
    6. 反向推导:
      • 对于目标字符串的第一个字符,在查找表中找到它,记下其索引 idx
      • 寻找一个输入字符,使得该字符经过循环中的转换操作(如 & 0xf)后,结果等于 idx
      • 对目标字符串的每个字符重复此过程,找到所有输入字符。

Phase 6: 复杂数据结构与排序

  • 常见逻辑: 通常涉及更复杂的数据结构,最常见的是链表。程序会读取一系列数字(通常是 1 到 N 的排列),验证输入的有效性(范围、唯一性),然后根据输入排列来重新组织内存中预设的一组节点(链表)的链接顺序,最后检查新形成的链表是否满足某种排序要求(通常是按节点内存储的值升序或降序)。
  • 分析方法:
    1. disas phase_6 找到读取输入(如 read_six_numbers)和验证输入的代码。
    2. 识别原始数据结构: 找到代码中加载的链表头节点地址(例如 movl $0x804b174,-0x40(%ebp))。使用 GDB x/xw <地址> 仔细追踪这个原始链表的链接顺序 (next 指针通常在节点偏移+8 或 +16 的位置) 和每个节点存储的关键值(通常在偏移 0 的位置)。精确记录原始链表中节点的顺序和它们的值至关重要。
    3. 分析重排序逻辑: 理解输入排列是如何控制新链表形成的。常见的模式是:输入数字 n[i] 表示原始链表中的第 n[i] 个节点(按原始链接顺序计数)应该成为新链表中的第 i 个节点。仔细分析汇编代码中构建新链表指针数组或直接修改 next 指针的部分。
    4. 识别最终检查: 找到遍历新链表并比较相邻节点值的代码。确定比较的是哪个字段的值,以及要求的顺序是升序还是降序 (jgejle 跳转方向是关键)。
    5. 推导输入排列:
      • 确定原始链表中各节点的值及其原始顺序(索引)。
      • 根据最终检查的要求,确定节点值需要按什么顺序排列(例如降序)。
      • 找出要达到目标顺序,新链表的节点应该按什么原始索引顺序排列。
      • 将这个原始索引序列转换成输入排列(通常是 索引 + 1)。

Secret Phase: 隐藏的挑战

  • 存在性: 通过 strings bomb 查找特殊提示,或 objdump -t bomb 查找名为 secret_phase 的函数来判断。
  • 触发: 触发方式各异,常见的是在某个常规阶段(如 Phase 4)答案后附加特定字符串。需要分析 main 函数或 phase_defused 函数寻找线索,或者直接在 secret_phase 入口设置断点,尝试正常通关看是否会意外进入。找到 phase_defused 中用于比较的字符串地址 (x/s <地址>) 可能会发现触发密码。
  • 分析: 一旦触发,分析方法与常规阶段类似,但逻辑可能更复杂,例如涉及二叉树搜索 (fun7 函数很常见)、递归或其他算法。需要仔细分析其输入要求、执行流程和最终检查条件。

祝同学们拨开迷雾,聚焦精要,顺利完成这个有挑战性的Lab!


Comments

One response to “Bomb Lab——柳暗花明”

Leave a Reply

Your email address will not be published. Required fields are marked *