拆炸弹是一个非常有趣的实验,在这里简单记录一下我的拆炸弹历程。
前置准备
VSCode扩展
安装了用于高亮asm
代码的扩展: x86 and x86_64 Assembly.
阅读main.c
阅读代码以了解炸弹的主要流程
该程序的每一个部分都是这样:首先读入input
,然后执行phase_x(input)
,每个phase_x
的内部都会出现走向explode_bomb
的分支,因此拆除炸弹的关键就在于读懂phase_x
的汇编代码,并推算出正确的input
,避免代码走到explode_bomb
的步骤。
GDB学习
指令 |
全称 |
描述 |
r |
run |
开始执行程序,直到遇到断点或程序结束 |
q |
quit |
退出 GDB 调试器 |
b |
break |
在指定位置设置断点 |
tbreak |
temporary break |
临时断点,只会停一次 |
c |
cont |
从当前位置继续执行程序,直到下一个断点或结束 |
si |
stepi |
执行当前指令,如果是函数调用则进入函数 |
ni |
nexti |
执行下一条指令,但不进入函数 |
s |
step |
执行当前行,进入函数 |
n |
next |
执行当前行,但不进入函数 |
p |
print |
打印变量的值 |
set var |
set variable |
修改变量的值 |
x |
examine |
打印内存中的值 |
info registers |
info registers |
打印寄存器的值 |
disas |
disassemble |
反汇编当前函数或指定代码区域 |
layout asm |
— |
显示汇编代码视图 |
layout regs |
— |
显示当前寄存器状态和它们的值 |
j |
jump |
跳转到程序指定位置 |
info breakpoints |
— |
查看所有断点 |
delete |
delete |
删除指定断点 |
disable |
disable |
禁用断点 |
enable |
enable |
启用断点 |
watch |
watch |
设置监视点,当变量被修改时停下 |
backtrace |
bt |
打印调用栈 |
frame |
— |
切换当前栈帧 |
info locals |
— |
打印当前函数的局部变量 |
info args |
— |
打印当前函数参数 |
finish |
— |
执行完当前函数并返回 |
signal |
— |
向程序发送信号 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| x /[N][F][M] ADDRESS
|
举例:如果想要查看位于0x404783
的字符串,可以使用指令x/s 0x404783
GDB初始化
为了在gdb
调试的时候不触碰到explode_bomb()
,同时进入每个phase_n
的内部观察行为,需要在GDB
初始化的时候打断点。
执行以下指令,使得gdb
能够读取任何目录下的.gdbinit
,该文件将用来配置初始化:
1 2 3
| touch .gdbinit mkdir -p ~/.config/gdb echo "set auto-load safe-path /" > ~/.config/gdb/gdbinit
|
然后,打开bomb lab所在目录,建立一个.gdbinit
,输入:
1 2 3 4 5 6 7 8 9 10 11
| set args psol.txt
b phase_1 b phase_2 b phase_3 b phase_4 b phase_5 b phase_6
b explode_bomb
|
关于psol.txt
的坑
在我做完phase_1
的时候,将答案填入了psol.txt
再次运行,却发现炸弹爆炸。原因是该程序不会读取文件的最后一个字符,因此每次填完新的答案,记得在文件末尾加一个换行。
Phase_1
考查点:函数调用
phase_1
的asm代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 0000000000400ee0 <phase_1>:
400ee0: 48 83 ec 08 sub $0x8,%rsp
400ee4: be 00 24 40 00 mov $0x402400,%esi
400ee9: e8 4a 04 00 00 callq 401338 <strings_not_equal>
400eee: 85 c0 test %eax,%eax
400ef0: 74 05 je 400ef7 <phase_1+0x17>
400ef2: e8 43 05 00 00 callq 40143a <explode_bomb>
400ef7: 48 83 c4 08 add $0x8,%rsp
400efb: c3 retq
|
strings_not_equal
的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| int strings_not_equal(const char *s1, const char *s2) { int len1 = string_length(s1); int len2 = string_length(s2);
if (len1 != len2) { return 1; }
while (*s1 != '\0') { if (*s1 != *s2) { return 1; } s1++; s2++; }
return 0; }
|
phase_1
非常简单,它使用函数strings_not_equal
来判断输入字符串与$0x402400
处的字符串是否相等,如果相等则炸弹不爆炸,因此只要找到$0x402400
处的字符串即可解开炸弹。
执行:
输出的字符串就是第一个key string
phase_2
考查点:函数调用、数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| 0000000000400efc <phase_2>:
400efc: 55 push %rbp
400efd: 53 push %rbx
400efe: 48 83 ec 28 sub $0x28,%rsp
400f02: 48 89 e6 mov %rsp,%rsi
400f05: e8 52 05 00 00 callq 40145c <read_six_numbers>
400f0a: 83 3c 24 01 cmpl $0x1,(%rsp)
400f0e: 74 20 je 400f30 <phase_2+0x34>
400f10: e8 25 05 00 00 callq 40143a <explode_bomb>
400f15: eb 19 jmp 400f30 <phase_2+0x34>
400f17: 8b 43 fc mov -0x4(%rbx),%eax
400f1a: 01 c0 add %eax,%eax
400f1c: 39 03 cmp %eax,(%rbx)
400f1e: 74 05 je 400f25 <phase_2+0x29>
400f20: e8 15 05 00 00 callq 40143a <explode_bomb>
400f25: 48 83 c3 04 add $0x4,%rbx
400f29: 48 39 eb cmp %rbp,%rbx
400f2c: 75 e9 jne 400f17 <phase_2+0x1b>
400f2e: eb 0c jmp 400f3c <phase_2+0x40>
400f30: 48 8d 5c 24 04 lea 0x4(%rsp),%rbx
400f35: 48 8d 6c 24 18 lea 0x18(%rsp),%rbp
400f3a: eb db jmp 400f17 <phase_2+0x1b>
400f3c: 48 83 c4 28 add $0x28,%rsp
400f40: 5b pop %rbx
400f41: 5d pop %rbp
400f42: c3 retq
|
从phase_2
开始,我发现一种解决问题的好办法,就是边看边分析其c代码,这样我们能够使得代码越来越可读. phase_2
的c代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void phase_2() { int numbers[6]; read_six_numbers(numbers);
if (numbers[0] != 1) { explode_bomb(); }
for (int i = 1; i < 6; i++) { if (numbers[i] != numbers[i - 1] * 2) { explode_bomb(); } } }
|
其中read_six_numbers
会调用sscanf
,从stdin
中的读取6个数字.
read_six_numbers
关键部分:
1 2 3 4 5
| 401480: be c3 25 40 00 mov $0x4025c3,%esi
401485: b8 00 00 00 00 mov $0x0,%eax
40148a: e8 61 f7 ff ff callq 400bf0 <__isoc99_sscanf@plt>
|
而sscanf
的函数签名为:
1
| int sscanf(const char *str, const char *format, ...);
|
其格式字符串是第二个参数,也就是esi
寄存器保存的参数,执行:
可以看到:
由phase_2
的代码可知,这6个数字存在这样的规律:第一个数字是1,且后续每个数字都是前者的2倍,由此,答案了然.
phase_3
考查点:switch 跳表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| 0000000000400f43 <phase_3>: 400f43: 48 83 ec 18 sub $0x18,%rsp 400f47: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx 400f4c: 48 8d 54 24 08 lea 0x8(%rsp),%rdx 400f51: be cf 25 40 00 mov $0x4025cf,%esi 400f56: b8 00 00 00 00 mov $0x0,%eax 400f5b: e8 90 fc ff ff callq 400bf0 <__isoc99_sscanf@plt> 400f60: 83 f8 01 cmp $0x1,%eax 400f63: 7f 05 jg 400f6a <phase_3+0x27> 400f65: e8 d0 04 00 00 callq 40143a <explode_bomb> 400f6a: 83 7c 24 08 07 cmpl $0x7,0x8(%rsp) 400f6f: 77 3c ja 400fad <phase_3+0x6a> 400f71: 8b 44 24 08 mov 0x8(%rsp),%eax 400f75: ff 24 c5 70 24 40 00 jmpq *0x402470(,%rax,8) 400f7c: b8 cf 00 00 00 mov $0xcf,%eax 400f81: eb 3b jmp 400fbe <phase_3+0x7b> 400f83: b8 c3 02 00 00 mov $0x2c3,%eax 400f88: eb 34 jmp 400fbe <phase_3+0x7b> 400f8a: b8 00 01 00 00 mov $0x100,%eax 400f8f: eb 2d jmp 400fbe <phase_3+0x7b> 400f91: b8 85 01 00 00 mov $0x185,%eax 400f96: eb 26 jmp 400fbe <phase_3+0x7b> 400f98: b8 ce 00 00 00 mov $0xce,%eax 400f9d: eb 1f jmp 400fbe <phase_3+0x7b> 400f9f: b8 aa 02 00 00 mov $0x2aa,%eax 400fa4: eb 18 jmp 400fbe <phase_3+0x7b> 400fa6: b8 47 01 00 00 mov $0x147,%eax 400fab: eb 11 jmp 400fbe <phase_3+0x7b> 400fad: e8 88 04 00 00 callq 40143a <explode_bomb> 400fb2: b8 00 00 00 00 mov $0x0,%eax 400fb7: eb 05 jmp 400fbe <phase_3+0x7b> 400fb9: b8 37 01 00 00 mov $0x137,%eax 400fbe: 3b 44 24 0c cmp 0xc(%rsp),%eax 400fc2: 74 05 je 400fc9 <phase_3+0x86> 400fc4: e8 71 04 00 00 callq 40143a <explode_bomb> 400fc9: 48 83 c4 18 add $0x18,%rsp 400fcd: c3 retq
|
这个炸弹非常简单,关键在于找到跳表并确定正确的、没有炸弹的分支。其c代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| void phase_3(const char *input) { int x, y; if (sscanf(input, "%d %d", &x, &y) < 2) { explode_bomb(); }
if (x > 7) { explode_bomb(); }
int val; switch (x) { case 0: val = 0xcf; break; case 1: val = 0x2c3; break; case 2: val = 0x100; break; case 3: val = 0x185; break; case 4: val = 0xce; break; case 5: val = 0x2aa; break; case 6: val = 0x147; break; case 7: val = 0x137; break; default: val = 0; break; }
if (y != val) { explode_bomb(); } }
|
因此,我们需要找这样的2个数:a == case(b),其中case是switch分支形成的映射。
答案有很多可能,例如:
phase_4
这里就不再展示asm了,而是直接展示其对应的c代码。
1 2 3 4 5 6 7 8 9 10 11 12 13
| int func4(int a, int b, int c) { int d = (c < b) ? 1 : 0; int e = (c - b + d) / 2; int mid = e + b;
if (mid > a) { return 2 * func4(a, b, mid - 1); } else if (mid < a) { return 2 * func4(a, mid + 1, c) + 1; } else { return 0; } }
|
这是一个 二分搜索递归函数,它返回的是根据路径计算出来的一个整数编码(往左走结果乘 2,往右走结果乘 2 再加 1,找到目标返回 0)。
1 2 3 4 5 6 7 8 9 10 11
| void phase_4(const char *input) { int a, b;
if (sscanf(input, "%d %d", &a, &b) != 2 || a > 14) { explode_bomb(); }
if (func4(a, 0, 14) != 0 || b != 0) { explode_bomb(); } }
|
解题条件
要让 phase_4
不炸:
- 输入必须是 两个整数。
- 第一个数
a ≤ 14
。
func4(a, 0, 14) == 0
。
- 也就是说 a 必须刚好是递归二分的 mid,直接命中,不进入左子树或右子树。
- 第二个数
b == 0
。
从而答案为:
phase_5
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void phase_5(const char *in) { if (string_length(in) != 6) explode_bomb();
char out[7]; for (int i = 0; i < 6; i++) { unsigned idx = (unsigned char)in[i] & 0x0F; out[i] = *(char *)(0x4024B0 + idx); } out[6] = '\0';
if (strings_not_equal(out, "flyers")) explode_bomb(); }
|
关键asm:
1 2 3 4 5
| 40108b: 0f b6 0c 03 movzbl (%rbx,%rax,1),%ecx ; 取输入字符 401092: 48 8b 14 24 mov (%rsp),%rdx 401096: 83 e2 0f and $0xf,%edx ; 只保留低 4 位 401099: 0f b6 92 b0 24 40 00 movzbl 0x4024b0(%rdx),%edx ; 查表取字符 4010a0: 88 54 04 10 mov %dl,0x10(%rsp,%rax,1) ; 存到输出串
|
关键:输入的串中的每个字节经过*(char *)(0x4024B0 + idx)
这样的映射后,所得串应该是"flyers"
. 因此,我们先来查看0x4024B0
中保存了什么:
1 2
| (gdb) x/s 0x4024B0 0x4024b0 <array.3449>: "maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?"
|
答案构造
- 只看每个输入字符的低 4 位(与
0x0F
)。
- 查的是一张 16 字节的表(典型 Bomb Lab 表是
"maduiersnfotvbyl"
;也可以在 gdb 里用 x/s 0x4024b0
实测确认)。
- 目标是得到
"flyers"
,即需要索引出 f l y e r s
这 6 个字符。
若表为 "maduiersnfotvbyl"
,其索引如下:
因此 `"flyers"` 对应索引序列:[9, 15, 14, 5, 6, 7]。
- 只要让 6 个输入字符的低 4 位分别等于上述索引即可。
举例选一组好记的可打印字符(低 4 位分别为 9, F, E, 5, 6, 7):
- 9 →
'i'
(0x69)
- 15 →
'o'
(0x6F)
- 14 →
'n'
(0x6E)
- 5 →
'e'
(0x65)
- 6 →
'f'
(0x66)
- 7 →
'g'
(0x67)
一个可行输入:ionefg
(长度正好 6).
说明:同一个低 4 位有很多选择,比如
9 可用 '9','I','Y','i','y'
;
F 可用 '/' ,'?' ,'O','_','o'
等(十六进制末位为 F 的字符)。