内核栈回溯原理学习应用

独自空忆成欢 提交于 2020-04-08 08:45:36

  这篇主要是杭州操作系统大会前辈的文档进行学习,因为文档公开了故而总结学习一下,如若其中有侵权的地方,请及时联系我,谢谢

...........................................................................................................................................................................................................................

问题:

    一台客户现场机器,运行一周左右偶然发生一次应用段错误或者double free问题,cpu可能是arm、mips、x86等架构,有什么好的方法捕捉异常日志?

困难点:

  1.  研发环境常使用gdb+coredump技术解决此类问题,客户现场等非研发环境的偶现应用异常问题,不方便使用,操作起来有一定难度

  2. 不同架构(arm32、arm64、mips、x86),不同版本C库和gdb,栈回溯效果差异很大。PC ubuntu系统测试,glibc 2.15,发生应用double free,直接打印栈回溯信息,其他架构的CPU上测试没有这个功能。arm64架构的某款CPU上测试,gdb对strip过的应用程序无法栈回溯, PC ubuntu系统测试没有这个问题。

 

栈回溯的原理

 

 

 

 当执行入栈操作时,lr和fp寄存器的值存入栈中,然后令fp寄存器指向函数栈的栈顶,本例是函数栈第二片内存地址(函数无局部变量)。栈回溯时,首先根据fp寄存器指向的地址,取出保存在函数栈中lr和fp寄存器的数据,lr的值是函数返回地址,fp的值是上一级函数栈的栈顶地址

1.堆栈指针r13(SP):每一种异常模式都有其自己独立的r13,它通常指向异常模式所专用的堆栈,也就是说五种异常模式、非异常模式(用户模式和系统模式),           都有各自独立的堆栈,用不同的堆栈指针来索引。这样当ARM进入异常模式的时候,程序就可以把一般通用寄存器压入堆栈,返回时再出栈,保证了各种模式下程序的状态的完整性
2.连接寄存器r14(LR):每种模式下r14都有自身版组,它有两个特殊功能
    (1)保存子程序返回地址。使用BL或BLX时,跳转指令自动把返回地址放入r14中;子程序通过把r14复制到PC来实现返回
    (2)当异常发生时,异常模式的r14用来保存异常返回地址,将r14如栈可以处理嵌套中断。

3、程序计数器r15(PC):PC是有读写限制的。当  没有超过读取限制的时候,读取的值是指令的地址加上8个字节,由于ARM指令总是以字对齐的,故bit[1:0]总是00。  当用str或stm存储PC的时候,偏移量有可能是8或12等其它值

 

 

函数unwind_frame中: frame->pc = *(unsigned long *)(fp + 8)计算上一级函数指令地址,也就是当前函数的返回地址。 frame->fp = *(unsigned long *)(fp)计算上一级函数栈的栈顶地址

 

假设应用程序函数执行流程是test_c()->test_b()->test_a(),test_a()函数发生段错误,内核将自动执行do_page_fault(……,struct pt_regs *regs)函数,该结构体中regs->pc是发生段错误test_a()函数的指令地址,假如是0x400538,regs->regs[29]就是fp寄存器。怎么实现内核对段错误应用的栈回溯?

策略:(对应用段错误栈回溯)

模仿unwind_frame函数,增加user_unwind_frame函数,以实现do_page_fault函数中,对段错误应用程序栈回溯,代码如图。经过栈回溯,假设从test_a函数栈中分析出test_a函数返回地址是0x400550(处于test_b函数中),继续栈回溯,找到test_b函数的返回地址是0x400588(处于test_c函数)

 

 

这样可以在内核do_page_fault中,对段错误应用程序栈回溯,执行过程打印如下:
user thread backstrace
pc1:0x400538
pc2:0x400550
pc3:0x400588

反汇编后可以知道函数调用流程是test_c()->test_b()->test_a()。这个方法还可以继续优化:还是do_page_fault函数中,对应用栈回溯过程,读取可执行程序elf文件信息,分析并打印出该指令地址所在的函数的名字。这需要用到elf可执行程序文件数据分布的原理,尤其是elf文件 section部分的数据

 

 

 

 在内核里读取elf可执行程序文件的“.symtab”和”.strtab” section的数据,就可以分析出该文件的test_c()、test_b()、test_a()三个函数名字字符串、函数运行首地址、函数指令字节数。比如数据如下

函数名字  函数指令首地址      函数指令结束地址
test_c         0x400518                  0x400518 +0x27
test_b         0x400545                 0x400545 +0x35
user thread backstrace
[<0x400538>]  test_a + 0x20/0x27
[<0x400550>]  test_b +0x0b/0x35
[<0x400588>]  test_c + 0x08 /0x20

 

分析实例

#include <stdio.h>
#include <stdlib.h>

char buf[5];
int test_a()
{
    printf("%s \n", __func__);
    memcpy(buf, "12345677", 7);
    return 0;
}
int test_b()
{
    printf("%s \n", __func__);
    memset(buf, 0, sizeof(buf));
    test_a();
    return 0;
}
int test_c()
{
    printf("%s \n", __func__);
    sleep(1);
    test_b();
    return 0;
}
int main()
{
    printf("%s \n", __func__);
    test_c();
    return 0;
}

例子是一个可执行程序test演示代码,用到了memcpy等库函数,本例是C库文件libc.so中的函数

可执行程序文件的“.dynstr” section包含了用到的库函数名字,” .dynsym”  section的数据是一个个struct elf64_sym结构体,每个对应一个用到的库函数结构体。两个section表述的库函数信息是一一对应的,如下图:

 

 ibc.so库文件的“.dynstr” section包含了C库所有的库函数名字,” .dynsym”  section的数据也是一个个struct elf64_sym结构体,每个对应一个C库的库函数结构体

libc.so的”.dynsym” section的库函数结构体struct elf64_sym中, st_value是库函数原始首地址、st_size是库函数指令字节数。为什么是原始首地址?因为可执行程序调用C库函数时,会对C库函数进行一次重定向,然后映射到可执行程序的应用空间,最后才执行C库函数的指令代码

 

1           “.plt”  section汇编代码
2 0000000000400480 <memcpy@plt>:
3   …………..
4   400484:    f944fa11     ldr    x17, [x16,#2544]
5   400488:    9127c210     add    x16, x16, #0x9f0
6   40048c:    d61f0220     br    x17
1            test_a函数汇编代码
2 0000000000400650 <test_a>:
3   400650:    a9bf7bfd     stp    x29, x30, [sp,#-16]!
4   400654:    910003fd     mov    x29, sp
5   ………………….
6   400660:    97ffffa0     bl    4004e0 <puts@plt>
7   ………………              
8   400678:    97ffff82     bl    400480 <memcpy@plt>

如test_a函数汇编代码,当执行memcpy函数,实际是先执行“.plt” section的memcpy@plt 函数。然后在memcpy@plt函数汇编代码里,ldr x17, [x16,#2544]计算出memcpy库函数实际运行地址在“.got.plt” section的内存地址0x410a38,取出该地址的数据存于x17寄存器。如右图所示,就是把橙色内存单元的数据0x7f91db5a40保存到x17,然后br x17就是跳转到memcpy库函数实际首地址,执行该函数的代码

 

使用方法:

如果我们能知道libc.so中所有库函数的运行首地址和结束地址,这样当在C库中崩溃,比如此时pc值是0x7f91db5a60,我们就能知道0x7f91db5a60处于哪个库函数,这样就知道怎么在C库中栈回溯了。

具体实现方法:

  1. 以memcpy中崩溃为例, 从libc.so文件的” .dynsym” section找到memcpy库函数的struct elf64_sym结构,该结构的成员st_value就是memcpy库函数的原始首地址
  2. 从可执行程序”.got.plt” section找到库函数memcpy的运行首地址,memcpy的运行首地址减去其原始首地址就是库函数的原始首地址与运行首地址之差,命名为dx
  3. 从libc.so分离出所有库函数的struct elf64_sym结构,知道每个库函数的原始首地址,原始首地址+dx就是每个库函数的运行首地址,再结合st_size就知道库函数的运行结束地址。从libc.so文件的“.dynstr” section又知道了每个库函数的名字。这样知道了每个库函数运行首地址、结束地址、函数名字,就具备了栈回溯的条件

 

 

double free应用:

double free是C库检测到异常,然后向当前进程发送SIGABRT信号,然后进入内核空间,会执行到do_send_specific函数发送信号。在该函数中,检测到是SIGABRT信号,通过task_pt_regs(current)获取异常进程进入内核空间前pc、lr、fp等寄存器,然后运用前文的栈回溯原理,对double free应用流程栈回溯,如下是演示效果。

演示效果

应用在test_a函数调用free库函数两次后,内核打印:

[< 0x7f91dxxxx>] raise() 0x38/0x78
[< 0x7f91dxxxx>] abort() 0x1b0/0x308
[<0x000400538>] test_a() 0x6c/0xa4
[<0x000400550>] test_b() 0x20/0x458
[<0x000400588>] test_c() 0x20/0x64

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!