摘要:当程序运行出现段错误时,目标文件没有调试符号,也没配置产生 core dump,如何定位到出错的文件和函数,并尽可能提供更详细的一些信息,如参数,代码等。
第一板斧
准备一段测试代码 018.c
#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
获取一些有用的信息
$ 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
的具体路径。
$ 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 代码来找到出错代码的偏移量,如下:
$ python3 -c "print((0x00007f93d96cf3cc-0x7f93d9674000).to_bytes(4, 'big').hex())"
0005b3cc
使用 nm
可以看到目标文件里的函数列表,我们先尝试一下(如果函数名不可读,可用 c filt 命令尝试转换)。
$ nm /lib/x86_64-linux-gnu/libc.so.6
nm: /lib/x86_64-linux-gnu/libc.so.6: no symbols
发现目标文件并没有符号,那么再尝试使用 objdump
命令来查看函数列表,因为得到的 0005b3cc
不一定是某个函数的首地址,所以我们可以用前缀 5b3
进行一下过滤。
$ 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
相关的地方,大概率就能定位到原因。(printf
和 fprintf
最后都会间接调用vfprintf
)
第二板斧
如果要看更详细的信息,我们继续用 objdump
查看该函数的汇编代码,要使用 --start-address
设置汇编的起始位置,以便只针对该函数进行反汇编。
$ 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
里面(再多参数可能就要压栈了),而被调函数直接去这两个寄存器里面把参数拿出来。(传递参数都是用的 edi
和 esi
,是因为 C 语言中 int 是 32位的,而 rdi
和 rsi
都是 64 位的,edi
和 esi
可以分别当成 rdi
和 rsi
的一部分来使用。)
由此我们大概知道这里是读取函数的第一个参数的某个偏移量,推测第一个参数是一个结构,这个偏移量是结构的某个成员,而这个结构的地址目前是个无效地址,所以取偏移量会引起读取内存出错。
我们查资料知道 vfprintf
的第一个参数是 FILE
类型,所以推断,是用户代码间接调用了 vfprintf
函数,但第一个参数传了个无效地址。
int vfprintf(FILE *stream, const char *format, va_list arg)
这样,如果我们 grep
源码如果寻找到大量 fprintf
,vfprintf
调用的话,可以着重分析调用前第一个参数有没有做必要的检查以保证参数有效的情况。
第三板斧
我们可以继续再分析下,前面的 objdump
只能看到汇编代码,是因为 /lib/x86_64-linux-gnu/libc.so.6
这个库是不包含符号文件的,这种情况看不到源码信息,我们再寻找下本机有没有安装 libc
的调试符号:
$ 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
手动安装)。
$ 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 文件目录是否符合预期。
$ gdb -batch -ex 'show debug-file-directory'
The directory where separate debug symbols are searched for is "/usr/lib/debug".
符合预期,这样就可以用 gdb
进行反汇编了:
$ 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.c
的 1283
行了,我们再去找下当前 2.27
版本的 libc
源码下载试试,libc
源码在这个网址:https://sourceware.org/git/?p=glibc.git,从 tag
里找到 2.27
版本,点击 tree
链接进行文件浏览,在 stdio-common
目录下找到 vfprintf.c
文件,并下载到本地当前目录,再次执行 gdb
命令
$ 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
,根据源码看到它是一个宏,有如下两种定义:
# 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
文件大小和路径设置:
# 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 文件
# 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
空指针,这就是问题所在,再来看一下调用栈:
(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
参数的话,具体的行号和代码也会显示出来,如下:
(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?