Linux 下如何定位剥去调试信息后的程序崩溃

原作者:tangxinfa,发表于 2017 年 11 月 14 日。本文经授权完整核查整理自 linux 下调试剥去调试信息的程序崩溃,保留 core、调用栈日志和 MAP 三条诊断路径,并明确区分历史实验与现行工具条件。

发布时去掉调试信息,可以减少可执行文件体积。原文提到某些程序可能缩到原来的五分之一;这只是可能出现的结果,并非固定比例。去掉部分函数和变量名称也会增加分析成本,但 strip 不构成保密或防逆向的安全边界,不能用来保护编译进二进制的秘密。

GCC 的 -g 选项会生成调试信息,strip 可移除相关符号。调试剥离后的崩溃,关键不是事后猜源码,而是在构建时保存匹配的符号、二进制和库,再把现场地址正确映射回它们。

构建阶段保存匹配的二进制与符号,现场保留core或栈地址与映射;确认build ID和加载偏移后定位源码。
原创诊断流程示意,依据原文与官方补充资料绘制,不表示已运行崩溃实验。

先理解原文的故意崩溃程序

原文使用以下多线程程序 app.c,通过访问非法指针制造崩溃。它是历史故障演示,不是可投入业务的示例,也没有在本轮编译或运行:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#include <stdint.h>

void bug(int id) {
  /* 原文故意设置非法指针以触发故障。 */
  printf("[%d] This is bug\n", id);
  int *p = (int *)-1;
  printf("%d\n", *p);
}

void extra_func() {
  printf("");
}

int func_b(int id)
{
  printf("[%d] This is func_b\n", id);
  sleep(rand()%5);
  extra_func();
  bug(id);
  extra_func();
}

int func_a(int id)
{
  printf("[%d] This is func_a\n", id);
  sleep(rand()%5);
  extra_func();
  func_b(id);
  extra_func();
}

void* func(void* param) {
  int id = (int)param;
  sleep(1);
  extra_func();
  func_a(id);
  extra_func();
  return NULL;
}

int main(int argc, char *argv[])
{
  int i;
  for(i = 0; i < 10; ++i) {
    pthread_t pid;
    if (0 != pthread_create(&pid, NULL, func, (void*)i)) {
      fprintf(stderr, "create thread %d failed (%d).\n", errno);
      exit(1);
    }
  }
  sleep(100);
  return 0;
}

静态审查:非法指针解引用是刻意保留的未定义行为,不能承诺每个编译器都以完全相同的方式崩溃。此外,原例还存在真实代码缺陷:fprintf 有两个 %d 却只传入一个参数;pthread_create 的错误码应取其返回值,而不是据 errno 判断;指针与 int 直接互转具有可移植性问题;两个声明返回 int 的辅助函数没有返回值;线程没有 join 或 detach,等待 100 秒也不是可靠的线程生命周期管理。本文保留历史代码以便对照地址和行号,不把这些问题静默修成另一份实验。

原文的编译命令如下。它会在当前目录生成或覆盖 app,只能在已有源文件的隔离演示目录中使用:

gcc -g -O2 app.c -lm -lpthread -o app

-O2 可能内联函数、删除无作用调用并重排指令,因此栈帧和源码行映射未必与源码逐行对应。原文的地址和输出来自 Arch Linux/x86-64 环境,日志中可见 glibc 2.26;重新构建后不可照搬这些地址。

在 strip 之前保存匹配符号

最简单的办法是保留未剥离的可执行文件,再处理发布副本:

cp app app_debug
strip app

strip 会修改目标文件;必须先确认备份确实存在且与即将部署的构建相同。另一种方式是把调试信息单独保存:

objcopy --only-keep-debug app app.debug
strip app
objcopy --add-gnu-debuglink=app.debug app

GDB 可以通过 .gnu_debuglink 的文件名及校验信息,或 build ID,查找匹配的调试文件。原文建议放在当前目录或设置 set debug-file-directory /tmp/debug;更准确地说,应按 GDB 的搜索规则组织可执行文件旁的目录、.debug 子目录或全局调试目录,不能把任意当前工作目录视为必然可被自动发现的位置。符号文件和部署的可执行文件、共享库必须来自相同构建。

某些工具与版本的分离符号支持方式不同,原文还展示了用 elfutils 重新组合文件的办法:

cp ./app.debug ./app_debug
eu-unstrip ./app ./app_debug

这组命令可能覆盖已有 app_debug,应使用受控副本并核对本机 eu-unstrip 语法。原文写到的工具名 str2line 与后续实际使用的 addr2line 不一致;本文按实际命令讨论,不据此声称所有版本的 addr2line 都不支持任何分离符号方式。

路径一:从 core 中取出调用栈

原文为了制造并收集示例 core,先解除 shell 的 core 大小限制,再运行会崩溃的程序:

# 历史故障演示:可能生成大文件,只适用于授权的隔离环境。
ulimit -c unlimited
./app

这会执行故意非法访存的程序,不应在生产服务中照做。Core 可以包含密钥、用户输入和业务内存;采集、保存、传输都需要授权与访问控制,同时限制磁盘占用。实际生产采集应遵循已有的 core 管理策略,不默认采用无限大小。

原文在由 systemd 接管 core 的 Arch Linux 环境中使用以下命令,把最近一次匹配的 core 导出到当前目录:

coredumpctl -r -1 -o app.core dump app

命令会写入 app.core;使用前应核对事件时间、PID、程序路径和版本,避免“最近一个 app”实际来自别的构建。现场已有 core 时,可以用剥离后的匹配可执行文件提取调用栈:

gdb -q --nh --nx --batch -ex bt ./app --core=./app.core

原文输出中的关键帧如下,未知符号显示为 ??:

#0  0x000055de44fb79a4 in ?? ()
#1  0x000055de44fb7a0b in ?? ()
#2  0x000055de44fb7a65 in ?? ()
#3  0x00007f60872e508a in start_thread () from /usr/lib/libpthread.so.0
#4  0x00007f608701c47f in clone () from /usr/lib/libc.so.6

这是原文记录,不是本轮输出。能否可靠回溯还取决于栈是否受损、展开信息、优化和库匹配。--nh --nx 用于忽略初始化文件,但不应被理解为调试任意不可信文件的完整沙箱;应使用可信的构建产物,维持 GDB 自动加载的安全限制。

用 eu-addr2line 联合 core 定位

有 core 和未剥离的匹配文件时,原文通过 elfutils 的 eu-addr2line 解析运行时地址:

eu-addr2line -e ./app_debug --core ./app.core 0x000055de44fb79a4 0x000055de44fb7a0b 0x000055de44fb7a65

原例对应输出为 app.c:12、app.c:26、app.c:44。第 12 行恰好是 printf("%d\n", *p);,但另外两项落在函数尾部。若一个函数在多处调用同一个函数,这种结果不足以准确判断哪个调用点导致崩溃。

这一条路径依赖 core。若现场设备不受直接控制,core 很大,或者网络带宽与流量费用受限,就可能希望只转移栈地址和映射信息,把符号化留在调试环境中。

不用转移 core 时,必须正确计算加载偏移

直接把运行时地址交给 GNU addr2line,原例得到 ??:0:

addr2line -e ./app_debug 0x000055de44fb79a4

原因是文件中的链接虚拟地址与进程的实际加载地址可能不同。为了理解它们的关系,可以在进程仍运行时读取 /proc/<pid>/maps,或在 GDB 中查看 info target 与 info proc mapping。原文认为后者的映射信息更容易分析:

gdb -q --nh --nx --batch -ex 'info proc mapping' --core ./app.core

在原文另一轮运行中,故障地址是 0x563b029d09a4,对应映射起始地址为 0x563b029d0000,文件偏移为零。对这份布局,二者相减得到 0x9a4,再查询:

addr2line -e ./app_debug 0x9a4

原例由此定位到 app.c:12。这说明可以在现场从 core 提取栈和映射,随后只转移这些资料,在调试环境用对应构建做源码定位,而不用搬运整个 core。

编辑修正:“找出地址所在映射段,再减去该段起点”不是通用规则。对一般 PIE 和共享库,应把映射的文件偏移与 ELF 中相应 PT_LOAD 的 p_offset、p_vaddr 对齐,考虑页对齐后求出 load bias,再用“运行时地址 − load bias”得到文件中的虚拟地址。ELF 规范以实际加载地址与相应段虚拟地址的差定义基址;不能把文件偏移不为零的可执行映射起点直接当成基址。

原例的减法只在所展示的零偏移布局下成立。不同库要分别确认匹配的文件和加载偏移,也不要把上述两轮运行中的地址相互混用。

路径二:从历史 libSegFault 日志获取栈

原文的第二条路线利用 glibc 曾经提供的 libSegFault.so,通过 LD_PRELOAD 提前加载信号处理逻辑。它能够在段错误时打印寄存器、回溯和内存映射;catchsegv 是其封装,会处理临时文件等细节,避免崩溃信息与程序自己的错误输出混在一起。

版本限制:glibc 2.35 发布说明明确移除了 catchsegv 和 libSegFault.so。以下命令只属于仍提供这些工具的旧环境,不能作为现代发行版通用步骤,也不应为复现它而随意预加载来源不明的库:

# 历史故障演示,仍会运行并崩溃;输出重定向会覆盖 app.log。
catchsegv ./app > ./app.log
cat ./app.log

原例日志除完整寄存器和内存映射外,包含这些关键回溯条目:

./app(+0x9a4)[0x55a0ae63e9a4]
./app(+0xa0b)[0x55a0ae63ea0b]
./app(+0xa65)[0x55a0ae63ea65]
/usr/lib/libpthread.so.0(+0x708a)[0x7fb65d61808a]
/usr/lib/libc.so.6(clone+0x3f)[0x7fb65d34f47f]

原文使用 grep、sed、xargs 提取 app 的偏移并传给 addr2line。这里将同一用途整理为只接受十六进制偏移的示意命令,避免把任意日志文本作为参数;这一替换没有执行验证:

# 编辑整理:仅接受所示固定格式的十六进制偏移。
sed -nE 's@^\./app\(\+(0x[[:xdigit:]]+)\)\[0x[[:xdigit:]]+\]$@\1@p' app.log |
  xargs -r addr2line -e ./app_debug

该命令针对原例日志格式和 GNU 工具,不是跨平台日志解析器;只处理主程序行,没有处理共享库。格式检查避免参数混入,但真实业务还要限制输入量并确认符号文件来源。原例仍得到第 12、26、44 行,非顶层返回地址的行号歧义并未消失。

路径三:结合 MAP 与汇编清单定位

原文最初只查看 MAP 文件,发现其中主要记录函数地址,没有完整源码行映射,因而不能单靠 MAP 达到目标。后来结合编译时的汇编清单 .cod:先用 MAP 找函数地址,再计算函数内偏移,最后在清单里查对应指令和源码行。

原文生成清单和链接映射的命令为:

# 会写入或覆盖 app.cod、app.o、app.map、app;先保留匹配构建资料。
gcc -c -g -Wa,-a,-ad app.c > app.cod
gcc -g3 app.o -lpthread -Wl,-Map,app.map -o app
strip app

此路径的构建参数与前面的 -O2 构建不同,因此地址也不同。必须把这次生成的二进制、MAP、COD 和对应崩溃绑定起来,不能跨构建混用。它仍需要前两类方法之一获得现场调用地址;MAP 本身不会捕获崩溃。

原文随后用旧 catchsegv 工具取得偏移 0x917、0x998、0xa07、0xa45。在 MAP 中,bug 的起始地址是 0x8ea,所以函数内偏移为:

0x917 - 0x8ea = 0x2d

对应清单中能看到:

12:app.c         ****   printf("%d\n", *p);
32                    .loc 1 12 0
33 0029 488B45F8      movq    -8(%rbp), %rax
34 002d 8B00          movl    (%rax), %eax

这次正确定位到了第 12 行。但原文继续对 0x998 查找时,虽然找到 func_b,算出的 0x62 却对应第 21 行的打印语句,而非预期调用位置。这项失败结果也应保留:MAP 与汇编清单需要手工判断,函数布局、优化及返回地址语义都可能影响定位,不能声称它必然精确。

容器里的 core:关键是匹配环境

Docker 容器共享宿主机的 Linux 内核。原文指出 core 的处理受宿主机 /proc/sys/kernel/core_pattern 配置影响。实际文件落点还要结合管道收集器、挂载命名空间、工作目录、权限和服务配置判断,不应简单要求“宿主机和容器各建同一个目录”就认为足够。

原文建议在哪里生成就在哪里分析,以减少程序和库路径、环境变量及版本不同造成的麻烦。本文将它收紧为:分析环境必须能找到与崩溃匹配的可执行文件、共享库和调试符号,并正确设置路径。可以在容器内、宿主机或专门的分析环境完成,只要这些条件满足;生成位置并不决定唯一的分析位置。

三条路径如何选择

Core 路径信息丰富,适合已有系统级崩溃采集的环境;也可以在现场提取栈与映射后只传输较小的诊断资料。历史 libSegFault 路径更轻量,但要求提前预加载,并且已经不属于现代 glibc 的通用工具。MAP 路径降低了符号化阶段对完整可执行文件的依赖,却仍需匹配的 MAP、COD 与现场地址,且手工操作较多。

三者共有的前提是提前保存构建资料。顶层故障指令能够定位,不代表所有调用者帧都能精确映射;优化、返回地址、栈破坏和构建不匹配都需要继续核对。本文没有制造 core、没有运行故意崩溃程序,也没有执行任何源文命令。

为便于阅读,重复的线程列表、整组寄存器和大段相同内存映射在正文中只保留用于推导的条目;完整源文抓取记录随附在 sources/。原文涉及的三条流程、命令用途、成功与失败结果、容器部分及其局限均保留。

补充核验:GDB 分离调试文件;ELF Program Loading 与基址规则。原文参考讨论及其引用关系保留在随附源文中;本稿对新版条件的判断以这些一手资料为依据。

原文版权归 tangxinfa,原站标示 © 2011–2020 tangxinfa。未发现可另行承诺的开放许可证。编辑改动包括说明演示代码缺陷、限定历史环境、补充 load bias 规则、修正容器分析绝对说法,以及替换日志提取表达式。所有代码仅静态审查,未实测。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容