Bomb Lab 是 CSAPP 的第二个实验。程序包含多个阶段,每个阶段都会根据用户输入执行一系列检查。如果输入不满足要求,程序便会调用 explode_bomb()explode\_bomb()explode_bomb() (其实就是程序强制退出爆炸)。
实验的核心并不只是得到正确答案,而是学会通过 GDB 分析程序的汇编代码,理解底层的函数调用、寄存器使用、条件跳转、栈内存管理和数据结构,最终将汇编程序还原为等价的 C 语言实现。
一. GDB 常用命令与 x86-64 寄存器约定
GDB 常用调试命令
首先在终端进入 Bomb Lab 所在目录:
bash
gdb ./bomb
如果已经准备好包含前面阶段答案的文本文件,可以执行:
bash
gdb --args ./bomb answers.txt
进入 GDB 后,常用命令如下:
| 命令 | 功能 |
|---|---|
| runrunrun / rrr | 启动程序 |
| break phase_6break\ phase\_6break phase_6 | 在指定函数入口设置断点 |
| break ∗0x401176break\ *0x401176break ∗0x401176 | 在指定汇编地址设置断点 |
| break explode_bombbreak\ explode\_bombbreak explode_bomb | 在爆炸函数入口设置断点 |
| info breakpointsinfo\ breakpointsinfo breakpoints | 查看全部断点 |
| delete 1delete\ 1delete 1 | 删除编号为 1 的断点 |
| continuecontinuecontinue / ccc | 继续执行程序 |
| ninini | 单步执行一条机器指令,不进入函数调用 |
| sisisi | 单步执行一条机器指令,可以进入被调用函数 |
| nextnextnext / nnn | 按源代码行执行,不进入函数调用 |
| stepstepstep / sss | 按源代码行执行,可以进入被调用函数 |
| finishfinishfinish | 执行到当前函数返回 |
| disassemble phase_6disassemble\ phase\_6disassemble phase_6 | 反汇编指定函数 |
| display/idisplay/idisplay/i $pc | 每次停止时显示当前指令 |
| info registersinfo\ registersinfo registers | 查看寄存器状态 |
| p/d eaxeaxeax | 以十进制显示寄存器值 |
| p/x raxraxrax | 以十六进制显示寄存器值 |
| x/6dw rsprsprsp | 查看六个连续的 32 位整数 |
| x/6gx rsp+0x20rsp+0x20rsp+0x20 | 查看六个连续的 64 位数据 |
| x/s rdirdirdi | 查看内存中的字符串 |
| x/10i ripriprip | 查看当前地址开始的十条汇编指令 |
| quitquitquit | 退出 GDB |
其中最容易混淆的是 ppp 与 xxx。
例如:
c
p/x $rax
表示打印寄存器 raxraxrax 保存的数值。
而:
c
x/gx $rax
表示将 raxraxrax 保存的数值当作内存地址,并读取该地址处的 8 字节数据
因此:
- ppp 主要用于打印表达式或寄存器的值。
- xxx 主要用于检查指定地址处的内存内容。
另一个重要区别是 ninini 和 sisisi。两者都按机器指令执行,但遇到 callcallcall 时,ninini 通常执行完整个被调用函数,而 sisisi 可以进入函数内部(类比VS Studio里的next和step步进)
实际分析中,不需要从头到尾机械地逐条执行。更高效的方法是先反汇编整个函数,确定关键跳转和功能块,再设置断点观察寄存器和内存的变化。
x86-64 通用寄存器
x86-64 架构包含 16 个主要的 64 位通用寄存器。每个寄存器还可以通过不同名称访问其低位部分
PS: r表示64位,e表示32位
| 64 位寄存器 | 32 位部分 | 常见用途 |
|---|---|---|
| raxraxrax | eaxeaxeax | 函数返回值、算术运算、临时数据 |
| rbxrbxrbx | ebxebxebx | 通用寄存器,被调用者保存 |
| rcxrcxrcx | ecxecxecx | 第四个整数或指针参数、临时数据 |
| rdxrdxrdx | edxedxedx | 第三个整数或指针参数、临时数据 |
| rsirsirsi | esiesiesi | 第二个整数或指针参数 |
| rdirdirdi | ediediedi | 第一个整数或指针参数 |
| rbprbprbp | ebpebpebp | 帧指针或通用寄存器 |
| rsprsprsp | espespesp | 栈顶指针 |
| r8r8r8 | r8dr8dr8d | 第五个整数或指针参数 |
| r9r9r9 | r9dr9dr9d | 第六个整数或指针参数 |
| r10r10r10 | r10dr10dr10d | 临时寄存器 |
| r11r11r11 | r11dr11dr11d | 临时寄存器 |
| r12r12r12 | r12dr12dr12d | 通用寄存器,被调用者保存 |
| r13r13r13 | r13dr13dr13d | 通用寄存器,被调用者保存 |
| r14r14r14 | r14dr14dr14d | 通用寄存器,被调用者保存 |
| r15r15r15 | r15dr15dr15d | 通用寄存器,被调用者保存 |
需要注意,寄存器的约定用途不等于它在程序中的固定含义。
例如,raxraxrax 可以先保存数组下标,随后保存链表节点地址,再用于保存一个整数。分析时必须结合当前指令及其上下文确定寄存器的实际角色
另外,x86-64 有一个重要特性:向 32 位寄存器写入数据,会自动将对应 64 位寄存器的高 32 位清零。
例如:
c
mov $0x1,%eax // eax = 1,同时 rax = 1
但向 8 位或 16 位子寄存器写入数据,通常不会自动清零其余位。
Linux x86-64 函数调用约定
Bomb Lab 使用 Linux x86-64 环境,通常遵循 System V AMD64 ABI。
对于整数和指针类型的参数,前六个参数一般按照以下顺序传递:
| 参数 | 寄存器 |
|---|---|
| 第一个参数 | rdirdirdi |
| 第二个参数 | rsirsirsi |
| 第三个参数 | rdxrdxrdx |
| 第四个参数 | rcxrcxrcx |
| 第五个参数 | r8r8r8 |
| 第六个参数 | r9r9r9 |
函数的普通整数或指针返回值通常保存在 raxraxrax 中;如果返回 32 位整数,则使用 eaxeaxeax。
例如:
c
int add(int a, int b)
{
return a + b;
}
其核心汇编可能是:(rdi存第一个参数,rsi存第二个参数)
c
mov %edi,%eax // eax = a
add %esi,%eax // eax = a + b
ret // 返回,结果保存在 eax 中
此外,根据调用约定,可以将寄存器分为两类。
-
调用者保存寄存器(Caller-saved) :raxraxrax、rcxrcxrcx、rdxrdxrdx、rsirsirsi、rdirdirdi、r8r8r8~r11r11r11。调用者不能假设这些寄存器在函数调用之后仍保持原值。
-
被调用者保存寄存器(Callee-saved) :rbxrbxrbx、rbprbprbp、r12r12r12~r15r15r15。被调用函数如果修改了这些寄存器,需要在返回前恢复原值,所以在进入函数的时候,会经常看到这些寄存器被压入函数栈帧中.
栈指针 rsprsprsp 也必须在函数返回时恢复到正确状态。
这可以解释为什么在函数开头经常看到:
c
push %r14 // 保存 r14
push %r13 // 保存 r13
push %r12 // 保存 r12
push %rbp // 保存 rbp
push %rbx // 保存 rbx
sub $0x50,%rsp // 为局部变量分配 80 字节栈空间
而函数结束时:
c
add $0x50,%rsp // 释放局部栈空间
pop %rbx // 恢复原来的寄存器值
pop %rbp
pop %r12
pop %r13
pop %r14
ret // 返回调用者
这些指令通常属于编译器生成的函数栈帧管理操作,不一定直接对应 C 语言源代码中的具体语句。
AT&T 汇编语法与内存寻址
GDB 默认显示的 x86-64 反汇编通常采用 AT&T 语法,其特点是源操作数在前,目标操作数在后。
例如:
c
mov %rax,%rbx // rbx = rax
add $0x4,%rax // rax += 4
sub $0x1,%eax // eax -= 1
其中,寄存器名称前使用 %,立即数前使用 $。
对于内存访问:
c
mov (%rax),%edx // edx = *(int *)rax
mov %edx,(%rax) // *(int *)rax = edx
括号表示将寄存器中的值作为内存地址。
更一般的寻址方式可以表示为:
Address=Base+Index×Scale+Displacement\]\[ \\text{Address}=\\text{Base}+\\text{Index}\\times\\text{Scale}+\\text{Displacement} \]\[Address=Base+Index×Scale+Displacement
例如:
c
mov (%rsp,%rax,4),%edx
表示从以下内存地址读取一个 32 位整数:
rsp+rax×4\]\[ rsp+rax\\times4 \]\[rsp+rax×4
如果 rsprsprsp 保存整数数组的首地址,raxraxrax 保存数组下标,那么这条指令就相当于:
c
edx = numbers[i];
这里比例因子为 4,是因为一个 int 通常占 4 字节。
还需要特别区分 movmovmov 和 lealealea:
c
lea 0x8(%rdx),%rdx // rdx = rdx + 8
mov 0x8(%rdx),%rdx // rdx = *(uint64_t *)(rdx + 8)
第一条通过 lealealea 计算有效地址,不读取内存。
第二条通过 movmovmov 读取内存中的 8 字节数据,并将其写入寄存器。
例如,对于以下链表节点:
c
struct Node
{
int value;
int index;
struct Node *next;
};
在常见的 64 位内存布局中,nextnextnext 指针位于节点起始位置偏移 8 字节处。
因此:
c
mov 0x8(%rdx),%rdx // 读取当前节点的 next 指针
就可以还原为:
c
p = p->next;
条件码与条件跳转
程序通常使用 cmpcmpcmp 或 testtesttest 设置条件码,再由条件跳转决定执行路径。
例如:
c
cmp %eax,%ebx // 按 ebx - eax 设置条件码
je equal // 相等则跳转
jne not_equal // 不相等则跳转
常见跳转指令包括:
| 指令 | 含义 |
|---|---|
| jejeje / jzjzjz | 相等或结果为零(equal) |
| jnejnejne / jnzjnzjnz | 不相等或结果非零(not equal) |
| jgjgjg / jgejgejge | 有符号大于 / 大于等于(greater) |
| jljljl / jlejlejle | 有符号小于 / 小于等于(less) |
| jajaja / jaejaejae | 无符号大于 / 大于等于(above) |
| jbjbjb / jbejbejbe | 无符号小于 / 小于等于(below) |
| jmpjmpjmp | 无条件跳转 |
需要注意,有符号比较和无符号比较使用不同的条件判断。例如 jlejlejle 与 jbejbejbe 并不等价。
此外,cmpcmpcmp 不是唯一能够设置条件码的指令。addaddadd、subsubsub、testtesttest 等指令也可能更新条件码。
例如:
c
sub $0x1,%ebp // ebp--,同时更新条件码
jne loop // 结果非零,则继续循环
这里不需要额外执行 cmpcmpcmp,因为 subsubsub 已经设置了 jnejnejne 所使用的条件码。
根据汇编识别控制流
C 语言中的 ififif、forforfor、whilewhilewhile 等结构,在汇编层面通常由比较和跳转实现。
例如:
c
cmp $0x5,%eax // 比较 eax 和 5
jle valid // eax <= 5 时跳转
call explode_bomb // 否则爆炸
valid:
可以还原为:
c
if (x > 5)
{
explode_bomb();
}
再例如:
c
loop:
add $0x1,%eax // i++
cmp $0x6,%eax // 比较 i 和 6
jl loop // i < 6 时继续循环
这里存在一条向前跳转,说明当前指令区域很可能包含循环结构。
不过,在实际逆向分析中,不能只根据一条跳转指令就直接写出完整的循环。应该先明确跳转目标地址,判断条件成立与不成立时分别执行哪些指令,再确定循环入口、变量更新位置和退出条件。
最终将整个功能块还原成相应的 C 语言结构,而不是机械地逐行翻译汇编指令。
这就是后续 Phase 1~6 统一采用的分析方法。
二. Phase 1:字符串比较与条件跳转
Phase 1 是 Bomb Lab 的第一关,主要涉及函数调用、参数传递、函数返回值和条件跳转。程序会将用户输入与预设字符串进行比较,如果两者不相同,则调用 explodebomb()explode_bomb()explodebomb();如果相同,则正常返回。
函数初始化与参数传递
首先:
c
sub $0x8,%rsp // rsp -= 8
mov $0x402400,%esi // esi = 0x402400
call 0x401338 // strings_not_equal
第一条指令将栈指针 rsprsprsp 减少 8 字节,为当前函数调整栈空间。这主要与函数调用时的栈对齐要求有关,并不代表程序一定创建了一个 8 字节的 C 语言局部变量。
接着:
bash
mov $0x402400,%esi
根据 System V AMD64 调用约定,前两个整数或指针参数分别通过 rdirdirdi 和 rsirsirsi 传递。
此时,rdirdirdi 已经保存了调用 phase1()phase_1()phase1() 时传入的用户输入字符串地址,因此程序只需要为第二个参数赋值。
由于写入 esiesiesi 会自动清零 rsirsirsi 的高 32 位,所以执行后:
rsi=0x402400
接下来的函数调用:
c
call 0x401338 // strings_not_equal
可以理解为:
c
int result = strings_not_equal(input, expected);
其中:
- inputinputinput 是用户输入字符串,地址由 rdirdirdi 提供。
- expectedexpectedexpected 是预设字符串,地址为 0x4024000x4024000x402400。
- resultresultresult 是比较函数的返回值,保存在 eaxeaxeax 中。
此时我们还需要确定 0x4024000x4024000x402400 指向的字符串内容。在 GDB 中可以执行:
c
x/s 0x402400
根据调试结果,该地址保存的字符串为:
c
Border relations with Canada have never been better.
根据返回值分析条件跳转
继续观察:
c
0x400eee: test %eax,%eax // 检查 eax 是否为 0
0x400ef0: je 0x400ef7 // 相等时跳转
0x400ef2: call 0x40143a // explode_bomb
这里的关键是 testtesttest 指令。
c
test %eax,%eax
testtesttest 会对两个操作数执行按位与,并根据运算结果设置条件码,但不会把结果写回寄存器。
由于两个操作数相同,所以这条指令实际上是在判断 eaxeaxeax 是否为零
当 eax=0eax=0eax=0 时,零标志位 ZF=1ZF=1ZF=1;否则 ZF=0ZF=0ZF=0。
后面的:
c
je 0x400ef7
表示如果 ZF=1ZF=1ZF=1,则跳转到地址 0x400ef70x400ef70x400ef7。
因此,程序存在两条执行路径:
strings_not_equal()
|
v
test eax,eax
|
v
eax == 0 ?
/ \
是 否
| |
v v
0x400ef7 0x400ef2
恢复栈空间 explode_bomb()
|
v
ret
结合函数名称以及我们此前的运行结果,可以确定:
- 两个字符串相同,strings_not_equal() 返回 0。
- 两个字符串不同,返回非零值。
所以这一段的 C 语言逻辑为:
c
int result = strings_not_equal(input, expected);
if (result != 0)
{
explode_bomb();
}
注意,汇编使用的是 jejeje,但还原成 C 语言时使用的是条件不等于。这是因为汇编通过条件成立来跳过爆炸函数,而 C 语言直接表达触发爆炸的条件。两种写法的控制逻辑完全一致。
函数返回与栈空间恢复
最后两条指令:
c
0x400ef7: add $0x8,%rsp // rsp += 8
0x400efb: ret // 返回调用者
第一条恢复函数入口时的栈指针位置,与最开始的 subsubsub 对应。
第二条 retretret 从栈中取得返回地址,将程序控制权交还给调用者。
它们属于底层函数调用和栈空间管理操作,在 C 语言实现中不需要专门写出。
完整汇编代码
c
0x400ee0 <+0>: sub $0x8,%rsp // 分配 8 字节栈空间
0x400ee4 <+4>: mov $0x402400,%esi // 第二个参数 = 预设字符串地址
0x400ee9 <+9>: call 0x401338 // 调用 strings_not_equal
0x400eee <+14>: test %eax,%eax // 判断返回值是否为 0
0x400ef0 <+16>: je 0x400ef7 // 返回值为 0,则跳过爆炸
0x400ef2 <+18>: call 0x40143a // 调用 explode_bomb
0x400ef7 <+23>: add $0x8,%rsp // 恢复栈指针
0x400efb <+27>: ret // 函数返回
对应C语言代码
将整个 Phase 1 组合起来,可以还原为:
c
void phase_1(const char *input)
{
const char *expected =
"Border relations with Canada have never been better.";
if (strings_not_equal(input, expected))
{
explode_bomb();
}
}
也可以使用标准 C 语言库函数 strcmp()strcmp()strcmp() 表达同样的字符串比较逻辑:
cpp
#include <string.h>
void phase_1(const char *input)
{
const char *expected =
"Border relations with Canada have never been better.";
if (strcmp(input, expected) != 0)
{
explode_bomb();
}
}
其中,strcmp()strcmp()strcmp() 返回 0 表示两个字符串相同,返回非零值表示不同。
本阶段最终通过的输入为:
Border relations with Canada have never been better.
三. Phase 2:数组遍历与循环结构
Phase 2 要求输入六个整数,程序首先判断第一个整数是否为 1,然后通过循环检查相邻元素之间的关系,要求后一个整数必须是前一个整数的两倍。这一关的重点是理解数组的内存布局、指针运算以及汇编中循环结构的实现。
首先观察完整的反汇编代码:
c
0x400efc: push %rbp // 保存 rbp
0x400efd: push %rbx // 保存 rbx
0x400efe: sub $0x28,%rsp // 分配 40 字节栈空间
0x400f02: mov %rsp,%rsi // rsi = 数组首地址
0x400f05: call 0x40145c // 调用 read_six_numbers
0x400f0a: cmpl $0x1,(%rsp) // 检查 numbers[0] 是否等于 1
0x400f0e: je 0x400f30 // 相等则跳转到循环初始化
0x400f10: call 0x40143a // 否则调用 explode_bomb
0x400f15: jmp 0x400f30 // 跳转到循环初始化
0x400f17: mov -0x4(%rbx),%eax // eax = 当前元素的前一个整数
0x400f1a: add %eax,%eax // eax *= 2
0x400f1c: cmp %eax,(%rbx) // 比较当前整数与前一个整数的两倍
0x400f1e: je 0x400f25 // 相等则继续
0x400f20: call 0x40143a // 不相等则爆炸
0x400f25: add $0x4,%rbx // 移动到下一个整数
0x400f29: cmp %rbp,%rbx // 比较当前指针与结束地址
0x400f2c: jne 0x400f17 // 未结束则继续循环
0x400f2e: jmp 0x400f3c // 退出循环
0x400f30: lea 0x4(%rsp),%rbx // rbx = &numbers[1]
0x400f35: lea 0x18(%rsp),%rbp // rbp = &numbers[6]
0x400f3a: jmp 0x400f17 // 进入循环体
0x400f3c: add $0x28,%rsp // 释放局部栈空间
0x400f40: pop %rbx // 恢复 rbx
0x400f41: pop %rbp // 恢复 rbp
0x400f42: ret // 函数返回
读取整数与检查初始条件
首先,程序执行:
c
push %rbp
push %rbx
sub $0x28,%rsp
mov %rsp,%rsi
call 0x40145c
其中,pushpushpush 用于保存被调用者保存寄存器,刚开始我们就将rbp,rbx两个被调用者保护寄存器压栈;而 subsubsub 将栈指针减少 0x28=400x28=400x28=40 字节,为当前函数分配局部栈空间。
根据 Linux x86-64 函数调用约定,第二个整数或指针参数由 rsirsirsi 传递。由于 rsprsprsp 指向当前分配的栈空间,所以将其复制到 rsirsirsi 后,read_six_numbers()read\_six\_numbers()read_six_numbers() 就可以把解析出的六个整数连续写入这块内存。
这一部分可还原为:
c
int numbers[6];
read_six_numbers(input, numbers);
由于一个 int 占 4 字节,所以六个整数依次位于 rsprsprsp、rsp+4rsp+4rsp+4、rsp+8rsp+8rsp+8、rsp+12rsp+12rsp+12、rsp+16rsp+16rsp+16 和 rsp+20rsp+20rsp+20。
接下来:
c
cmpl $0x1,(%rsp) // 比较第一个整数与 1
je 0x400f30 // 相等则跳过爆炸
call 0x40143a // 不相等则调用 explode_bomb
jmp 0x400f30 // 进入后续逻辑
这里的 (rsp)(rsp)(rsp) 就是数组的第一个元素,因此程序首先要求 numbers0=1numbers0=1numbers0=1。jejeje 根据前面 cmplcmplcmpl 设置的零标志位决定是否跳转,如果两个数相等,就直接跳到地址 0x400f300x400f300x400f30,否则调用爆炸函数。
对应的 C 语言代码为:
c
if (numbers[0] != 1)
{
explode_bomb();
}
识别循环结构并分析指针运算
接下来观察两条跳转指令:
c
0x400f2c: jne 0x400f17
0x400f3a: jmp 0x400f17
它们都跳转到 0x400f170x400f170x400f17。其中,0x400f3a0x400f3a0x400f3a 是循环初始化结束后首次进入循环体的跳转,而 0x400f2c0x400f2c0x400f2c 则根据条件决定是否重复执行循环体。
因此,我们可以将代码划分为初始化、循环体和循环条件判断三个部分。
首先是循环初始化:
c
0x400f30: lea 0x4(%rsp),%rbx // rbx = rsp + 4
0x400f35: lea 0x18(%rsp),%rbp // rbp = rsp + 24
0x400f3a: jmp 0x400f17 // 进入循环
这里需要特别注意 lealealea 指令。它只计算有效地址,并不会读取该地址中的内容。
因为 rsprsprsp 指向数组首地址,所以 rsp+4rsp+4rsp+4 是第二个整数的地址,而 rsp+24rsp+24rsp+24 是最后一个整数之后的位置。由此可知,rbxrbxrbx 保存当前元素指针,rbprbprbp 保存数组结束位置。
对应的 C 语言代码为:
c
int *p = &numbers[1];
int *end = numbers + 6;
为什么当前指针从第二个元素开始?因为程序需要比较当前元素和前一个元素。如果 ppp 指向 numbers1numbers1numbers1,那么 p−1p-1p−1 就指向 numbers0numbers0numbers0,能够直接比较相邻元素。
接着分析循环体:
c
0x400f17: mov -0x4(%rbx),%eax // eax = *(int *)(rbx - 4)
0x400f1a: add %eax,%eax // eax = eax * 2
0x400f1c: cmp %eax,(%rbx) // 比较 *rbx 与 eax
0x400f1e: je 0x400f25 // 相等则继续
0x400f20: call 0x40143a // 不相等则爆炸
第一条指令使用 −0x4(%rbx)-0x4(\%rbx)−0x4(%rbx) 访问当前指针前面 4 字节的内存。由于一个 int 占 4 字节,这正好是前一个数组元素。随后,add %eax,%eaxadd\ \%eax,\%eaxadd %eax,%eax 将寄存器中的整数翻倍。
最后,cmp %eax,(%rbx)cmp\ \%eax,(\%rbx)cmp %eax,(%rbx) 将当前元素与前一个元素的两倍进行比较。如果相等,jejeje 跳过爆炸函数;否则执行 explode_bomb()explode\_bomb()explode_bomb()。
所以整个循环体对应:
c
if (*p != 2 * *(p - 1))
{
explode_bomb();
}
也可以用数组下标表示为:
c
if (numbers[i] != 2 * numbers[i - 1])
{
explode_bomb();
}
最后分析循环更新与退出条件:
c
0x400f25: add $0x4,%rbx // rbx += 4
0x400f29: cmp %rbp,%rbx // 当前指针是否到达 end
0x400f2c: jne 0x400f17 // 未到达则继续
0x400f2e: jmp 0x400f3c // 退出循环
由于 rbxrbxrbx 保存的是 int 类型元素的地址,因此每次增加 4 字节就相当于 C 语言中的指针自增:
c
p++;
随后程序检查 ppp 是否等于 endendend。如果尚未到达数组末尾,就继续循环;否则结束。
因此,整个循环可还原为:
c
int *p = &numbers[1];
int *end = numbers + 6;
while (p != end)
{
if (*p != 2 * *(p - 1))
{
explode_bomb();
}
p++;
}
如果改写成更加常见的数组下标形式:
c
for (int i = 1; i < 6; i++)
{
if (numbers[i] != 2 * numbers[i - 1])
{
explode_bomb();
}
}
这就是汇编中的指针遍历与 C 语言数组循环之间的对应关系。编译器不一定使用一个整数变量作为下标,也可以直接让指针沿着数组移动,从而实现同样的循环逻辑。
对应C语言代码
将上面分析过的功能组合起来,Phase 2 的完整 C 语言实现为:
c
void phase_2(const char *input)
{
// 读取六个整数
int numbers[6];
read_six_numbers(input, numbers);
// 检查第一个整数
if (numbers[0] != 1)
{
explode_bomb();
}
// 检查相邻元素之间的关系
for (int i = 1; i < 6; i++)
{
if (numbers[i] != 2 * numbers[i - 1])
{
explode_bomb();
}
}
}
根据这段代码,第一个整数必须为 1,其余整数必须满足:
ai=2ai−1,i=1,2,...,5\]\[ a_i=2a_{i-1},\\qquad i=1,2,\\ldots,5 \]\[ai=2ai−1,i=1,2,...,5
因此,输入的是一个首项为 1、公比为 2 的等比数列:
ai=2i,i=0,1,...,5\]\[ a_i=2\^i,\\qquad i=0,1,\\ldots,5 \]\[ai=2i,i=0,1,...,5
最终答案:
c
1 2 4 8 16 32
本阶段最重要的知识点是:C 语言中的数组下标循环,在汇编层面不一定需要显式的数组下标。编译器可以通过指针初始化、内存偏移、指针递增和结束地址比较来实现循环。
此外,汇编指令并不一定按照 C 语言的书写顺序排列。例如本阶段的循环初始化位于循环体之后,需要通过 jmpjmpjmp 的目标地址恢复程序的真实执行顺序。
因此,分析汇编时应优先识别控制流,再确定寄存器和内存操作的含义,最后还原成完整的 C 语言逻辑。
四. Phase 3:switch-case 与跳转表
Phase 3 要求输入两个整数,程序首先利用 sscanf()sscanf()sscanf() 解析输入,然后根据第一个整数选择不同的分支,最后检查第二个整数是否等于该分支对应的固定值。这一关的核心是理解 switch-case 的汇编实现,以及编译器如何使用跳转表(Jump Table)优化多分支判断。
首先观察完整的反汇编代码:
c
0x400f43: sub $0x18,%rsp
0x400f47: lea 0xc(%rsp),%rcx
0x400f4c: lea 0x8(%rsp),%rdx
0x400f51: mov $0x4025cf,%esi
0x400f56: mov $0x0,%eax
0x400f5b: call 0x400bf0 <__isoc99_sscanf@plt>
0x400f60: cmp $0x1,%eax
0x400f63: jg 0x400f6a
0x400f65: call 0x40143a <explode_bomb>
0x400f6a: cmpl $0x7,0x8(%rsp)
0x400f6f: ja 0x400fad
0x400f71: mov 0x8(%rsp),%eax
0x400f75: jmp *0x402470(,%rax,8)
0x400f7c: mov $0xcf,%eax
0x400f81: jmp 0x400fbe
0x400f83: mov $0x2c3,%eax
0x400f88: jmp 0x400fbe
0x400f8a: mov $0x100,%eax
0x400f8f: jmp 0x400fbe
0x400f91: mov $0x185,%eax
0x400f96: jmp 0x400fbe
0x400f98: mov $0xce,%eax
0x400f9d: jmp 0x400fbe
0x400f9f: mov $0x2aa,%eax
0x400fa4: jmp 0x400fbe
0x400fa6: mov $0x147,%eax
0x400fab: jmp 0x400fbe
0x400fad: call 0x40143a <explode_bomb>
0x400fb2: mov $0x0,%eax
0x400fb7: jmp 0x400fbe
0x400fb9: mov $0x137,%eax
0x400fbe: cmp 0xc(%rsp),%eax
0x400fc2: je 0x400fc9
0x400fc4: call 0x40143a <explode_bomb>
0x400fc9: add $0x18,%rsp
0x400fcd: ret
输入读取与范围检查
首先分析函数的入口:
c
sub $0x18,%rsp // 分配 24 字节栈空间
lea 0xc(%rsp),%rcx // rcx = &b
lea 0x8(%rsp),%rdx // rdx = &a
mov $0x4025cf,%esi // rsi = 格式字符串地址
mov $0x0,%eax // 为可变参数函数调用设置 al = 0
call 0x400bf0 // 调用 sscanf()
根据 Linux x86-64 的函数调用约定,前四个整数或指针参数分别使用 rdirdirdi、rsirsirsi、rdxrdxrdx、rcxrcxrcx 传递。
其中,rdirdirdi 保存用户输入字符串的地址;rsirsirsi 保存格式字符串地址;rdxrdxrdx 和 rcxrcxrcx 分别保存两个整数变量的地址。
因此,这一部分对应的 C 语言逻辑为:
c
int a, b;
int count = sscanf(input, "%d %d", &a, &b);
格式字符串的实际内容可以通过 GDB 命令 x/s 0x4025cf 检查。
这里还要注意,调用前执行的 mov $0x0,%eax 并不是设置 sscanf()sscanf()sscanf() 的返回值,而是因为 sscanf()sscanf()sscanf() 属于可变参数函数。System V AMD64 ABI 使用 alalal 传递向量寄存器参数数量的相关信息,此处设置为 0。
随后程序执行:
c
cmp $0x1,%eax // 比较成功解析的项目数量与 1
jg 0x400f6a // 返回值 > 1,继续执行
call 0x40143a // 否则爆炸
由于 sscanf()sscanf()sscanf() 的返回值保存在 eaxeaxeax 中,所以这里要求成功读取至少两个项目。对应 C 语言:
c
if (count <= 1)
{
explode_bomb();
}
之后继续检查第一个整数:
c
cmpl $0x7,0x8(%rsp) // 将第一个整数与 7 比较
ja 0x400fad // 无符号大于 7,则进入爆炸分支
这里使用的是无符号比较指令 jajaja,而不是有符号比较指令 jgjgjg。如果输入为负数,其补码按无符号整数解释时会成为很大的正数,因此同样无法通过检查。
最终可确定:
0≤a≤7 0 \le a \le 7 0≤a≤7
对应的 C 语言代码为:
c
if ((unsigned int)a > 7)
{
explode_bomb();
}
跳转表与 switch-case
接下来是整个 Phase 3 最重要的两条指令:
c
mov 0x8(%rsp),%eax // eax = a
jmp *0x402470(,%rax,8) // 根据 a 从跳转表中读取目标地址
这里使用了间接跳转,而不是普通的条件跳转。
跳转目标的寻址表达式为:
Address=0x402470+rax×8 Address = 0x402470 + rax \times 8 Address=0x402470+rax×8
其中,0x4024700x4024700x402470 是跳转表的起始地址,raxraxrax 保存第一个整数 aaa。由于每个跳转目标地址占 8 字节,所以比例因子是 8。
程序通过输入整数计算对应表项地址,读取其中存储的指令地址,再跳转到该位置执行。
例如,当 a=2a=2a=2 时,程序访问:
0x402470+2×8=0x402480 0x402470+2\times8=0x402480 0x402470+2×8=0x402480
然后将 0x4024800x4024800x402480 中存储的地址作为实际跳转目标。
因此,汇编中的间接跳转可以理解为以下结构:
c
// GCC 的标签地址扩展,仅用于说明间接跳转逻辑
goto *jump_table[a];
这就是编译器对 switch-case 的一种常见优化方式。相比依次执行多个 if-else 判断,跳转表可以直接利用输入整数定位目标代码块。
不过,跳转表中 case 编号与汇编代码地址的对应关系,不能仅凭代码出现的先后顺序确定。
例如:
c
0x400f7c: mov $0xcf,%eax // expected = 207
0x400f83: mov $0x2c3,%eax // expected = 707
0x400f8a: mov $0x100,%eax // expected = 256
虽然这些指令连续排列,但不代表它们一定对应 case 0、case 1、case 2。具体的分支顺序由跳转表中的地址决定。
我们可以通过 GDB 查看跳转表:
c
x/8gx 0x402470
得到八个目标地址后,就能够准确确定 case 0~7 分别对应哪一段汇编代码。
继续观察各个分支:
c
0x400f7c: mov $0xcf,%eax // expected = 207
0x400f81: jmp 0x400fbe // 跳到统一比较位置
0x400f83: mov $0x2c3,%eax // expected = 707
0x400f88: jmp 0x400fbe
0x400f8a: mov $0x100,%eax // expected = 256
0x400f8f: jmp 0x400fbe
0x400f91: mov $0x185,%eax // expected = 389
0x400f96: jmp 0x400fbe
0x400f98: mov $0xce,%eax // expected = 206
0x400f9d: jmp 0x400fbe
0x400f9f: mov $0x2aa,%eax // expected = 682
0x400fa4: jmp 0x400fbe
0x400fa6: mov $0x147,%eax // expected = 327
0x400fab: jmp 0x400fbe
0x400fb9: mov $0x137,%eax // expected = 311
这些分支的共同特点是:将不同常量保存到 eaxeaxeax 中,然后进入相同的后续比较逻辑。
也就是说,switch-case 的每个分支并不一定需要执行复杂操作,它们也可能只负责产生一个不同的结果。
最终比较与 C 语言还原
观察所有分支的公共出口:
c
0x400fbe: cmp 0xc(%rsp),%eax // 比较 expected 与 b
0x400fc2: je 0x400fc9 // 相等则跳过爆炸
0x400fc4: call 0x40143a // 不相等则爆炸
0x400fc9: add $0x18,%rsp // 释放栈空间
0x400fcd: ret // 返回
其中,eaxeaxeax 保存 switch 分支生成的目标整数,而 rsp+0xcrsp+0xcrsp+0xc 保存第二个输入整数 bbb。
因此,程序最终判断:
b=expected b = expected b=expected
否则调用 explode_bomb()explode\_bomb()explode_bomb()。
对应的 C 语言实现为:
c
if (b != expected)
{
explode_bomb();
}
结合前面的输入检查、跳转表选择和最终比较,可以得到 Phase 3 的基本结构:
c
void phase_3(const char *input)
{
int a, b;
// 读取两个整数
if (sscanf(input, "%d %d", &a, &b) <= 1)
{
explode_bomb();
}
// 检查第一个整数的范围
if ((unsigned int)a > 7)
{
explode_bomb();
}
// 根据第一个整数选择目标值
int expected = 0;
switch (a)
{
case 2:
expected = 707;
break;
// 其他 case 需根据跳转表补充
//...
default:
explode_bomb();
return;
}
// 比较第二个整数
if (b != expected)
{
explode_bomb();
}
}
这里仅完整写出了已通过实际测试验证的 case 2,其他七个分支需要读取跳转表后才能可靠地补齐。
对应C语言代码
完整注释版汇编:
c
0x400f43: sub $0x18,%rsp // 分配 24 字节栈空间
0x400f47: lea 0xc(%rsp),%rcx // rcx = &b
0x400f4c: lea 0x8(%rsp),%rdx // rdx = &a
0x400f51: mov $0x4025cf,%esi // rsi = 格式字符串地址
0x400f56: mov $0x0,%eax // al = 0,准备调用可变参数函数
0x400f5b: call 0x400bf0 // sscanf(input, format, &a, &b)
0x400f60: cmp $0x1,%eax // 检查成功解析的项目数量
0x400f63: jg 0x400f6a // 大于 1,则继续
0x400f65: call 0x40143a // 否则爆炸
0x400f6a: cmpl $0x7,0x8(%rsp) // 检查 a 是否在 0~7 范围内
0x400f6f: ja 0x400fad // 无符号 a > 7,跳到爆炸分支
0x400f71: mov 0x8(%rsp),%eax // eax = a
0x400f75: jmp *0x402470(,%rax,8) // 根据跳转表选择对应代码
0x400f7c: mov $0xcf,%eax // expected = 207
0x400f81: jmp 0x400fbe // 跳转到统一比较
0x400f83: mov $0x2c3,%eax // expected = 707
0x400f88: jmp 0x400fbe
0x400f8a: mov $0x100,%eax // expected = 256
0x400f8f: jmp 0x400fbe
0x400f91: mov $0x185,%eax // expected = 389
0x400f96: jmp 0x400fbe
0x400f98: mov $0xce,%eax // expected = 206
0x400f9d: jmp 0x400fbe
0x400f9f: mov $0x2aa,%eax // expected = 682
0x400fa4: jmp 0x400fbe
0x400fa6: mov $0x147,%eax // expected = 327
0x400fab: jmp 0x400fbe
0x400fad: call 0x40143a // 输入越界,触发爆炸
0x400fb2: mov $0x0,%eax // 后续默认值
0x400fb7: jmp 0x400fbe // 跳转到统一比较
0x400fb9: mov $0x137,%eax // expected = 311
0x400fbe: cmp 0xc(%rsp),%eax // 比较 expected 与 b
0x400fc2: je 0x400fc9 // 相等则通过
0x400fc4: call 0x40143a // 不相等则爆炸
0x400fc9: add $0x18,%rsp // 释放栈空间
0x400fcd: ret // 返回调用者
完整 C 语言逻辑:
c
#include <stdio.h>
void phase_3(const char *input)
{
int a, b;
// 1. 读取两个整数
int count = sscanf(input, "%d %d", &a, &b);
if (count <= 1)
{
explode_bomb();
}
// 2. 检查第一个整数的范围
if ((unsigned int)a > 7)
{
explode_bomb();
}
// 3. 根据第一个整数选择对应的目标值
int expected = 0;
switch (a)
{
case 2:
expected = 707;
break;
// 其余 case 等待读取跳转表后补充
default:
explode_bomb();
return;
}
// 4. 检查第二个整数
if (b != expected)
{
explode_bomb();
}
}
已验证的一组正确输入为:
c
2 707
本阶段最重要的知识点是:编译器可能将 switch-case 编译为一张保存代码地址的跳转表,利用输入整数进行间接跳转。分析时必须通过跳转表确定 case 与代码块的对应关系,而不能仅依靠汇编指令的排列顺序。
五. Phase 4:递归与二分查找
Phase 4 要求输入两个整数,程序首先检查输入数量和取值范围,随后调用递归函数 func4()func4()func4(),利用二分查找缩小搜索区间,并根据递归路径计算返回值。最后要求递归返回值和第二个输入整数均为 0。
本阶段的重点是通过汇编中的函数调用、条件跳转及返回值计算,还原递归函数的完整执行过程。
首先观察两个函数的原始汇编代码。
phase_4 汇编:
c
0x40100c: sub $0x18,%rsp
0x401010: lea 0xc(%rsp),%rcx
0x401015: lea 0x8(%rsp),%rdx
0x40101a: mov $0x4025cf,%esi
0x40101f: mov $0x0,%eax
0x401024: call 0x400bf0 <__isoc99_sscanf@plt>
0x401029: cmp $0x2,%eax
0x40102c: jne 0x401035
0x40102e: cmpl $0xe,0x8(%rsp)
0x401033: jbe 0x40103a
0x401035: call 0x40143a <explode_bomb>
0x40103a: mov $0xe,%edx
0x40103f: mov $0x0,%esi
0x401044: mov 0x8(%rsp),%edi
0x401048: call 0x400fce <func4>
0x40104d: test %eax,%eax
0x40104f: jne 0x401058
0x401051: cmpl $0x0,0xc(%rsp)
0x401056: je 0x40105d
0x401058: call 0x40143a <explode_bomb>
0x40105d: add $0x18,%rsp
0x401061: ret
func4 汇编:
c
0x400fce: sub $0x8,%rsp
0x400fd2: mov %edx,%eax
0x400fd4: sub %esi,%eax
0x400fd6: mov %eax,%ecx
0x400fd8: shr $0x1f,%ecx
0x400fdb: add %ecx,%eax
0x400fdd: sar %eax
0x400fdf: lea (%rax,%rsi,1),%ecx
0x400fe2: cmp %edi,%ecx
0x400fe4: jle 0x400ff2
0x400fe6: lea -0x1(%rcx),%edx
0x400fe9: call 0x400fce <func4>
0x400fee: add %eax,%eax
0x400ff0: jmp 0x401007
0x400ff2: mov $0x0,%eax
0x400ff7: cmp %edi,%ecx
0x400ff9: jge 0x401007
0x400ffb: lea 0x1(%rcx),%esi
0x400ffe: call 0x400fce <func4>
0x401003: lea 0x1(%rax,%rax,1),%eax
0x401007: add $0x8,%rsp
0x40100b: ret
输入检查与函数调用
首先分析 phase_4 的入口:
c
sub $0x18,%rsp // 分配 24 字节栈空间
lea 0xc(%rsp),%rcx // rcx = 第二个整数的地址
lea 0x8(%rsp),%rdx // rdx = 第一个整数的地址
mov $0x4025cf,%esi // rsi = 格式字符串地址
mov $0x0,%eax // al = 0,准备调用可变参数函数
call 0x400bf0 // 调用 sscanf()
根据 System V AMD64 调用约定,前四个整数或指针参数分别由 rdirdirdi、rsirsirsi、rdxrdxrdx 和 rcxrcxrcx 传递。因此,当前代码相当于:
c
int a, b;
int count = sscanf(input, "%d %d", &a, &b);
随后:
c
cmp $0x2,%eax // 判断是否成功解析两个项目
jne 0x401035 // 不等于 2,则爆炸
cmpl $0xe,0x8(%rsp) // 比较 a 与 14
jbe 0x40103a // 无符号 a <= 14,则通过
call 0x40143a // 否则爆炸
由于 sscanf()sscanf()sscanf() 的返回值保存在 eaxeaxeax 中,所以程序首先要求成功解析两个整数。接着使用无符号比较指令 jbejbejbe 检查第一个整数的范围。负数按照无符号整数比较时会被视为很大的数,因此同样不能通过。
所以第一项输入约束为:
0≤a≤14 0\le a\le14 0≤a≤14
对应的 C 语言代码:
c
if (count != 2)
{
explode_bomb();
}
if ((unsigned int)a > 14)
{
explode_bomb();
}
继续观察:
c
mov $0xe,%edx // high = 14
mov $0x0,%esi // low = 0
mov 0x8(%rsp),%edi // target = a
call 0x400fce // 调用 func4(a, 0, 14)
根据前三个参数寄存器的位置,可以确定递归函数的调用形式:
c
int result = func4(a, 0, 14);
其中 aaa 是搜索目标,0 是初始区间下界,14 是初始区间上界。
返回后:
c
test %eax,%eax // 检查 func4 返回值是否为 0
jne 0x401058 // 不为 0,则爆炸
cmpl $0x0,0xc(%rsp) // 检查第二个整数 b
je 0x40105d // b == 0,则通过
call 0x40143a // 否则爆炸
程序要求:
func4(a,0,14)=0,b=0 func4(a,0,14)=0,\qquad b=0 func4(a,0,14)=0,b=0
这里我们只能确定递归函数的参数和主函数对返回值的要求。至于 func4()func4()func4() 为什么能够返回 0,以及哪些输入能使其返回 0,必须继续分析递归函数内部。
func4 的递归结构
首先根据汇编入口确定寄存器角色:
| 寄存器 | C 语言中的含义 |
|---|---|
| ediediedi | 搜索目标 targettargettarget |
| esiesiesi | 当前区间下界 lowlowlow |
| edxedxedx | 当前区间上界 highhighhigh |
| ecxecxecx | 当前区间中点 midmidmid |
| eaxeaxeax | 临时计算结果及函数返回值 |
先分析中点计算:
c
mov %edx,%eax // eax = high
sub %esi,%eax // eax = high - low
mov %eax,%ecx // ecx = high - low
shr $0x1f,%ecx // ecx = 差值的符号位
add %ecx,%eax // eax = 差值 + 符号修正
sar %eax // eax = 差值的一半
lea (%rax,%rsi,1),%ecx // ecx = low + eax
第一、二条指令得到区间长度:
d=high−low d=high-low d=high−low
接下来的 shr 31shr\ 31shr 31 将差值的最高位移动到最低位,因此当 ddd 为负数时得到 1,非负时得到 0。
之后使用 sarsarsar 执行算术右移。由于算术右移对负数相当于向负无穷方向取整,而 C 语言中的有符号整数除法向零截断,所以编译器先加入符号修正量,使结果符合除以 2 的语义
这组指令在常规输入范围内对应:
c
int mid = low + (high - low) / 2;
例如,初始区间为 0,140,140,14:
mid=0+14−02=7 mid=0+\frac{14-0}{2}=7 mid=0+214−0=7
因此,第一次递归判断的中点为 7。
接下来是整个递归函数的关键部分:
c
cmp %edi,%ecx // 比较 mid 与 target
jle 0x400ff2 // mid <= target,则跳到右侧判断
lea -0x1(%rcx),%edx // high = mid - 1
call 0x400fce // 递归搜索左半区间
add %eax,%eax // result = 2 * result
jmp 0x401007 // 跳转到函数返回
这里特别需要注意 AT&T 语法的比较顺序:
c
cmp %edi,%ecx
比较的是 mid−targetmid-targetmid−target。因此,jlejlejle 成立意味着 mid≤targetmid\le targetmid≤target。
如果该条件不成立,就说明 mid>targetmid>targetmid>target,也就是目标位于左半区间。程序将 highhighhigh 更新为 mid−1mid-1mid−1,再递归调用自身。
递归调用返回后:
c
add %eax,%eax
将结果乘以 2。因此左分支对应:
c
if (target < mid)
{
return 2 * func4(target, low, mid - 1);
}
如果 mid≤targetmid\le targetmid≤target,程序跳转到:
c
mov $0x0,%eax // 先准备返回值 0
cmp %edi,%ecx // 再次比较 mid 与 target
jge 0x401007 // mid >= target,则直接返回
注意,前一个条件已经确定 mid≤targetmid\le targetmid≤target,这里又要求 mid≥targetmid\ge targetmid≥target,两个条件同时成立只能说明:
mid=target mid=target mid=target
因此,程序会直接返回 0:
c
if (target == mid)
{
return 0;
}
如果 mid<targetmid<targetmid<target,则继续执行:
c
lea 0x1(%rcx),%esi // low = mid + 1
call 0x400fce // 递归搜索右半区间
lea 0x1(%rax,%rax,1),%eax // result = 2 * result + 1
这里的 lealealea 不访问内存,而是执行地址形式的算术计算:
eax=1+rax+rax=2rax+1 eax=1+rax+rax=2rax+1 eax=1+rax+rax=2rax+1
因此右分支对应:
c
if (target > mid)
{
return 2 * func4(target, mid + 1, high) + 1;
}
结合三个分支,整个 func4()func4()func4() 可以还原为:
c
int func4(int target, int low, int high)
{
int mid = low + (high - low) / 2;
if (target < mid)
{
return 2 * func4(target, low, mid - 1);
}
else if (target > mid)
{
return 2 * func4(target, mid + 1, high) + 1;
}
else
{
return 0;
}
}
这与普通二分查找最重要的区别是:普通二分查找通常返回目标是否存在或目标位置,而 func4()func4()func4() 会对递归路径进行编码。
左分支将结果乘以 2,右分支将结果乘以 2 后加 1,最终结果由搜索路径决定。
递归返回值与答案推导
主函数要求:
func4(a,0,14)=0 func4(a,0,14)=0 func4(a,0,14)=0
首先考虑 a=7a=7a=7,初始中点为:
mid=0+142=7 mid=\frac{0+14}{2}=7 mid=20+14=7
由于 target=midtarget=midtarget=mid,函数立即返回 0,因此 a=7a=7a=7 满足要求。
不过,递归返回值等于 0,并不意味着只能输入 7。
考虑 a=3a=3a=3:
c
func4(3, 0, 14)
第一次中点为 7,由于 3<73<73<7,递归搜索左半区间:
c
return 2 * func4(3, 0, 6);
第二次中点为 3,直接返回 0:
c
func4(3, 0, 6) = 0
因此:
func4(3,0,14)=2×0=0 func4(3,0,14)=2\times0=0 func4(3,0,14)=2×0=0
同样,a=1a=1a=1 和 a=0a=0a=0 也可以通过连续的左分支得到返回值 0。
所以,当前递归逻辑中能够使返回值等于 0 的第一个整数包括:
a∈{0,1,3,7} a\in\{0,1,3,7\} a∈{0,1,3,7}
而第二个整数必须为 0。因此可以得到多组有效输入:
c
0 0
1 0
3 0
7 0
这也说明,逆向分析不能只找到一组能够通过的输入就直接认定它是唯一答案,而应该进一步检查程序条件是否允许其他结果。
对应C语言代码
phase_4 完整注释版汇编:
c
0x40100c: sub $0x18,%rsp // 分配 24 字节栈空间
0x401010: lea 0xc(%rsp),%rcx // rcx = &b
0x401015: lea 0x8(%rsp),%rdx // rdx = &a
0x40101a: mov $0x4025cf,%esi // rsi = 格式字符串地址
0x40101f: mov $0x0,%eax // al = 0,准备调用 sscanf()
0x401024: call 0x400bf0 // count = sscanf(input, format, &a, &b)
0x401029: cmp $0x2,%eax // 检查成功解析的项目数量
0x40102c: jne 0x401035 // count != 2,则爆炸
0x40102e: cmpl $0xe,0x8(%rsp) // 比较 a 与 14
0x401033: jbe 0x40103a // 无符号 a <= 14,则继续
0x401035: call 0x40143a // 输入不合法,爆炸
0x40103a: mov $0xe,%edx // 第三个参数 high = 14
0x40103f: mov $0x0,%esi // 第二个参数 low = 0
0x401044: mov 0x8(%rsp),%edi // 第一个参数 target = a
0x401048: call 0x400fce // result = func4(a, 0, 14)
0x40104d: test %eax,%eax // 检查返回值是否为 0
0x40104f: jne 0x401058 // result != 0,则爆炸
0x401051: cmpl $0x0,0xc(%rsp) // 检查第二个整数 b
0x401056: je 0x40105d // b == 0,则通过
0x401058: call 0x40143a // 否则爆炸
0x40105d: add $0x18,%rsp // 释放栈空间
0x401061: ret // 返回调用者
func4 完整注释版汇编:
c
0x400fce: sub $0x8,%rsp // 调整栈空间,满足递归调用的栈对齐要求
// 计算区间中点 mid
0x400fd2: mov %edx,%eax // eax = high
0x400fd4: sub %esi,%eax // eax = high - low
0x400fd6: mov %eax,%ecx // ecx = high - low
0x400fd8: shr $0x1f,%ecx // ecx = 差值的符号位
0x400fdb: add %ecx,%eax // 对负数除法进行修正
0x400fdd: sar %eax // eax = 修正后的差值 / 2
0x400fdf: lea (%rax,%rsi,1),%ecx // mid = low + eax
// 判断是否进入左半区间
0x400fe2: cmp %edi,%ecx // 比较 mid 与 target
0x400fe4: jle 0x400ff2 // mid <= target,跳到后续判断
0x400fe6: lea -0x1(%rcx),%edx // high = mid - 1
0x400fe9: call 0x400fce // func4(target, low, mid - 1)
0x400fee: add %eax,%eax // 左分支:result *= 2
0x400ff0: jmp 0x401007 // 跳转到统一返回位置
// 判断是否找到目标
0x400ff2: mov $0x0,%eax // 默认返回值为 0
0x400ff7: cmp %edi,%ecx // 再次比较 mid 与 target
0x400ff9: jge 0x401007 // mid >= target,说明相等,返回 0
// 进入右半区间
0x400ffb: lea 0x1(%rcx),%esi // low = mid + 1
0x400ffe: call 0x400fce // func4(target, mid + 1, high)
0x401003: lea 0x1(%rax,%rax,1),%eax // 右分支:result = 2 * result + 1
// 递归函数返回
0x401007: add $0x8,%rsp // 恢复栈空间
0x40100b: ret // 返回上一级调用者
完整 C 语言代码:
c
#include <stdio.h>
// 递归二分查找,并对搜索路径进行编码
int func4(int target, int low, int high)
{
int mid = low + (high - low) / 2;
// 目标位于左半区间
if (target < mid)
{
return 2 * func4(target, low, mid - 1);
}
// 目标位于右半区间
else if (target > mid)
{
return 2 * func4(target, mid + 1, high) + 1;
}
// 找到目标,返回 0
else
{
return 0;
}
}
void phase_4(const char *input)
{
int a, b;
// 1. 读取两个整数
int count = sscanf(input, "%d %d", &a, &b);
// 2. 检查输入数量
if (count != 2)
{
explode_bomb();
}
// 3. 检查第一个整数的取值范围
if ((unsigned int)a > 14)
{
explode_bomb();
}
// 4. 调用递归函数
int result = func4(a, 0, 14);
// 5. 检查递归返回值
if (result != 0)
{
explode_bomb();
}
// 6. 检查第二个整数
if (b != 0)
{
explode_bomb();
}
}
本阶段的关键在于:通过函数调用约定确定递归函数的参数,再根据条件跳转区分左分支、右分支和终止条件,最后分析每个分支如何修改递归返回值。
此外,shrshrshr、sarsarsar 与 lealealea 的组合展示了编译器如何利用移位和地址计算完成整数运算。虽然汇编指令较多,但还原后的核心算法就是一个根据左右递归路径计算返回值的二分查找函数。
六. Phase 5:字符映射与查找表
Phase 5 要求输入一个长度为 6 的字符串,程序将每个字符的 ASCII 编码低 4 位作为索引,从预先定义的字符表中取出对应字符,生成新的字符串,最后与目标字符串进行比较。
本阶段的核心是通过位运算和数组寻址实现字符映射,并理解循环控制和栈保护机制。
首先观察完整反汇编代码:
c
0x401062: push %rbx
0x401063: sub $0x20,%rsp
0x401067: mov %rdi,%rbx
0x40106a: mov %fs:0x28,%rax
0x401073: mov %rax,0x18(%rsp)
0x401078: xor %eax,%eax
0x40107a: call 0x40131b <string_length>
0x40107f: cmp $0x6,%eax
0x401082: je 0x4010d2
0x401084: call 0x40143a <explode_bomb>
0x401089: jmp 0x4010d2
0x40108b: movzbl (%rbx,%rax,1),%ecx
0x40108f: mov %cl,(%rsp)
0x401092: mov (%rsp),%rdx
0x401096: and $0xf,%edx
0x401099: movzbl 0x4024b0(%rdx),%edx
0x4010a0: mov %dl,0x10(%rsp,%rax,1)
0x4010a4: add $0x1,%rax
0x4010a8: cmp $0x6,%rax
0x4010ac: jne 0x40108b
0x4010ae: movb $0x0,0x16(%rsp)
0x4010b3: mov $0x40245e,%esi
0x4010b8: lea 0x10(%rsp),%rdi
0x4010bd: call 0x401338 <strings_not_equal>
0x4010c2: test %eax,%eax
0x4010c4: je 0x4010d9
0x4010c6: call 0x40143a <explode_bomb>
0x4010cb: nopl 0x0(%rax,%rax,1)
0x4010d0: jmp 0x4010d9
0x4010d2: mov $0x0,%eax
0x4010d7: jmp 0x40108b
0x4010d9: mov 0x18(%rsp),%rax
0x4010de: xor %fs:0x28,%rax
0x4010e7: je 0x4010ee
0x4010e9: call 0x400b30 <__stack_chk_fail@plt>
0x4010ee: add $0x20,%rsp
0x4010f2: pop %rbx
0x4010f3: ret
输入长度与循环初始化
首先观察函数入口:
c
push %rbx // 保存 rbx
sub $0x20,%rsp // 分配 32 字节栈空间
mov %rdi,%rbx // rbx = input
mov %fs:0x28,%rax // 读取栈保护值
mov %rax,0x18(%rsp) // 保存到当前栈帧
xor %eax,%eax // eax = 0
call 0x40131b // string_length(input)
这里将 rdirdirdi 中的输入字符串地址保存到 rbxrbxrbx,因为后续需要重复访问字符串,而 rbxrbxrbx 属于被调用者保存寄存器,可以在函数调用之后继续保留该地址。
其中:
c
mov %fs:0x28,%rax
mov %rax,0x18(%rsp)
属于编译器生成的栈保护机制 。程序通过 fsfsfs 段寄存器访问当前线程保存的栈保护值,并将其复制到当前函数的栈帧中。函数返回前还会再次检查,以判断栈空间是否遭到破坏。这部分并不属于字符映射算法本身。
接下来:
c
cmp $0x6,%eax // 比较字符串长度与 6
je 0x4010d2 // 长度为 6,则进入循环初始化
call 0x40143a // 否则爆炸
jmp 0x4010d2
由于 string_length()string\_length()string_length() 的返回值存储在 eaxeaxeax 中,因此要求输入字符串长度恰好为 6,对应:
c
if (string_length(input) != 6)
{
explode_bomb();
}
检查通过后,程序跳转到:
c
mov $0x0,%eax // i = 0
jmp 0x40108b // 进入循环体
可以看出,eaxeaxeax 此时不再保存字符串长度,而是被重新用作循环下标。这说明同一个寄存器在不同功能块中可能承担不同角色,需要根据具体指令重新判断。
字符映射与循环结构
这一关的核心汇编位于 0x40108b0x40108b0x40108b~0x4010ac0x4010ac0x4010ac:
c
movzbl (%rbx,%rax,1),%ecx // ecx = (unsigned char)input[i]
mov %cl,(%rsp) // 将当前字符写入栈内存
mov (%rsp),%rdx // 读取栈中的数据到 rdx
and $0xf,%edx // index = 当前字符编码 & 0xF
movzbl 0x4024b0(%rdx),%edx // 从字符查找表中读取一个字符
mov %dl,0x10(%rsp,%rax,1) // output[i] = table[index]
add $0x1,%rax // i++
cmp $0x6,%rax // 检查是否处理完六个字符
jne 0x40108b // 没有处理完则继续循环
首先:
c
movzbl (%rbx,%rax,1),%ecx
这里的有效地址为:
rbx+rax×1 rbx+rax\times1 rbx+rax×1
由于 rbxrbxrbx 是字符串首地址,而 raxraxrax 是循环下标,因此这条指令读取的是 inputiinputiinputi。
movzblmovzblmovzbl 表示将 8 位数据读取出来,再零扩展为 32 位。因此可以理解为:
c
unsigned char ch = (unsigned char)input[i];
接下来:
c
mov %cl,(%rsp)
mov (%rsp),%rdx
and $0xf,%edx
程序先将字符写入栈内存,再通过 64 位加载读取。虽然编译器生成的指令较为繁琐,但后续的按位与操作只保留低 4 位,因此对于字符映射的有效结果,可以简化为:
c
int index = ch & 0xF;
其中:
0xF=000011112 0xF=00001111_2 0xF=000011112
因此,任何字符经过按位与后,结果都位于 0~15 之间。
例如字符 i 的 ASCII 编码为 105:
105=0x69 105=0x69 105=0x69
所以:
0x69 & 0x0F=0x09 0x69\ \&\ 0x0F=0x09 0x69 & 0x0F=0x09
得到索引 9。
接下来:
c
movzbl 0x4024b0(%rdx),%edx
表示从地址 0x4024b0+index0x4024b0+index0x4024b0+index 中读取一个字符,并零扩展到 edxedxedx。
因此可以推断,地址 0x4024b00x4024b00x4024b0 保存一张字符查找表,其前 16 个字符会被程序用于映射。
对应:
c
char mapped = table[index];
最后:
c
mov %dl,0x10(%rsp,%rax,1)
将查表得到的字符写入栈上的输出数组。由于输出数组从 rsp+0x10rsp+0x10rsp+0x10 开始,因此:
c
output[i] = mapped;
循环末尾通过 raxraxrax 加 1,以及 cmpcmpcmp、jnejnejne 判断是否继续执行。整个循环可以还原为:
c
char output[7];
for (int i = 0; i < 6; i++)
{
unsigned char ch = (unsigned char)input[i];
int index = ch & 0xF;
output[i] = table[index];
}
注意,这里的 tabletabletable 不是普通的数字数组,而是一个保存字符的查找表。不同字符只要低 4 位相同,就会映射到相同的输出字符。因此,这一关可能存在多组不同的有效输入。
字符串比较与栈保护
六个字符处理完毕后:
c
movb $0x0,0x16(%rsp) // output[6] = '\0'
mov $0x40245e,%esi // rsi = 目标字符串地址
lea 0x10(%rsp),%rdi // rdi = output
call 0x401338 // strings_not_equal(output, target)
test %eax,%eax // 检查字符串比较结果
je 0x4010d9 // 相同则跳过爆炸
call 0x40143a // 不相同则爆炸
其中:
c
movb $0x0,0x16(%rsp)
向输出数组的第七个位置写入零字节,即字符串终止符 '\0',因此输出缓冲区需要 7 字节。
之后程序使用 strings_not_equal()strings\_not\_equal()strings_not_equal() 比较生成的字符串和目标字符串。
可以在 GDB 中分别查看查找表和目标字符串:
c
x/s 0x4024b0 // 查看字符查找表
x/s 0x40245e // 查看目标字符串
对应输出结果如下:

整个比较过程可以还原为:
c
output[6] = '\0';
if (strings_not_equal(output, target))
{
explode_bomb();
}
由于索引的取值范围只能是 0~15。
这意味着虽然字符串后面还有很多字符,但程序实际上只能访问前 16 个字符:
c
"maduiersnfotvbyl"
我们将它们按照下标展开:
| 索引(十进制) | 索引(十六进制) | 对应字符 |
|---|---|---|
| 0 | 0x0 | m |
| 1 | 0x1 | a |
| 2 | 0x2 | d |
| 3 | 0x3 | u |
| 4 | 0x4 | i |
| 5 | 0x5 | e |
| 6 | 0x6 | r |
| 7 | 0x7 | s |
| 8 | 0x8 | n |
| 9 | 0x9 | f |
| 10 | 0xA | o |
| 11 | 0xB | t |
| 12 | 0xC | v |
| 13 | 0xD | b |
| 14 | 0xE | y |
| 15 | 0xF | l |
目标字符串是:
c
"flyers"
我们依次在字符表中找到每个字符的位置:
| 输入字符 | ASCII(十六进制) | 低 4 位索引 | 映射结果 |
|---|---|---|---|
| i | 0x69 | 9 | f |
| o | 0x6F | 15 | l |
| n | 0x6E | 14 | y |
| e | 0x65 | 5 | e |
| f | 0x66 | 6 | r |
| g | 0x67 | 7 | s |
因此,我们最终需要构造一个长度为 6 的输入字符串,使其每个字符满足:
input\[i\] \& 0xF=index\[i\]\]\[ input\[i\]\\ \\\&\\ 0xF = index\[i\] \]\[input\[i\] \& 0xF=index\[i\]
所需的索引序列为:
c
9 15 14 5 6 7
通过检查以后,程序进入函数收尾部分:
c
mov 0x18(%rsp),%rax // 读取函数入口保存的栈保护值
xor %fs:0x28,%rax // 与当前线程的栈保护值比较
je 0x4010ee // 相同则正常返回
call 0x400b30 // 不同则调用 __stack_chk_fail
add $0x20,%rsp // 释放局部栈空间
pop %rbx // 恢复 rbx
ret // 返回调用者
这里使用异或完成相等性检查,因为:
x⊕x=0 x\oplus x=0 x⊕x=0
如果保存的栈保护值和当前值相同,异或结果为零,jejeje 跳过栈检查失败函数。
如果不同,则调用 stack_chk_fail()stack\_chk\_fail()stack_chk_fail(),说明当前栈帧可能遭到破坏。
因此,栈保护机制属于编译器自动加入的安全检查,并不是 Phase 5 本身的解题条件。
对应C语言代码
完整注释版汇编:
c
0x401062: push %rbx // 保存被调用者保存寄存器 rbx
0x401063: sub $0x20,%rsp // 分配 32 字节栈空间
0x401067: mov %rdi,%rbx // rbx = input
// 保存栈保护值
0x40106a: mov %fs:0x28,%rax // 读取当前线程的栈保护值
0x401073: mov %rax,0x18(%rsp) // 保存到当前函数栈帧
0x401078: xor %eax,%eax // eax = 0
// 检查输入字符串长度
0x40107a: call 0x40131b // string_length(input)
0x40107f: cmp $0x6,%eax // 判断长度是否等于 6
0x401082: je 0x4010d2 // 等于 6,则进入循环初始化
0x401084: call 0x40143a // 长度不正确,爆炸
0x401089: jmp 0x4010d2 // 跳转到循环初始化
// 遍历字符串并进行字符映射
0x40108b: movzbl (%rbx,%rax,1),%ecx // ecx = input[i],零扩展
0x40108f: mov %cl,(%rsp) // 将当前字符写入栈内存
0x401092: mov (%rsp),%rdx // 从栈中读取数据
0x401096: and $0xf,%edx // index = input[i] & 0xF
0x401099: movzbl 0x4024b0(%rdx),%edx // mapped = table[index]
0x4010a0: mov %dl,0x10(%rsp,%rax,1) // output[i] = mapped
0x4010a4: add $0x1,%rax // i++
0x4010a8: cmp $0x6,%rax // i == 6 ?
0x4010ac: jne 0x40108b // 未完成则继续循环
// 构造输出字符串并比较
0x4010ae: movb $0x0,0x16(%rsp) // output[6] = '\0'
0x4010b3: mov $0x40245e,%esi // rsi = 目标字符串地址
0x4010b8: lea 0x10(%rsp),%rdi // rdi = output
0x4010bd: call 0x401338 // strings_not_equal(output, target)
0x4010c2: test %eax,%eax // 返回值是否为 0
0x4010c4: je 0x4010d9 // 相同则跳转到函数收尾
0x4010c6: call 0x40143a // 不相同则爆炸
0x4010cb: nopl 0x0(%rax,%rax,1) // 多字节空操作,用于代码对齐
0x4010d0: jmp 0x4010d9 // 跳转到函数收尾
// 初始化循环计数器
0x4010d2: mov $0x0,%eax // i = 0
0x4010d7: jmp 0x40108b // 进入字符映射循环
// 检查栈保护值
0x4010d9: mov 0x18(%rsp),%rax // 读取保存的栈保护值
0x4010de: xor %fs:0x28,%rax // 与当前栈保护值进行异或比较
0x4010e7: je 0x4010ee // 相同则正常返回
0x4010e9: call 0x400b30 // 栈保护检查失败
// 函数返回
0x4010ee: add $0x20,%rsp // 释放 32 字节栈空间
0x4010f2: pop %rbx // 恢复 rbx
0x4010f3: ret // 返回调用者
完整 C 语言代码:
c
void phase_5(const char *input)
{
// 1. 检查输入长度
if (string_length(input) != 6)
{
explode_bomb();
}
// 2. 创建输出字符串
char output[7];
// 查找表位于程序的数据段中
const char *table = (const char *)0x4024b0;
const char *target = (const char *)0x40245e;
// 3. 遍历六个输入字符
for (int i = 0; i < 6; i++)
{
// 读取当前字符
unsigned char ch = (unsigned char)input[i];
// 保留 ASCII 编码的低四位
int index = ch & 0xF;
// 根据索引从字符表中取出对应字符
output[i] = table[index];
}
// 4. 添加字符串终止符
output[6] = '\0';
// 5. 比较生成的字符串与目标字符串
if (strings_not_equal(output, target))
{
explode_bomb();
}
}
这里省略了编译器生成的栈保护代码,因为栈保护机制并不是原始字符映射算法的一部分。
通过实验得到的一组有效输入为(答案不唯一,只要码值低八位异或满足9 15 14 5 6 7即可):
c
ionefg
本阶段最重要的知识点是:程序并不是直接比较用户输入的字符串,而是先对每个字符执行按位与操作,提取低 4 位作为查找表索引,再根据查表结果构造新的字符串进行比较。
通过这一过程,可以理解位运算、数组寻址和字符映射之间的关系。同时,函数开头和结尾的栈保护指令也展示了编译器如何利用 Stack Canary 检测潜在的栈内存破坏。
七. Phase 6:链表重排与降序检查
Phase 6 是 Bomb Lab 中结构最复杂的一关。程序首先读取六个整数,检查它们是否处于 1~6 的范围且互不重复,再将每个整数转换为 7−x7-x7−x。随后,程序根据转换后的整数从原始链表中选择节点,将节点地址保存到指针数组中,并按照该数组的顺序重新连接链表,最后检查新链表的节点值是否按照降序排列。
这一关主要涉及嵌套循环、指针运算、数组寻址、结构体内存布局、链表遍历与重排。为了还原完整程序,我们将汇编划分为六个功能块。
首先观察原始反汇编代码:
c
0x4010f4: push %r14
0x4010f6: push %r13
0x4010f8: push %r12
0x4010fa: push %rbp
0x4010fb: push %rbx
0x4010fc: sub $0x50,%rsp
0x401100: mov %rsp,%r13
0x401103: mov %rsp,%rsi
0x401106: call 0x40145c <read_six_numbers>
0x40110b: mov %rsp,%r14
0x40110e: mov $0x0,%r12d
0x401114: mov %r13,%rbp
0x401117: mov 0x0(%r13),%eax
0x40111b: sub $0x1,%eax
0x40111e: cmp $0x5,%eax
0x401121: jbe 0x401128
0x401123: call 0x40143a <explode_bomb>
0x401128: add $0x1,%r12d
0x40112c: cmp $0x6,%r12d
0x401130: je 0x401153
0x401132: mov %r12d,%ebx
0x401135: movslq %ebx,%rax
0x401138: mov (%rsp,%rax,4),%eax
0x40113b: cmp %eax,0x0(%rbp)
0x40113e: jne 0x401145
0x401140: call 0x40143a <explode_bomb>
0x401145: add $0x1,%ebx
0x401148: cmp $0x5,%ebx
0x40114b: jle 0x401135
0x40114d: add $0x4,%r13
0x401151: jmp 0x401114
0x401153: lea 0x18(%rsp),%rsi
0x401158: mov %r14,%rax
0x40115b: mov $0x7,%ecx
0x401160: mov %ecx,%edx
0x401162: sub (%rax),%edx
0x401164: mov %edx,(%rax)
0x401166: add $0x4,%rax
0x40116a: cmp %rsi,%rax
0x40116d: jne 0x401160
0x40116f: mov $0x0,%esi
0x401174: jmp 0x401197
0x401176: mov 0x8(%rdx),%rdx
0x40117a: add $0x1,%eax
0x40117d: cmp %ecx,%eax
0x40117f: jne 0x401176
0x401181: jmp 0x401188
0x401183: mov $0x6032d0,%edx
0x401188: mov %rdx,0x20(%rsp,%rsi,2)
0x40118d: add $0x4,%rsi
0x401191: cmp $0x18,%rsi
0x401195: je 0x4011ab
0x401197: mov (%rsp,%rsi,1),%ecx
0x40119a: cmp $0x1,%ecx
0x40119d: jle 0x401183
0x40119f: mov $0x1,%eax
0x4011a4: mov $0x6032d0,%edx
0x4011a9: jmp 0x401176
0x4011ab: mov 0x20(%rsp),%rbx
0x4011b0: lea 0x28(%rsp),%rax
0x4011b5: lea 0x50(%rsp),%rsi
0x4011ba: mov %rbx,%rcx
0x4011bd: mov (%rax),%rdx
0x4011c0: mov %rdx,0x8(%rcx)
0x4011c4: add $0x8,%rax
0x4011c8: cmp %rsi,%rax
0x4011cb: je 0x4011d2
0x4011cd: mov %rdx,%rcx
0x4011d0: jmp 0x4011bd
0x4011d2: movq $0x0,0x8(%rdx)
0x4011da: mov $0x5,%ebp
0x4011df: mov 0x8(%rbx),%rax
0x4011e3: mov (%rax),%eax
0x4011e5: cmp %eax,(%rbx)
0x4011e7: jge 0x4011ee
0x4011e9: call 0x40143a <explode_bomb>
0x4011ee: mov 0x8(%rbx),%rbx
0x4011f2: sub $0x1,%ebp
0x4011f5: jne 0x4011df
0x4011f7: add $0x50,%rsp
0x4011fb: pop %rbx
0x4011fc: pop %rbp
0x4011fd: pop %r12
0x4011ff: pop %r13
0x401201: pop %r14
0x401203: ret
输入读取与合法性检查
首先,函数将多个被调用者保存寄存器压栈,并分配 80 字节的局部栈空间:
c
push %r14
push %r13
push %r12
push %rbp
push %rbx
sub $0x50,%rsp
mov %rsp,%r13
mov %rsp,%rsi
call 0x40145c
mov %rsp,%r14
根据 Linux x86-64 调用约定,rdirdirdi 保存用户输入字符串的地址,rsirsirsi 保存用于存放六个整数的数组首地址,因此这里可以还原为:
c
int numbers[6];
read_six_numbers(input, numbers);
随后,程序对六个整数执行范围检查和重复检查:
c
mov $0x0,%r12d
mov %r13,%rbp
mov 0x0(%r13),%eax
sub $0x1,%eax
cmp $0x5,%eax
jbe 0x401128
call 0x40143a
这里先读取当前整数,然后执行减 1 和无符号比较。由于 jbejbejbe 是无符号小于等于跳转,因此判断条件为:
(unsigned)(x−1)≤5 (unsigned)(x-1)\le5 (unsigned)(x−1)≤5
也就要求:
1≤x≤6 1\le x\le6 1≤x≤6
随后:
c
add $0x1,%r12d
cmp $0x6,%r12d
je 0x401153
mov %r12d,%ebx
movslq %ebx,%rax
mov (%rsp,%rax,4),%eax
cmp %eax,0x0(%rbp)
jne 0x401145
call 0x40143a
这里通过 r12dr12dr12d 和 ebxebxebx 控制双重循环。外层循环依次选择当前整数,内层循环从下一个整数开始检查是否存在相同元素。
其中:
c
mov (%rsp,%rax,4),%eax
表示从地址 rsp+rax×4rsp+rax\times4rsp+rax×4 读取整数,而:
c
cmp %eax,0x0(%rbp)
则比较当前外层整数与内层整数。
若相等,调用爆炸函数;若不相等,内层计数器继续增加。
这整个功能块可以还原为:
c
for (int i = 0; i < 6; i++)
{
if (numbers[i] < 1 || numbers[i] > 6)
{
explode_bomb();
}
for (int j = i + 1; j < 6; j++)
{
if (numbers[i] == numbers[j])
{
explode_bomb();
}
}
}
因此,六个输入必须是 1~6 的某种排列,不能重复,也不能越界。
整数转换
完成输入检查后,程序执行:
c
lea 0x18(%rsp),%rsi // rsi = numbers + 6
mov %r14,%rax // rax = numbers
mov $0x7,%ecx // ecx = 7
mov %ecx,%edx // temp = 7
sub (%rax),%edx // temp = 7 - *p
mov %edx,(%rax) // *p = temp
add $0x4,%rax // p++
cmp %rsi,%rax // 是否到达数组末尾
jne 0x401160 // 未到达则继续
这里的 raxraxrax 指向当前整数,rsirsirsi 指向数组结束位置。因为一个 int 占 4 字节,所以每次 raxraxrax 增加 4,就相当于指针移动到下一个整数。
关键的三条指令是:
c
mov %ecx,%edx
sub (%rax),%edx
mov %edx,(%rax)
它们依次完成读取常量 7、减去当前整数、将结果写回原数组,因此整个循环为:
c
for (int i = 0; i < 6; i++)
{
numbers[i] = 7 - numbers[i];
}
例如,原始输入为:
c
1 2 3 4 5 6
经过转换后得到:
c
6 5 4 3 2 1
这样做的目的,需要结合后续链表节点选择的逻辑才能理解。
根据整数查找链表节点
程序接下来会根据转换后的整数选择链表节点。首先需要通过 GDB 检查链表的内存布局:
c
x/24wx 0x6032d0
输出结果如下:

原始链表从地址 0x6032d00x6032d00x6032d0 开始。每个节点占 16 字节,其结构可以还原为:
c
typedef struct Node
{
int value; // 偏移 +0,4 字节
int index; // 偏移 +4,4 字节
struct Node *next; // 偏移 +8,8 字节
} Node;
通过 GDB 获取的节点数据如下:
| 节点 | 地址 | value | index | next |
|---|---|---|---|---|
| node1 | 0x6032d0 | 332 | 1 | node2 |
| node2 | 0x6032e0 | 168 | 2 | node3 |
| node3 | 0x6032f0 | 924 | 3 | node4 |
| node4 | 0x603300 | 691 | 4 | node5 |
| node5 | 0x603310 | 477 | 5 | node6 |
| node6 | 0x603320 | 443 | 6 | NULL |
现在分析节点查找的汇编:
c
mov $0x0,%esi
jmp 0x401197
mov 0x8(%rdx),%rdx // p = p->next
add $0x1,%eax // j++
cmp %ecx,%eax // 比较 j 与 position
jne 0x401176 // 尚未找到则继续遍历
jmp 0x401188
mov $0x6032d0,%edx // p = &node1
mov %rdx,0x20(%rsp,%rsi,2)
add $0x4,%rsi
cmp $0x18,%rsi
je 0x4011ab
mov (%rsp,%rsi,1),%ecx // position = numbers[i]
cmp $0x1,%ecx
jle 0x401183
mov $0x1,%eax // j = 1
mov $0x6032d0,%edx // p = &node1
jmp 0x401176
这段代码的控制流不是简单地按照地址从小到大执行。程序首先跳转到 0x4011970x4011970x401197,读取当前整数作为目标位置 positionpositionposition
如果 position=1position=1position=1,就直接选择第一个节点;如果 position>1position>1position>1,则从第一个节点开始,通过 nextnextnext 指针不断向后遍历。
其中,最关键的指令是:
c
mov 0x8(%rdx),%rdx
由于 rdxrdxrdx 保存当前节点地址,而节点的 nextnextnext 指针位于偏移 8 字节的位置,所以它实际上执行:
c
p = p->next;
这里不是将 rdxrdxrdx 简单增加 8,而是读取 rdx+8rdx+8rdx+8 所指内存中的 8 字节指针值。
随后:
c
mov %rdx,0x20(%rsp,%rsi,2)
将找到的节点地址保存到栈上。
由于 rsirsirsi 依次取 0、4、8、12、16、20,因此保存地址依次为:
c
rsp + 0x20
rsp + 0x28
rsp + 0x30
rsp + 0x38
rsp + 0x40
rsp + 0x48
这些位置相邻间隔 8 字节,正好对应 64 位指针数组的内存布局。
因此可以确定,程序创建了一个节点指针数组:
c
Node *nodes[6];
这一功能块的完整 C 语言实现为:
c
Node *nodes[6];
for (int i = 0; i < 6; i++)
{
int position = numbers[i];
Node *p = &node1;
for (int j = 1; j < position; j++)
{
p = p->next;
}
nodes[i] = p;
}
例如,转换后的整数为:
c
6 5 4 3 2 1
则指针数组保存的节点顺序为:
c
nodes[0] = &node6;
nodes[1] = &node5;
nodes[2] = &node4;
nodes[3] = &node3;
nodes[4] = &node2;
nodes[5] = &node1;
需要注意,到这一步只是在数组中保存节点地址,还没有修改节点之间原来的连接关系。
重新连接链表
接下来程序根据指针数组中的顺序,重新设置节点之间的 nextnextnext 指针:
c
mov 0x20(%rsp),%rbx // head = nodes[0]
lea 0x28(%rsp),%rax // p = &nodes[1]
lea 0x50(%rsp),%rsi // end = &nodes[6]
mov %rbx,%rcx // current = head
mov (%rax),%rdx // nextNode = *p
mov %rdx,0x8(%rcx) // current->next = nextNode
add $0x8,%rax // p++
cmp %rsi,%rax // 是否到达数组末尾
je 0x4011d2 // 已到达则退出循环
mov %rdx,%rcx // current = nextNode
jmp 0x4011bd // 继续连接节点
movq $0x0,0x8(%rdx) // 最后一个节点的 next = NULL
这里首先将 nodes0nodes0nodes0 作为新链表的头节点,同时令 raxraxrax 指向指针数组中的第二个元素。
由于数组中的每个元素都是 Node 指针,所以 raxraxrax 保存的实际上是 Node 二级指针。
最关键的是:
c
mov (%rax),%rdx
mov %rdx,0x8(%rcx)
第一条读取数组中保存的节点地址,第二条将这个地址写入当前节点的 nextnextnext 成员。
因此,对应:
c
Node *nextNode = *p;
current->next = nextNode;
接下来让指针数组的遍历位置前进,并把当前节点更新为刚刚连接的节点,继续进行下一轮连接。
最后执行:
c
movq $0x0,0x8(%rdx)
将新链表最后一个节点的 nextnextnext 设置为 NULL。
这是因为节点原本已经存在旧的连接关系,如果不清空新尾节点的 nextnextnext,就可能沿着旧指针继续访问,甚至形成环形链表。
因此整个功能块可以还原为:
c
Node *head = nodes[0];
Node *current = head;
for (int i = 1; i < 6; i++)
{
Node *nextNode = nodes[i];
current->next = nextNode;
current = nextNode;
}
current->next = NULL;
例如,若节点指针数组顺序为:
c
node6, node5, node4, node3, node2, node1
则新链表的连接关系变为:
c
node6 -> node5 -> node4 -> node3 -> node2 -> node1 -> NULL
这里没有复制节点,也没有移动节点在内存中的实际位置,只是改变了各节点的 nextnextnext 指针,从而改变链表的逻辑顺序。
检查链表是否降序排列
链表重新连接以后,程序还需要检查节点的 valuevaluevalue 是否满足降序要求:
c
mov $0x5,%ebp // 需要执行五次相邻节点比较
mov 0x8(%rbx),%rax // rax = current->next
mov (%rax),%eax // eax = nextNode->value
cmp %eax,(%rbx) // 比较 current->value 与 nextValue
jge 0x4011ee // 当前值 >= 下一节点值则通过
call 0x40143a // 否则爆炸
mov 0x8(%rbx),%rbx // current = current->next
sub $0x1,%ebp // 剩余比较次数减 1
jne 0x4011df // 继续比较下一对节点
首先,rbxrbxrbx 保存新链表头节点的地址。
c
mov 0x8(%rbx),%rax
读取当前节点的 nextnextnext 指针,得到下一个节点的地址。
随后:
c
mov (%rax),%eax
读取下一节点的前 4 字节,也就是 valuevaluevalue 成员。
这里要注意:执行这条指令之后,raxraxrax 不再保存原先的节点地址,因为写入 eaxeaxeax 会将 raxraxrax 的高 32 位清零。此时寄存器保存的是读取出的整数值。
最后:
c
cmp %eax,(%rbx)
jge 0x4011ee
比较的是当前节点的 valuevaluevalue 与下一节点的 valuevaluevalue,要求:
current−>value≥nextNode−>value current->value\ge nextNode->value current−>value≥nextNode−>value
如果当前值小于下一节点的值,就调用 explode_bomb()explode\_bomb()explode_bomb()。
由于链表有六个节点,因此只需要进行五次相邻节点比较。每轮比较完成后,程序把当前节点移动到下一个节点,并将计数器减 1,直到五次比较全部完成。
这一部分对应的 C 语言代码为:
c
Node *current = head;
for (int i = 0; i < 5; i++)
{
Node *nextNode = current->next;
if (current->value < nextNode->value)
{
explode_bomb();
}
current = nextNode;
}
至此,程序的核心逻辑已经全部还原。最后只需释放栈空间、恢复保存的寄存器,再通过 retretret 返回调用者。
答案推导
根据我们此前通过 GDB 得到的节点数据:
| 节点 | value |
|---|---|
| node1 | 332 |
| node2 | 168 |
| node3 | 924 |
| node4 | 691 |
| node5 | 477 |
| node6 | 443 |
由于程序要求按照 valuevaluevalue 降序排列,所以正确的节点顺序应该是:
c
node3 -> node4 -> node5 -> node6 -> node1 -> node2
对应的节点编号为:
c
3 4 5 6 1 2
但是,程序在选择节点之前会执行:
c
numbers[i] = 7 - numbers[i];
因此,需要对目标节点编号再次进行反向转换:
| 目标节点编号 | 实际输入 |
|---|---|
| 3 | 4 |
| 4 | 3 |
| 5 | 2 |
| 6 | 1 |
| 1 | 6 |
| 2 | 5 |
所以最终答案为:
c
4 3 2 1 6 5
对应C语言代码
完整注释版汇编:
c
// ==================== ① 读取六个整数 ====================
0x4010f4: push %r14 // 保存 r14
0x4010f6: push %r13 // 保存 r13
0x4010f8: push %r12 // 保存 r12
0x4010fa: push %rbp // 保存 rbp
0x4010fb: push %rbx // 保存 rbx
0x4010fc: sub $0x50,%rsp // 分配 80 字节栈空间
0x401100: mov %rsp,%r13 // r13 = numbers
0x401103: mov %rsp,%rsi // rsi = numbers
0x401106: call 0x40145c // read_six_numbers(input, numbers)
0x40110b: mov %rsp,%r14 // r14 = numbers
// ==================== ② 范围与重复检查 ====================
0x40110e: mov $0x0,%r12d // 外层计数器初始化
0x401114: mov %r13,%rbp // rbp = &numbers[i]
0x401117: mov 0x0(%r13),%eax // eax = numbers[i]
0x40111b: sub $0x1,%eax // eax = numbers[i] - 1
0x40111e: cmp $0x5,%eax // 无符号比较 eax <= 5
0x401121: jbe 0x401128 // 范围合法则继续
0x401123: call 0x40143a // 越界,爆炸
0x401128: add $0x1,%r12d // 外层计数器加 1
0x40112c: cmp $0x6,%r12d // 是否处理到第六个元素
0x401130: je 0x401153 // 是,退出检查阶段
0x401132: mov %r12d,%ebx // j = i + 1
0x401135: movslq %ebx,%rax // 将 j 扩展为 64 位
0x401138: mov (%rsp,%rax,4),%eax // eax = numbers[j]
0x40113b: cmp %eax,0x0(%rbp) // 比较 numbers[i] 与 numbers[j]
0x40113e: jne 0x401145 // 不相等则继续
0x401140: call 0x40143a // 出现重复整数,爆炸
0x401145: add $0x1,%ebx // j++
0x401148: cmp $0x5,%ebx // j <= 5 ?
0x40114b: jle 0x401135 // 继续内层循环
0x40114d: add $0x4,%r13 // 移动到下一个 int
0x401151: jmp 0x401114 // 返回外层循环
// ==================== ③ numbers[i] = 7 - numbers[i] ====================
0x401153: lea 0x18(%rsp),%rsi // rsi = numbers + 6
0x401158: mov %r14,%rax // rax = numbers
0x40115b: mov $0x7,%ecx // ecx = 7
0x401160: mov %ecx,%edx // temp = 7
0x401162: sub (%rax),%edx // temp = 7 - *p
0x401164: mov %edx,(%rax) // *p = temp
0x401166: add $0x4,%rax // p++
0x40116a: cmp %rsi,%rax // p == end ?
0x40116d: jne 0x401160 // 未到末尾则继续
// ==================== ④ 根据整数查找节点 ====================
0x40116f: mov $0x0,%esi // 当前输入整数的字节偏移量
0x401174: jmp 0x401197 // 读取当前整数
0x401176: mov 0x8(%rdx),%rdx // p = p->next
0x40117a: add $0x1,%eax // j++
0x40117d: cmp %ecx,%eax // j == position ?
0x40117f: jne 0x401176 // 未找到则继续遍历
0x401181: jmp 0x401188 // 找到节点,保存地址
0x401183: mov $0x6032d0,%edx // p = &node1
0x401188: mov %rdx,0x20(%rsp,%rsi,2) // nodes[i] = p
0x40118d: add $0x4,%rsi // 移动到下一个整数
0x401191: cmp $0x18,%rsi // 六个整数是否全部处理完成
0x401195: je 0x4011ab // 完成则进入链表重排
0x401197: mov (%rsp,%rsi,1),%ecx // position = numbers[i]
0x40119a: cmp $0x1,%ecx // position <= 1 ?
0x40119d: jle 0x401183 // 是,直接选择 node1
0x40119f: mov $0x1,%eax // j = 1
0x4011a4: mov $0x6032d0,%edx // p = &node1
0x4011a9: jmp 0x401176 // 开始遍历链表
// ==================== ⑤ 重建链表 ====================
0x4011ab: mov 0x20(%rsp),%rbx // head = nodes[0]
0x4011b0: lea 0x28(%rsp),%rax // p = &nodes[1]
0x4011b5: lea 0x50(%rsp),%rsi // end = &nodes[6]
0x4011ba: mov %rbx,%rcx // current = head
0x4011bd: mov (%rax),%rdx // nextNode = *p
0x4011c0: mov %rdx,0x8(%rcx) // current->next = nextNode
0x4011c4: add $0x8,%rax // p++
0x4011c8: cmp %rsi,%rax // p == end ?
0x4011cb: je 0x4011d2 // 是,结束连接
0x4011cd: mov %rdx,%rcx // current = nextNode
0x4011d0: jmp 0x4011bd // 继续连接节点
0x4011d2: movq $0x0,0x8(%rdx) // 尾节点 next = NULL
// ==================== ⑥ 检查链表是否降序 ====================
0x4011da: mov $0x5,%ebp // 需要比较五对相邻节点
0x4011df: mov 0x8(%rbx),%rax // rax = current->next
0x4011e3: mov (%rax),%eax // eax = nextNode->value
0x4011e5: cmp %eax,(%rbx) // 比较 current->value 与 nextValue
0x4011e7: jge 0x4011ee // 当前值 >= 下一节点值则通过
0x4011e9: call 0x40143a // 不满足降序则爆炸
0x4011ee: mov 0x8(%rbx),%rbx // current = current->next
0x4011f2: sub $0x1,%ebp // 剩余比较次数减 1
0x4011f5: jne 0x4011df // 未完成则继续
// ==================== ⑦ 恢复栈帧并返回 ====================
0x4011f7: add $0x50,%rsp // 释放 80 字节栈空间
0x4011fb: pop %rbx // 恢复 rbx
0x4011fc: pop %rbp // 恢复 rbp
0x4011fd: pop %r12 // 恢复 r12
0x4011ff: pop %r13 // 恢复 r13
0x401201: pop %r14 // 恢复 r14
0x401203: ret // 返回调用者
完整 C 语言代码:
c
typedef struct Node
{
int value;
int index;
struct Node *next;
} Node;
void phase_6(const char *input)
{
// ① 读取六个整数
int numbers[6];
read_six_numbers(input, numbers);
// ② 检查输入范围与重复元素
for (int i = 0; i < 6; i++)
{
if (numbers[i] < 1 || numbers[i] > 6)
{
explode_bomb();
}
for (int j = i + 1; j < 6; j++)
{
if (numbers[i] == numbers[j])
{
explode_bomb();
}
}
}
// ③ 将整数转换为 7 - x
for (int i = 0; i < 6; i++)
{
numbers[i] = 7 - numbers[i];
}
// ④ 根据转换后的整数查找节点
Node *nodes[6];
for (int i = 0; i < 6; i++)
{
int position = numbers[i];
Node *p = &node1;
for (int j = 1; j < position; j++)
{
p = p->next;
}
nodes[i] = p;
}
// ⑤ 按 nodes 数组的顺序重建链表
Node *head = nodes[0];
Node *current = head;
for (int i = 1; i < 6; i++)
{
Node *nextNode = nodes[i];
current->next = nextNode;
current = nextNode;
}
current->next = NULL;
// ⑥ 检查链表的 value 是否满足降序
current = head;
for (int i = 0; i < 5; i++)
{
Node *nextNode = current->next;
if (current->value < nextNode->value)
{
explode_bomb();
}
current = nextNode;
}
}
本阶段最重要的知识点是:程序通过整数索引选取链表节点,再借助指针数组重新组织节点之间的连接关系,最后沿着新链表检查节点值是否满足降序要求。
分析时应当先通过跳转关系划分功能块,再根据栈内存布局判断数组元素类型,结合结构体成员偏移理解指针操作,最后将复杂的汇编代码还原成清晰的 C 语言循环与链表操作。
通过这一关,可以将数组寻址、嵌套循环、指针、结构体和链表等知识串联起来,也能更加直观地理解高级语言中的数据结构在机器级程序中的实际实现方式。