Hexo


  • Home

  • Archives

stack_broken crash -- 栈被破坏导致的crash

Posted on 2018-12-23

在众多的coredump中,有一类crash调试起来是最麻烦的,那就是“栈被破坏”导致的函数调用回溯结构破坏引发的coredump。本文,主要讲讲这一类crash的成因、原理以及调试方法。

1. SMTC(show me the code)

首先,让我们来看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 1 #include <stdio.h>
2 #include <string.h>
3 void fun(int n)
4 {
5 printf("The %d step begin.\n", n);
6 int a[10];
7 for (int i = 0; i< n; i++) {
8 a[i] = i;
9 }
10 if (n < 20) {
11 fun(n +1);
12 }
13 printf("The %d step end\n", n);
14 }
15
16 int main(void)
17 {
18 fun(8);
19 return 0;
20 }

这段代码的关键在于fun函数会有递归调用,而在参数大于10的时候会导致写入的空间超过了栈上的“合法”内存。我们先来看下这段代码的输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
The 8 step begin.
The 9 step begin.
The 10 step begin.
The 11 step begin.
The 12 step begin.
The 13 step begin.
The 14 step begin.
The 15 step begin.
The 16 step begin.
The 17 step begin.
The 18 step begin.
The 19 step begin.
The 20 step begin.
The 20 step end
Segmentation fault (core dumped)

对于输出,我们做简要的解释:

  1. 所有的栈上地址都是合法(此处的合法,指的是操作系统允许写入)的,也就是说即使写入到a[19]这样的非预期地址,也不会crash
  2. 由于栈的增长方向是高地址向低地址,而“写坏”的是“高地址”,也就是说已经申请过的地址。
  3. 栈上与函数调用最相关的数据信息是rip&&esp,而直接导致crash的原因是因为rip被“写坏”,导致执行对应的指令出现问题。

2.原因详细解释

首先,我们来回顾一下函数调用过程中的stack数据分布与变化(link)。接着看上文的程序输出就明白:在fun函数调用的末尾,需要执行ret指令,也就是说会将地址为rbp+8对应的栈上数据放入rip寄存器中,然后执行rip对应的这一条这令,这里现在是存放的是一个0~19的数字,不是一个合法的指令地址,于是产生了crash。

stack func call list

也就是说,正常情况下,ebp数据结合栈上的数据实际上构成了一个单向链表,链表头是当前执行的函数,往链表尾部,是对应在各个层次的调用者函数。stack上对应的函数调用链表如上图。看到这里我们可以得出结论:

1.stack crash 的本质是rbp&&rip的数据错乱导致。而具体crash的位置,取决于rbp和rip对应的数据。另一方面,栈上的数据错乱也不一定导致crash,有可能仅仅是把应该写入变量a的数据写到了变量b。
2.stack crash时,函数的执行已经脱离了出问题的函数。也就是说,A调用B,B函数中产生了栈上空间的错误写入,但是crash往往发生在A函数之中,因为只有B函数对应的汇编代码的最后一句retq执行完毕之后,才会发生crash,此时,程序的控制权在函数A之中。
3.stack crash时,函数调用栈已经被破坏。但是被破坏的是调用栈的头部。这也是唯一值得欣慰的信息了,函数调用栈尾部的信息依然完好无损。而我们可以据此,推测出函数调用的蛛丝马迹。

3.手动恢复函数调用栈

需要指出的是,被破坏的函数调用栈部分已经无法得到恢复了。此处我们能恢复的,仅仅是没有被破坏的部分。恢复函数栈的原理也很简单,那就是根据栈空间中的内存内容,找到那个“链表”即可。

继续使用上文我们对应的coredump文件,我们可以看到,由于函数调用最近的RBP对应的栈上内容已经被破坏,此时我们已经无法用bt指令得到正确的函数栈了。

1
2
3
4
5
6
7
8
Missing separate debuginfos, use: debuginfo-install glibc-2.17-105.el7.x86_64
(gdb) bt
#0 0x0000000f0000000e in ?? ()
#1 0x0000001100000010 in ?? ()
#2 0x0000001300000012 in ?? ()
#3 0x0000000100000000 in ?? ()
#4 0x0000000300000002 in ?? ()
#5 0x0000000500000004 in ?? ()

我们知道,函数调用栈在回溯过程中会执行两条关键的指令move %rbp %rsp; pop %rbp。而回溯行为对应的retq指令是在这两条指令之后执行的,此时rsp的值仍然是有效的。所以我们可以根据ESP的值打印出目前栈空间的数据。具体命令x/256xg 0x7ffd79ef9e40(rsp对应的值)和结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(gdb) info reg rsp
rsp 0x7ffd79ef9e40 0x7ffd79ef9e40
(gdb) x/256xg 0x7ffd79ef9e40
0x7ffd79ef9e40: 0x0000001100000010 0x0000001300000012
0x7ffd79ef9e50: 0x0000000100000000 0x0000000300000002
0x7ffd79ef9e60: 0x0000000500000004 0x0000000700000006
0x7ffd79ef9e70: 0x0000000900000008 0x000000130000000a
......
0x7ffd79efa070: 0x00007fbe1d989d58 0x0000000c00000005
0x7ffd79efa080: 0x0000000100000000 0x0000000300000002
0x7ffd79efa090: 0x0000000500000004 0x0000000700000006
0x7ffd79efa0a0: 0x0000000900000008 0x0000000c0000000a
0x7ffd79efa0b0: 0x00007ffd79efa100 0x0000000000400583
0x7ffd79efa0c0: 0x00007fbe1d989d58 0x0000000b00000005
0x7ffd79efa0d0: 0x0000000100000000 0x0000000300000002
0x7ffd79efa0e0: 0x0000000500000004 0x0000000700000006
0x7ffd79efa0f0: 0x0000000900000008 0x0000000b0000000a
0x7ffd79efa100: 0x00007ffd79efa150 0x0000000000400583
0x7ffd79efa110: 0x00007fbe1dcfce80 0x0000000a00000000
0x7ffd79efa120: 0x0000000100000000 0x0000000300000002
0x7ffd79efa130: 0x0000000500000004 0x0000000700000006
0x7ffd79efa140: 0x0000000900000008 0x0000000a0040032a
0x7ffd79efa150: 0x00007ffd79efa1a0 0x0000000000400583

这里,函数调用关系比较长,我们以栈开始部分的数据来说明。使用x/256xg 0x7ffd79ef9e40+0x100获取栈跳过rsp开始被写坏的部分数据,得到如下rbp对应的list

broken_stack

好了,此时,我们已经找到了这个list,那么如果通过这个list找到函数调用关系呢?

通过rbpList恢复函数调用关系

通过《从汇编语言看函数调用》这篇文章,我们已经知道,栈上和rbp相邻的位置,就是对应的rip的值。而知道了rip的值,就能知道对应的代码位置。具体操作如下。

通过上图,我们根据这个list得到对应的rip-list对应的地址(rbp + 8对应的内容)依次全部为 0x0000000000400583 >> 0x0000000000400583 > ... > 0x00000000004005a7, 如下图:

ripList

有了rip地址,接下来只需要找出该地址对应的代码位置即可。这里,我们可以使用addr2line工具来分析代码段地址对应的源代码位置,结果如下。

1
2
3
4
[ykhuang@ykhuang-temp ~]$ addr2line -e test 0x0000000000400583
/home/ykhuang/test.cpp:13
[ykhuang@ykhuang-temp ~]$ addr2line -e test 0x00000000004005a7
/home/ykhuang/test.cpp:19

我们可以看到,这两个地址分别对应源代码的13和19行。 这里分别对应的printf和return 0对应的位置。实际上出问题的位置发生在这两行代码的上一行,因为rip对应的意义是下一条指令的地址. 至此,我们已经得到了部分函数调用关系。实际debug的过程中,这也几乎是我们能从一个crash的堆栈上能够获取的全部信息了。有了这部分信息,可以让我们迅速定位问题。当然,结合实际的代码,我们可以从stack中靠近rsp被写坏的数据是什么,来反推和代码的对应关系。

4.总结

“栈被写花”导致的crash虽然难以排查,但是我们还是能根据栈上仅存的信息,尽可能缩小“问题”代码所在的位置。这其中的原来就是函数调用过程中函数栈的建立和销毁过程。当然,除此之外,你需要熟悉一些基本的gdb指令(查看内存、反汇编、查看对应寄存器的值等),也需要了解一些汇编指令的实际含义。其实,对于这种crash,还有另外的方式能够保存函数调用栈,我们以后再展开讨论。在实际的生产中,由于crash文件比较大,对crash现场的保存往往采用保存函数调用堆栈的方式。但是这种情况下,函数堆栈是无意义的,所以保存一些栈上数据,有利于我们更快定位问题,毕竟stack空间本来就不大。

从汇编角度看C语言函数调用

Posted on 2018-12-16

从汇编角度理解C/C++函数调用,能够加深我们对代码的理解,提升调试能力。本文主要讲函数调用栈的建立和销毁、call与ret指令的本质、栈变量的申请、函数参数-返回地址-ebp在栈上的相对位置。

1.前言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//main.c
int bar(int c, int d)
{
int e = c + d;
return e;
}
int foo(int a, int b)
{
return bar(a, b);
}
int main(void)
{
foo(2, 5);
return 0;
}

编译这段代码,进入gdb,然后对main函数进行反汇编(disassemble main),得到如下汇编代码.

1
2
3
4
5
6
7
8
9
10
(gdb) disassemble main
Dump of assembler code for function main:
0x0000000000400506 <+0>: push %rbp
0x0000000000400507 <+1>: mov %rsp,%rbp //以上两句,建立了main函数的栈
=> 0x000000000040050a <+4>: mov $0x5,%esi
0x000000000040050f <+9>: mov $0x2,%edi // 将实参放入寄存器
0x0000000000400514 <+14>: callq 0x4004e7 <foo>
0x0000000000400519 <+19>: mov $0x0,%eax
0x000000000040051e <+24>: pop %rbp // 销毁main函数栈
0x000000000040051f <+25>: retq

此时,我们查看对应寄存器,得到如下结果. 我们可以看到,此时rbp和rsp指向同一个位置,这是在gdb中执行start命令之后,指令所在的位置。

1
2
3
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe380 0x7fffffffe380

接下来我们将用这个例子来详细探索函数调用过程中的参数传递、控制转移call、新函数栈的建立、新函数栈的销毁、控制转移的恢复ret.

2.进入被调用函数的准备

40050a~40050f两条指令为函数调用传递参数的过程,我们可以看到是函数参数是倒序传入的:先传入第N个参数,再传入第N-1个参数。(注意,目前64位的机器上,函数参数的传递很少是通过栈进行的。)

接下来的三行汇编,对应的是实参的传递和对foo函数的调用。下面,我们接着执行几条汇编(si 3),进入foo函数内部,具体执行如下与解释如下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(gdb) si 3
(gdb) disassemble foo
=> 0x00000000004004e7 <+0>: push %rbp //
0x00000000004004e8 <+1>: mov %rsp,%rbp //新的函数栈的建立
0x00000000004004eb <+4>: sub $0x8,%rsp //申请a和b对应的栈空间
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp) //将传入参数,存放到当前函数栈
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi //将局部变量,放入寄存器
0x00000000004004ff <+24>: callq 0x4004cd <bar>
0x0000000000400504 <+29>: leaveq
0x0000000000400505 <+30>: retq

call指令

注意当指令走到0x00000000004004e7的时候(尚未执行),也就是callq执行刚刚执行完毕,对应寄存器的值如下。

1
2
3
4
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
`

和上一次我们查看rsp/rbp相比,rsp向低地址移动了8位(有数据入栈),结合上下文,这一寄存器的变化只会出现在callq 0x4004e7之中。进一步,我们看一下入栈的数据是什么,也就是原来rsp被存放了什么数值。从下面的代码中我们可以看到,原来的rsp上方存放的数值是callq之后的指令的地址。

1
2
3
(gdb) x/8 0x7fffffffe370
0x7fffffffe370: 0xffffe460 0x00007fff 0x00400519(注意) 0x00000000
0x7fffffffe380: 0x00000000 0x00000000 0xf7a30445 0x00007fff

总结一下:call指令的实际用途:1.push IP 2.通过设置 IP实现指令之间的跳转

3.新函数栈的建立与销毁

函数栈的建立

对应4004e7~4004e8处的汇编指令:重新设置rsp和rbp.

函数中局部变量的申请与赋值

对应的代码位于4004eb~4004f2. 从这里我们可以看出1.同一个函数内部,栈变量的申请是同时发生的,但是赋值是逐条执行。2.栈变量的申请,仅仅涉及rsp指针的移动,不会导致segment fault,但是变量的读写,会具体访问到对应的内存,将会触发segment fault。这里我们来看一个例子,猜一猜,这段代码会crash到哪一行?答案是会crash在func()这一行。因为数组a对应的空间申请发生在func()之前,而调用func的时候会将IP入栈,而此时,栈已经溢出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
int func(void)
{
int b;
b = 1;
return b;
}
int main(){
func();
int a[1024*1024*8];
a[0] = 1;
a[1024*1024*8 -1] = 1;
return 0;
}

函数栈的销毁

函数栈的销毁涉及两方面:栈上空间的释放与rsp和rbp的重置。我们来看看foo函数末尾对应的汇编指令

0x0000000000400504 <+29>: leaveq

继续用si来逐条执行对应指令,同时查看这条指令前后rpb和rsp的值如下

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
(gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
=> 0x0000000000400504 <+29>: leaveq
0x0000000000400505 <+30>: retq
End of assembler dump.
(gdb) info registers rbp rsp
rbp 0x7fffffffe370 0x7fffffffe370
rsp 0x7fffffffe368 0x7fffffffe368
(gdb) si
0x0000000000400505 9 }
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
(gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
0x0000000000400504 <+29>: leaveq
=> 0x0000000000400505 <+30>: retq

从理论上来说,leaveq 应该正好是入栈的逆向过程mov %rbp %rsp; pop %rbp.

控制转移的恢复ret/retq

最后我们看看retq执行完毕之后,寄存器前后的变化。

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
(gdb) info registers rbp rsp rip
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
rip 0x400505 0x400505 <foo+30>
(gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
0x0000000000400504 <+29>: leaveq
=> 0x0000000000400505 <+30>: retq
End of assembler dump.
(gdb) si
main () at main.c:13
13 return 0;
(gdb) info registers rbp rsp rip
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe380 0x7fffffffe380
rip 0x400519 0x400519 <main+19>

这里我们得到几点结论:

  1. retq指令的调用,导致出栈了一个8位的数,这个就是调用者的下一条指令。
  2. retq调用之前,已经处在调用者的栈帧。retq的调用,仅仅是一个栈上保存的地址存放到rip寄存器。
  3. 对比main和foo函数的末尾,我们可以看到有细小的差别:main函数栈的销毁仅仅有push %rbp一句,但foo函数的结尾是leaveq。导致这一差别的原因是main函数建立调用栈之后,并没有移动rsp.

总结

函数调用过程中栈的变化如图 栈变化图

我们可以看到子程序调用之前和之后(1&6),函数栈是没有任何变化的,有变化的在于rip等相关寄存器的值;call指令执行之后与ret指令执行之前,函数栈也是相同的,而ret指令之所以能转交控制权,是因为ip的值被保存到栈上。 这里我们可以看到,栈不仅仅有保存的局部变量数据,也有对控制转移指令至关重要的寄存器临时存储。一旦栈被写坏,控制转移指令就无法正常执行。下一节,我们将讲解与栈相关的控制转移指令被写坏的场景。

John Doe

2 posts
© 2018 John Doe
Powered by Hexo
|
Theme — NexT.Muse v5.1.4