如何优雅的调试段错误

2020-05-18 12:10:21 浏览数 (1)

摘要:当程序运行出现段错误时,目标文件没有调试符号,也没配置产生 core dump,如何定位到出错的文件和函数,并尽可能提供更详细的一些信息,如参数,代码等。

第一板斧

准备一段测试代码 018.c

代码语言:javascript复制
#include <stdio.h>

int main(int argc, char *argv[])
{
    FILE *fp = NULL;
    fprintf(fp, "%sn", "hello");
    fclose(fp);
    return 0;
}

编译运行

代码语言:javascript复制
$ gcc 018.c
$ ./a.out
Segmentation fault (core dumped)

可以看到发生了段错误。假设我们没有配置进程崩溃生成 core dump,那么我们可以用 dmesg  获取一些有用的信息

代码语言:javascript复制
$ dmesg | tail -n1
[1105761.999602] a.out[7822]: segfault at c0 ip 00007f93d96cf3cc sp 00007ffcc490e7f0 error 4 in libc-2.27.so[7f93d9674000 1e7000]

提示信息里的 error 是 4 , 转成二进制就是100,这里具体的解释如下:

  • bit2: 值为 1 表示是用户态程序内存访问越界,值为 0 表示是内核态程序内存访问越界。
  • bit1: 值为 1 表示是写操作导致内存访问越界,值为 0 表示是读操作导致内存访问越界。
  • bit0: 值为 1 表示没有足够的权限访问非法地址的内容,值为 0 表示访问的非法地址根本没有对应的页面,也就是无效地址。

综上,可以看出引起问题的原因是:用户态程序,读内存越界,原因是非法地址,而不是没权限,这在后面我们会用到。

从提示中还可以看到出错的文件是 libc-2.27.so,用 ldd 查看目标程序 a.out 的依赖库,找到 libc-2.27.so 的具体路径。

代码语言:javascript复制
$ ldd a.out
        linux-vdso.so.1 (0x00007ffe67ffd000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f786946f000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f7869a62000)

再看提示信息里的 00007f93d96cf3cc ,这是出错时指令寄存器  ip 指向的位置,而末尾的 7f93d9674000 是进程启动后 libc-2.27.so  在内存中的动态位置(同一个程序多次启动后起始位置不一样,为了防止黑客猜测代码区位置),我们可以用一段 python 代码来找到出错代码的偏移量,如下:

代码语言:javascript复制
$ python3 -c "print((0x00007f93d96cf3cc-0x7f93d9674000).to_bytes(4, 'big').hex())"
0005b3cc

使用 nm 可以看到目标文件里的函数列表,我们先尝试一下(如果函数名不可读,可用 c filt 命令尝试转换)。

代码语言:javascript复制
$ nm /lib/x86_64-linux-gnu/libc.so.6
nm: /lib/x86_64-linux-gnu/libc.so.6: no symbols

发现目标文件并没有符号,那么再尝试使用 objdump 命令来查看函数列表,因为得到的 0005b3cc 不一定是某个函数的首地址,所以我们可以用前缀 5b3  进行一下过滤。

代码语言:javascript复制
$ objdump -tT /lib/x86_64-linux-gnu/libc.so.6 | grep 5b3
000000000005b390 g    DF .text  0000000000003235  GLIBC_2.2.5 _IO_vfprintf
000000000005b390 g    DF .text  0000000000003235  GLIBC_2.2.5 vfprintf

可以看到在 0005b3cc 附近有两个函数 vfprintf_IO_vfprintf,位置都是 000000000005b390,其实到这一步我们就大致知道问题是和 vfprintf 相关了,去源码里搜一下和 vfprintf 相关的地方,大概率就能定位到原因。(printffprintf 最后都会间接调用vfprintf

第二板斧

如果要看更详细的信息,我们继续用 objdump 查看该函数的汇编代码,要使用 --start-address 设置汇编的起始位置,以便只针对该函数进行反汇编。

代码语言:javascript复制
$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 --start-address=0x5b390 | head -n100 | grep 5b3cc
   5b3cc:       8b 87 c0 00 00 00       mov    0xc0(%rdi),�x

可以看到 5b3cc 位置的汇编代码是 mov 0xc0(%rdi),�x,可以看到这是 AT&T 格式汇编(因为寄存器有 % 前缀),所以是这里表示从第一个参数复制到第二个参数,这和Intel 汇编格式的参数顺序是相反的。

整行代码的意思要把 rdi 寄存器的某个偏移处的数据复制给 eax 寄存器,前面我们知道引起错误的原因是 用户态程序,读内存越界,原因是非法地址,而不是没权限,所以就是说读取 0xc0(%rdi) 发生错误。

根据 x86-64 汇编的约定 ,调用函数时调用者负责把第一个参数放在 rdi里面,第二个参数放在 rsi 里面(再多参数可能就要压栈了),而被调函数直接去这两个寄存器里面把参数拿出来。(传递参数都是用的 ediesi ,是因为 C 语言中 int 是 32位的,而 rdirsi 都是 64 位的,ediesi 可以分别当成 rdirsi 的一部分来使用。)

由此我们大概知道这里是读取函数的第一个参数的某个偏移量,推测第一个参数是一个结构,这个偏移量是结构的某个成员,而这个结构的地址目前是个无效地址,所以取偏移量会引起读取内存出错。

我们查资料知道 vfprintf 的第一个参数是 FILE 类型,所以推断,是用户代码间接调用了 vfprintf 函数,但第一个参数传了个无效地址。

代码语言:javascript复制
int vfprintf(FILE *stream, const char *format, va_list arg)

这样,如果我们 grep 源码如果寻找到大量 fprintfvfprintf 调用的话,可以着重分析调用前第一个参数有没有做必要的检查以保证参数有效的情况。

第三板斧

我们可以继续再分析下,前面的 objdump 只能看到汇编代码,是因为 /lib/x86_64-linux-gnu/libc.so.6 这个库是不包含符号文件的,这种情况看不到源码信息,我们再寻找下本机有没有安装 libc 的调试符号:

代码语言:javascript复制
$ locate libc-2.27.so
/lib/i386-linux-gnu/libc-2.27.so
/lib/x86_64-linux-gnu/libc-2.27.so
/usr/lib/debug/lib/x86_64-linux-gnu/libc-2.27.so

发现 /usr/lib/debug/lib/x86_64-linux-gnu/  下有一个 libc 的 so,看下有没有符号(如果没有的话,可用sudo apt-get install libc6-dbg 手动安装)。

代码语言:javascript复制
$ nm /usr/lib/debug/lib/x86_64-linux-gnu/libc-2.27.so | head -n3
000000000004f9f0 T a64l
00000000001a9c20 r a64l_table
00000000000406c0 T abort

有符号,这样我们就可以挂上符号进行反汇编了,可以得到一些源码的信息,首先确认 gdb 的 debug 文件目录是否符合预期。

代码语言:javascript复制
$ gdb -batch -ex 'show debug-file-directory'
The directory where separate debug symbols are searched for is "/usr/lib/debug".

符合预期,这样就可以用 gdb 进行反汇编了:

代码语言:javascript复制
$ gdb /lib/x86_64-linux-gnu/libc.so.6 -batch -ex 'disassemble/rs _IO_vfprintf' 2>/dev/null | grep 5b3cc -B3 -A3
1281    in vfprintf.c
1282    in vfprintf.c
1283    in vfprintf.c
   0x000000000005b3cc < 60>:    8b 87 c0 00 00 00       mov    0xc0(%rdi),�x
   0x000000000005b3d2 < 66>:    85 c0   test   �x,�x
   0x000000000005b3d4 < 68>:    0f 85 d6 01 00 00       jne    0x5b5b0 <_IO_vfprintf_internal 544>
   0x000000000005b3da < 74>:    c7 87 c0 00 00 00 ff ff ff ff   movl   $0xffffffff,0xc0(%rdi)

这次可以看出5b3cc 这条指令大概在 vfprintf.c1283 行了,我们再去找下当前 2.27 版本的 libc 源码下载试试,libc 源码在这个网址:https://sourceware.org/git/?p=glibc.git,从 tag 里找到 2.27 版本,点击 tree 链接进行文件浏览,在 stdio-common 目录下找到 vfprintf.c 文件,并下载到本地当前目录,再次执行 gdb 命令

代码语言:javascript复制
$ gdb /lib/x86_64-linux-gnu/libc.so.6 -batch -ex 'disassemble/rs _IO_vfprintf' 2>/dev/null | grep 5b3cc -B3 -A3
1281      /* Orient the stream.  */
1282    #ifdef ORIENT
1283      ORIENT;
   0x000000000005b3cc < 60>:    8b 87 c0 00 00 00       mov    0xc0(%rdi),�x
   0x000000000005b3d2 < 66>:    85 c0   test   �x,�x
   0x000000000005b3d4 < 68>:    0f 85 d6 01 00 00       jne    0x5b5b0 <_IO_vfprintf_internal 544>
   0x000000000005b3da < 74>:    c7 87 c0 00 00 00 ff ff ff ff   movl   $0xffffffff,0xc0(%rdi)

可以看到 1283 行代码是 ORIENT,根据源码看到它是一个宏,有如下两种定义:

代码语言:javascript复制
# define ORIENT         if (_IO_fwide (s, 1) != 1) return -1
# define ORIENT         if (_IO_vtable_offset (s) == 0 && _IO_fwide (s, -1) != -1) return -1

看函数名感觉是判断当前的流 FILE 是否是宽字节流,推测是从 FILE 结构里取信息,结果 FILE 结构地址非法,所以内存读取错误,直接就段错误了。

补充

如果进程崩溃时生成了 core dump 的话,定位问题就比较简单了,我们先进行 core dump 文件大小和路径设置:

代码语言:javascript复制
# ulimit -c 1024
# echo '/var/log/%e.core.%p' > /proc/sys/kernel/core_pattern
# /sbin/sysctl kernel.core_pattern
kernel.core_pattern = /var/log/%e.core.%p

再次执行 a.out 就会在 /var/log 下产生 core dump 文件,用 gdb 来分析该 dump,第一个参数是可执行文件,第二个参数是 dump 文件

代码语言:javascript复制
# gdb a.out /var/log/a.out.core.35095
...
Core was generated by `./a.out'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  _IO_vfprintf_internal (s=0x0, format=0x560a28c3476a "%sn", ap=ap@entry=0x7fff74e66260) at vfprintf.c:1283

warning: Source file is more recent than executable.
1283      ORIENT;

可以看到刚进入 gdb 就能看到一些有用的信息,因为我们前面下载了源码文件,所以出错的文件,行号,代码等都显示了出来,另外一个有用的信息是 _IO_vfprintf_internal 函数的参数都显示了出来,很明显第一个参数 s 指向的是一个 0x0 空指针,这就是问题所在,再来看一下调用栈:

代码语言:javascript复制
(gdb) bt
#0  _IO_vfprintf_internal (s=0x0, format=0x560a28c3476a "%sn", ap=ap@entry=0x7fff74e66260) at vfprintf.c:1283
#1  0x00007fd6d829be54 in __fprintf (stream=<optimized out>, format=<optimized out>) at fprintf.c:32
#2  0x0000560a28c346c0 in main ()

可以直接看到出错时完整的调用栈,入口是 main 函数,这就直接定位到问题代码了,不需要再去 grep 代码里所有调用 fprintf 的地方了。

如果编译 a.out 时加了 -g 参数的话,具体的行号和代码也会显示出来,如下:

代码语言:javascript复制
(gdb) bt
#0  _IO_vfprintf_internal (s=0x0, format=0x560a28c3476a "%sn", ap=ap@entry=0x7fff74e66260) at vfprintf.c:1283
#1  0x00007fd6d829be54 in __fprintf (stream=<optimized out>, format=<optimized out>) at fprintf.c:32
#2  0x0000560a28c346c0 in main (argc=1, argv=0x7fff74e66448) at 018.c:6
(gdb) frame 2
#2  0x0000560a28c346c0 in main (argc=1, argv=0x7fff74e66448) at 018.c:6
6           fprintf(fp, "%sn", "hello");
(gdb) l
1       #include <stdio.h>
2
3       int main(int argc, char *argv[])
4       {
5           FILE *fp = NULL;
6           fprintf(fp, "%sn", "hello");
7           fclose(fp);
8           return 0;
9       }

很明显第 5 行没有给 fp 指向有效的文件,第 6 行就调用 fprintf 了。

全文完。

参考

  • 从汇编层面看函数调用的实现原理
  • How to disassemble one single function using objdump?

0 人点赞