0%
计算机系统April 14, 2026

拆弹实验报告

实验原理

二进制炸弹是作为一个目标代码文件提供给学生们的程序,运行时,它提示用户输入6个不同的字符串。如果其中任何一个不正确,炸弹就会“爆炸”:打印出一条错误信息。学生通过反汇编和逆向工程来确定是哪六个字符串,从而解除他们各自炸弹的雷管。


实验过程

1. 前置工作

  1. 将二进制文件转换为txt文件,查看完整的bomb指令,观察指令结构,找到引爆函数的名称
bash
objdump -d bomb > my_bomb.txt

打开my_bomb.txt文件后,根据跳转条件得出引爆函数的名称

可以得知,引爆名称为:explode_bomb,所以在每一轮拆炸弹前,设置断点:b explode_bomb,防止爆炸


2.开始拆弹

分析指令:

把phase_1每行命令的作用分析清楚后我们可以发现:只需要去查看0x8049264的字符串内容即可过第一关!

bash
gdb bomb//gdb调试 b explode_bomb r//运行 test//随便输入一个字符串 x/s 0x8040264//查看0x8040264里的内容

ImageImage

得到0x8049264的字符串内容是:“When a problem comes along, you must zip it!”

ImageImage

成功过关!


第二关的指令

ImageImage

核心代码段

bash
cmpl $0x1,0x18(%esp) je 8048922 <phase_2+0x3e> call 8048e65 <explode_bomb>

第一个数字与1 对比,可得第一个数字是1,然后跳到8048922

bash
8048922: lea 0x1c(%esp),%ebx 8048926: lea 0x30(%esp),%esi 804892a: jmp 804890b <phase_2+0x27>

设置循环的起始指针和结束指针,然后跳到804890b

bash
804890b: mov -0x4(%ebx),%eax # 获取前一个数字 804890e: add %eax,%eax # 将前一个数字翻倍 (eax = eax + eax) 8048910: cmp %eax,(%ebx) # 与当前数字比较 8048912: je 8048919 <phase_2+0x35> # 如果相等,安全通过本次循环 8048914: call 8048e65 <explode_bomb> # 如果不相等,炸弹爆炸 8048919: add $0x4,%ebx # 指针移动到下一个数字 804891c: cmp %esi,%ebx # 检查是否到达数组末尾边界 804891e: jne 804890b <phase_2+0x27> # 如果没到末尾,继续循环

所以很容易得到:第二关的密码为1 2 4 8 16 32,输入后,成功过关!

ImageImage


第三关指令

ImageImage

分析核心代码段

bash
8048959: cmp $0x1,%eax 804895c: jg 8048963 <phase_3+0x31> 804895e: call 8048e65 <explode_bomb>

检查输入数量,若只读取01个数字,则爆炸,否则跳到0x8048963

bash
8048963: cmpl $0x7,0x18(%esp) 8048968: ja 80489a6 <phase_3+0x74> ... 80489a6: call 8048e65 <explode_bomb>

检查第一个输入(Switch 语句的范围边界,必须在0-7之间)

bash
804896a: mov 0x18(%esp),%eax 804896e: jmp *0x80492c0(,%eax,4)

实现switch-case,第一个数字放进%eax,然后去跳转到0x80492c0 + (第一个数字 * 4)的位置

在此我们需要调用x/8wx 0x80492c0指令来查看跳转表

ImageImage

继续分析,根据跳转表,可以查看需要比较的%eax的值,比如第一个输入0,需要和0x18a(394)比较,最后比较,若不符合则引爆炸弹

最后根据跳转表,得到答案。

ImageImage


ImageImage

分析核心代码段

bash
8048a27: sub $0x2c,%esp # 在栈上分配 44 (0x2c) 字节的空间,用于存放局部变量和函数参数。 8048a2a: lea 0x1c(%esp),%eax # 计算地址 %esp + 0x1c(28),存入 %eax。这相当于局部变量 var2 的地址 (&var2)。 8048a2e: mov %eax,0xc(%esp) # 将 &var2 作为 sscanf 的第 4 个参数压栈。 8048a32: lea 0x18(%esp),%eax # 计算地址 %esp + 0x18(24),存入 %eax。这相当于局部变量 var1 的地址 (&var1)。 8048a36: mov %eax,0x8(%esp) # 将 &var1 作为 sscanf 的第 3 个参数压栈。 8048a3a: movl $0x8049431,0x4(%esp) # 将内存地址 0x8049431 作为第 2 个参数压栈。格式化字符串地址压栈。 8048a42: mov 0x30(%esp),%eax # 从 %esp + 0x30(48=44+4) 处读取 phase_4 的传入参数(即用户输入的字符串指针)。 8048a46: mov %eax,(%esp) # 将用户输入的字符串指针作为 sscanf 的第 1 个参数压栈。 8048a49: call 8048600 <__isoc99_sscanf@plt> 8048a4e: cmp $0x2,%eax 8048a51: jne 8048a5a <phase_4+0x33>

我的疑问:为什么8048a4e: cmp $0x2,%eax不是要求第一个参数为2呢?

查阅资料得知,在x86调用约定中,函数的返回值(如果是整数)会存放在%eax中,sscanf的返回值表示成功匹配并赋值的参数个数,所以要求必须传入两个参数。

bash
8048a53: cmpl $0xe,0x18(%esp) 8048a58: jbe 8048a5f <phase_4+0x38> 8048a5a: call 8048e65 <explode_bomb>

监测范围,必须小于等于14(jbe指令)

bash
8048a5f: movl $0xe,0x8(%esp) # 将 14 (0xe) 作为 func4 的第 3 个参数压栈。 8048a67: movl $0x0,0x4(%esp) # 将 0 作为 func4 的第 2 个参数压栈。 8048a6f: mov 0x18(%esp),%eax # 将 var1 (第一个输入数字) 取出放入 %eax。 8048a73: mov %eax,(%esp) # 将 var1 作为 func4 的第 1 个参数压栈。 8048a76: call 80489c6 <func4> # 调用 func4(var1, 0, 14)。

然后调用了func4,传入三个参数(var1,0,14)

bash
8048a7b: test %eax,%eax # 测试 func4 的返回值 %eax 8048a7d: jne 8048a86 <phase_4+0x5f> # 如果 %eax 不为 0 ,跳转到引爆。所以 func4 的返回值必须是 0。 8048a7f: cmpl $0x0,0x1c(%esp) # 将第二个输入数字 (var2, 位于 0x1c(%esp)) 与 0 比较。 8048a84: je 8048a8b <phase_4+0x64> # 如果 var2 等于 0 (Jump if Equal),跳转到结尾准备返回(通关)。 8048a86: call 8048e65 <explode_bomb> # 如果上面两个条件任一不满足,爆炸!

所以需要查看func4的返回值,让func4的返回值为0,并且第二个数字必须是0,即可过关,现在我们查看func4的片段

ImageImage

分析核心代码段

bash
80489c6: push %esi 80489c7: push %ebx 80489c8: sub $0x14,%esp 80489cb: mov 0x20(%esp),%edx # 提取参数 1 (传入的 var1) 80489cf: mov 0x24(%esp),%eax # 提取参数 2 (传入的 0) 80489d3: mov 0x28(%esp),%ebx # 提取参数 3 (传入的 14)

即将var1存入到%edx中,0存入到%eax中,14存入到%ebx

bash
80489d7: mov %ebx,%ecx # %ecx = 14 80489d9: sub %eax,%ecx # %ecx = 14 - 0 80489db: mov %ecx,%esi 80489dd: shr $0x1f,%esi # 处理符号位(应对负数除法向零取整,这里都是正数,%esi 为 0) 80489e0: add %esi,%ecx 80489e2: sar %ecx # %ecx 右移一位,相当于除以 2 80489e4: add %eax,%ecx # %ecx = 0 + (14 - 0) / 2
bash
80489e6: cmp %edx,%ecx # 比较 %ecx和 var1(%edx) 80489e8: jle 8048a01 <func4+0x3b> # 如果 7 <= var1,跳转到 8048a01 80489ea: sub $0x1,%ecx # 走到这里说明 7 > var1。将 7 - 1,%ecx=6 80489ed: mov %ecx,0x8(%esp) # 将 6 作为新的参数 3 80489f1: mov %eax,0x4(%esp) # 将 0 保持为参数 2 80489f5: mov %edx,(%esp) # 将 var1 保持为参数 1 80489f8: call 80489c6 <func4> # 递归调用 func4(var1, 0, 6) 80489fd: add %eax,%eax # 返回值 = 2 * 递归调用的返回值 80489ff: jmp 8048a21 <func4+0x5b> # 跳到结尾返回
bash
8048a01: mov $0x0,%eax # 先假设返回值为 0 8048a06: cmp %edx,%ecx # 再次比较 7 和 var1 8048a08: jge 8048a21 <func4+0x5b> # 由于之前已经确认 mid <= x,这里如果 mid >= x,意味着 mid == x!直接跳到结尾,返回 0。 8048a0a: mov %ebx,0x8(%esp) # 走到这里说明 7 < var1。将14保持为参数 3 8048a0e: add $0x1,%ecx # %ecx=7 8048a11: mov %ecx,0x4(%esp) # 将 7 作为新的参数 2 8048a15: mov %edx,(%esp) # 将 var1 保持为参数 1 8048a18: call 80489c6 <func4> # 递归调用 func4(var1, 7, 14) 8048a1d: lea 0x1(%eax,%eax,1),%eax # 返回值 = 2 * 递归调用的返回值 + 1 8048a21: add $0x14,%esp ... ret # 恢复栈帧并返回

回到 phase_4 的约束条件:我们必须让 func4(x, 0, 14) 的最终返回值等于 0。

根据我们上面的剖析:

  1. 如果我们向右递归(进入 mid < x 的分支),返回值中必定会带上一个 + 1。无论它之后再被乘多少次 2,它永远是个奇数,绝对不可能等于 0。炸弹必炸。
  2. 因此,我们绝对不能触发向右递归的路径。我们只能直接命中目标(返回 0),或者向左递归(返回 2 * 0 = 0)。 这意味我们的输入 x 必须恰好是二分查找过程中只往左走所经过的节点:
  • 第一层:low=0, high=14,中点 mid = 7。(直接输入 7 就会在这里命中返回 0)
  • 如果向左走,第二层:low=0, high=6,中点 mid = 3。(输入 3 会在第二层命中,外层乘以 2 依然是 0)
  • 如果再向左,第三层:low=0, high=2,中点 mid = 1。
  • 如果再向左,第四层:low=0, high=0,中点 mid = 0。 所以,过关密码是以下任意一组:

ImageImage

输出7 0,成功过关!


ImageImage

bash
8048a8f: sub $0x2c,%esp # 分配栈帧 ... (省略中间的地址计算) 8048aa2: movl $0x8049431,0x4(%esp) # 格式化字符串地址压栈 (老朋友了,估计还是 "%d %d") ... 8048ab1: call 8048600 <__isoc99_sscanf@plt> 8048ab6: cmp $0x1,%eax # 检查 sscanf 返回值 8048ab9: jg 8048ac0 <phase_5+0x31> # 如果返回值 > 1 (即等于 2),跳过爆炸 8048abb: call 8048e65 <explode_bomb> # 否则爆炸

和上一关一样,需要输入两个数字。我们将第一个输入称为 var1,第二个输入称为 var2

bash
8048ac0: mov 0x18(%esp),%eax # 将 var1 放入 %eax 8048ac4: and $0xf,%eax # 将 var1 与 0xF (15) 进行按位与运算(低4位) 8048ac7: mov %eax,0x18(%esp) # 将结果存回 var1 8048acb: cmp $0xf,%eax # 将结果与 15 进行比较 8048ace: je 8048afa <phase_5+0x6b>

保留var1的低4位,并且要求不能等于15

bash
8048ad0: mov $0x0,%ecx # 初始化一个累加器 (sum = 0) 8048ad5: mov $0x0,%edx # 初始化一个计数器 (count = 0) # --- 循环开始 --- 8048ada: add $0x1,%edx # count++ 8048add: mov 0x80492e0(,%eax,4),%eax # 极其关键:数组寻址!取内存 [0x80492e0 + eax * 4] 的值赋给 eax 8048ae4: add %eax,%ecx # sum += eax (将新取出的值累加到 %ecx 中) 8048ae6: cmp $0xf,%eax # 检查新取出的值是否等于 15 8048ae9: jne 8048ada <phase_5+0x4b> # 如果不等于 15,跳回循环开头继续! # --- 循环结束 ---

指令 mov 0x80492e0(,%eax,4),%eax 是典型的比例变址寻址,表示读取一个整型数组(每个元素占 4 字节)。数组的首地址是 0x80492e0,索引是 %eax(也就是我们处理过的 var1)。 每一次循环:

  1. 它用当前的 %eax 作为索引去数组里取出一个新值,并覆盖旧的 %eax。这就构成了一个类似链表跳跃的寻址逻辑:next_val = array[current_val]
  2. 它把取出的值累加到 %ecx (sum) 中。
  3. 它用 %edx (count) 记录跳跃的次数。
  4. 循环的终止条件是:从数组中取出的值为 15。
bash
8048aeb: mov %eax,0x18(%esp) # (不重要,存回栈) 8048aef: cmp $0xf,%edx # 将循环执行的次数 (count) 与 15 比较 8048af2: jne 8048afa <phase_5+0x6b> # 如果次数不等于 15,引爆! 8048af4: cmp 0x1c(%esp),%ecx # 将累加的总和 sum (%ecx) 与你的第二个输入 var2 比较 8048af8: je 8048aff <phase_5+0x70> # 如果相等,跳过引爆,安全通关! 8048afa: call 8048e65 <explode_bomb> # 否则爆炸

我们可以得知,不引爆炸弹的条件为:步数必须是15,总和必须等于输入的var2

所以现在我们输入x/16d 0x80492e0 ,查看内存0x80492e0 里面的数组,结果如下

ImageImage

我们目前已知三个硬性通关条件:

  1. 终点:从数组中取出的值必须是 15,循环才会停止。查看上面的映射表,数字 15 存放在索引 6。
  2. 步数:我们必须在这个迷宫里精确地跳跃 15 次(count == 15)。
  3. 跳转规则:下一个索引 = 当前取出的值。 既然正向穷举比较麻烦,我们不妨采用逆向倒推的策略。
  • 第 15 步(最后一步):必然要落在存放值 15 的位置,也就是索引 6。那么,是哪个索引里面存放了数字 6 呢?
  • 第 14 步:查表可知,索引 14 里面的值是 6。所以上一步是从索引 14 跳过来的。接着问,谁里面存了 14?
  • 第 13 步:索引 2 里面的值是 14。
  • 第 12 步:索引 1 里面的值是 2。
  • 第 11 步:索引 10 里面的值是 1。
  • 第 10 步:索引 0 里面的值是 10。
  • 第 9 步:索引 8 里面的值是 0。
  • 第 8 步:索引 4 里面的值是 8。
  • 第 7 步:索引 9 里面的值是 4。
  • 第 6 步:索引 13 里面的值是 9。
  • 第 5 步:索引 11 里面的值是 13。
  • 第 4 步:索引 7 里面的值是 11。
  • 第 3 步:索引 3 里面的值是 7。
  • 第 2 步:索引 12 里面的值是 3。
  • 第 1 步(起点):索引 5 里面的值是 12。 所以要想精确跳跃 15 步到达终点,我们最初始的起始索引(也就是 var1 & 0xF 的结果)必须是 5。

既然路径已经确定,程序在遍历时会把沿途所有的“值”累加到 %ecx 寄存器中作为最终的密码 var2。我们按照正向顺序把它们加起来:

取出的值序列为:12 -> 3 -> 7 -> 11 -> 13 -> 9 -> 4 -> 8 -> 0 -> 10 -> 1 -> 2 -> 14 -> 6 -> 15

累加计算: 12 + 3 + 7 + 11 + 13 + 9 + 4 + 8 + 0 + 10 + 1 + 2 + 14 + 6 + 15 = 115

所以最简单的过关密码为:5 115

ImageImage


因phase_6内容太多,就不粘贴phase_6的完整内容了,直接进入分析指令阶段

分析核心代码段

bash
8048b17: call 8048e8c <read_six_numbers> ... 8048b21: mov 0x10(%esp,%esi,4),%eax # 获取第 i 个输入的数字 8048b25: sub $0x1,%eax # 将其减 1 8048b28: cmp $0x5,%eax # 与 5 比较 8048b2b: jbe 8048b32 <phase_6+0x2f> # 如果 <= 5,安全通过 8048b2d: call 8048e65 <explode_bomb> # 否则爆炸 ... (8048b32 到 8048b53 是一个嵌套循环) 8048b40: cmp %eax,0xc(%esp,%esi,4) # 比较当前数字与前面的数字是否相等 8048b44: jne 8048b4b <phase_6+0x48> 8048b46: call 8048e65 <explode_bomb> # 如果相等(有重复),爆炸

必须输入1-6,这六个数字的全排列,不能重复

bash
8048b5d: mov $0x7,%ecx # 将常数 7 放入 ecx 8048b62: mov %ecx,%edx 8048b64: sub (%eax),%edx # 7 - 当前数组元素 8048b66: mov %edx,(%eax) # 将减法结果写回数组

数字转换,把每一个数字x 都替换成了 7 - x

bash
8048b82: mov $0x804b11c,%edx # 极其关键的内存地址!这是一个链表的头指针! ... 8048b76: mov 0x8(%edx),%edx # edx = edx + 8 (相当于 C 语言中的 ptr = ptr->next) 8048b7c: cmp %ecx,%eax 8048b7e: jne 8048b76 <phase_6+0x73> # 循环找节点 ... 8048b87: mov %edx,0x28(%esp,%esi,4) # 找到对应的节点指针,存入一个指针数组中

可以得知,0x804b11c 是一个固定的内存地址,里面存放着一个链表,并且next 指针存放在偏移量为8的地址

bash
8048baa: mov 0x28(%esp),%ebx # 取出我们重排后的第一个节点指针 ... 8048bb8: mov (%eax),%edx # 取出下一个节点指针 8048bba: mov %edx,0x8(%ecx) # 将前一个节点的 next 指针指向它 (node->next = next_node) ... 8048bc8: movl $0x0,0x8(%edx) # 把最后一个节点的 next 指向 0 (NULL)

程序按照你指定的顺序,修改了每个节点的 next 指针,把原本的链表彻底打乱,重组成了一条新的链表。

bash
8048bcf: mov $0x5,%esi # 准备比较 5 次 (6个节点有5个相邻对) 8048bd4: mov 0x8(%ebx),%eax # 获取当前节点的 next 指针 8048bd7: mov (%eax),%eax # 获取 next 节点的值 (node->next->value) 8048bd9: cmp %eax,(%ebx) # 比较 node->value 和 node->next->value 8048bdb: jge 8048be2 <phase_6+0xdf> # 如果当前节点的值 >= 下一个节点的值,跳过爆炸 8048bdd: call 8048e65 <explode_bomb> # 如果前一个比后一个小,立刻引爆!

通过条件:降序排列,链表中节点的值必须是严格递减(降序)排列的

我们通过调用x/d 位置 查看链表某一node的值,然后通过x/wx 位置+8 查看下一个node 的位置

ImageImage

这说明,程序内部重组链表时,需要的节点顺序必须是:3, 4, 2, 6, 5, 1。

既然我们现在知道,转换后的结果(也就是程序去寻找节点的顺序)必须是 3, 4, 2, 6, 5, 1,我们只需要将公式逆转回来。 因为 转换后 = 7 - 原始输入,所以 原始输入 = 7 - 转换后。

我们逐个计算你真正需要敲入终端的数字:

  • 第 1 个数:7 - 3 = 4
  • 第 2 个数:7 - 4 = 3
  • 第 3 个数:7 - 2 = 5
  • 第 4 个数:7 - 6 = 1
  • 第 5 个数:7 - 5 = 2
  • 第 6 个数:7 - 1 = 6 所以最后的通关密码就是: 4 3 5 1 2 6

ImageImage

至此,成功拆解炸弹!


心得体会

这次“二进制炸弹”实验是一次极具挑战性但也充满成就感的底层探索之旅。通过对目标代码文件进行反汇编和逆向工程 ,我不仅加深了对计算机系统运行机制的理解,更在逻辑推理和动手调试能力上得到了极大的锻炼。

  1. 汇编语言与底层机制的具象化理解

以往对高级语言(如C/C++)的认知多停留在语法层面,而本次实验让我直观地看到了高级语言结构在底层的真实面貌。例如:

  • 控制流的实现: 在 Phase 3 中,我通过分析 jmp *0x80492c0(,%eax, 4) 深入理解了 switch-case 语句底层如何依赖跳转表来提升执行效率 。
  • 函数调用与栈帧: 在 Phase 4 的递归函数 func4 中,我清晰地追踪了参数如何压栈(如 sub $0x2c,%esp 开辟空间)以及返回值的传递过程(通过 %eax 寄存器)。
  • 复杂数据结构的内存寻址: Phase 5 中利用比例变址寻址 mov 0x80492e0(,%eax,4),%eax 模拟链表跳跃 ,以及 Phase 6 中直接通过偏移量 0x8 操作链表的 next 指针并进行节点重组 ,极大地增强了我对内存布局和指针操作的敏感度。
  1. 逆向工程思维与调试技巧的飞跃在面对庞大且晦涩的汇编代码时,我学会了使用 objdump 查看指令结构 ,并利用 GDB 工具进行动态调试 。通过设置断点(b explode_bomb 防御性调试)、单步执行和查看内存数据(如 x/16d 打印数组内容),我能够将静态的代码逻辑与动态的寄存器状态结合起来。这种“抽丝剥茧”的逆向推导过程,培养了我面对复杂系统时的耐心。

  2. 算法思维的底层印证拆弹过程不仅是翻译代码,更是破解算法。在 Phase 4 中,通过分析二分查找的逻辑,我逆向推导出了必须避免向右递归(进入 mid < x 的分支)才能使最终返回值为 0 的数学规律 。在 Phase 6 中,通过抽象出链表降序排列的条件,并结合 7 - 原始输入 的数字转换公式,最终推导出正确的输入序列 。这种在底层指令中剖析算法时间复杂度和执行路径的过程,与平时追求算法优化的思维不谋而合,让我意识到优秀的算法不仅在于高级逻辑的设计,更在于如何被高效地翻译为机器指令。