CSAPP - Bomb Lab

Computer Systems: A Programmer’s Perspective, 3/E (CS:APP3e) – Bomb Lab, 实现源码可从 HangX-Ma/csapp: 02-bomblab 获取。

  • Progress and timeline [2/14]

阅读 CS:APP3e Student Site - Online GDB Materials 章节的内容可以快速熟悉 GDB 的使用指令, 另外 GDB Notes 罗列了一系列常用的 GDB 指令, 比网上搜索的要简洁完备。

开始实验之前用 objdump -d bomb > bomb.s 反汇编这段二级制代码, 后续就不用在 GDB 里用 print 找半天了, 便捷很多。

1. Phase1

bomb.c 文件可以看到总共有 6 个 phase 的问题, 不难直接定位 phase_1 所在的部分:

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       	call   401338 <strings_not_equal>
  400eee:	85 c0                	test   %eax,%eax
  400ef0:	74 05                	je     400ef7 <phase_1+0x17>
  400ef2:	e8 43 05 00 00       	call   40143a <explode_bomb>
  400ef7:	48 83 c4 08          	add    $0x8,%rsp
  400efb:	c3                   	ret    

这里可以看到 string_not_equal 函数会进行字符串匹配, 而在调用函数之前, %esi 寄存器实际上传递的是第二个函数参数, 这个需要留意。 那索性看看 string_note_equal 函数做了什么:

0000000000401338 <strings_not_equal>:
  401338:	41 54                	push   %r12
  40133a:	55                   	push   %rbp
  40133b:	53                   	push   %rbx
  40133c:	48 89 fb             	mov    %rdi,%rbx
  40133f:	48 89 f5             	mov    %rsi,%rbp
  401342:	e8 d4 ff ff ff       	call   40131b <string_length>
  401347:	41 89 c4             	mov    %eax,%r12d
  40134a:	48 89 ef             	mov    %rbp,%rdi
  40134d:	e8 c9 ff ff ff       	call   40131b <string_length>
  401352:	ba 01 00 00 00       	mov    $0x1,%edx
  401357:	41 39 c4             	cmp    %eax,%r12d
  ...

这里没有放全部的代码, 但前面的处理也比较清晰了。 %rdi 存储的是第一个函数参数, 可以看两次 string_length 函数调用存储的结果会进行比较, 如果不同就会 Explore Bomb。 而函数的输入参数在观察后也是清晰的, 仅有一个输入参数, 第一次给的 %rdi 中的内容, 第二次则是 %rsi 中的内容。 而在进入 strings_not_equal 之前 %esi 中的内容被赋值为了 0x402400, 说明这个地址应当存储着被比较的答案。 那么, 在 GDB 调试的时候不妨在 0x400ee9 调用 strings_not_equal 函数处设置断点, 调用如下命令打印的内容就是答案了:

(gdb) b *0x400ee9
(gdb) r
(gdb) print (char *)0x402400
$1 = "Border relations with Canada have never been better."

用如上命令的主要原因是此处比较的是字符串是否匹配, 因而猜测答案应该是一串字符串。

Note

将上述答案放入 result.txt 文件的时候, 需要单独留一空行在最后, 否则 EOF 读取会有误。

2. Phase2

开始在 read_six_numbers 函数里面看了半天, 发现就是读了几个数塞到特定的位置, 其中调用了 sscanf。 在过来看这段 phase_2 的代码, 获取数据后对 %rsp 存储的内容进行比较, 只有为 1 才会跳转到 0x400f30 位置, 取了一些值后继续跳转到 0x400f17 位置。 获取了 %rsp 寄存器中的内容, 一开始这个寄存器被设置为 1, 0x400f17 到 0x400f29 这部分代码就是检测前当前 (%rbx) 的值是否为 -0x4%(rbx) 即前一个值的两倍, 甚至这里的 4 字节宽度可以看出是个 int 类型数据, 总共循环 6 次。

不难推断出答案应当为 1 2 4 8 16 32, 当然可以在 0x400f1a 处打断点, 查看上一个值 %eax 和 现在的值 (%rbx) 的内容也是一样的。

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       	call   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       	call   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       	call   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                   	ret    
(gdb) print /d $rax
$1 = 1
(gdb) print *(int *) ($rbx)
$2 = 2
Note

这里要注意的是, 在进入 GDB 调试之前, 需要用 set args result.txt 设置程序的读取参数。 另外, bomb 程序会要求输入一组数, 一定要记得先随便输入 6 个数, 否则在判断输入数量就会出错了。

3. Phase3

乍一看是理不出具体的操作的, 不过有几个地方还是能找到一些信息的。 如下代码先将 %rsp 预留出的几个位置的地址放到了 %rcx%rdx 寄存器。 我们知道 %rdx%rcx 是用以传输第三第四个参数的, 根据 int sscanf(const char *str, const char *format, ...) 的函数声明, 不难得知 %esi 中的内容通过 sscanf 格式化后写入到了 %rdx%rcx 并返回格式化参数的个数。

通过读取 0x4025cf 处的内容得知为 “%d %d” 说明会格式化 2 个 int 类型的数据, 与猜测相符合。

按照下面的代码, 需要返回参数数量在需要大于 1 并且, 并且 %rdx 的值需要小于等于 7 否则会 Explore Bomb。

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       	call   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       	call   40143a <explode_bomb>
  400f6a:	83 7c 24 08 07       	cmpl   $0x7,0x8(%rsp)
  400f6f:	77 3c                	ja     400fad <phase_3+0x6a>

之后就是一个间接跳转的指令了, 这个间接跳转的指令和第一个输入的值有关系, 第一个输入的值在 0 到 7 之间, 那么就会根据第一参数跳转到不同的位置, 可以查看 0x402470 的值为 0x400f7c, 需要第二个输入的值为 0x137 这不巧了, 下面一对类似的语句应该都是间接跳转的情况。

  400f71:	8b 44 24 08          	mov    0x8(%rsp),%eax
  400f75:	ff 24 c5 70 24 40 00 	jmp    *0x402470(,%rax,8)

如此有不同的答案, 分别为 0 2071 3112 7073 2564 3895 2066 6827 327。 真贴心, 还打乱了顺序!

(gdb) print /x *(int *) 0x402470
$1 = 0x400f7c
(gdb) print /x *(int *) 0x402478
$2 = 0x400fb9
(gdb) print /x *(int *) 0x402480
$3 = 0x400f83
(gdb) print /x *(int *) 0x402488
$4 = 0x400f8a
(gdb) print /x *(int *) 0x402490
$5 = 0x400f91
(gdb) print /x *(int *) 0x402498
$6 = 0x400f98
(gdb) print /x *(int *) 0x4024a0
$7 = 0x400f9f
(gdb) print /x *(int *) 0x4024a8
$8 = 0x400fa6

4. Phase4

同样也是读取数据如出一辙, 甚至连字符串的地址都和 Phase3 的一致。 不过 cmpl $0xe,0x8(%rsp) 这一句要求 %rdx 获取到的第一个参数值要小于等于 0xe。

后面的这几句也很明确, 为了调用 func4 函数, 将其第三个参数 %edx 设置为 0xe, 第二个参数 %esi 设置为 0x0, 第一个参数则是从 sscanf 输入读取的第一个参数。 暂且放一放 func4, 0x401051 地址的语句是判断 sscanf 输入的第二个参数是否为 0, 否则就 Explore Bomb, 答案的一半已经呼之欲出了 (就是 0)。

  40103a:	ba 0e 00 00 00       	mov    $0xe,%edx
  40103f:	be 00 00 00 00       	mov    $0x0,%esi
  401044:	8b 7c 24 08          	mov    0x8(%rsp),%edi
  401048:	e8 81 ff ff ff       	call   400fce <func4>
  40104d:	85 c0                	test   %eax,%eax
  40104f:	75 07                	jne    401058 <phase_4+0x4c>
  401051:	83 7c 24 0c 00       	cmpl   $0x0,0xc(%rsp)
  401056:	74 05                	je     40105d <phase_4+0x51>
  401058:	e8 dd 03 00 00       	call   40143a <explode_bomb>
  40105d:	48 83 c4 18          	add    $0x18,%rsp
  401061:	c3                   	ret    

func4 的内容还是有些神奇, 跟着流程走下来, 比如我当前设定的第一个数是 1, 0x400fdb 时 %ecx 的值从 0xe, 会递归更新到 0x6, 0x2, 而相应的 %eax 的值在 sar %eax 语句执行后会变更为 0x7, 0x3, 0x1, 这个 sar 的指令的使用我没见过, 但看流程应该是将 %eax 向右移动一位, 因为 %ecx 的值是更新到 %eax 中的, 正好和上述结果对应。

  400fe2:	39 f9                	cmp    %edi,%ecx
  400fe4:	7e 0c                	jle    400ff2 <func4+0x24>
  400fe6:	8d 51 ff             	lea    -0x1(%rcx),%edx
  400fe9:	e8 e0 ff ff ff       	call   400fce <func4>

这个函数还处理了了第一个输入的数更大的情况, 但范围是也是被限制在了 0 到 7 之内。 也就是说, 根据 func4 的处理逻辑, 只有输入的值为 1 03 07 0 才能返回 %eax 为 0。

  400ff7:	39 f9                	cmp    %edi,%ecx
  400ff9:	7d 0c                	jge    401007 <func4+0x39>
  400ffb:	8d 71 01             	lea    0x1(%rcx),%esi
  400ffe:	e8 cb ff ff ff       	call   400fce <func4>

5. Phase5

phase_5 开头有这么一段, 在堆栈的特定位置存入了一个 Canary 值以监测程序是否有被栈溢出问题修改内容。 对程序主体没有影响。

  40106a:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
  401071:	00 00 
  401073:	48 89 44 24 18       	mov    %rax,0x18(%rsp)

而后则是判断输入的字符串的长度, 如果长度相等则会跳转到 0x4010d2 处, 清空 %rax 中的内容跳到实际的函数主体的入口地址 0x40108b。

  401078:	31 c0                	xor    %eax,%eax # clear %eax
  40107a:	e8 9c 02 00 00       	call   40131b <string_length> # check length equals to 6 or not
  40107f:	83 f8 06             	cmp    $0x6,%eax
  401082:	74 4e                	je     4010d2 <phase_5+0x70>
  401084:	e8 b1 03 00 00       	call   40143a <explode_bomb>
  401089:	eb 47                	jmp    4010d2 <phase_5+0x70>
  40108b:	0f b6 0c 03          	movzbl (%rbx,%rax,1),%ecx

这个主体部分的逻辑也非常清晰, %rbx 中的内容是我们输入的字符串的起始地址, 由 %rdi 赋值得到。 可以看到这段代码是根据 %rax 的值进行循环逐个取输入字符串中的单个字符, 处理传入的 char input[6], 并将 input[n] 的低 4 位作为 offset 取以 0x4024b0 为首地址的字符串中的字符。

  40108b:	0f b6 0c 03          	movzbl (%rbx,%rax,1),%ecx
  40108f:	88 0c 24             	mov    %cl,(%rsp)
  401092:	48 8b 14 24          	mov    (%rsp),%rdx
  401096:	83 e2 0f             	and    $0xf,%edx
  401099:	0f b6 92 b0 24 40 00 	movzbl 0x4024b0(%rdx),%edx
  4010a0:	88 54 04 10          	mov    %dl,0x10(%rsp,%rax,1)
  4010a4:	48 83 c0 01          	add    $0x1,%rax
  4010a8:	48 83 f8 06          	cmp    $0x6,%rax
  4010ac:	75 dd                	jne    40108b <phase_5+0x29>

上述循环结束后就会进入下列代码中, 又见到老朋友 strings_not_equal 了, 我们很清楚 0x40245e 地址存储着最后的字符组合, 打印出来是 flyers。 因此根据这个值我们就可以进行逆推了, 打印 0x4024b0 为首地址的字符串可以得到 maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?, 我们可以找到 flyers 的每个对应的位置而得出 offset 的值为 9, 15, 14, 5, 6, 7

  4010ae:	c6 44 24 16 00       	movb   $0x0,0x16(%rsp)
  4010b3:	be 5e 24 40 00       	mov    $0x40245e,%esi
  4010b8:	48 8d 7c 24 10       	lea    0x10(%rsp),%rdi
  4010bd:	e8 76 02 00 00       	call   401338 <strings_not_equal>
  4010c2:	85 c0                	test   %eax,%eax

反正最后是要的低 4 位, 根据 ASCII Table 的内容, 答案可以有很多种, 9?>567(0x30 + offset), IONEFG(0x40 + offset) 等等。

6. Phase6

代码好长, 建议看一点就在旁边做点注释。

最开始先是读取了 6 个数, 之后就进入下列代码中, 这段代码的目的就是判断这 6 个数是两两不同的, 否则会 Explore Bomb, 流程可以跟着代码中的注释走一遍留个印象。 另外要求这几个数在减 1 之后要小于等于 5, 因而可以肯定的是这几个数的范围是 1 到 6, 就是目前顺序还不确定。

40110e:	41 bc 00 00 00 00    	mov    $0x0,%r12d
401114:	4c 89 ed             	mov    %r13,%rbp
401117:	41 8b 45 00          	mov    0x0(%r13),%eax
40111b:	83 e8 01             	sub    $0x1,%eax
40111e:	83 f8 05             	cmp    $0x5,%eax
401121:	76 05                	jbe    401128 <phase_6+0x34> # %eax <= 5, otherwise explore bomb, means the six numbers need to be less or equal to 6
401123:	e8 12 03 00 00       	call   40143a <explode_bomb>
401128:	41 83 c4 01          	add    $0x1,%r12d # %r12d start from 0, indicating the six numbers
40112c:	41 83 fc 06          	cmp    $0x6,%r12d
401130:	74 21                	je     401153 <phase_6+0x5f>
401132:	44 89 e3             	mov    %r12d,%ebx
401135:	48 63 c3             	movslq %ebx,%rax
401138:	8b 04 84             	mov    (%rsp,%rax,4),%eax # int type, get input six number iteratively
40113b:	39 45 00             	cmp    %eax,0x0(%rbp) # compare current value to first value for 5 times, needs to be not equal
40113e:	75 05                	jne    401145 <phase_6+0x51>
401140:	e8 f5 02 00 00       	call   40143a <explode_bomb>
401145:	83 c3 01             	add    $0x1,%ebx
401148:	83 fb 05             	cmp    $0x5,%ebx
40114b:	7e e8                	jle    401135 <phase_6+0x41>
40114d:	49 83 c5 04          	add    $0x4,%r13 # move to next number

这段的逻辑比较清晰, 对每一项储存在堆栈上的数 num[i]num[i] = 7 - num[i] 的操作, 对每个数进行重新赋值。 跳出循环的条件是 %rsi 中存储的地址和 %rax 一致。

  401153:	48 8d 74 24 18       	lea    0x18(%rsp),%rsi # 0x18 = 24, move the last number address into %rsi
  401158:	4c 89 f0             	mov    %r14,%rax # initial stack base address
  40115b:	b9 07 00 00 00       	mov    $0x7,%ecx
  401160:	89 ca                	mov    %ecx,%edx # iteratively use 7 to subtract the stored number and replace the orignal ones
  401162:	2b 10                	sub    (%rax),%edx
  401164:	89 10                	mov    %edx,(%rax)
  401166:	48 83 c0 04          	add    $0x4,%rax
  40116a:	48 39 f0             	cmp    %rsi,%rax
  40116d:	75 f1                	jne    401160 <phase_6+0x6c>

做好上述工作后进入如下代码段, 本质上做了一个映射, 将 0x6032d0 起始, 以 0x10 为步长的地址中, 按照所给数据的顺序将对应的地址填入堆栈中。 例如 0x603320 对应数字 6, 数字 6 在第一个位置, 则 0x20(%rsp) 存储着 0x603320 这个地址。

通过调试可以清楚 *(0x6032d0 + 8)=0x6032f0, 即在这一过程中存在着链表结构, 当前链表节点中存储着下一个链表节点的地址。

  40116f:	be 00 00 00 00       	mov    $0x0,%esi
  401174:	eb 21                	jmp    401197 <phase_6+0xa3>
  401176:	48 8b 52 08          	mov    0x8(%rdx),%rdx # update %rdx content, which store the address 0x6032d0 + 8n *(0x6032d0 + 8n) = 0x6032d0 + 0x10 * n
  40117a:	83 c0 01             	add    $0x1,%eax
  40117d:	39 c8                	cmp    %ecx,%eax
  40117f:	75 f5                	jne    401176 <phase_6+0x82>
  401181:	eb 05                	jmp    401188 <phase_6+0x94>
  401183:	ba d0 32 60 00       	mov    $0x6032d0,%edx
  401188:	48 89 54 74 20       	mov    %rdx,0x20(%rsp,%rsi,2) # store the address into stack, whose address start from 20(%rsp)
  40118d:	48 83 c6 04          	add    $0x4,%rsi
  401191:	48 83 fe 18          	cmp    $0x18,%rsi
  401195:	74 14                	je     4011ab <phase_6+0xb7>
  401197:	8b 0c 34             	mov    (%rsp,%rsi,1),%ecx # entry
  40119a:	83 f9 01             	cmp    $0x1,%ecx
  40119d:	7e e4                	jle    401183 <phase_6+0x8f> # less or equal to 1 (you know must be 1)
  40119f:	b8 01 00 00 00       	mov    $0x1,%eax
  4011a4:	ba d0 32 60 00       	mov    $0x6032d0,%edx
  4011a9:	eb cb                	jmp    401176 <phase_6+0x82>

上述代码完成工作后, %rbx 首先被赋值为排序后的栈顶 0x20(%rsp) 内容, %rax 则被赋值为排序后的栈顶偏移 8 byte 的地址 0x28(%rsp), 而 %rsi 则填入栈底地址 0x50(%rsp)

如下代码所要做的工作, 将堆栈栈顶 0x20(%rsp) 中的内容(该内容为一段地址)取出放在 %rcx, 并将相邻的 0x28(%rsp) 中的地址也取出放到 %rdx, 根据之前的描述 *(0x6032d0 + 8n) = 0x6032d0 + 0x10 * n 成立, 即表明这是一种链式结构, mov %rdx,0x8(%rcx) 就是将当前栈中的链表节点存储的下一个链表节点地址, 更新为存储在堆栈相邻位置的链表节点的地址。 最后一个链表节点中则存一个空地址。 这样按照堆栈的存储顺序, 栈顶成了头节点, 栈底成了尾节点。

  4011ab:	48 8b 5c 24 20       	mov    0x20(%rsp),%rbx
  4011b0:	48 8d 44 24 28       	lea    0x28(%rsp),%rax
  4011b5:	48 8d 74 24 50       	lea    0x50(%rsp),%rsi
  4011ba:	48 89 d9             	mov    %rbx,%rcx
  4011bd:	48 8b 10             	mov    (%rax),%rdx
  4011c0:	48 89 51 08          	mov    %rdx,0x8(%rcx)
  4011c4:	48 83 c0 08          	add    $0x8,%rax
  4011c8:	48 39 f0             	cmp    %rsi,%rax
  4011cb:	74 05                	je     4011d2 <phase_6+0xde>
  4011cd:	48 89 d1             	mov    %rdx,%rcx
  4011d0:	eb eb                	jmp    4011bd <phase_6+0xc9>
  4011d2:	48 c7 42 08 00 00 00 	movq   $0x0,0x8(%rdx)
  4011d9:	00 

最后就是遍历堆栈啦, %rbx 的值没有修改过仍然指向栈顶位置 0x20(%rsp), 接着判断当前位置取到的值是否会比栈中下一个元素取得的值大或相等, 不满足则会 Explore Bomb。

  4011da:	bd 05 00 00 00       	mov    $0x5,%ebp
  4011df:	48 8b 43 08          	mov    0x8(%rbx),%rax
  4011e3:	8b 00                	mov    (%rax),%eax
  4011e5:	39 03                	cmp    %eax,(%rbx)
  4011e7:	7d 05                	jge    4011ee <phase_6+0xfa>
  4011e9:	e8 4c 02 00 00       	call   40143a <explode_bomb>
  4011ee:	48 8b 5b 08          	mov    0x8(%rbx),%rbx
  4011f2:	83 ed 01             	sub    $0x1,%ebp
  4011f5:	75 e8                	jne    4011df <phase_6+0xeb>

我们知道存储值的地址分别为 0x6032d00x6032e00x6032f00x6033000x6033100x603320, 不妨都打印出来排个序, 顺序从大到小排序。 根据排序后的关系, 以及前述映射, 可以得到我们的输入为 3 4 5 6 1 2。 但是, 在此之前有一个 7 - num[i] 的转换, 因此正确答案应当为 4 3 2 1 6 5

924 -> 0x6032f0 -> 3
691 -> 0x603300 -> 4
477 -> 0x603310 -> 5
443 -> 0x603320 -> 6
332 -> 0x6032d0 -> 1
168 -> 0x6032e0 -> 2

7. 写在最后

通关之后的情况如下, 最后一个 phase_6 有些坑人, 还有一层转换容易忘掉, 做的时候怎么都过不去人都懵了, 后来看了 Exely - Bomb 做的 Bomb 的笔记才意识到这个问题。 GDB 仅用过 VSCode 的图形化版本, 对 CLI 版本非常不熟悉, 这次实验也算是锻炼了一下这方面的能力。

CSAPP 拿来复习基础知识是真的很不错!

# result.txt
Border relations with Canada have never been better.
1 2 4 8 16 32
7 327
1 0
IONEFG
4 3 2 1 6 5
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Phase 1 defused. How about the next one?
That's number 2.  Keep going!
Halfway there!
So you got that one.  Try this one.
Good work!  On to the next...
Congratulations! You've defused the bomb!