0%

请小心栈溢出

引言

写这篇文章本意是帮助萌新们对栈溢出能够有一个较为直观的理解,可能废话有点多,让各位大佬见笑了,还望不喜勿喷。

阅读本文前,建议掌握一定汇编基础

接下来我们进入正题。
或许你在平常时有在老师、朋友、或是其他的程序员的口中听过“栈溢出”这个词,那到底什么是栈溢出呢?为什么栈会溢出呢?
我们先来看看百度百科的描述:

栈溢出就是缓冲区溢出的一种。由于缓冲区溢出而使得有用的存储单元被改写,往往会引发不可预料的后果。程序在运行过程中,为了临时存取数据的需要,一般都要分配一些内存空间,通常称这些空间为缓冲区。如果向缓冲区中写入超过其本身长度的数据,以致于缓冲区无法容纳,就会造成缓冲区以外的存储单元被改写,这种现象就称为缓冲区溢出。缓冲区长度一般与用户自己定义的缓冲变量的类型有关。

来自百度百科

简单的说,内存在储存数据时,程序会分配到一部分内存空间,但是这一部分的空间并不会加上一个围墙当着拦着,也就是说,如果程序操作不当,则会跨出这块空间,对属于其他正常运行着的程序的空间进行了操作(修改),这就会造成其他程序无法正常运行。(再简单一点,就是碰了不该碰的东西)

栈溢出

栈的初步理解

内存
栈和堆里面的内容都时时刻刻都在变化着,随着我们程序的运行,这一块内存区域会不断的被释放、申请。而里面储存着的大都是程序运行时所需要的临时变量(数据),若正常程序的内容被别的程序修改了,则可能会带来一些较为严重后果。
运行一段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
#include <stdio.h>
#include <stdlib.h>

typedef struct {
int a[2];
double d;
} struct_t;

double fun(int i) {
volatile struct_t s;
s.d = 3.14;
s.a[i] = 1073741824; /* Possibly out of bounds */
return s.d; /* Should be 3.14 */
}

int main(int argc, char *argv[]) {
int i = 0;
if (argc >= 2)
i = atoi(argv[1]);
double d = fun(i);
printf("fun(%d) --> %.10f\n", i, d);
return 0;
}
/*
程序接收参数i,传入函数fun中,使得内存地址为(&s.a[0] + 4*i)的内容改为0x40000000
*/

在Ubuntu 18.04 LTS下用gcc 7.4.0进行编译后运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
hpj@asus:/mnt/d/code/csapp$ ./struct 1
fun(1) --> 3.1400000000
hpj@asus:/mnt/d/code/csapp$ ./struct 2
fun(2) --> 3.1399998665
hpj@asus:/mnt/d/code/csapp$ ./struct 3
fun(3) --> 2.0000006104
hpj@asus:/mnt/d/code/csapp$ ./struct 4
fun(4) --> 3.1400000000
hpj@asus:/mnt/d/code/csapp$ ./struct 5
fun(5) --> 3.1400000000
hpj@asus:/mnt/d/code/csapp$ ./struct 6
*** stack smashing detected ***: <unknown> terminated
已放弃 (核心已转储)

当参数为2、3的时候,程序能够正常的运行,但所输出的内容却不对;而当参数为6的时候,没有内容输出,程序无法正常运行。那为什么会这样呢?
我们一步步地来看看它是如何运行
首先我们观察一下汇编代码:

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
58
00000000000006fa <fun>:
6fa: 55 push %rbp
6fb: 48 89 e5 mov %rsp,%rbp
6fe: 48 83 ec 30 sub $0x30,%rsp
702: 89 7d dc mov %edi,-0x24(%rbp)
705: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
70c: 00 00
70e: 48 89 45 f8 mov %rax,-0x8(%rbp)
712: 31 c0 xor %eax,%eax
714: f2 0f 10 05 44 01 00 movsd 0x144(%rip),%xmm0 # 860 <_IO_stdin_used+0x20>
71b: 00
71c: f2 0f 11 45 e8 movsd %xmm0,-0x18(%rbp)
721: 8b 45 dc mov -0x24(%rbp),%eax
724: 48 98 cltq
726: c7 44 85 e0 00 00 00 movl $0x40000000,-0x20(%rbp,%rax,4)
72d: 40
72e: f2 0f 10 45 e8 movsd -0x18(%rbp),%xmm0
733: 48 8b 45 f8 mov -0x8(%rbp),%rax
737: 64 48 33 04 25 28 00 xor %fs:0x28,%rax
73e: 00 00
740: 74 05 je 747 <fun+0x4d>
742: e8 69 fe ff ff callq 5b0 <__stack_chk_fail@plt>
747: c9 leaveq
748: c3 retq

0000000000000749 <main>:
749: 55 push %rbp
74a: 48 89 e5 mov %rsp,%rbp
74d: 48 83 ec 30 sub $0x30,%rsp
751: 89 7d ec mov %edi,-0x14(%rbp)
754: 48 89 75 e0 mov %rsi,-0x20(%rbp)
758: c7 45 f4 00 00 00 00 movl $0x0,-0xc(%rbp)
75f: 83 7d ec 01 cmpl $0x1,-0x14(%rbp)
763: 7e 16 jle 77b <main+0x32>
765: 48 8b 45 e0 mov -0x20(%rbp),%rax
769: 48 83 c0 08 add $0x8,%rax
76d: 48 8b 00 mov (%rax),%rax
770: 48 89 c7 mov %rax,%rdi
773: e8 58 fe ff ff callq 5d0 <atoi@plt>
778: 89 45 f4 mov %eax,-0xc(%rbp)
77b: 8b 45 f4 mov -0xc(%rbp),%eax
77e: 89 c7 mov %eax,%edi
780: e8 75 ff ff ff callq 6fa <fun>
785: 66 48 0f 7e c0 movq %xmm0,%rax
78a: 48 89 45 f8 mov %rax,-0x8(%rbp)
78e: 48 8b 55 f8 mov -0x8(%rbp),%rdx
792: 8b 45 f4 mov -0xc(%rbp),%eax
795: 48 89 55 d8 mov %rdx,-0x28(%rbp)
799: f2 0f 10 45 d8 movsd -0x28(%rbp),%xmm0
79e: 89 c6 mov %eax,%esi
7a0: 48 8d 3d a1 00 00 00 lea 0xa1(%rip),%rdi # 848 <_IO_stdin_used+0x8>
7a7: b8 01 00 00 00 mov $0x1,%eax
7ac: e8 0f fe ff ff callq 5c0 <printf@plt>
7b1: b8 00 00 00 00 mov $0x0,%eax
7b6: c9 leaveq
7b7: c3 retq
7b8: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
7bf: 00

可以看到程序在第0x780行的时候调用fun函数

在第0x726行将指定内存区域修改为0x40000000

我们先来看看,如果我们带参数3,程序是如何运行的。

准备进入fun函数,参数3储存在寄存器edi里

准备进入fun函数,参数3储存在寄存器edi


把3.14存入0x00007fffe71a0618之中

把3.14存入0x00007fffe71a0618之中


储存3.14的被破坏,返回被破坏的的数据

储存3.14的被破坏,返回被破坏的的数据


我们可以看到在0x00007fffe71a0618原本的0x40091eb8被改为了0x4000000

3.14就被改成了2.0000006104,且被储存进xmm0用于返回上一层函数main

Ps:对应上面的汇编代码第0x726行到第0x72e行的内容

这就是为什么我们参数为3的时候,输出的结果却不是3.14的原因

我们的程序在执行s.a[i] = 1073741824;的时候并不会检查你的i值合不合适,导致了我们正常的数据3.14被修改为了一个我们无法控制的结果

那如果我们的参数为6的时候又会发生什么呢?

前面的基本一样(我就不把图片贴出来了),但是到了第0x726行的时候就有点不一样了。通常来说,编译器会为我们的代码增加普通的栈溢出检查步骤:检测canary值是否被修改!

我们的程序在进入函数之前,都会把我们下一步要执行的命令所在的位置存入到栈里面,我们称之为返回地址(返回上一层函数的道路),如果发生了栈溢出,就很有可能会把返回地址给修改了,当我们要执行返回操作时,返回的位置就不一定是正常的位置了,这将导致我们的程序无法正常的执行。所以在返回地址前面增添一个哨兵,如果哨兵的值被修改了,则意味着返回地址很有可能被修改了。

在第0x70e行置入金丝雀值
在第0x70e行置入金丝雀值


在 第0x726行时修改了金丝雀的值
同样的,在第0x726行时修改了金丝雀的值


在第0x737行对金丝雀进行检测
在第0x737行对金丝雀进行检测


上图是参数为6时,金丝雀被修改的大致过程。我们可以看到,在第0x737行进行异或运算,因为金丝雀值已经被修改了,异或的结果就不会是0,则无法执行第0x740行的转跳命令(转跳到0x747行执行leave指令),在第0x742行的函数__stack_chk_fail就会被调用,以结束程序。

栈溢出的初级利用

那到了这里,就可能有小伙伴觉得,既然都有金丝雀值来保护了,那我们岂不是就不用去担心这个栈溢出会对我们的系统造成破坏了?
不不不!
刚刚只是说“如果哨兵的值被修改了,则意味着返回地址很有可能被修改了”
并不意味着返回地址被修改,哨兵的值也会被修改 !
而且,返回地址被修改了,是一件非常可怕的事情
我们尝试运行一下下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
void fun()
{
printf("blog.hpjpw.com\n");
}
void jump(int i)
{
long a[2];
a[0] = 0x11111111;
a[1] = 0x22222222;
a[i] = a[i]-122;
}
int main()
{
jump(5);
return 0;
}
/*
运行结果如下:
hpj@asus:/mnt/d/code/C/stack_overflow_hack_test$ ./a
blog.hpjpw.com
注意:我在不同的终端下运行这个程序时可能会出现段错误的情况,目前还未搞清楚原因,我并没有深入的了解系统对栈溢出的防护,还希望能够有大佬指点一二。
*/

明明fun函数没有被调用,那为什么还会输出一串字符串呢?

先看一下反汇编能得到些什么东西吧:

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
00000000000006bd <jump>:
6bd: 55 push %rbp
6be: 48 89 e5 mov %rsp,%rbp
6c1: 48 83 ec 30 sub $0x30,%rsp
6c5: 89 7d dc mov %edi,-0x24(%rbp)
6c8: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
6cf: 00 00
6d1: 48 89 45 f8 mov %rax,-0x8(%rbp)
6d5: 31 c0 xor %eax,%eax
6d7: 48 c7 45 e0 11 11 11 movq $0x11111111,-0x20(%rbp)
6de: 11
6df: 48 c7 45 e8 22 22 22 movq $0x22222222,-0x18(%rbp)
6e6: 22
6e7: 8b 45 dc mov -0x24(%rbp),%eax
6ea: 48 98 cltq
6ec: 48 8b 44 c5 e0 mov -0x20(%rbp,%rax,8),%rax
6f1: 48 8d 50 86 lea -0x7a(%rax),%rdx
6f5: 8b 45 dc mov -0x24(%rbp),%eax
6f8: 48 98 cltq
6fa: 48 89 54 c5 e0 mov %rdx,-0x20(%rbp,%rax,8)
6ff: 90 nop
700: 48 8b 45 f8 mov -0x8(%rbp),%rax
704: 64 48 33 04 25 28 00 xor %fs:0x28,%rax
70b: 00 00
70d: 74 05 je 714 <jump+0x57>
70f: e8 6c fe ff ff callq 580 <__stack_chk_fail@plt>
714: c9 leaveq
715: c3 retq

可以看到在第0x6ec行把返回地址(看实际如何运行)放入寄存器rax,处理过后,在第0x6fa行把返回地址替换成处理过后的数据。

下面再来看看实际运行的结果是怎么样的

准备调用函数jump
准备调用函数jump,参数为5,存放在寄存器rdi


进入函数jump
进入函数jump,把下一条指令的地址(返回地址)压入栈
返回地址为0x00007f281d400724,被放在0x00007fffdb781ee8里面


把返回地址放入寄存器
把返回地址放入寄存器rax
再对返回地址进行处理a[i] = a[i]-122;
Ps:这里减122是因为fun函数入口在第0x6aa行,与返回地址第0x724行相差了122(十进制)
处理后再把它放回到0x00007fffdb781ee8


修改后的返回地址入内存
修改后的返回地址入内存


返回地址
a[5]指向的是返回地址


程序只是修改了返回地址,并没有碰金丝雀的值,所以在第0x70f行的__stack_chk_fail不会被调用,程序能够继续往下执行,当执行到第0x715行的ret时,就会进入到fun函数的入口位置继续执行,所以我们能够看到那串字符串被打印出来。
Ps:fun函数里面调用的printf函数被编译器优化为了puts函数,不得不说现在的编译器越来越聪明了。

总结

最后来个总结。尽管我们的系统有机制以防护栈溢出带来的不良影响,但是对我们程序的稳定性来说却是一个致命的打击,在平时写代码的时候还是要多多注意栈溢出带来的不良影响。就比如说,对于strcpyputsgets等一些没有对边界进行检查的函数,我们要尽可能的避免使用,以免造成不好的后果。