前言
阅读本文需要拥有《深入理解计算机系统》第三章前置知识。本文不会对基础指令做过多说明,但会说明部分较难知识。笔者学识稍浅,若有疏漏或错误处,欢迎各位大佬指正。
安装Lab
切换到想安装Lab的目录后,使用wget下载Lab。
1 | wget csapp.cs.cmu.edu/3e/bomb.tar
|
解压Lab。
前置GDB知识
运行
gdb <filename>使用gdb调试文件
r运行程序,遇到断点则停止
c继续运行,直到遇到下一个断点或程序结束
si单步步入(遇到函数则进入)
ni单步步过(遇到函数则直接执行完函数,不进入)
until运行到循环结束
until <line>运行到行号处
断点
b <line>在第n行设置断点
b <function name>在某个函数入口设置断点
b *(<address>)在地址处设置断点
info b显示当前设置的断点
delete <breakpoint n>删除第n个断点
delete breakpoints删除所有断点
disable <breakpoint n>暂停第n个断点
enable <breakpoint n>开启第n个断点
窗口
layout regs显示寄存器与反汇编窗口
layout asm显示反汇编窗口
显示内容
x/<count><format> <address/registers>打印内存值。从所给地址处开始,以format格式显示count个值。
d: 十进制
x: 十六进制
t: 二进制
f: 浮点数
i: 指令
c: 字符
s: 字符串
example:
Ctrl + L清屏
Phase_1
首先使用objdump将反汇编代码储存,以方便览阅。
1 | objdump -d bomb > bomb.asm
|
然后使用gdb打开程序,并给Phase_1打上断点。
运行程序r,随机输入一些字符串后发现程序运行到断点处。

显示寄存器与反汇编窗口。
layout regs
观察显示的反汇编代码。

发现在地址0x400ee9处调用了函数strings_not_equal,然后在0x400eee处测试返回值(%eax)使否等于0,如果等于0则跳转到0x400ef7(je指令,即jump equal),否则调用0x400ef2处的explode_bomb。
复习CSAPP第三章内容,得知函数调用时数据传输的顺序(第1个参数~第6个参数使用寄存器,如参数大于6个则使用栈传递)。
| 操作数大小(位) |
1 |
2 |
3 |
4 |
5 |
6 |
| 64 |
%rdi |
%rsi |
%rdx |
%rcx |
%r8 |
%r9 |
| 32 |
%edi |
%esi |
%edx |
%ecx |
%r8d |
%r9d |
| 16 |
%di |
%si |
%dx |
%cx |
%r8w |
%r9w |
| 8 |
%dil |
%sil |
%dl |
%cl |
%r8b |
%r9b |
由以上推断我们可以猜测string_not_equal的两个参数就是我们的输入与标准答案。运行到0x400ee9后,我们查看%rdi与%rsi的值观察看看。
x/s $rdi
x/s $rsi

得到答案
Border relations with Canada have never been better.
Phase_2
在phase_2处打断点,随机输入几个数字(我选择1 2 4 8 16 32),观察其代码。

发现有名为read_six_numbers的函数,跟进查看代码。

发现有一个函数sscanf,查询后发现sscanf函数是用来从指定字符串中按格式提取数据。
其原型为:
1 | int sscanf(const char *str, const char *format, ...);
|
参数解释:
- str:待解析的字符串
- format:格式化字符串
- ...:可变参数列表,用来接收解析后的数据 (须为变量地址)
我们可以猜测sscanf就是用来提取用户输入。通过phase_1中展示的数据传输顺序,查看%rdi与%rsi。

可以发现我们的猜测是正确的,故调用sscanf的参数应该是:
1 | sscanf("<user input>", "%d %d %d %d %d %d", &rdx, &rcx, &r8, &r9, &rsp, &(rsp+8))
|
通过阅读read_six_numbers函数的代码可以发现各参数存放的地址:

步过这个函数,继续观察phase_2的代码。

首先0x400f0a检查(%rsp)是否为0x1,如果不等则直接爆炸。由0x400f02处的代码可知%rsp = %rsi,且在函数read_six_numbers中%rsi = %rdx,所以(%rsp)中的值就是用户输入的第一个数字(即检查用户输入的第一个数字是否等于1)。
如果通过该检查,则跳转到0x400f30处,初始化%rbx与%rbp。通过观察,可以发现0x400f17~`0x400f2c`是一个for循环。

仔细观察循环,发现%eax读取-0x4(%rbx)中的值。因为int类型占4字节,所以若%rbx指向用户输入的第i个数,M[%rbx - 0x4]会读取用户输入的第i - 1个数。同时,因为执行add指令比执行mul指令速度快,所以程序使用add指令优化%eax *= 2。
最后两条指令比对%eax与(%rbx)的值,若是不相等则爆炸。由此我们可以构造出一个长度为6的数组,首项为1,其后每一项都是前一项的两倍。
得到答案:1 2 4 8 16 32。
Phase_3
在phase_3处打断点,输入1 2,观察该处代码。

发现了熟悉的sscanf函数,如我们做phase_2时一样,查看sscanf的几个参数。

透过观察0x400f47与0x400f4c处的lea指令发现将用户输入读入%rsp+0x8与%rsp+0xc处。为更好理解,我们假设%rsp+0x8与%rsp+0xc为一个数组userinput[2],%rsp+0x8为userinput[0],%rsp+0xc为userinput[1]。

0x400f60~`0x400f65处的代码检查输入长度,通过查看sscanf函数参数我们知道输入应该为两位。0x400f6a处的代码检查userinput[0],若大于7则爆炸。然后0x400f71处的指令将%eax赋值userinput[0]`。

特别注意0x400f75处的代码,发现其为switch指令的跳转表,跳转的公式即:<基地址> + <寄存器值> * <表项类型大小>。查看0x402470中的值,即跳转表的基地址。由0x400f6a处的判断可以推测该跳转表大小应为7项,因为跳转表储存的值占8字节,所以跳转表所占的字节大小为7×8=56字节。

因为该机器为小端序,所以地址是倒序的,恢复后地址如下。

观察phase_3剩余代码,在跳转表跳转到的地址处,我标注了跳转过来对应的userinput[0]值。我们可以看到在每个跳转后代码都会给%eax赋予一个特定的值。

观察0x400fbe处的代码,是一个判断语句。若赋予%eax的特定值等于userinput[1]则成功,所以此题不仅一个解。其中一个解为1 311(0x137)。
Phase_4
依照惯例,在phase_4处打断点,随便输入几个数字,开始阅读反汇编代码。

注意观察0x40103a~`0x401048处的代码,调用了func4函数。我们可以看到0x40104d要求func4的返回值要等于0才可正确解弹,我们还可以发现0x401051`处要求用户输入的第二个数字等于0才可正确解弹。
查看func4的反汇编代码。

直接阅读汇编码并不好理解这段代码的运行规则,我们人肉把这段代码转换成C++代码,试试这样可读性会不会提高。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | int func4(int input, int a, int b) {
int tmp1, tmp2;
tmp1 = b - a;
tmp2 = tmp1 >> 31;
tmp1 += tmp2;
tmp1 /= 2;
tmp2 = tmp1 + a;
if (tmp2 < input) {
func4(input, tmp2 + 1, b);
return 1;
}
else if (tmp2 == input) {
return 0;
}
else {
func4(input, a, tmp2 - 1);
return tmp1 * 2;
}
}
|
这样阅读起来方便多了。还记得我们的目标是使func4返回0,最简单的方式是使tmp2 == input。通过初始传入的参数计算tmp2的值,我们可以得到tmp2等于7。
得到答案:7 0(不仅一组解)。
Phase_5
对phase_5打断点,随便输入几个数字,开始我们的分析。

读者可能会疑惑0x40106a处的mov %fs:0x28, %rax是甚么?复习一下CSAPP第三章可以发现,这个位置存放着程序的金丝雀值(canary),用来检测栈溢出。

在代码的末尾我们可以看到程序再次取出栈中之前储存的金丝雀值,通过xor比对是否与真正的金丝雀值相等,如果不相等则代表发生栈溢出。
0x40107a0x4010d7处代码检测输入长度是否为6,并置零%rax的值。
接下来是重中之重,分析代码中的循环,即0x40108b~`0x4010a8`处的代码。

通过汇编代码很难看懂代码逻辑,我们同样将其转成C++观察。
1 2 3 4 5 6 7 8 9 10 | void EncryptLoop() {
char* tmp;
char msg[6];
char userinput[6];
for (int i = 0; i < 6; i++) {
*tmp = userinput[i] & 0xf;
tmp = (char*)(0x4024b0 + (int)tmp);
msg[i+0x10] = *tmp;
}
}
|
我们发现在0x401096处的and指令,只取%rdx的最低一位。0x401099处的代码中发现一个内存地址0x4024b0,查看其中内容。

我们用不到那么长的内容,只取前面第一串内容即可。
1 2 | tmp = (char*)(0x4024b0 + (int)tmp);
msg[i+0x10] = *tmp;
|
对于这两段代码,作用是将从0x4024b0开始的第(int)tmp个字符放入msg[i+0x10](M[%rsp+0x10])中,作为后续字符串对比的输入。

观察0x4010ae~`0x4010bd处代码,我们可以发现strings_not_equal调用的两个参数分别是处理后的用户输入M[%rsp+0x10]与硬性写入的内存地址0x40245e。查看0x40245e`存放的内容。

对比0x4024b0处的字符串,我们需要计算如何使用"maduiersnfotvbylSo"凑出"flyers"。通过计算我们很容易可以得出每个字浮需要的偏移为"0x9 0xf 0xe 0x5 0x6 0x7"。(即:0x4024b0 + 0x9为'f',0x4024b0 + 0xf为'l'...其余类推)
现在我们需要思考甚么输入可以造成%rdx依序为"0x9 0xf 0xe 0x5 0x6 0x7"。我们需要<输入> & 0xf后,可以得到所需的偏移,查询ASCII码对照表后,我们可以找到其中一组解:9/.567(十六进制为:39 2f 2e 35 36 37)。
Phase_6
这题有点难度,读者们可以先去休息下,精神充沛后我们继续解phase_6。
我们可以看到phase_6中又出现了熟悉的read_six_numbers函数。打上断点,随便输入六个数字,我们开始分析。

观察代码,我们可以看到0x40110e0x401148(Loop 1)。
Loop 1的作用是利用循环检测userinput[i]之后的所有数字是否有任何一个数字与userinput[i]相等。Check 1 Loop的作用在递增i,确保整个数组中的数字都不相等且皆不大于6。相等于以下代码:
1 2 3 4 5 6 | for (int i = 0; i < 6; i++) {
for(int j = i+1; j < 6; j++) {
if (userinput[i] == userinput[j])
explode_bomb();
}
}
|

0x401153~`0x40116d处代码遍历userinput,将每一项修改为0x7 - userinput[i]`。相当于以下代码:
1 2 3 | for(int i = 0; i < 6; i++) {
userinput[i] = 0x7 - userinput[i];
}
|

在0x401183处我们发现一个内存地址0x6032d0,通过gdb我们查看一下该内存地址内储存的值。

通过名字与内存中的值,我们可以猜测这是一个链表的头节点。链表的定义如下:
1 2 3 4 5 | struct Node {
int val;
int key;
Node *next;
};
|
首先看一进入此部分就跳转到的地址0x401197处的代码:首次执行时如果修改后的userinput[0]等于0x1则将M[%rsp+0x20]处写入0x6032d0(即Node 1的内存地址),否则则跳转到Loop 4执行循环。观察Loop 4的循环,我们可以发现这一段代码就是读取第%ecx个链表节点。Loop 3则将读取的节点存入M[%rsp + %rsi*2 + 0x20]中。
我们可以将其转换成等价的C++代码观察。
1 2 3 4 5 6 7 8 9 10 11 | Node *head;
for (int i = 0; i < 6; i++) {
head = 0x6032d0;
for (int j = 0; j < userinput[i]; j++) {
head = head->next;
}
*NodeList[i] = head;
}
|

这一段代码稍微较好了解,比较容易困惑的点在0x4011b5处的%rsp + 0x50。读者可能会想这个值好像不在我们的链表范围内,但是仔细阅读代码后可以发现0x4011c4与0x4011c8处的代码使用的是%rax(计数器) + 0x8的值与%rsp + 0x50比对,所以循环真正运行的范围在%rsp + 0x20~ %rsp + 0x48的40个字节。
1 2 3 4 5 6 7 | Node *head = *NodeList[0];
for (int i = 1; i < 6; i++) {
Node *next = *NodeList[i];
head -> next = next;
head = head -> next;
}
head -> next = nullptr;
|

Loop 6的功能是取得每个节点的值,并检验链表是否依节点的值由大排到小。与以下C++代码功能相等
1 2 3 4 5 6 7 8 9 | Node *head = 0x6032d0;
Node *next = head -> next;
for (int i = 0; i < 5; i++) {
if (head -> val < next -> val) {
explode_bomb();
}
head = head -> next;
next = next -> next;
}
|
综合以上分析,可以得知我们需要让链表依据节点的值由大排到小,首先查看各节点的值。

Node1->val: 0x14c = 332
Node2->val: 0xa8 = 168
Node3->val: 0x39c = 924
Node4->val: 0x2b3 = 691
Node5->val: 0x1dd = 477
Node6->val: 0x1bb = 443
所以我们可以得知,被程序修改过的userinput(0x7 - userinput[i])应该为3 4 5 6 1 2。反推原始值我们可以得到答案:4 3 2 1 6 5。
以上,phase_1到phase_6全部被我们解开。


Lab在最后还留有一个secret_phase,在此就留作读者读完本文的课后练习。笔者写完这篇题解准备溜去早点休息,为读者们写下一个Attack Lab题解做准备与学习。
后记
笔者仍是一位正在学习的菜鸟,正利用高中毕业后与上大学前的空闲时间增强学习稍微底层一点的东西。这段时间也会是笔者发文的高发期,不知道进入大学后还有没有这么多时间进行创作或自由时间学习。若发现本文某些地方代码错误或分析逻辑错误,欢迎各位大佬指正,笔者会虚心学习。日后可能会发一些CSAPP相关Lab的题解、密码学、逆向工程相关的笔记或者教学创作,虽然笔者学识尚浅,但是仍希望可以将这些知识传递给拥有相同困惑或相同兴趣的网友们。