《Linux栈破坏了,如何还原》

发布于:2025-03-10 ⋅ 阅读:(10) ⋅ 点赞:(0)

【栈破坏导读】栈破坏有了解过吗?何为栈破坏,栈破坏了,程序会立刻引发崩溃,我们通过gdb去调试coredump,栈被破坏的栈帧是没法被恢复的,这也给我们调试程序带来很大的困难,那如何还原栈破坏的第一现场?本文将为你详细解答。

       何为栈破坏,我写个小程序给各位看下:

int function(int a)
{    unsigned long long int *p = (unsigned long long int *)&a;    p[0]=p[1]=p[2] = 0xffffffffffffffff;    return 0;}int main()
{    int a = 10;    return function(a);}

     在function中,我们定义了个unsigned long long int类型的指针指向int类型变量a的地址,因为int类型a占用的栈内存大小为4字节,unsigned long long int类型的指针步长就是8字节,连续给4字节的栈内存赋值3个大小为8字节的整形变量,肯定破坏了cpu为function分配的栈空间,甚至可能把上一层的栈帧给写坏了。那么程序崩溃时,就算我们有coredump,gdb也是没法把function的栈帧给还原回来。 那针对这种情况,我们从栈帧切换的原理去反推函数调用的栈帧。

       Linux系统读取磁盘中二进制程序的elf文件,通过内存映射的方式,为当前二进制程序分配一段独立的虚拟地址空间,有了独立的虚拟地址空间,当前二进制程序便以独立进程的方式运行起来了。那进程的虚拟地址空间布局是怎样的?

图片

      重点关注的共享内存和当前进程加载的动态库在虚拟地址空间的位置,比栈顶指针rsp的地址要小,比堆的最大值要大。因为栈空间是由高地址到低地址生长的,堆空间是由低地址到高地址扩展的。

       好,在介绍推栈之前,我们必须要熟悉函数调用中涉及到的栈帧切换的流程,且看如下的小程序:

int function1(int i){    function2(++i);    return 0;}int main(){    return function1(0);}

        main函数调用function1,那栈帧是如何从main切换到funtion1的,栈顶指针rsp、栈底指针ebp是如何切换的,函数调用返回时,rip是如何帮助当前栈帧返回到上一层栈帧的?我们用gdb调试程序,并在function1设置断点。在调用function1之前,我们看下寄存器rsp、rbp、rip这些是怎样的?

图片

       可以很清晰看到当前rbp、rsp在同一个位置,因为main函数的栈空间并没有定义任何栈变量,所以栈顶和栈底的地址都在同一个位置,rip指向的指令地址是0x400606,这条指令依然在main+4位置。那么执行disassemble查看当前程序的汇编指令,看看0x400606表示是哪条语句。

图片

      可以看到0x400606表示将0赋值给edi寄存器。这个暂且不管,继续执行si指令,跳转到0x000000000040060b地址,也即将调用函数function1。

图片

     调用完函数function1,0x0000000000400610指向的便是function1执行完之后要执行的指令地址,也是eip需要存储的地址,继续执行si指令,跳转到function1中,查看寄存器rsp、rbp的值,可以很清晰地看到rsp减少了8字节,也就是栈空间往下扩张了。rip指向了指令地址值0x4005e2(function1)。

图片

     那返回上一层栈帧(main)的指令地址0x0000000000400610去哪里了?我们执行下x/64xg $rsp(把当前栈帧往调用者方向溯源64*8个字节的内容给打出来)。

图片

     可以看到上一层栈帧(main)的指令地址0x0000000000400610已经被压到栈中保存起来了。

图片

     继续执行si指令,得到如下的结果:

图片

      将main函数的rbp存入rsp指向的内容(mov %rsp,%rbp),也就是将rsp执行的指令地址设置为新的栈底(rbp:0x7fffffffe2e0),随后sub $0x10,%rsp为函数参数、局部变量的所占用的空间分配内存,此时就完成了从main函数栈帧切换到function1函数栈帧。那此时function1的栈帧范围就是0x7fffffffe2e0—0x7fffffffe2d0(16字节)。

     分析完函数栈帧切换流程,那再介绍下动态库中符号地址、二进制程序中代码段中符号地址在进程虚拟地址空间中的布局。

动态库

 上文提到的进程虚拟地址空间布局图,动态库中函数的地址,一般位于以0x7f开头地址处,比rsp,rbp的指令地址要小,比程序入口main函数地址、堆地址要大,比如:libc库的一些函数如printf,fopen,fread,fwrite等都位于这个范围。

图片

代码段

 代码段位于elf文件的.text段,进程启动,会通过内存映射的方式将磁盘空间二进制文件elf中.text映射到进程虚拟空间的指定范围内。

图片

 虽然程序每次运行时,具体段对应的虚拟内存值会有变化,假如程序崩溃,当前的虚拟内存布局是固定的,这些信息都会写在coredump文件中。我们可以使用readelf命令来读取各个段的虚拟地址范围。

图片

gdb中可以使用info shared命令来找到对应的动态库的代码段在虚拟内存中的地址。

图片

推栈方法

     1、用gdb调试程序并附加上当前的进程对应的coredump。

      2、使用gdb获取寄存器信息,找到rsp、rbp、rip对应的地址值。

      3、根据rsp、rbp的地址范围在x/64xg $rsp指令输出的内容中去寻找大小相近的地址。

      4、rip是进程崩溃那一刻执行的指令地址,结合动态库在当前进程的虚拟地址范围,在x/64xg $rsp指令输出的内容中去寻找大小相近的地址。

      5、基于第4步收集到的地址集合,结合动态库在进程虚拟地址空间中的起始地址,计算出各地址相对于起始地址的偏移量。

      6、基于第3步收集到的地址集合,结合当前二进制程序中代码段.text在进程虚拟地址空间中起始地址(通过info files指令可以查看),计算出各地址相对于起始地址的偏移量。

      7、准备程序的符号文件,使用addr2line计算出函数所在的源码文件及对应的行号。

结合实际案例进行演练

    先准备下面的代码main.c、libcrash.c

typedef int (*FUNC)(int);extern int crash(int);int function2(int i){    FUNC f;    f = crash;    return f(i);}int function1(int i){    function2(++i);    return 0;}int main(){    return function1(0);}
#include <stdio.h>#include <stdlib.h>int function6(int a){    unsigned long long int *p = (unsigned long long int *)&a;    p[0]=p[1]=p[2] = 0xffffffffffffffff;    return 0;}int function5(int a){    int b = a;    return function6(b);}int function4(int a){    int b = a;    return function5(b);}int function3(int a){    int b = a;    return function4(b);}int crash(int i){    int a;    function3(a);    return ++i;}

    libcrash.c最后会编译成libcrash.so库,也就是这个库中function6破坏了栈空间。还有些生成符号gensym、删除符号rmsym、MakeFile脚本、设置coreDump和加载符号路径的脚本。

生成符号脚本:

function gensym()
{    if [ ! -d .debug ]; then        mkdir .debug    fi    objcopy --only-keep-debug libcrash.so .debug/libcrash.so.`md5sum libcrash.so| awk '{print $1}'`
    objcopy --add-gnu-debuglink=.debug/libcrash.so.`md5sum libcrash.so| awk '{print $1}'` libcrash.so
    objcopy --only-keep-debug main .debug/main.`md5sum main| awk '{print $1}'`
    objcopy --add-gnu-debuglink=.debug/main.`md5sum main| awk '{print $1}'` main
 }
 gensym
function rmsym()
{    if [ -d .debug ]; then        rm -rf .debug    fi
}
rmsym

  cmake脚本:

all:    gcc -fno-stack-protector -g --shared libcrash.c -o libcrash.so    gcc -fno-stack-protector -g main.c -L`pwd` -lcrash -o main    bash -c "./gensym.sh"    strip main libcrash.so.PYTHON: cleanclean:    -rm -f main libcrash.so    bash -c "./rmsym.sh"

​​​​​​​  设置符号和coreDump脚本:

sysctl -n kernel.core_pattern > ~/kernel.core_pattern.baksudo sysctl -w kernel.core_pattern=/tmp/core/core-%e-%s-%p-%u-%g-%tmkdir /tmp/coreulimit -c unlimitedexport LD_LIBRARY_PATH=`pwd`

    执行make,生成main二进制程序,再运行main程序,生成coredump,使用gdb调试coredump,查看堆栈信息。

图片

    因为我们的程序调用链很长,当前堆栈并不完整,只展示部分。再比如有些极端的场景下,栈帧完整看不到了,没有任何相关符号信息。那么想推导出完整的堆栈信息,此时就需要用到上文介绍的推栈方法了。

图片

    回溯当前栈帧往调用者方向64*8个字节的内容打印出来:

图片

      看到当前栈帧的基址是0x7fffffffe1d0,rip指向的指令地址是0x7ffff7bd96ee(function6),那么我们在上图中寻找和0x7fffffffe1d0相近的栈基rbp地址,寻找和rip指令地址相近的动态库函数地址。

        动态库libcrash.so的虚拟地址范围如下。

图片

      结合上图动态库虚拟地址范围,寻找和rip指令地址相近的,来自动态库libcrash.so的符号地址集合。

图片

     再结合main二进制程序中.text代码段在进程虚拟地址空间的地址范围,进一步推导main二进制程序中的符号地址集合。

图片

图片

       好,经过上次的查找和收集,我们可以得到如下的函数地址偏移信息:

图片

     最后一步通过addr2line指令,结合上图中的地址偏移量,把整个堆栈的符号信息全部还原出来(具体调用哪些库,调用了哪个函数,来自哪一行,全部还原回来了)。

图片

   结合两个c文件,看看还原得对不对。

图片

图片