分析没有核心文件的分段错误

Posted

技术标签:

【中文标题】分析没有核心文件的分段错误【英文标题】:Analyzing segmentation fault without core file 【发布时间】:2021-06-25 13:36:02 【问题描述】:

假设我的二进制文件在我无法使用 ulimit -c 启用 core dump 生成的客户站点中运行。工程师如何在这样的真实场景中调试segmentation faults?是否有任何其他方法可以在不生成core dumps 的情况下调试或识别崩溃。

【问题讨论】:

【参考方案1】:

过去,我不得不多次处理这种限制。必须调查分段错误或更一般的异常进程终止,并注意核心转储不可用。

对于本演练我们选择的 Linux 平台,我想到了几个原因:

完全禁用核心转储生成(使用limits.confulimit) 目标目录(当前工作目录或/proc/sys/kernel/core_pattern 中的目录)不存在或由于文件系统权限或 SELinux 而无法访问 目标文件系统磁盘空间不足导致部分转储

对于所有这些,最终结果是相同的:没有(有效的)核心转储可用于分析。幸运的是,有一种解决方法可用于事后调试,有可能挽救局面,但鉴于它的固有限制,您的工作量可能会因情况而异。

识别错误指令

以下示例包含一个典型的 use-after-free 内存错误:

#include <iostream>

struct Test

  const std::string &m_value;

  Test(const std::string &value):
    m_value(value)
  
  

  void print()
  
    std::cout << m_value << std::endl;
  
;

int main()

  std::string *value = new std::string("this is a test");
  Test test(*value);
  delete value;
  test.print();
  return 0;

delete value 之后,std::string 引用Test::m_value 指向不可访问的内存。因此,运行它会导致分段错误:

$ ./a.out
Segmentation fault

当进程因访问冲突而终止时,Linux 内核会创建一个可通过dmesg 访问的日志条目,并根据系统的配置创建系统日志(通常为/var/log/messages)。该示例(使用-O0 编译)创建以下条目:

$ dmesg | grep segfault
[80440.957955] a.out[7098]: segfault at ffffffffffffffe8 ip 00007f9f2c2b56a3 sp 00007ffc3e75bc48 error 5 in libstdc++.so.6.0.19[7f9f2c220000+e9000]

对应的Linux内核源码来自arch/x86/mm/fault.c

    printk("%s%s[%d]: segfault at %lx ip %px sp %px error %lx",
        loglvl, tsk->comm, task_pid_nr(tsk), address,
        (void *)regs->ip, (void *)regs->sp, error_code);

错误 (error_code) 揭示了触发器是什么。这是一个特定于 CPU 的位集 (x86)。在我们的例子中,值5(二进制的101)表示由错误地址0xffffffffffffffe8 表示的页面已被映射,但由于页面保护而无法访问,并且尝试了读取。

日志消息标识执行错误指令的模块:libstdc++.so.6.0.1。该示例未经优化编译,因此对std::basic_ostream&lt;char, std::char_traits&lt;char&gt; &gt;&amp; std::operator&lt;&lt; &lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt;(std::basic_ostream&lt;char, std::char_traits&lt;char&gt; &gt;&amp;, std::basic_string&lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt; const&amp;) 的调用未内联:

  400bef:       e8 4c fd ff ff          callq  400940 <_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RK
SbIS4_S5_T1_E@plt>

STL 执行读取访问。了解了这些基础知识,我们如何确定分段错误发生的确切位置?日志条目包含我们这样做所需的两个基本地址:

ip 00007f9f2c2b56a3 [...] error 5 in
   ^^^^^^^^^^^^^^^^ 
  libstdc++.so.6.0.19[7f9f2c220000+e9000]                                     
                      ^^^^^^^^^^^^

第一个是访问冲突时的指令指针(rip),第二个是库的.text 部分映射到的地址。通过rip 减去.text 基地址,我们得到指令在库中的相对地址,可以使用objdump 反汇编实现(你可以简单地搜索偏移量):

0x7f9f2c2b56a3-0x7f9f2c220000=0x956a3
$ objdump --demangle -d /usr/lib64/libstdc++.so.6
[...]
00000000000956a0 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, s
td::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<ch
ar>, std::allocator<char> > const&)@@GLIBCXX_3.4>:
   956a0:       48 8b 36                mov    (%rsi),%rsi
   956a3:       48 8b 56 e8             mov    -0x18(%rsi),%rdx
   ^^^^^
   956a7:       e9 24 4e fc ff          jmpq   5a4d0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
   956ac:       0f 1f 40 00             nopl   0x0(%rax)
[...]

这是正确的指令吗?我们可以咨询 GDB 来确认我们的分析:

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7b686a3 in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) () from /lib64/libstdc++.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-323.el7_9.x86_64 libgcc-4.8.5-44.el7.x86_64 libstdc++-4.8.5-44.el7.x86_64
(gdb) disass
Dump of assembler code for function _ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E:
   0x00007ffff7b686a0 <+0>: mov    (%rsi),%rsi
=> 0x00007ffff7b686a3 <+3>: mov    -0x18(%rsi),%rdx
   0x00007ffff7b686a7 <+7>: jmpq   0x7ffff7b2d4d0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
End of assembler dump.

GDB 显示了相同的指令。我们还可以使用调试会话来验证读取地址:

(gdb) print /x $rsi-0x18
$2 = 0xffffffffffffffe8

此值与日志条目中的读取地址匹配。

识别调用者

因此,尽管没有核心转储,但内核输出使我们能够确定分段错误的确切位置。然而,在许多情况下,这还远远不够。一方面,我们缺少让我们到达那个点的调用列表 - 调用堆栈或堆栈跟踪。

如果背包中没有转储,您有两种选择来获取调用者:您可以使用 catchsegv(一个 glibc 实用程序)启动您的进程,或者您可以实现自己的信号处理程序。

catchsegv 充当包装器,生成堆栈跟踪,并转储寄存器值和内存映射:

$ catchsegv ./a.out
*** Segmentation fault
Register dump:

 RAX: 0000000002158040   RBX: 0000000002158040   RCX: 0000000002158000
[...]
Backtrace:
/lib64/libstdc++.so.6(_ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E+0x3)[0x7f1794fd36a3]
??:?(_ZN4Test5printEv)[0x400bf4]
??:?(main)[0x400b2d]
/lib64/libc.so.6(__libc_start_main+0xf5)[0x7f179467a555]
??:?(_start)[0x4009e9]

Memory map:

00400000-00401000 r-xp 00000000 08:02 50331747 /home/user/a.out
[...]
7f1794f3e000-7f1795027000 r-xp 00000000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795027000-7f1795227000 ---p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f1795227000-7f179522f000 r--p 000e9000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
7f179522f000-7f1795231000 rw-p 000f1000 08:02 33600977 /usr/lib64/libstdc++.so.6.0.19
[...]

catchsegv 是如何工作的?它本质上是使用LD_PRELOAD 和库libSegFault.so 注入一个信号处理程序。如果您的应用程序已经为SIGSEGV 安装了信号处理程序并且您打算利用libSegFault.so,则您的信号处理程序需要将信号转发给原始处理程序(由sigaction(SIGSEGV, NULL) 返回)。

第二种选择是使用自定义信号处理程序和backtrace() 自己实现堆栈跟踪功能。这允许您自定义输出位置和输出本身。

基于这些信息,我们基本上可以做与以前相同的事情 (0x7f1794fd36a3-0x7f1794f3e000=0x956a3)。这一次,我们可以回到调用者那里进行更深入的挖掘。第二帧由以下行表示:

??:?(_ZN4Test5printEv)[0x400bf4]

0x400bf4 是被调用者在Test::print() 之后返回的地址,它位于可执行文件中。我们可以将调用站点可视化如下:

$ objdump --demangle -d ./a.out
[...]
  400bea:       bf a0 20 60 00          mov    $0x6020a0,%edi
  400bef:       e8 4c fd ff ff          callq  400940 <std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std:
:char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::basic_string<char, std::char_trai
ts<char>, std::allocator<char> > const&)@plt>
  400bf4:       be 70 09 40 00          mov    $0x400970,%esi
  ^^^^^^
  400bf9:       48 89 c7                mov    %rax,%rdi
  400bfc:       e8 5f fd ff ff          callq  400960 <std::ostream::operator<<(std::ostream& (*)(std::ostream&))@plt>
[...]

请注意,objdump 的输出与此实例中的地址匹配,因为我们针对可执行文件运行它,该可执行文件在 x86_64 上的默认基地址为 0x400000 - objdump 将这一点考虑在内。启用地址空间布局随机化 (ASLR)(使用 -fpie 编译,与 -pie 链接)后,必须按照前面所述考虑基地址。

进一步返回涉及相同的步骤:

??:?(main)[0x400b2d]
$ objdump --demangle -d ./a.out
[...]
  400b1c:       e8 af fd ff ff          callq  4008d0 <operator delete(void*)@plt>
  400b21:       48 8d 45 d0             lea    -0x30(%rbp),%rax
  400b25:       48 89 c7                mov    %rax,%rdi
  400b28:       e8 a7 00 00 00          callq  400bd4 <Test::print()>
  400b2d:       b8 00 00 00 00          mov    $0x0,%eax
  ^^^^^^
  400b32:       eb 2a                   jmp    400b5e <main+0xb1>
[...]

到目前为止,我们一直在手动将绝对地址转换为相对地址。相反,模块的基地址可以通过--adjust-vma=&lt;base-address&gt; 传递给objdump。这样就可以直接使用rip的值或者调用者的地址了。

添加调试符号

我们已经走了很长一段路,没有垃圾场。但是,要使调试有效,还缺少另一个关键难题:调试符号。没有它们,可能很难将程序集映射到相应的源代码。使用-O3 编译示例并且没有调试信息说明了问题:

[98161.650474] a.out[13185]: segfault at ffffffffffffffe8 ip 0000000000400a4b sp 00007ffc9e738270 error 5 in a.out[400000+1000]

作为内联的结果,日志条目现在指向我们的可执行文件作为触发器。使用 objdump 可以得到以下结果:

  400a3e:       e8 dd fe ff ff          callq  400920 <operator delete(void*)@plt>
  400a43:       48 8b 33                mov    (%rbx),%rsi
  400a46:       bf a0 20 60 00          mov    $0x6020a0,%edi
  400a4b:       48 8b 56 e8             mov    -0x18(%rsi),%rdx
  ^^^^^^
  400a4f:       e8 4c ff ff ff          callq  4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
  400a54:       48 89 c5                mov    %rax,%rbp
  400a57:       48 8b 00                mov    (%rax),%rax

部分流实现被内联,使得识别相关源代码变得更加困难。如果没有符号,则必须使用导出符号、调用(如operator delete(void*))和周围的指令(mov $0x6020a0 加载std::cout 的地址:00000000006020a0 &lt;std::cout@@GLIBCXX_3.4&gt;)来进行定位。

使用调试符号 (-g),可以通过调用 objdump--source 来获得更多上下文:

  400a43:       48 8b 33                mov    (%rbx),%rsi
    operator<<(basic_ostream<_CharT, _Traits>& __os,
               const basic_string<_CharT, _Traits, _Alloc>& __str)
    
      // _GLIBCXX_RESOLVE_LIB_DEFECTS
      // 586. string inserter not a formatted function
      return __ostream_insert(__os, __str.data(), __str.size());
  400a46:       bf a0 20 60 00          mov    $0x6020a0,%edi
  400a4b:       48 8b 56 e8             mov    -0x18(%rsi),%rdx
  ^^^^^^
  400a4f:       e8 4c ff ff ff          callq  4009a0 <std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@plt>
  400a54:       48 89 c5                mov    %rax,%rbp

按预期工作。在现实世界中,调试符号并未嵌入到二进制文件中——它们在单独的 debuginfo 包中进行管理。在这些情况下,objdump 会忽略调试符号,即使它们已安装。为了解决这个限制,必须将符号重新添加到受影响的二进制文件中。以下过程创建分离的符号并使用 eu-unstripelfutils 重新添加它们,以使 objdump 受益:

# compile with debug info
g++ segv.cxx -O3 -g
# create detached debug info
objcopy --only-keep-debug a.out a.out.debug
# remove debug info from executable
strip -g a.out
# re-add debug info to executable
eu-unstrip ./a.out ./a.out.debug -o ./a.out-debuginfo
# objdump with executable containing debug info
objdump --demangle -d ./a.out-debuginfo --source

使用 GDB 代替 objdump

到目前为止,我们一直在使用 objdump,因为它通常可用,即使在生产系统上也是如此。我们可以只使用 GDB 吗?是的,通过对感兴趣的模块执行gdb。我使用 0x0x400a4b 和之前的 objdump 调用一样:

$ gdb ./a.out
[...]
(gdb) disass 0x400a4b
Dump of assembler code for function main():
[...]
   0x0000000000400a43 <+67>:    mov    (%rbx),%rsi
   0x0000000000400a46 <+70>:    mov    $0x6020a0,%edi
   0x0000000000400a4b <+75>:    mov    -0x18(%rsi),%rdx
   0x0000000000400a4f <+79>:    callq  0x4009a0 <_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt>
   0x0000000000400a54 <+84>:    mov    %rax,%rbp

与 objdump 相比,GDB 可以毫不费力地处理外部符号信息。 disass /m对应objdump --source

(gdb) disass /m 0x400a4b
Dump of assembler code for function main():
[...]
21    Test test(*value);
22    delete value;
   0x0000000000400a25 <+37>:    test   %rbx,%rbx
   0x0000000000400a28 <+40>:    je     0x400a43 <main()+67>
   0x0000000000400a3b <+59>:    mov    %rbx,%rdi
   0x0000000000400a3e <+62>:    callq  0x400920 <_ZdlPv@plt>

23    test.print();
24    return 0;
25  
   0x0000000000400a88 <+136>:   add    $0x18,%rsp
[...]
End of assembler dump.

在优化二进制的情况下,如果源代码无法明确映射,GDB 可能会跳过此模式下的指令。我们在0x400a4b 的说明未列出。 objdump 从不跳过指令,而是可能跳过源上下文——我更喜欢在这个级别进行调试的一种方法。这并不意味着 GDB 对这项任务没有用处,它只是需要注意的事情。

最后的想法

终止原因、寄存器、内存映射和堆栈跟踪。这一切都在那里,甚至没有核心转储的痕迹。虽然绝对有用(我以这种方式修复了很多崩溃),但您必须记住,通过该路线您仍然会丢失有价值的信息,最值得注意的是堆栈和堆以及每个线程的数据(线程元数据,寄存器,堆栈)。

因此,无论情况如何,您都应该认真考虑启用核心转储生成,并确保在紧急情况下能够成功生成转储。调试本身就足够复杂,在没有您技术上可能拥有信息的情况下进行调试会不必要地增加复杂性和周转时间,更重要的是,显着降低了及时找到和解决根本原因的可能性.

【讨论】:

希望对您有所帮助!如果您回答了您的问题,或者我是否应该详细说明特定方面,请告诉我。 感谢您的回答。这非常具有描述性,并且很好地解释了这些概念。谢谢。 我尝试了一个简单的程序int main() char * ptr; *ptr = 'A'; //This segfaults dmesg 显示 segfault at 0 ip 000055cdcfe6d602 sp 00007ffc298bae20 error 6 in a.out[55cdcfe6d000+1000] 。我做了一个减法 000055cdcfe6d602 - 55cdcfe6d000,这把我带到了正确的指令。但是,我在这里确实有一些疑问。为什么 rip 在 dmesggdb 中显示两个值。在 gdb rip 0x555555554602 0x555555554602 &lt;main+8&gt; 和 dmesg 中是 000055cdcfe6d602 。这是否意味着在gdb 中它计算rip 相对于它自己的进程地址区域,但在dmesg 中,它是相对于整个内核的rip 那是因为 gdb 默认禁用 ASLR。您可以通过set disable-randomization off 禁用该功能。那么,基地址就不会是0x555555554000$rip 永远是绝对的。

以上是关于分析没有核心文件的分段错误的主要内容,如果未能解决你的问题,请参考以下文章

分析分段错误核心转储 (gdb)

正确编译的代码(没有错误)给出分段错误核心转储

为啥写作主要;在 C 中给出一个段错误

分段错误(核心转储) - 无法访问的计数器值

获取“分段错误核心转储”

macOS Big Sur 中的分段错误后未生成核心转储文件