第一次拿到“拆除二进制炸弹”这个实验的时候,我对着终端敲了一个自认为很合理的单词,下一秒屏幕上就弹出了BOOM!!!。那一刻我才反应过来,这门课不是在开玩笑。所谓二进制炸弹,其实就是一个编译好的可执行程序,里面藏着多个关卡(通常叫phase),每一关都要求你输入一串特定内容。输入正确,程序放你进入下一关;输入错误,程序调用内部的爆炸函数直接终止。你要做的不是绕过炸弹,而是把每道关卡的正确输入逐条推理出来。
这个实验在计算机系统基础课里几乎是标配,不同学校会调整关卡数量和难度,但核心训练目标非常一致:逼你学会读汇编、用调试器、理解函数调用和内存布局。对正在做实验的同学来说,这篇分享能帮你少走很多弯路;对还没开始的人,它是一份预习地图;对纯粹好奇程序怎么校验输入的人,也可以看看机器层面到底发生了什么。我会按照自己实际拆弹的顺序,从准备环节讲到六颗雷的常见结构,最后聊几个让我断掉头发才想明白的翻车瞬间。
1. 这枚“炸弹”到底是个什么东西
1.1 一句话定义:带密码锁的程序
你可以把二进制炸弹理解成一个带密码锁的程序。它从标准输入读入你敲的字符串,然后交给若干个phase函数去校验。每个phase函数的工作很简单:拿到输入,和程序内部早就设定好的期望值比较,相等就继续,不等就调用explode_bomb。为了增加难度,程序不会直接显示期望值,你得从汇编和内存里把它挖出来。比较的结果通常通过辅助函数返回,比如一个叫strings_not_equal的函数,名字已经非常直白:两个字符串不相等就返回非0。掌握了这种命名习惯,拆弹初期会轻松不少。
不同版本的炸弹会有差异。我见过有6个phase再加一个隐藏phase的,也见过只有4个phase但每个phase都更绕的。有的版本从标准输入逐行读取答案,有的版本直接把一个答案文件作为命令行参数读进去。甚至有的版本会在你拆完第六关后,悄悄调用隐藏函数。所以不要死记某个具体的答案,而是先通过file、objdump、gdb摸清这一份炸弹的脾气。炸弹的设计原则只有一个:输入就是钥匙,每把钥匙都必须亲手找到。
1.2 课程为什么要拿“拆弹”当实验
原因很简单:汇编和函数调用是计算机系统课程的硬骨头。光看书,你能理解寄存器、栈帧、调用约定,但合上书还是不会用。拆弹实验把知识变成了动手任务,你必须真正读懂phase函数里的每条指令,才能推理出输入。这个过程会逼着你理解三件事。第一,高级语言里的if/else、for、switch在机器层面不过是一堆cmp、test、jmp的组合。第二,函数间传递数据不是靠魔法,而是靠约定好的寄存器和栈位置。第三,数组、链表、字符串在内存里就是连续的字节,靠一个基地址加偏移量访问。
我还要说一句体会:这个实验的“游戏化”设计其实很巧妙。失败时的BOOM!!!让人印象深刻,成功进入下一关时又真的像拆掉了一颗雷。它把本来枯燥的逆向分析变成了闯关,很多人靠着一股“我一定要拆完”的劲头,在一个晚上硬生生把汇编啃了下来。这种效果是普通练习题很难达到的。所以我一直认为,二进制炸弹不只是作业,它是课程组送给学生的一份“强制升级”礼物。
2. 拆弹之前,先认识一下现场环境
2.1 文件清单:你手上通常有什么
先别急着运行。把压缩包解压后,先拿file命令看看到手的是什么。常见情况是一个Linux下的ELF可执行文件,保留了符号表。保留符号表意味着你可以用objdump -t直接看到phase_1、phase_2这种函数名,这等于实验给你发了一张地图。有些版本还会附带一份只包含main框架的C代码,但不会给你phase函数的具体实现;没有也不算事,反汇编里都能看到。
$ file bomb bomb: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, not stripped $ objdump -t bomb | grep phase 00000000004013e0 g F .text 0000000000000040 phase_1 0000000000401420 g F .text 0000000000000110 phase_2 0000000000401530 g F .text 00000000000000a0 phase_3 ... 0000000000401870 g F .text 0000000000000080 explode_bomb注意这些地址只是用来举例,不同机器上会不同。我建议一上来就把explode_bomb的地址记在小本子上。它虽然是个错误处理函数,但后面调试时是最重要的路标。同时准备好一个answer.txt文件,用来逐行保存你解出的答案。不要小看这个文件:在终端里手动一行行输入很容易输错,而且每次改答案后重新运行都要重新敲一遍,效率很低。把答案写进文件,用重定向喂给程序,调试起来会舒服很多。命令行大概是:
./bomb answer.txt或
./bomb < answer.txt具体是哪种,从main函数的反汇编里看,看它有没有用argv[1]打开文件。
2.2 工具准备:三条命令打天下
下面这张表是我拆弹时最常用的工具组合,每个工具都负责一个层面,缺一个都会多花不少时间。
| 工具 | 作用 | 我最常用的姿势 |
|---|---|---|
| file | 判断可执行文件类型、架构、是否被strip | 拿到文件先跑一次,心里有底 |
| objdump | 反汇编、查看节区数据、符号表 | objdump -d bomb > bomb.asm;objdump -s .rodata |
| gdb | 动态调试、单步、查看寄存器/内存/调用栈 | break explode_bomb; run < answer.txt; x/s 地址 |
这三个工具基本所有Linux发行版都会预装,不用额外配置。唯一可以提前做的是在home目录写好.gdbinit,把常用设置固化下来。我自己的配置是:
set pagination off set confirm off set disassembly-flavor intelset pagination off避免输出一屏就停下,set confirm off省去每次删除断点都要确认,set disassembly-flavor intel让反汇编用Intel语法。如果你更习惯AT&T语法,也可以不改。关键是别在实验中途被这类小事打断思路。
2.3 一次爆炸会发生什么:搞懂explode_bomb和phase_N的关系
拿到可执行文件后,最先反汇编的不是phase_1,而是main。main才是整张地图的起点。反汇编后你会看到一连串函数调用,大概长这样:
<main>: call read_line call phase_1 call read_line call phase_2 ... call read_line call phase_6 call secret_phase用C的视角翻译就是:
int main() { char *input; input = read_line(); phase_1(input); input = read_line(); phase_2(input); // ... return 0; }当然真实版本可能有些细节差异,但骨架基本如此。搞清楚这个调用关系后,你就知道每个phase函数的入参是一个字符串指针,放在%rdi;读进来的答案可能是字符串、数字、字符组合。phase函数内部验证失败时调用explode_bomb,该函数会输出BOOM!!!并终止进程。所以调试时最爽的一个操作是:在explode_bomb上下断点。一旦程序快要爆炸,gdb立刻停下来,你用bt看调用栈,就能精确知道是哪一个phase、哪一行认为输入不对。这个反馈机制是整场拆弹行动里最高效的指南针。
3. 我实际拆弹时采用的完整流程:从静态到动态
3.1 第一轮侦察:反汇编并画出调用关系
把objdump -d bomb > bomb.asm导出的文件打开后,先不要钻进某个phase,而是先找main。你需要做的不是看懂每条指令,而是把调用地图画出来:main调了哪些辅助函数、每个phase在哪个地址、explode_bomb在哪个地址。我通常会新建一个文本笔记,写下:
- read_line:负责读入一行答案
- phase_1到phase_6:六个主关卡
- explode_bomb:错误处理,拆弹时最重要的断点
- 其他名字奇怪的函数:通常是辅助校验函数
如果某些函数没有符号名,也不用慌,通过地址定位。比如phase_1里callq 0x401500,你再到bomb.asm里搜401500,就能看到那个函数的代码和它调用什么。如果整个二进制被strip过,所有函数名都没了,工作量大一些,但思路完全一样:从main入口开始顺着call把每个阶段函数的地址找出来。我建议即便如此也先别急着放弃,gdb里照样可以按地址打断点。
3.2 看懂phase函数里的“校验三件套”
大部分phase函数的结构非常相似,我总结为校验三件套:准备参数、调用辅助校验函数、根据返回值决定是否爆炸。以第一关为例,反汇编大概是:
phase_1: sub $0x8, %rsp mov $0x402400, %esi call strings_not_equal test %eax, %eax je phase_1_ok call explode_bomb phase_1_ok: add $0x8, %rsp ret这段代码里,$0x402400是目标字符串的内存地址。strings_not_equal接收两个参数:%rdi是用户输入,%rsi是目标字符串。如果相等函数返回0,test %eax,%eax会把零标志位ZF置1,je就跳到正常出口;如果不相等,就直接调用explode_bomb。
为什么我反复强调这个模式?因为后面的关卡虽然更长更绕,但核心都是同一个思路:去比较指令之前,看看两个待比较的值分别是什么。第二关可能是栈上的连续整数,第三关可能是跳转表后的某个字符,第四关是递归函数的返回值,第六关是链表中节点值的排列顺序。你只要能定位“比较指令前那一刻”的数据,答案就已经在眼前了。
3.3 动态调试:gdb是我的探针和工作台
静态分析告诉你答案大概在哪,真正把答案抠出来要靠gdb。我的常规操作是先用gdb bomb启动,然后执行:
(gdb) break explode_bomb (gdb) break phase_1 (gdb) run < answer.txt当程序停在断点后,我最常用的命令是:
- bt:看调用栈,知道从哪冒出来的
- info registers:看寄存器当前值
- p $rdi / p $rsi:看函数参数
- x/s 地址:把地址当作字符串打印
- x/8gx 地址:按8字节为单位打印内存
- stepi / nexti:单步执行指令
- finish:执行完当前函数并停在下一条指令
第一次用gdb的人容易把它当成一个“带界面的黑盒”,其实在终端里敲命令反而更精确。比如选中答案地址后,直接x/s $rsi就能看到目标字符串;用x/6dw $rsp能看到栈上的六个整数;用x/16gx能看出链表节点布局。这些命令组合成一个小工作流,就足够应付大部分关卡了。千万别背命令,要用的时候查,用多了自然记住。
3.4 为什么我坚持静态和动态交替进行
纯静态分析容易卡在跳转表、递归和链表上,因为代码跳来跳去你很难在脑内模拟;纯动态调试又容易变成瞎试,因为你没有整体的函数边界和数据结构概念。正确的节奏是:先用objdump把函数边界、辅助调用、关键比较圈出来,再回到gdb里验证数据和地址。比如第三关的跳转表,看起来恐怖,但只要你用objdump -s .rodata把那段数据导出,再用gdb按地址读一遍,所有case的目标地址立刻清晰,switch就变回了一张普通查表。
尤其到第六关链表排序,我几乎一半时间在objdump里确认next指针的偏移量,一半时间在gdb里打印节点内容。做完之后你会发现,纯静态和纯动态不是对立关系,它们就像读地图和开车:先用地图确定方向,再开车到实地确认路牌。这大概是拆弹实验真正想训练的能力:面对一个陌生程序,如何用工具快速建立模型,而不是靠猜。
4. 六颗雷的常见“弹体结构”与破解思路
4.1 第一关:字符串常量比对,答案常在数据段
第一关通常最友好,答案直接躺在只读数据段。用gdb拿到目标地址后x/s即可看到。别高兴太早,有几个细节要注意:字符串可能包含空格、标点甚至换行;x/s显示到\0为止,如果字符串里自带\0,显示就会提前截断。为了稳妥,可以再用x/32bx 地址看十六进制字节。另外有些版本会预先对输入做转换,比如去空格或大小写归一化,但大多数第一关就是普通字符串相等。
这一关还有个容易被忽略的点:答案字符串的地址可能不在phase_1里,而是在一个辅助常量区,比如0x402400,你需要跟着mov $imm, %esi把这个立即数抓出来。我之前见过有人盯着%rdi(用户输入)看半天,忘了答案在%rsi里。所以记住:比较两个字符串时,一个是用户输入,另一个必然藏在程序的某个内存地址。这个“找常量地址”的习惯,后面所有关卡都用得上。
4.2 第二关:循环读入多个整数,答案是一个序列
第二关一般会要求输入六个整数,常见的辅助函数叫read_six_numbers。它底层多半是sscanf,按空格分隔读入,然后把六个数字依次写到栈上。反汇编里会有一段循环:从栈上取第i个数,做某种运算或比较,再决定是否继续。常见版本有两种。
第一种要求序列严格递增或有倍数关系,那只要读懂循环里比较的是num[i+1]和num[i],随便写一个满足关系的数列即可。第二种要求后一个数由前一个数经过固定运算得到,比如乘2加1,那么就需要先算出首项,再按公式推后面几项。我的建议是别背答案,把循环里的比较指令抄成伪代码,拿笔算一遍,然后写到answer.txt里验证。这个阶段你会频繁碰到lea (%rax,%rax,1), %rbx之类的复合寻址,不要慌,它就是“算一个地址/算式”。
4.3 第三关:switch跳转表,答案是一个组合
第三关常见套路是输入两个数:第一个数用来选switch分支,第二个字符用来和目标字符比较。编译器把switch实现成跳转表,反汇编里你会看到一条jmp *0x402760(,%rax,8),后面的内存就是跳转表。每个case会执行不同的代码,往某个寄存器放进不同的字符,最后再和目标值比较。
破解的关键是把跳转表里的地址和函数内各段代码对上。用objdump -s .rodata把表导出来,或者用gdb的x/16gx 0x402760,看到一个个case地址后,回到bomb.asm里找对应分支。你不需要把所有分支都解出来,挑一个最好算的分支即可。比如第一个数选0,分支里直接mov某个字符到寄存器,那第二个字符就出来了。注意输入格式里通常是“%d %c”,所以答案里要有空格。这关其实是第一次遇到“数据区+指令区”联动的结构,值得停下来多研究一会儿。
4.4 第四关:递归函数,回归树和参数栈帧
第四关开始上强度。常见结构是phase_4读取两个数,第一个数传给递归函数func4,第二个数则直接和返回值比较。func4内部会有一堆test、js、jle之类,翻译成人话就是:给定参数n,如果满足某个条件就返回一个小常数,否则递归调用自己,再把返回值组合起来。它本质是一个数学函数,只是套了汇编外壳。
我建议先把func4的递归逻辑抄成伪代码,不要傻乎乎地单步追进去。比如如果函数是:
int func4(int x) { if (x <= 1) return 1; return func4(x - 1) + func4(x - 2); }那答案就是把某个n带进去算出值,再让phase_4要求的期望返回值等于这个结果。为了验证你的伪代码是否正确,给func4打断点,并在gdb里用commands自动打印参数:
(gdb) break func4 (gdb) commands > silent > printf "func4(%d)\n", $edi > continue > end运行后你就能看到一整套递归参数序列,立刻知道函数行为。小心有符号比较和边界条件,别数错层。递归函数不是什么玄学,它只是同一个函数反复进栈,每次的局部数据都在栈帧里独立存放,明白这一点后就不可怕了。
4.5 第五关:字符到索引的映射,答案不唯一
第五关往往是“指针和位运算”的专场。典型实现:读入一个长度固定的字符串,对每个字符做掩码运算,通常是取低4位作为索引,从一个固定字符表(比如一段乱序字符串)里取出对应字符,拼接成新字符串,最后和另一个目标字符串比较。
破解步骤很清晰:第一步,找出字符表和目标字符串,都在数据段,用x/s就能看到;第二步,对目标字符串里的每个字符,找到它在这个字符表中的下标;第三步,反推输入字符的低4位。因为一个低4位可以对应多个ASCII字符,比如0x1可以是'1'(0x31),也可以是'a'(0x61),答案通常不唯一,只要低4位正确都能通过。常见错误是输入长度不对,或者字符里有空白符号导致读取被拆断。这关最值得学的是“数据在ASCII编码下如何被当成数字来运算”,非常经典的计算机视角。
4.6 第六关:链表排序,把所有节点信息打印出来
第六关是压轴,核心是链表。反汇编里到处是mov 0x8(%rdx), %rdx这种移动next指针的指令,第一次看会头晕。我的办法是先把链表节点结构挖出来。在数据段找到node1到node6,然后暴力打印内存:
(gdb) x/16gx 0x6032d0假设每个节点是一个struct:int val; int id; struct node *next;,那么地址+0是val,+8是id,+16是next指针。把六个节点的val都记下来,再看校验逻辑要求按val升序还是降序排列节点。
最后把需要的节点编号按顺序作为答案输入。要特别小心一种变体:程序会先对输入数字做7 - x的变换,再去索引链表。如果你看到一段类似mov $0x7, %eax; sub ...的代码,记得把最终序列反推回原始输入。第六关检验的不只是汇编能力,更是耐心。我当时拆这一关花了整整一个晚上,但拆完再回头看链表相关的C代码,脑子里会自动浮现出内存布局。
4.7 隐藏关:secret_phase的触发线索
如果你做的是完整版,主线拆完后可能还有一个隐藏关。触发方式五花八门,但突破口永远在字符串和函数调用关系里。先用objdump -t搜一下有没有secret_phase,再用strings搜一下提示性字符串,最后反汇编main或某个phase,看有没有额外的call。有的版本在某一关输入特定数字后触发,有的版本phase_6通过后无条件调用。
隐藏关里常见的是二叉树或哈希表查询,做法和前面类似:先反汇编查找函数,再用gdb把节点数据打印出来,推输入。我个人的建议是主线还不太熟的时候先跳过隐藏关,但如果你已经拆完六关,多拆一颗雷的成就感真的很不一样。而且隐藏关往往会用到strtol之类的解析函数,你能顺带复习一下字符串转整数的底层行为。
5. 拆弹时最容易翻车的几个瞬间
5.1 启动之后立刻爆炸,根本来不及反应
我第一次做实验就遇到这个问题:明明在main下了断点,run之后程序却像疯了一样直接BOOM。原因往往是输入文件内容格式不对,或者程序在read_line阶段就没拿到合法输入。正确做法是第一时间在explode_bomb下断点,而不是在main。这样即使要炸,也会先停在断点处,再用bt看调用栈,就能知道是哪一步失败。另一个常见原因是没有正确传输入文件。如果程序从argv[1]读文件,就要run answer.txt;如果从stdin读,就要run < answer.txt。搞错输入来源会浪费大量时间。
我还见过有人直接在gdb里敲run,然后手动输入答案。这样不是不行,但很容易在连续多关输入时错位,而且你没法回头精确控制。用文件重定向的好处是,改了答案后只需要重新编辑answer.txt再run一次,过程可复现。
5.2 想靠改二进制骗过炸弹,最后坑的是自己
拆弹拆到凌晨三点心态崩了的时候,我见过有人直接用十六进制编辑器修改反汇编,把call explode_bomb改成nop,或者把条件跳转翻转,让程序永远不会爆炸。这种操作确实能骗过当前运行,但两个问题:一是很多版本带了额外的完整性校验,改过的程序可能根本跑不起来;二是你交掉实验后,这门课真正想练的汇编阅读和调试能力一点没练到。拆弹的核心不是“让程序不炸”,而是“找到合法的钥匙”。
如果实在卡住,我建议先跳过这一关,去做后面自己更有感觉的,或者找同学聊思路。很多关卡的突破点只是一句“你看这里其实调用了func4”的提示,而不是代码本身。求助和改二进制之间,永远选前者。
5.3 断点下在函数入口,却错过了最关键的参数
在phase_1入口打断点,只能看到用户输入字符串的地址%rdi,答案地址%rsi要到函数内部才被设置。如果你在call strings_not_equal之后才去观察寄存器,答案地址已经被覆盖了。所以我更推荐把断点下在真正的比较指令之前,也就是test %eax,%eax那一行的内存地址。在gdb里可以用break *0x4013ed来指定地址断点。
另一个技巧是,如果你想看辅助函数的参数,可以在辅助函数入口打断点。比如在strings_not_equal入口,%rdi和%rsi分别就是两个待比较字符串的地址,直接x/s $rsi就能看到目标答案所在地址的内容。这种方法不仅适用于各个关卡,也适用于所有逆向调试场景。
5.4 递归函数里跟丢调用栈,不知道返回值落在哪
调试递归函数时,最怕的是单步追进一层层调用,最后在func4里迷路。我后来采用的方案是不进入函数内部,而是在调用点用finish执行完当前函数并停在返回处,然后info registers $rax看返回值。对递归函数,还可以给函数入口打断点,用commands自动打印每次参数,一次性收集所有递归调用记录。
另外一个容易忽略的点:x86-64的返回值约定在%rax,但递归调用内部可能会保存/恢复寄存器。你不要在中间层随便看%eax,那样很可能是某个子调用的临时值。正确的观察时机是函数返回后立刻看%rax,或者干脆在调用指令之后给下一条指令下断点。
5.5 答案字符串不可见字符:空格、换行、低四位陷阱
很多人的答案“看着对”,却一直爆炸。用x/s看字符串时,换行符和\0都不显示。如果你从终端复制答案,可能漏掉换行或者多敲一个空格。建议用x/20bx 地址确认每个字节。尤其phase_5里输入字符的低四位决定索引,空格、引号、换行都可能满足条件,但输入文件解析时可能会因为空白字符被拆开,导致长度不对。
处理这个坑的方法很简单:构造输入时确保没有会被scanf跳过的空白字符,或者把输入字符限制在字母和数字范围内。如果答案需要特殊字符,可以用十六进制编辑器配合输入文件生成答案。一句话总结:凡是从x/s里复制字符串,都该先看一眼十六进制,很多“看不见的坑”其实是看得见的字节。
6. 拆完这枚炸弹,我留下的一些习惯
6.1 每拆一关,写一份拆弹报告
无论最后交不交报告,我都建议把每一关的推理过程记录下来:输入格式是什么、辅助函数是哪个、期望值是怎么算出来的、卡在哪一步。这样做最大的好处是,当你拆到第五关时回头翻第一关的记录,会发现“找地址、读数据、验证”这套方法论已经可以复用。写报告的过程本身就在逼你把“模糊的感觉”变成“清晰的步骤”。我后来遇到任何需要排查程序行为的问题,都会下意识地用这套拆弹思维。
6.2 把注意力放在数据流上,而不是每条指令上
拆完六关之后,我最大的变化是看汇编不再一行行地读,而是先找数据怎么流动。输入数据从read_line来,被phase函数加工或校验,最后和某个期望值比较。指令只是数据流的交通工具。这种视角在之后看真实程序的崩溃、段错误、内存泄漏时特别有用。面对一个复杂函数时,先问:它输入什么?输出什么?中间是否改变了内存?这比逐指令理解快得多。
6.3 调试器不应该是最后的救命稻草,而是日常工具
很多同学只在实验里用一次gdb,做完就丢了。但拆弹实验让我养成了遇到程序行为不对就先开着gdb的习惯。哪怕是一个很小的C程序,打印寄存器和内存也比瞎改代码强。我在实验里积累的那些gdb命令——commands、finish、x/...系列——后来工作中继续用,价值远超一次作业。拆弹到最后,真正的收获不是通过实验的分数,而是你终于敢直视反汇编代码,并且知道下一步该往哪看。