《Linux栈粉碎了,怎样还原》

张春  论坛元老 | 2025-3-10 19:19:16 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 1004|帖子 1004|积分 3012

【栈粉碎导读】栈粉碎有了解过吗?作甚栈粉碎,栈粉碎了,步伐会立刻引发崩溃,我们通过gdb去调试coredump,栈被粉碎的栈帧是没法被恢复的,这也给我们调试步伐带来很大的困难,那怎样还原栈粉碎的第一现场?本文将为你具体解答。
       作甚栈粉碎,我写个小步伐给各位看下:
  1. int function(int a)
  2. {    unsigned long long int *p = (unsigned long long int *)&a;    p[0]=p[1]=p[2] = 0xffffffffffffffff;    return 0;}int main()
  3. {    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的地址要小,比堆的最大值要大。因为栈空间是由高地址到低地址生长的,堆空间是由低地址到高地址扩展的。
       好,在先容推栈之前,我们必须要熟悉函数调用中涉及到的栈帧切换的流程,且看如下的小步伐:
  1. 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
  1. 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);}
复制代码
  1. #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和加载符号路径的脚本。
生成符号脚本:
  1. function gensym()
  2. {    if [ ! -d .debug ]; then        mkdir .debug    fi    objcopy --only-keep-debug libcrash.so .debug/libcrash.so.`md5sum libcrash.so| awk '{print $1}'`
  3.     objcopy --add-gnu-debuglink=.debug/libcrash.so.`md5sum libcrash.so| awk '{print $1}'` libcrash.so
  4.     objcopy --only-keep-debug main .debug/main.`md5sum main| awk '{print $1}'`
  5.     objcopy --add-gnu-debuglink=.debug/main.`md5sum main| awk '{print $1}'` main
  6.  }
  7. gensym
复制代码
  1. function rmsym()
  2. {    if [ -d .debug ]; then        rm -rf .debug    fi
  3. }
  4. rmsym
复制代码
  cmake脚本:
  1. 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脚本:
  1. 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文件,看看还原得对不对。





免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

张春

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表