二进制拆弹实验全流程解析:从汇编逆向到隐藏关卡实战
1. 拆弹之前这个实验到底在练什么计算机系统基础这门课里二进制拆弹实验算是把“读汇编、看内存、推逻辑”三件事揉在一起的高强度训练。标题里的“拆除二进制炸弹”说白了就是一个编译好的可执行程序里面埋了若干关卡每一关都在等你输入特定的字符串或者数字。输对了这一关过程序继续往下走输错了程序直接打印“BOOM!!!”然后退出。整个过程没有任何源码给你看你手里只有两个东西一个二进制可执行文件以及一颗随时会炸的“炸弹”。这个实验能练到什么程度我自己的感受是它逼着你把课本上那些“寄存器、栈帧、调用约定、跳转表”从纸面概念变成肌肉记忆。你不再是在试卷上填“%eax存返回值”而是真的要在调试器里盯着寄存器窗口看每一步执行之后值怎么变、栈怎么长、跳转怎么走。适合谁来啃如果你正在学汇编、操作系统或者编译原理想找个能把理论串起来的实战靶子这个实验非常合适。哪怕你之前只写过高级语言只要愿意花时间跟调试器死磕也能一关一关拆下来。我前后完整拆过两轮第一轮磕磕绊绊第二轮才摸出比较顺的节奏。下面把我踩过的坑、总结出来的套路按拆弹的完整流程摊开讲。核心工具就一个调试器。核心方法就一句话别猜去看内存和寄存器。2. 拆弹前的环境与工具准备2.1 调试器选型为什么我最终锁定了gdb拆二进制炸弹调试器就是你的眼睛。可选的有gdb、lldb也有图形化的工具。我试过几种组合最后稳定用gdb原因很实在它对反汇编、寄存器查看、内存检查的支持最直接而且几乎每台Linux机器上都有不用折腾环境。gdb配合几个插件会舒服很多比如能高亮显示寄存器变化的增强工具但这不是必须的。裸gdb完全够用关键是你得熟悉几条核心命令。我常用的就这几条objdump -d bomb先把整个反汇编导出来存成文本慢慢看比在gdb里翻页高效。gdb ./bomb加载程序。break *0x地址在关键地址下断点。run跑起来。info registers看所有寄存器的值。x/20x $rsp以十六进制查看栈顶往后的内存。x/s 0x地址把某块内存当字符串看。stepi/nexti单步执行一条汇编一条汇编地走。disas反汇编当前函数。提示第一次跑之前先把反汇编完整导出一份。objdump -d bomb bomb.asm然后用编辑器打开。gdb里翻汇编容易迷路文本文件里可以搜索、可以做标记效率高得多。2.2 先摸清炸弹的“引信”结构在动手拆第一关之前我建议先花十分钟做一件事搞清楚这个炸弹的整体结构。用objdump -d导出反汇编后先找main函数。main里通常会调用一个初始化函数然后依次调用phase_1、phase_2……一直到最后一关中间可能还夹着一个phase_defused之类的函数。看main的调用顺序你就能知道一共有几关每关的函数名是什么。这一步看起来简单但很多人一上来就扎进phase_1拆到后面才发现还有隐藏关卡节奏就乱了。先把地图画出来后面每一关都知道自己在哪、下一步往哪走。另外main里往往还会调用一个initialize_bomb函数里面可能注册了信号处理防止你用某些手段绕过。这个不用太担心正常拆就行。2.3 输入方式文件输入比手动敲更稳炸弹程序通常从标准输入读你的答案。手动一行一行敲当然可以但拆到后面关卡多了每次重跑都要重新输入前面所有答案非常烦。我的做法是建一个答案文件比如answers.txt每行一个关卡的答案然后这样跑./bomb answers.txt这样每次重跑前面的答案自动填进去你只需要专注当前这一关。拆弹过程中会反复重跑这个习惯能省下大量时间。3. 第一关字符串比较的经典套路3.1 从phase_1的反汇编里找线索第一关通常是送分题但也是建立信心的关键。打开反汇编找到phase_1。你会看到它大概做这么几件事先把栈帧建好然后调用一个函数常见的是strings_not_equal把用户输入的字符串和一个内置字符串做比较。如果相等返回不等调用explode_bomb。关键就在那个内置字符串的地址。反汇编里会看到类似这样的指令mov $0x402400, %esi call strings_not_equal这个0x402400就是内置字符串的地址。你不需要去猜字符串内容直接在gdb里看x/s 0x402400屏幕上就会打印出那个字符串。把它原样写进答案文件第一关就过了。3.2 为什么不能直接猜字符串有人会想我能不能从反汇编里把字符串直接读出来理论上可以但反汇编里字符串是以地址形式出现的你看到的是0x402400这样的数字不是文本。必须去内存里看。这就是为什么调试器不可替代——它让你能直接访问程序运行时的内存。注意不同机器、不同编译选项下这个地址可能不一样。所以不要抄别人的地址一定要自己跑一遍看自己这份二进制里那个地址是什么。3.3 第一关的实操心得第一关我建议你亲手走一遍完整流程导出反汇编、找到phase_1、定位字符串地址、在gdb里查看、写入答案文件、重跑验证。这一套动作后面每一关都要重复第一关练熟了后面就是换汤不换药。有个小技巧在gdb里查看字符串时如果字符串很长x/s可能显示不全可以用x/40cb 0x地址按字符逐个看或者用x/10s 0x地址看多个字符串。第一关的字符串一般不长x/s就够了。4. 第二关循环与数组的拆解4.1 识别循环结构第二关开始上强度了。这一关通常要求你输入一串数字程序会检查这些数字之间是否存在某种递推关系。反汇编里你会看到一个循环初始化一个计数器然后每次迭代读取一个数、做运算、比较、跳转。识别循环的关键是找跳转指令。jmp、jle、jge、jne这些指令配合标签就构成了循环。你要做的是搞清楚三件事循环从哪开始、循环条件是什么、每次迭代对数据做了什么。4.2 从寄存器追踪递推关系第二关的典型模式是第一个数由你指定后面的每个数由前一个数通过某个公式算出来。比如第一个数是x第二个数是2x第三个数是4x以此类推。反汇编里会看到类似add %eax, %eax相当于乘2或者imul这样的指令。我的做法是在循环开始的地方下断点单步走一遍每走一步看一次寄存器。比如break *0x地址 run answers.txt info registers stepi info registers这样走几轮递推公式就清楚了。然后反推如果程序期望的序列是a1, a2, a3, ...而递推关系是a(n) f(a(n-1))那么你只需要确定a1后面的都能算出来。a1往往由某个比较指令给出比如cmp $0x1, %eax说明第一个数不能是1或者必须是某个特定值。4.3 参数计算的实际例子假设反汇编显示第一个数存在%eax然后循环里执行add %eax, %eax再cmp某个上界。这意味着序列是a1, 2*a1, 4*a1, ...。如果循环要求6个数且最后一个数不能超过某个值你就能反推出a1的范围。我遇到过一个变体递推是a(n) a(n-1) n也就是每次加一个递增的数。这种就要看循环计数器怎么参与运算。关键是别慌单步走两轮把每一步的寄存器值记下来规律自然浮现。提示第二关的数字通常不大可以用计算器辅助。但更重要的是理解递推逻辑因为后面关卡可能会复用类似的模式。4.4 第二关的避坑要点第二关最常见的坑是只看了一部分循环就下结论。有些炸弹的循环里嵌套了条件判断比如根据某个标志位决定走哪条分支。如果你只看了主路径可能会漏掉一个条件。我的建议是把循环体完整走一遍包括所有跳转分支确认没有遗漏。另一个坑是数字的进制。反汇编里出现的数字可能是十六进制而你输入的是十进制。比如cmp $0x1e, %eax0x1e是30不是1e。这个转换要细心。5. 第三关跳转表与switch-case的破解5.1 跳转表的识别第三关通常会引入switch-case结构反汇编里表现为跳转表。你会看到类似这样的指令cmp $0x7, %eax ja 0x地址 jmp *0x402500(,%rax,8)这就是典型的跳转表先判断输入是否超过某个范围然后用输入值作为索引从一张表里取出目标地址跳过去执行。那张表就在0x402500附近每个条目8字节64位系统上是指针大小。5.2 用gdb查看跳转表内容跳转表的内容不能直接读得用gdb看x/8gx 0x402500这会打印出8个8字节的值每个值是一个地址。这些地址对应各个case分支的入口。你要做的是找到每个case分支看它要求什么输入、做什么运算、最后和什么比较。5.3 反推每个case的合法输入第三关的套路通常是你输入两个数第一个数决定走哪个case第二个数经过该case的运算后必须等于某个值。所以你需要对每个case反推给定运算结果反推出第二个数应该是多少。比如某个case做的是a - b然后和0x1e比较要求相等。那么b a - 0x1e。如果a是你输入的第一个数b是第二个数你就能算出b。我一般会列一张表把每个case的运算和期望值都记下来然后选一个最简单的case来构造答案。没必要把所有case都解出来只要有一个能过就行。5.4 第三关的实操记录我拆第三关时先看了跳转表发现有8个条目。然后逐个看每个case的反汇编发现其中有一个case只做了一次减法最简单。我就选了这个case反推出第二个数输入过关。这里有个细节跳转表的索引是从0开始的但你的输入可能是从1开始的。反汇编里通常会有一个sub $0x1, %eax之类的指令把输入减1再作为索引。这个偏移量要搞清楚否则会选错case。注意有些炸弹的跳转表条目不是连续的中间可能有填充。用x/16gx多看几个确认表的边界。6. 第四关递归与函数调用的追踪6.1 递归函数的识别第四关往往会引入一个递归函数比如func4。反汇编里你会看到func4自己调用自己。这一关的难点在于递归的逻辑不像循环那么直观你需要理解递归的基准条件和递推步骤。识别递归很简单在func4的反汇编里找call func4。找到之后看它在什么条件下调用自己调用时参数怎么变返回值怎么用。6.2 追踪递归的输入输出关系递归函数通常接受一个或两个参数返回一个值。你的任务是找到一个输入使得返回值等于某个期望值。我的做法是先看基准条件递归终止的地方确定最简单的输入对应什么输出。然后一层一层往上推。比如func4的基准条件是if (n 1) return n;递推是return func4(n-1) func4(n-2);。这就是斐波那契数列。如果程序要求返回值等于某个数你就能反推出n。实际操作时我会在func4入口下断点然后手动调用它call func4(5)gdb会打印返回值。试几个值就能摸清输入输出关系。这比纯看汇编快得多。6.3 第四关的常见陷阱第四关的坑在于递归函数可能有多个参数而且参数之间有关联。比如第一个参数决定递归深度第二个参数参与运算。如果你只看了一个参数可能会漏掉另一个。另一个坑是递归的返回值可能不是直接比较而是经过一层变换。比如返回值先加1再比较。这个变换通常在调用func4之后的几条指令里要看清楚。提示如果递归逻辑太复杂可以写一个小脚本模拟。比如用Python把递归函数重写一遍然后暴力枚举输入找到满足条件的值。这比手动推快得多。7. 第五关字符串与位运算的综合7.1 字符串处理的常见模式第五关通常会结合字符串和位运算。程序可能要求你输入一个字符串然后对每个字符做某种变换最后和内置的数组比较。反汇编里你会看到循环遍历字符串对每个字符取低几位、做异或、或者查表。关键是要找到那个内置数组的地址然后用gdb查看它的内容。比如x/16bx 0x402600这会打印出16个字节的十六进制值。这些值就是变换后的目标。7.2 反推原始字符知道了目标值接下来就是反推原始字符。如果变换是“取字符的低4位”那么目标值就是低4位原始字符的高4位可以是任意值。但通常程序还会有一个约束比如字符必须是某个范围内的可打印字符。你需要结合约束来构造。如果变换是异或那就更简单原始字符 目标值 ^ 密钥。密钥通常在反汇编里以立即数形式出现或者在某个寄存器里。7.3 第五关的实操技巧我拆第五关时先看了内置数组发现是16个字节。然后看循环发现它对每个输入字符做and $0xf, %eax然后和数组对应位置比较。这意味着每个字符的低4位必须等于数组里的值。但低4位相等还不够因为高4位可以是任意值。程序通常还会检查字符是否在某个范围内比如0x20到0x7e之间。我就在这个范围内选字符使得低4位匹配。比如数组第一个值是0x2我可以选0x42B因为0x42 0xf 0x2。注意有些炸弹会要求字符不能重复或者有其它约束。这些约束通常在循环之后的比较指令里要看清楚。8. 第六关链表与排序的终极考验8.1 链表结构的识别第六关通常是最后一关也是最复杂的一关。它往往涉及一个链表链表节点里存有数值。程序要求你输入一串数字然后按照某种规则对链表节点排序最后检查排序后的顺序是否和你的输入匹配。反汇编里你会看到一系列mov指令把一个个节点的地址加载到栈上。这些节点就是链表的各个元素。每个节点通常有两个字段一个数值一个指向下一个节点的指针。8.2 用gdb遍历链表要搞清楚链表的结构最直接的方法是在gdb里遍历。先找到链表头节点的地址然后x/2gx 0x地址第一个8字节是数值第二个8字节是下一个节点的地址。顺着这个地址继续看直到遇到空指针。这样你就能把整个链表的值和顺序都列出来。8.3 反推输入顺序知道了链表的值接下来就是搞清楚程序要求你怎么排序。反汇编里会有一个排序过程可能是按数值降序也可能是按其它规则。排序之后程序会检查排序后的节点顺序是否和你的输入对应。你的任务是给定链表的值按照程序要求的排序规则反推出输入顺序。比如链表值是[10, 20, 30, 40, 50, 60]程序要求降序排列那么排序后的顺序是60, 50, 40, 30, 20, 10。你需要输入的是这些值对应的节点编号而不是值本身。8.4 第六关的避坑经验第六关最大的坑是输入的是节点编号不是节点值。很多人看到链表值就直接输入值结果炸了。一定要看清楚程序比较的是什么。另一个坑是排序规则可能不是简单的升序降序而是某种自定义规则。比如先按某个字段排序再按另一个字段排序。这个要看清楚反汇编里的比较指令。提示如果链表节点很多手动遍历容易出错。可以写一个gdb脚本自动遍历链表并打印所有值。这样既快又准。9. 隐藏关卡拆完主线之后的惊喜9.1 隐藏关卡的触发条件有些炸弹在拆完所有主线关卡后还会有一个隐藏关卡。触发条件通常比较隐蔽比如在某一关的答案后面附加一个特定字符串或者在某个特定关卡输入特定内容。我遇到过的触发方式是在第四关的答案后面加上一个字符串比如DrEvil。这个字符串通常藏在反汇编的某个角落或者在phase_defused函数里。你要做的是找到phase_defused看它检查什么条件。9.2 隐藏关卡的拆解思路隐藏关卡通常是前面关卡的加强版可能结合了递归、链表、位运算等多种技术。拆解思路和前面一样看反汇编、下断点、追踪寄存器、反推输入。但隐藏关卡往往没有明确的提示你需要自己摸索。我的建议是先把主线关卡的所有答案都保存好然后尝试在答案文件里添加一些可疑的字符串看程序是否进入隐藏关卡。9.3 隐藏关卡的价值隐藏关卡不是必须拆的但它能让你对整颗炸弹的理解更完整。拆完隐藏关卡你会对程序的整体设计有更深的体会。而且隐藏关卡往往是最能锻炼逆向思维的部分。10. 拆弹过程中的通用技巧与避坑清单10.1 通用技巧先导反汇编再开gdb文本文件里搜索比gdb里翻页快。答案文件自动化每关答案写一行重跑时自动填充。断点下在关键比较处cmp指令前后是信息最密集的地方。寄存器快照每走几步就info registers记录变化。内存查看x/s看字符串x/gx看指针x/bx看字节。写脚本辅助递归、链表这类逻辑用Python模拟比手动推快。10.2 避坑清单坑表现解决方法地址抄错不同机器地址不同自己跑自己看进制混淆十六进制当十进制统一转成十进制再算漏看分支只看了主路径完整走一遍所有跳转输入类型错输入值而非编号看清比较的是什么隐藏关卡漏触发主线拆完就停检查phase_defused递归推错基准条件看反从基准往上推链表遍历错指针看错用gdb脚本自动遍历10.3 我踩过的最大的坑我踩过的最大的坑是在第六关链表遍历时我把节点值当成了节点编号输入了一串值结果一直炸。后来在gdb里仔细看比较指令才发现程序比较的是节点在链表中的位置不是节点的值。这个坑让我多花了两个小时。另一个坑是在隐藏关卡我一直以为触发条件是在最后一关结果是在第四关。这个教训是不要假设要看反汇编。11. 拆完之后这个实验真正教会我的事拆完这颗炸弹我最大的收获不是学会了多少条汇编指令而是建立了一种“看底层”的思维习惯。以前写代码我只关心逻辑对不对现在我会想这行代码编译出来大概是什么样寄存器怎么用栈怎么变。这种思维在调性能、查崩溃、读第三方库的时候特别有用。另一个收获是耐心。拆弹过程中你会有很多次“这什么鬼”的时刻但只要你愿意一条指令一条指令地跟答案总会浮现。这种“不猜去看”的习惯比任何具体知识都值钱。如果你正在拆这颗炸弹或者准备开始拆我的建议是别怕慢别怕炸。每炸一次你就离答案近一步。把每次炸的原因记下来下次就不会再犯。拆到最后你会发现真正被拆掉的不是炸弹而是你对底层的那层恐惧。