Splint 是一个比较常用的静态分析工具,缓冲区溢出漏洞,或者说缓冲区溢出攻击,也是信息安全领域的基础知识,本文以此对用静态分析工具进行漏洞检测做一个最基本的例子。
Splint
Splint 是一个开源的静态代码检测工具,用于检查 C 语言程序安全弱点和编码错误。从官网下载源码:http://www.splint.org/downloads/splint-3.1.2.src.tgz
tar xvzf splint-3.1.2.src.tgz
cd splint-3.1.2
./configure
make
sudo make install
然后,即可在终端使用。
缓冲区溢出基础
缓冲区溢出是经典的安全漏洞,想理解它的话,需要对程序运行时系统堆栈的运行原理有基本的了解。简单地讲,一个程序能实现函数调用,主要是依赖堆栈的存在。学过计算机原理的都应该知道,计算机执行程序的方法就是 CPU 从内存中一条一条地取出指令,然后执行。而究竟去什么地方取指令,取决于 EIP
寄存器,EIP
里面记录了下一条指令的存放地址,如果想实现函数调用,或者说实现一个跳转,那么需要修改 EIP
的值,问题在于,函数执行完了之后怎么回到原来的调用处继续往下执行呢?这个过程必然需要堆栈帮忙,保存跳转前的各种状态,方便函数执行完毕后,返回主程序时,把上下文恢复成原来的情况。
程序中的每个函数都有自己的栈帧,关于栈帧不多解释了,可以参考《Linux C 程序设计大全》或者网络博客等等任何有关栈帧和内存管理的文章。
考虑下面一段程序:
int seeding (int a, int b)
{
char buf[32];
return 0;
}
int main()
{
ret = seeding(2, 5);
return 0;
}
在主函数中调用了一个 seeding
函数,那么主函数里面的逻辑如果用汇编来表示,大体上就是:
push 5
push 2
push addr_of_return
call seeding
而在 seeding
里面,则会 push ebp
, mov esp, ebp
,保护现场,进入自己的栈帧,等函数执行完毕,会把 ebp
恢复,之后根据 addr_of_return
的值修改 EIP
,返回到主函数中。
画图不太方便, 借用网络的一张图进行示意,函数调用过程堆栈的情况大体是这样。
不用管右边,只看左边。压栈是从上往下压的,data
上边的区域可以认为是主函数使用的,data
可以认为是存放了 a 和 b 两个参数,即 5 和 2,返回地址存放了主函数里面 return
语句的指令地址, ebp
存放了主函数的 ebp
(ebp
esp
是什么意思这里不解释,只说重点)。缓冲区就是buf[32]
,因为局部变量也是存放在栈里面的,所以如果往buf
里面赋值的话,内容就存放在图上的缓冲区中。
正常情况下,seeding
函数返回的时候,会把图上的ebp
值出栈,这样寄存器ebp
就恢复到了seeding
调用前的状态,之后把返回地址放到EIP
中,这样,下一条指令自然就会执行主函数的 return 0
语句,那么函数调用就成功结束,返回到了主函数中。
下面问题来了,C 语言是不检查数组边界的,会根据你的输入一直往后面写,所以如果你往buf[32]
里面存了 48 个字符,超过了数组的容量,那么由于缓冲区上面的空间是ebp
和返回地址,所以超出的部分会覆盖掉这些地方原来的值。特别的,如果精心设计超出部分的值,让我们设计好的数字恰好覆盖掉返回地址,那么程序返回的时候,就不是返回到原来的主函数,而是跳转到我们想要执行的恶意代码了。
这就是缓冲区溢出漏洞,也可以说是缓冲区溢出攻击,由于一般的做法是通过故意让缓冲区溢出,来打开一个 shell,所以这段用于溢出原缓冲区的代码又称为 shellcode。
构造带有缓冲区溢出漏洞的程序
为了做实验,我们写一个带溢出漏洞的程序。顺便将上面的原理在实践中演示一下。
首先是一个正常的程序,我们展示一下程序中调用栈的情况。源代码是:
// filename: overflow.c
#include <stdio.h>
#include <string.h>
int func(char in[])
{
char buf[10];
strcpy(buf, in);
return 0;
}
int main()
{
char *in = "myinput";
int ret;
ret = func(in);
return 0;
}
编译这段程序,注意要加上禁止堆栈保护选项:
gcc -g -fno-stack-protector overflow.c -o overflow
编译后,使用 gdb 在 func
里面的return
处下断点,观察内存、esp
以及栈帧情况。结果如图所示。
- 由于是 64 位系统,所以寄存器都是 rbp,rsp,实际就是上文的 ebp,esp。
- 由于是 64 位系统,所以地址有点长。
- 这里只关注第二张图,后文都说的是第二张图。
可以看到,倒数第四行,rsp(堆栈栈顶) 指向了 dee0。在正数第三行开始,我们查看了 dee0 开始到 df10 的内存数据,对照 ASCII 码表可以看到,堆栈最上面的元素(def0 那行)就是程序里面的 myinput
,紧接着就是 ebp(df00 那行的前两个数据),而 df00 行的第三个数据 0x0040056f,就是返回地址,与图片倒数第一行给出的栈帧情况相符,倒数第一行表明,函数结束后,应该返回 main 的 40056f 处,和堆栈中写入的数据是一致的。所以实验结果与上节原理的解释是相符的,堆栈里面先是缓冲区,缓冲区上面是 ebp,再上面是返回地址。
下面我们改动程序,让缓冲区溢出,覆盖掉返回地址。改动后的程序是这样:
// filename: exploit.c
#include <stdio.h>
#include <string.h>
int exploit()
{
printf("我是幼苗我自豪\n");
}
int func(char in[])
{
char buf[16];
strcpy(buf, in);
return 0;
}
int main()
{
char *in = "1234567890123456\x20\xdf\xff\xff\xff\x7f\xcc\xcc\x7d\x05\x40\x00";
// char *in = "123456789012345"; 正常版本
int ret;
ret = func(in);
return 0;
}
首先,程序里面多了一个 exploit 函数,这是我们想通过缓冲区溢出攻击来执行的恶意代码。可以注意到,func 里面 buf 的空间是 16,而在主函数中,给出的输入 in,前面的数字正好有 16 个,恰好用完 buf 的空间,后面以 16 进制方式硬编码的输入,就是我们想要通过溢出来覆盖掉 ebp 和返回地址的数据。
我们先使用 main 里面的正常版本输入来编译这个程序,看一下堆栈的情况。
和前文一样,第四行表明 rsp 是 ded0,dee0 那行数据就是输入 in 存储的字符,可以和 ASCII 码表对照来看。def0 行的前两个数据是 ebp,第三个数据就是返回地址,和倒数第一行的栈帧数据相符。
然后,更改前文源码变成溢出版本,将正常版本注释掉,编译并反汇编,查看 exploit 的函数地址。
可以看到,exploit 的调用地址是 40057d,这个数字已经填写在前文源码中 in 的最后几个字节上了, in 的作用就是让 buf 溢出,然后让这几个字节恰好覆盖在堆栈里返回地址的位置上。
用 gdb 看一下带漏洞的这个程序,结果是:
不难发现,倒数第九行 def0 行就是正常的缓冲区数据,df00 行的前两个数据是 ebp,然而 ebp 已经被我用溢出的方法非法改掉,对照前文的正常版本可以看出,第二个数据应该是 0x00007fff,我在输入 in 里面,改成了 0xcccc7fff。
当然,最重要的是,第三个数据返回地址,已经被溢出为 40057d,变成了 exploit 的调用地址,而并非原来的主函数返回地址,同时可以看到,由于非法把返回地址改写,最后几行 gdb 探测栈帧出现了异常,已经无法显示正常的栈帧。
最后,执行这个带有溢出攻击的程序,结果是:
在源码中,并没有调用 exploit 的语句,然而我们却通过利用缓冲区溢出漏洞,让这个函数执行了。
使用 Splint 分析漏洞程序
带漏洞的程序已经编出来了,最后,用静态分析工具 Splint 来分析一下它。假定我们完全不知道 exploit 这个程序的情况,就是别人给我的,那么,cd 到程序目录:
splint +bounds exploit.c
分析结果是:
它分析出 3 条警告,第一条说 exploit 函数里面没写 return 语句,第二条就是我们需要的,它展示出 strcpy 是不安全的,可能会出现 buffer 越界的隐患。(就是带有缓冲区溢出漏洞)
这就是用静态工具分析出漏洞的一个简单例子。但它究竟是用什么技术分析出来的,那我就说不清了。
文章所展示的层次,就是实验层次的会用,这工具的背后,就是各种语法分析,语义分析等等,那些学术原理。那些原理用来指导编写出这个工具。