☰
MIPS 反汇编拆炸弹:延迟槽、跳转表与 QEMU/GDB 调试
2026/11/3 7:13:28 网站建设 项目流程

第一次拿到一个 MIPS 架构的 bomb 二进制时,我的第一反应是——网上能搜到的拆炸弹笔记九成都是 x86 的,讲的是$eip、call/ret、栈帧对齐;而摆在我面前的是一份objdump -d输出,满屏$t0、$a0、jr $ra,还有一堆躲在跳转指令后面的nop。地址对不上,函数名对不上,连"这条分支到底跳没跳"都要愣一下才反应过来。MIPS 反汇编拆炸弹这件事,难点从来不在"看不懂汇编",而在于你脑子里的那套 x86 直觉在这里会持续给你错误答案。

这篇东西就是我完整拆一遍 MIPS 版 bomb 的过程记录:怎么从一份没有符号表的反汇编里把六个关卡一个个定位出来,怎么读延迟槽,怎么用 qemu 挂 gdb 多架构调试把静态推理变成寄存器里的实证,以及哪些坑我实打实卡了半天。适合正在做体系结构/汇编课程实验的人,也适合任何想练习 MIPS 反汇编阅读的同行——即使你手上不是炸弹实验,纯逆向一个 MIPS 程序,这套路子也是一样的。

1. 先看清战场:MIPS 版炸弹和 x86 版完全不是一类东西

1.1 bomb 的骨架其实非常朴素

抛开架构差异,这类实验程序的整体结构简单得不像话:一个main循环调用phase_1到phase_6,每一关通过read_line读一行输入,交给一个phase_n函数做校验,校验失败就调explode_bomb打印一句引信触发信息然后退出,成功则打印一句提示进入下一关。除此之外还有几个辅助函数:strings_not_equal、string_length、read_six_numbers,以及隐藏关相关的phase_defused。搞清楚这张"函数关系图",比逐条读懂指令重要得多。

我在动手之前会先做一件事:把objdump -d的输出全部导出到文件,然后用grep搜explode_bomb的所有调用点。理由很直接——谁调用爆炸函数,谁就是关卡校验逻辑的所在。这一招在任何架构上都成立,但在 MIPS 上尤其好用,因为jal后面跟的是绝对地址,objdump会贴心地把它翻译成jal 400f20 <explode_bomb>,一目了然。

mips-linux-gnu-objdump -d ./bomb > bomb.asm grep -n "explode_bomb" bomb.asm

执行完你会得到一张调用清单,大致长这样:

调用位置所属函数含义
phase_1+0x34phase_1字符串比较失败
phase_2+0x6cphase_2首元素不为 1
phase_3+0x88phase_3序号越界
phase_6+0x1c0phase_6链表节点值不满足降序
phase_defused+0x74phase_defused隐藏关输入不匹配

这张表就是你的作战地图。有了它,你甚至不需要先看main就能知道程序里有几个校验点、分别在哪。

1.2 MIPS 让你栽跟头的四个点

第一个是分支延迟槽。MIPS 的经典五级流水线设计决定了一条铁律:跳转指令后面的那一条指令,无论跳不跳,都会先执行。也就是说beq $v0, $zero, label的下一条指令不是"跳转失败才执行",而是"永远执行"。这在反汇编里的表现是:编译器会把一条有用的指令塞进延迟槽,你如果按顺序阅读,会觉得"这条指令怎么在这个位置"。我见过最常见的误判就是:jal strings_not_equal后面跟着addiu $a1, $a1, 0x10c0,新手会以为"调用完了才设置参数,那参数岂不是错的"——恰恰相反,它在调用之前就生效了,只是排版上排在了后面。

第二个是**$ra的语义**。jal label执行时把返回地址(实际上是指向下下条指令,因为要跨过延迟槽)写进$ra。所以任何一个非叶子函数,进门第一件事几乎必然是sw $ra, N($sp)把返回地址存栈。你在反汇编里看到addiu $sp,$sp,-0x20紧跟sw $ra,0x1c($sp),这就是函数入口的标志性动作,比找lui靠谱得多。

第三个是**$gp全局指针和 PIC 寻址**。位置无关代码里,全局变量、字符串常量、跳转表通常通过$gp相对寻址访问,反汇编出来是lw $v0, -0x7f30($gp)这种鬼东西。你得先在_ftext/__gnu_local_gp附近找到$gp的初始化值(通常是lui $gp,0x%hi(...)加addiu $gp,$gp,%lo(...)),才能把-0x7f30($gp)换算成真实地址。这一步不做,跳转表你永远算不对。

第四个是伪指令。li、la、move、b、beqz、blt、bgt、mul、rem、not全是汇编器造出来的假指令,机器码里根本不存在。objdump默认会帮你还原,但li $t0, 0x12345678在机器码里其实是两条:lui $t0,0x1234加ori $t0,$t0,0x5678。你要做二进制补丁的时候必须记着这一点,否则改出来的东西是废的。

1.3 动手前先跑 readelf,别猜端序

MIPS 有大端(mips)和小端(mipsel)两个世界,教材上的 MIPS 多半是大端,但很多实验环境给的是小端。搞错了端序,你读.rodata里的字符串会得到一堆乱码,然后开始怀疑人生。

file ./bomb readelf -h ./bomb | head -20

readelf -h里会明明白白写着Data: 2's complement, big endian或little endian,同时告诉你Class: ELF32、Machine: MIPS R3000。这三行信息决定了后面所有工作的走向:用哪个 qemu(qemu-mips还是qemu-mipsel)、用哪个 objdump(mips-linux-gnu-objdump还是mipsel-linux-gnu-objdump)、Ghidra 里选哪个语言(MIPS:BE:32:default还是MIPS:LE:32:default)。

提示:工具链和二进制端序不匹配时,objdump不会报错,它只会给你一份完全错误的指令流。看到0x00000000密集出现、指令前缀全是sdl、ldc1这种冷门指令,八成就是端序搞反了。

2. 静态分析:把 objdump 输出读成一张能导航的地图

2.1 认栈帧:prologue 和 epilogue 是唯一可靠的锚点

MIPS 没有push/pop,也没有enter/leave,函数入口和出口全靠固定套路。我总结了一个几乎是模板级的判断法:

00400c40 <phase_3>: 400c40: 27bdffc8 addiu sp,sp,-56 # 开栈帧,56 字节 400c44: afbf0034 sw ra,52(sp) # 存返回地址 400c48: afb20030 sw s2,48(sp) # 存被调用者保存寄存器 400c4c: afb1002c sw s1,44(sp) 400c50: afb00028 sw s0,40(sp) ... 400c9c: 8fbf0034 lw ra,52(sp) # 恢复 400ca0: 8fb20030 lw s2,48(sp) 400ca4: 8fb1002c lw s1,44(sp) 400ca8: 8fb00028 lw s0,40(sp) 400cac: 03e00008 jr ra 400cb0: 27bd0038 addiu sp,sp,56 # 延迟槽里恢复栈指针

注意最后两条:jr $ra后面那个addiu sp,sp,56就是在延迟槽里执行的。这个模式一旦认出来,整个二进制里所有函数边界你都能扫出来——jr $ra加一个延迟槽里的addiu sp,sp,N,就是函数结尾。

再顺手记一下 o32 ABI 的寄存器分工,这在读参数时是硬知识:

寄存器别名用途保存方
$zero$0恒为 0,写入无效—
$v0-$v1$2-$3返回值、系统调用号调用方
$a0-$a3$4-$7前四个整型参数调用方
$t0-$t9$8-$15,$24-$25临时寄存器调用方
$s0-$s7$16-$23跨调用保持的变量被调用方
$sp/$fp/$ra$29/$30/$31栈顶/帧指针/返回地址特殊

这里有个特别实用的推论:看到$s0到$s7被使用,说明这个函数里有需要跨越函数调用保持的局部变量,通常是循环计数器或数组基址。反过来,如果某个函数里只有$t系列和$a系列,那它大概率是个叶子函数,逻辑也简单,可以快速扫过。

2.2 立刻做的一件事:定位read_line和read_six_numbers

read_line是所有关卡的入口标志。每个phase_n的第一件事基本都是jal read_line,然后把返回值$v0搬到$a0作为参数继续处理。所以你在phase_1里看到:

400a2c: jal 400b80 <read_line> 400a30: nop 400a34: move a0,v0 400a38: lui a1,0x40 400a3c: jal 400bc0 <strings_not_equal> 400a40: addiu a1,a1,0x10c0 # 延迟槽里补全 a1 的低 16 位

这一小段已经把 phase_1 的全部秘密说完了:读一行输入,把输入指针放进$a0,把常量字符串地址0x40000000 | 0x10c0 = 0x400010c0放进$a1,然后调strings_not_equal。答案就在0x400010c0那个地址上。

mips-linux-gnu-objdump -s -j .rodata ./bomb | head -40

-s会把.rodata段按十六进制 dump 出来,配合地址一对照,字符串就出来了。大端机器上字符顺序直接读,小端机器上要注意一个字节一个字节看,别整段倒过来——字节序影响的是多字节整数,不是字符串本身,这一点很多人会搞反。

2.3 常量还原:lui+ori的拆解心算

MIPS 的立即数只有 16 位,加载一个 32 位常量必须两条指令。看到lui $t0, 0x0040后面跟ori $t0, $t0, 0x10c0,拼起来就是0x004010c0。但编译器更常用addiu而不是ori来拼低半部分,这时候如果低半部分最高位是 1,addiu会做符号扩展,结果会跟你手算的差0x10000。这个细节在还原跳转表地址时坑过我一次:算出来的表地址偏了 64KB,读出来的全是垃圾数据。

判断方法很简单:如果lui的值比目标地址的高 16 位小 1,那就是用了addiu的符号扩展套路。比如目标是0x00401200,你可能会看到lui $t1, 0x0040加addiu $t1, $t1, 0x1200(这个没问题),但如果目标是0x0040f100,可能会变成lui $t1, 0x0041加addiu $t1, $t1, -0x0f00。反汇编器通常会帮你显示成addiu $t1,$t1,-3840,看到负数立即数就要警觉。

2.4 交叉引用.rodata:字符串、跳转表、数组各有什么特征

.rodata里的东西按用途分三类,形态完全不同:

  • 字符串:连续的 ASCII 字节,末尾00结尾,长度通常十几到几十字节。
  • 跳转表:连续的 4 字节地址值,个数等于 switch 分支数,每个值都落在.text段的地址范围内。
  • 查找表/数组:连续的 4 字节整数或 1 字节字符,没有明显规律,也不落在代码段。

区分方法:用readelf -S拿到.text的起止地址,然后看.rodata里那段数据的每个 dword 是否落在代码段范围内。落进去的就是跳转表。这个判断我写成了一个小脚本,一次跑完整个.rodata:

import struct text_lo, text_hi = 0x00400000, 0x00410000 data = open("rodata.bin", "rb").read() for off in range(0, len(data) - 4, 4): v = struct.unpack(">I", data[off:off+4])[0] if text_lo <= v < text_hi: print(f"offset {off:#06x}: jump target {v:#010x}")

跑完你会立刻发现 phase_3 里那张表的位置和项数。

3. 六关逐层剥开:每一关到底在校验什么

3.1 phase_1 与 phase_4:从 strcmp 到递归的两种极端

phase_1 是所有关卡里最友好的,strings_not_equal的参数就是答案明文。但有个小陷阱:某些版本的 bomb 会把答案字符串做一次简单变形(比如逐字节异或某个常量)再比较,这时候你不能只看strings_not_equal,得看它的调用者有没有提前处理过$a0或$a1。我遇到过的变形是在read_line之后来一段循环:

400a50: lb v0,0(s0) 400a54: beq v0,zero,400a78 400a58: xori v0,v0,0x2a # 每个字符异或 0x2a 400a5c: sb v0,0(s0) 400a60: addiu s0,s0,1 400a64: j 400a50 400a68: nop

这种情况下答案是.rodata里那串密文再异或一次还原出来的明文。养成习惯:看完比较函数,回头看一眼它前面的循环。

phase_4 是另一个极端。经典设计里它会调用一个func4递归函数,输入一个整数,返回一个值,要求返回值等于某个常数。MIPS 递归函数的栈帧特别规整,读起来反而比 x86 舒服:

00400e80 <func4>: 400e80: 27bdffd8 addiu sp,sp,-40 400e84: afbf0024 sw ra,36(sp) 400e88: afb20020 sw s2,32(sp) 400e8c: afb1001c sw s1,28(sp) 400e90: afb00018 sw s0,24(sp) 400e94: 00808021 move s0,a0 # s0 = n 400e98: 1c800005 bgtz a0,400eb0 # if (n <= 0) return 0; 400e9c: 00001021 move v0,zero # 延迟槽:v0 = 0 400ea0: 08003ba8 j 400eea <func4+0x6a> 400ea4: nop

这里的关键信息全在寄存器搬运上:move s0,a0说明参数n需要跨递归调用保持,bgtz a0是出口条件,延迟槽里的move v0,zero就是返回值 0。把这种模式按顺序翻译成 C 语言草稿,比硬啃指令快得多。我一般会边读边在纸上写:

long func4(long n) { if (n <= 0) return 0; ... }

写完再回头看jal func4的调用点,看它传了什么参数、拿返回值跟什么比较,答案的取值范围就出来了。phase_4 这种关卡如果没有数学直觉,可以先用 Python 暴力枚举 0 到 10000,把func4的逻辑照着写一遍,跑出所有满足条件的解——通常会有好几个正确答案,挑一个填进去就行。

3.2 phase_2:循环里的递推关系怎么读

phase_2 的标志性动作是先调read_six_numbers,然后立刻验证数组首元素是不是 1,接着进一个循环。MIPS 的循环结构长得非常一致,识别特征是末尾的slt加bne组合:

400b60: sll v0,s1,2 # v0 = i * 4 400b64: addu v0,s0,v0 # v0 = &a[i] 400b68: lw a0,-4(v0) # a[i-1] 400b6c: lw a1,0(v0) # a[i] 400b70: sll a0,a0,1 # a[i-1] * 2 400b74: addu a1,a1,a0 400b78: sw a1,0(v0) 400b7c: addiu s1,s1,1 400b80: slti v0,s1,6 400b84: bne v0,zero,400b60 400b88: nop

这段代码说的事很清楚:a[i] += 2 * a[i-1],然后 i 从 1 走到 5。但校验的其实不是这个更新后的值——循环只是修改了内存里的数组,真正的校验通常在这一段之后,重新读一遍数组去比。这是编译器的常见产物:源码里可能是先算后比,-O0下就变成先全部算完再逐项比较。

读这种代码我的习惯是忽略中间过程,直奔比较点。找到beq/bne后面跟explode_bomb的那几条,看它们比较的是什么,然后倒推 v0 是怎么算出来的。这样比顺着指令流一条条读快三倍。

3.3 phase_3:jr跳转表的还原方法

phase_3 是 switch 语句,MIPS 编译出来的形态固定得像教科书:

400c90: lw v0,24(sp) # 读第二个数 400c94: sltiu v0,v0,8 # 无符号与 8 比较:num <= 7 400c98: beq v0,zero,400d10 # 越界就爆 400c9c: nop 400ca0: sll v0,v0,2 # v0 *= 4 400ca4: lui v1,0x40 400ca8: addiu v1,v1,0x1200 # v1 = 表基址 400cac: addu v0,v0,v1 400cb0: lw v0,0(v0) # 取出目标地址 400cb4: jr v0 # 间接跳转 400cb8: nop

注意sltiu而不是slti:这是无符号比较,说明源码里的判断条件写的是(unsigned)n <= 7,编译器顺手把负数的情况也挡住了。这是个很好的验证点——如果你填了个负数进去,程序会在跳转之前就爆,说明你还没走对路。

表在0x00401200,8 项,每项 4 字节。读出 8 个地址后,逐个跳过去看那一段在干什么。每一段基本都是 "设置$v0为某个常量,然后跳到统一比较点,把$v0和第一个输入数比"。把这些常量记下来,就得到答案集合:

400d00: li v0,0x1a5 400d04: b 400d60 400d08: move a0,s0

这里move a0,s0也在延迟槽里,它在跳转生效前把第一个输入数搬进a0。这类细节读多了就有肌肉记忆:看到b后面跟着一条看起来"不该在这"的指令,直接认为它会执行。

3.4 phase_5:查表变换加循环,最多四层

phase_5 的套路是:把输入字符串的每个字符取低 4 位,作为索引去查一张 16 字节的表,拼出新字符串,再和.rodata里的目标串比较。MIPS 版本里这段循环非常好认,因为取低 4 位只能用andi:

400e10: lbu v0,0(s0) 400e14: andi v0,v0,0xf # 只要低 4 位 400e18: addu v0,v0,s1 400e1c: lbu a1,0(v0) # 查表 400e20: sb a1,0(s2) ...

16 字节的表在.rodata里,可以直接 dump 出来。剩下的工作就是反推:目标串的第一个字符是表里的第几项?这一项的位置又对应原输入字符低 4 位的哪个值?由于低 4 位相同的字符有很多(比如a和q的低 4 位都是 1),答案不唯一,随便挑一个可打印字符即可。

注意:表里如果有无重复项,说明设计者刻意做成双射,映射唯一;如果有重复项,说明是故意留了多解。我拆过的一个版本表长度是 16 但只有 12 个不同字符,说明至少有两组重复,这时候你选哪个字符都能过,但选可打印字符最省事——别选到控制字符把自己坑了。

3.5 phase_6:链表 + 排序,最费纸的一关

phase_6 是所有关卡里最"工程化"的一关:读六个数字,要求每个都在 1 到 6 之间且互不重复,然后按这些数字作为索引,从一张预先构建好的链表里取节点,按索引顺序重排,最后检查重排后的节点值是否严格递减。

MIPS 版本的特点是从.data段读节点。每个节点是一个结构体,典型布局是{int value; int index; node *next},12 字节。反汇编里你会看到大量lw ... , 0($s0)、lw ..., 4($s0)、lw ..., 8($s0)这种固定偏移访问。识别出这个模式,就能确定节点大小和字段顺序。

重排的逻辑通常是先把六个节点的地址按顺序存到一个栈上数组里,然后对数组做两两交换(for i in 0..5: for j in i+1..5),交换条件是"若a[i]->value < a[j]->value则交换"。MIPS 里这个双层循环的内层是:

400f80: lw v0,0(a1) # a[i]->value 400f84: lw v1,0(a2) # a[j]->value 400f88: slt v0,v0,v1 400f8c: bne v0,zero,400fa0 # 若 value_i < value_j 交换 400f90: nop

把链表节点的 value 全 dump 出来,按大小排序,你就能反推出输入序列。这一步用gdb更方便:在检查前下断点,直接x/12wx &node1把整条链表打出来。静态分析算偏移容易出错,动态看一眼省半小时。

3.6 隐藏关:从phase_defused反推触发条件

隐藏关的触发点全在phase_defused这个函数里。它每关结束都会被调用一次,逻辑通常是:计数器到 6 之后,用sscanf按一个更宽松的格式解析某一关的原始输入,如果多解析出一个字符串,就把它和某个常量比较,相等就进隐藏关。

MIPS 版本里sscanf的调用形态是jal sscanf,参数是$a0(输入串)、$a1(格式串)、$a2/$a3加栈上若干目标地址。你只要盯住格式串在.rodata里的内容,就知道要几个字段、什么类型。对比一下phase_4正常解析用的格式串(通常是"%d")和这里用的(通常是"%d %d %s"之类),差出来的部分就是隐藏关的入口。

然后去找它比较的那个常量串,通常就藏在同一个.rodata区域附近。找到之后,回到对应的那一关(一般是第四关),把答案行改成"原答案 + 空格 + 隐藏关口令",再依次通关,隐藏关就会打开。这一关的校验通常是更复杂的指针操作或者数组变换,但结构跟前面几关同源,读起来不会更难。

4. 动态验证:用 qemu 挂 gdb,让寄存器自己说话

4.1 起一个带调试端口的 qemu

静态分析给出的是"推理",动态调试给出的是"事实"。MIPS 二进制在你的 x86 机器上跑不了,得靠 qemu 用户态模拟。标准做法是先装qemu-user和gdb-multiarch,然后:

# 终端 1:起 qemu,暴露 gdb 调试端口在 1234 qemu-mips -g 1234 -L /usr/mips-linux-gnu ./bomb # 终端 2:连上去 gdb-multiarch ./bomb \ -ex "set architecture mips" \ -ex "set endian big" \ -ex "target remote :1234" \ -ex "break phase_3" \ -ex "continue"

-L那个参数是指定动态链接库的搜索根目录,很多实验给的 bomb 是动态链接的,不给-L会直接报找不到解释器。如果二进制是静态链接的,-L可以省。set endian必须跟readelf的输出对上,写错了 gdb 会以错误的字节序解释内存,读出来的地址全是反的。

4.2 断点下在"比较之前",不是函数入口

在函数入口下断点只能让你看到参数,但你想看的是"程序算出来的中间结果和我推理的一不一样"。所以我习惯把断点下在比较指令的前一条。比如 phase_3 里那个sltiu之前,先x/4wx $sp+16把栈上的两个数字打出来,直接就知道sscanf解析出了什么。

常用的几条命令在 MIPS 上完全通用:

(gdb) info registers a0 a1 v0 s0 sp ra (gdb) x/8wx $sp (gdb) x/s $a0 (gdb) x/8i $pc-16 (gdb) display/i $pc

display/i $pc这条特别值得开,因为 MIPS 单步的时候,si会把延迟槽和跳转分开走,屏幕上实时显示当前指令能让你知道自己在延迟槽里还是在目标地址上。

4.3 单步陷阱:为什么按一次si感觉跳了两条

这是 MIPS 调试里最让人困惑的行为。当你si执行一条jal时,gdb 会先停一下(在延迟槽指令上),你需要再按一次si才真正进到被调函数。同理,beq走完延迟槽才会跳。第一次遇到会以为 gdb 出 bug 了,实际上这就是硬件的真实行为。

对应的技巧是:遇到跳转指令时,先看$pc和延迟槽里的指令,再决定下不下第二个断点。如果延迟槽里是一条sw或者move,说明它在为跳转目标准备参数,你要关心的数据变化就发生在这里。

4.4 改寄存器还是改二进制:两种取舍

调试到某一关想验证答案,有两条路。一条是临时改寄存器:在比较前set $v0 = 0,让程序以为匹配通过,直接看下一关的入口地址。这条路快,但只对当前会话有效,重启就没了。

另一条是二进制打补丁,把jal explode_bomb替换成nop。MIPS 的jal是 4 字节(0x0c000000 | (addr>>2)),nop是0x00000000,长度一致,直接原地替换不会破坏其他指令的对齐和地址。这招的好处是你可以随便乱填输入,程序只会告诉你"错误",不会退出,方便你逐关试答案。

import struct data = bytearray(open("bomb", "rb").read()) target = 0x00400b80 # explode_bomb 地址 jal_to_target = 0x0c000000 | (target >> 2) patched = 0 for off in range(0, len(data) - 4, 4): if struct.unpack(">I", data[off:off+4])[0] == jal_to_target: data[off:off+4] = b"\x00\x00\x00\x00" patched += 1 open("bomb_patched", "wb").write(data) print(f"patched {patched} call sites")

提示:改完记得chmod +x bomb_patched,并且保留原始二进制。很多实验的最后一步要求交原始炸弹的正确答案,补丁版只用来探路。

5. 那些让我卡了半天的具体坑

5.1 大小端搞混导致字符串全乱

我第一次 dump.rodata的时候,把整个段按小端解释,结果字符串全是倒着的。后来才反应过来:字符串是字节序列,跟端序无关;只有多字节整数(比如跳转表项、数组元素)才需要按端序解释。所以正确做法是:字符串直接用objdump -s的 ASCII 列看,整数用脚本按机器端序 unpack。搞混这一点,你会浪费大量时间在"这个字符串为什么倒过来了"上。

5.2-O0和-O2编译出来的关卡长得完全不一样

课程给的 bomb 通常是-O0编译的,代码冗长但直观,每个中间变量都写回栈上,读起来像流水账。如果拿到的是-O2版本,寄存器复用会非常激进,函数可能被内联,循环可能被展开,甚至func4的递归会被改写成迭代。这时候别硬读,先搜一下有没有明显的jal调用链,把函数边界重新划一遍。我一般会用grep -c "jr.*ra"数一下函数个数,心里有个底。

5.3$gp基址算错,跳转表读成垃圾

前面提过一次,这里再强调:跳转表如果是 PIC 相对寻址,lw $v0, xxx($gp)里的xxx是相对于$gp的偏移,而$gp的初值一般在中途的某个函数里设置。你得在__libc_csu_init或者_start之后的几条指令里找到lui $gp, ...加addiu $gp, $gp, ...,算出$gp的真实值,然后才能换算。算错的结果就是跳到一片没有指令的地方,程序直接段错误,你还会以为是自己的答案错了。

5.4 一份自查对照表

现象最可能原因处理方式
反汇编全是sdl、ldc1等冷门指令端序或架构选错readelf -h核对 Machine 和 Data
字符串显示为乱码用整数方式解释字节序列改看objdump -s的 ASCII 列
跳转目标落在非代码区$gp基址算错先定位$gp初始化指令
单步时感觉跳过了一条分支延迟槽观察$pc与display/i $pc
jalr $t9之后立刻崩$t9未正确加载目标地址回看前两条lui/addiu
明明答案对却爆了上一关答案被隐藏关格式影响检查phase_defused的sscanf格式串

6. 不靠手工:把校验逻辑搬到 Python 里批量验证

6.1 逐关复现校验函数

手工推一遍之后,最有价值的动作是把每一关的校验逻辑用 Python 重写一遍。写的过程就是第二次验证——你会发现某些细节你其实没读清楚。比如 phase_2 的递推关系,写完跑一遍,如果前六项跟你在 gdb 里看到的内存对不上,说明读错了。

def phase_2(): a = [1] for _ in range(5): a.append(2 * a[-1]) # 对应 a[i] += 2*a[i-1] 的等价形式 return " ".join(map(str, a)) def phase_3(table, consts): # table: 从 .rodata 读出的跳转目标序号到常量的映射 for idx, val in consts.items(): for first in range(0, 1000): if first == val: return f"{first} {idx}" return None if __name__ == "__main__": print(phase_2())

这种脚本的好处是:你可以随便改假设,几秒钟验证一次。改一个系数,跑一遍,看输出对不对,比在 gdb 里改寄存器快十倍。

6.2 用 qemu 做黑盒爆破的脚手架

有些关卡(尤其 phase_5)的答案是组合型的,手工反推容易漏。这时候写个黑盒爆破脚本更省事:固定前几关的正确答案,只对当前这一关做穷举,通过观察输出判断是否通过。

import subprocess PREFIX = ["answer1", "answer2", "answer3", "answer4"] QEMU = ["qemu-mips", "-L", "/usr/mips-linux-gnu", "./bomb"] def try_phase5(cand, timeout=2): payload = "\n".join(PREFIX + [cand]) + "\n" try: p = subprocess.run(QEMU, input=payload.encode(), capture_output=True, timeout=timeout) except subprocess.TimeoutExpired: return None return p.stdout.decode(errors="ignore") for cand in generate_candidates(): out = try_phase5(cand) if out and "失败" not in out and "触发" not in out: print("hit:", cand) break

判断是否通过时,别去匹配中文提示(不同版本的 bomb 措辞不一样),最稳的方式是看输出长度变化或者看是否出现了下一关的提示文本。我一般先手工跑一次正确答案,把输出存成模板,后面只做字符串比对。

6.3 静态推理与动态结果交叉验证

我最后一定会做一遍交叉验证:静态分析推出来的答案,填入程序跑一遍;跑通之后,再用 gdb 在每一关的关键比较点打一次断点,确认程序内部实际算出来的值和我的 Python 脚本算出来的一致。这一步听起来多余,但它能抓出那种"答案碰巧对了但理解错了"的情况——比如 phase_5 里有多解,你填的那个字符恰好在另一个分支上也能过,但你其实没搞懂那关在干什么。真正理解了,你才敢在报告里写清楚原理,而不是只交一串答案。

我在这类实验上栽得最惨的一次,是 phase_6 里把链表的节点大小算成了 8 字节而不是 12 字节,结果后面所有偏移全错,反推出来的顺序看着有模有样,填进去就爆。后来在 gdb 里x/18wx一把把六个节点打出来,肉眼一看就看出来了——节点之间的地址间隔是0x0c,不是0x08。这个教训让我养成了一个习惯:凡是从.data读结构体,先别急着算偏移,先在内存里把几个相邻实例打出来,量一下实际步长,一切以实测为准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询