详解GDB高级技巧—反向调试:调用栈全是问号,也能快速准确定位

反向调试(Reverse Debugging)有个很拉风的别名 - 时间旅行调试。听这个名字就知道有多牛X了吧。为了尽量讲清楚,本文篇幅较长,请耐心读完,相信你一定会有收获!

在调试程序的时候,你是否遇到过下面这些情况:

接下来,介绍一种针对上面这些情况非常简单有效的调试方法 - 反向调试

什么是反向调试?

反向调试是一种高级调试技术,可以让程序已经执行了一段时间后,回退到过去的状态并重新执行。这意味着你可以回到程序执行中的任何点,查看变量的值、堆栈跟踪以及程序执行路径。反向调试可以让我们快速、准确地定位出程序中的错误或异常的根本原因。

简单来说,就是一种可以让程序逻辑逆序执行的调试技术。通过它,你可以随时中断程序的正常执行,然后逆序执行,让程序回到过去,并可以查看任意时间点的任意信息。

先通过一个简单的例子,直观感受一下.

源码如图:

在GDB中启动程序,并停在程序入口处,并在第7行设置断点,让程序停下来,然后进行反向调试:

root@ubuntu:ReverseDebugging# gdb test
Reading symbols from test...
(gdb) start
Temporary breakpoint 1 at 0x40111d: file test.c, line 3.
Starting program: /opt/data/workspace/articles/gdb/articles/ReverseDebugging/test 

Temporary breakpoint 1, main () at test.c:3
3           int a = 0;
(gdb) b 7
Breakpoint 2 at 0x401139: file test.c, line 7.

执行record命令,开始记录程序执行轨迹

(gdb) record

执行c(continue)命令,让程序正常执行,然后在第7行触发断点,停下来

(gdb) c
Continuing.

Breakpoint 2, main () at test.c:7
7           return 0;

此时,查看a的值,a = 3

(gdb) p a
$1 = 3

执行reverse-next命令,让程序逆向执行一行代码。

(gdb) reverse-next
6           a = 3;

准确地说,这条命令的作用时是让程序回退一行代码的执行效果。也就是回退到刚执行完第5行代码,尚未执行第6行代码时的状态。此时查看a的值,不出意外的话,a的值应该是2,我们来验证一下

(gdb) p a
$2 = 2

果然,第6行代码的的执行效果已经被完全回退了。我们再执行一条reverse-next命令,此时,应该时回退第5行代码的执行效果。

(gdb) reverse-next
5           a = 2;

此时,程序的状态已经被回退到刚执行完第4行代码,尚未执行第5行代码时的状态,此时,a应该是1,验证一下:

(gdb) p a
$3 = 1

然后,回退第4行代码的执行效果,让程序状态恢复到刚执行完第3行代码,尚未执行第4行代码时的状态:

(gdb) reverse-next
4           a = 1;
(gdb) p a
$4 = 0

到这里,大家应该已经明白了吧,reverse-next的作用是让程序恢复到上一行代码刚执行完时的状态。 当然,我们仍然可以让程序以正常顺序执行下去:

(gdb) c
Continuing.

No more reverse-execution history.
main () at test.c:7
7           return 0;
(gdb) p a
$5 = 3
(gdb) 

再看一下整个调试过程,加深一下理解:

反向调试实现原理

反向调试技术的核心原理,简单来说,是在程序运行中,记录每一条指令对程序产生的状态变化,包括变量、寄存器、内存的数据变化等,并将这些信息存储在一个history文件中。当需要回溯到过去的状态时,调试器会按照相反的顺序逐条指令恢复这些状态,使得程序的执行状态回到已经被记录的任意时间点。(玩过Cheat Engine的小伙伴对这个应该不陌生吧!)

因此,反向调试还有一个雅称 - Time Travel Debugging

GDB反向调试用法

GDB作为Linux下的调试神器,很早就支持了反向调试的功能,常用的命令有:

简单解释一下:

- reverse-next(rc): 参考next(n), 逆向执行一行代码,遇函数调用不进入
- reverse-nexti(rni): 参考nexti(ni), 逆向执行一条指令,与函数调用不进入
- reverse-step(rs): 参考step(s), 逆向执行一行代码,遇函数调用则进入
- reverse-stepi(rsi): 参考setpi(si), 逆向执行一条指令,与函数调用则进入
- reverse-continue(rc): 参考continue(c), 逆向继续执行
- reverse-finish: 参考finish,逆向执行,一直到函数入口处
- reverse-search(): 参考search,逆向搜索
- set exec-direction reverse: 设置程序逆向执行,执行完此命令后,所有常用命令如next, nexti, step, stepi, continue、finish等全部都变成逆向执行
- set exec-direction forward: 设置程序正向执行,这也是默认的设置

在绝大多数环境下,在使用这些反向调试命令之前,必须要先通过record命令让GDB把程序执行过程中的所有状态信息全部记录下来。

通常是在程序运行起来之后,先设置断点让让程序停下来,然后输入record命令,开启状态信息记录,然后再继续执行。相关常用命令有:

- record: 记录程序执行过程中所有状态信息 
- record stop: 停止记录状态信息 
- record goto: 让程序跳转到指定的位置, 如record goto start、record goto end、record goto n 
- record save filename: 把程序执行历史状态信息保存到文件,默认名字是gdb_record.process_id 
- record restore filename: 从历史记录文件中恢复状态信息
- show record full insn-number-max:查看可以记录执行状态信息的最大指令个数,默认是200000 
- set record full insn-number-max limit:设置可以记录执行状态信息的最大指令个数 
- set record full insn-number-max unlimited:记录所有指令的执行状态信息

还有其他一些不常用的命令,不再赘述,感兴趣的童鞋可以查看GDB帮助文档。

下面我们来深入研究下,反向调试到底有什么用,该怎么用。

反向调试有什么用

GDB提供了无数的调试工具和手段,比如指令断点、单步执行、数据断点等常见的基础工具,以及我之前文章中介绍过的自定义命令、dynamic print、breakpoint command list等高级功能,这些工具和手段各有各的适用场景,合适的场景使用合适的工具,往往有事半功倍的效果。

反向调试在定位这些类型的问题时会特别有用:

下面,以调用栈显示全是问号的问题为例,演示一下反向调试的使用方法。

实例:Segmentation fault时调用栈全是问号

程序正常执行时,会发生Segmentation fault

默认生产了core dump,用GDB直接调试core,直接bt:

居然全是问号!(注:编译时已经添加-g选项)

直接在GDB中运行试试看:

仍然全是问号!现在怎么办呢?

是不是有点慌了?淡定!咱们先来分析一下!

分析

既然bt显示全是问号,那我们就从这个现象着手进行分析。

我们知道bt(backtrace)的作用是获取程序当前位置的函数调用关系。而函数调用关系,则是根据栈帧结构,从最深层次函数开始,逐层推导出来的。简单来说,就是逐层从栈中获取函数的返回地址,根据返回地址,从ELF的debug信息中找到对应的函数名、代码行号等信息。(函数栈帧结构不是本文重点,篇幅所限,不再赘述,感兴趣的小伙伴可以留言讨论)。

现在,bt打印出来全是问号,说明GDB无法获取到所需要的信息,也就是说,栈中的数据被破坏了!

那么思考一下,我们改如何定位究竟是哪里把栈中数据给破坏了呢?

printf ???No!不解释!

直接单步调试吗?对应代码量不大,且逻辑简单的程序,这不失为一种可行的调试手段,但不够高效,往往需要反复猜测,反复重启程序调试才有可能找到真正的root cause。No!

直接数据断点?看起来确实是最简单有效,可问题是,往哪个地址设置断点呢?而且,如果程序正常运行过程中,会反复去修改这个地址里的数据呢?No!

反向调试?我们让程序正常运行,一直到触发segmentation fault。既然已经确定是栈数据被破坏,那么我们直接在栈上设置数据断点,然后让程序逆向执行,第一个触发数据断点的地方,肯定就是把这个错误的数据写到这个栈地址的代码,也就是破坏我们栈数据的地方!Yes!Yes!

思路有了,Let's Go!

用反向调试方法进行定位

重新在GDB中运行程序,用record命令开始记录程序执行运行过程中的状态信息,然后让程序正常执行,直到发生segmentation fault停下来:

查看RSP寄存器,找到当前栈地址,然后在当前栈地址上设置数据断点:

我们直接在RSP地址处设置数据断点。需要解释一下的是,我所用的GDB版本中,在进行反向调试时,硬件数据断点会失效,因此需要通过“set can-use-hw-watchpoints 0”命令强制GDB使用软件断点。不知道最新的GDB版本中这个问题是否已经解决,感兴趣的童鞋可以去试一下。

然后,使用rc(reverse-continue)命令让程序开始逆向执行:

可以看到,我们上一步在栈上设置的数据断点被成功触发了,程序停在了第4行,给数组元素赋值的地方,看一下这个元素的地址:

确实是我们刚才设置的数据断点的地址。此时,再看下调用栈信息呢?

调用栈已经恢复正常!进一步证明,此时触发数据断点的这个地方,也就是第4行,就是破坏我们栈数据的罪魁祸首!

由于接下来都是常规调试,没有什么技术难点,篇幅所限,不再赘述。有疑问的童鞋可以留言讨论!

直接说结论: 很容易分析出来,传入bar的数组长度超出了数组的大小,数组访问越界了,踩掉了栈中函数bar的返回地址,在return的时候返回到了一个非法地址,最终导致segmentation fault。

这段测试程序源码如下,建议亲自上手操作一下,加深理解:

调试过程如下:

反向调试的缺点

前面介绍过,反向调试的实现原理是,调试器在程序执行每一条指令时,把这条指令所产生的效果记录下来,比如修改了寄存器的值、修改了内存中的值、跳转到另外一个地址等等,然后反向执行的时候,逐条把这些指令产生的效果还原回来,以此达到恢复程序执行状态的效果。

由此可见,这势必会在程序执行过程中产生不小的额外开销,对程序的性能产生比较大的影响。

不过,对于一些对性能不敏感的程序,或者规模不大的程序,可以不考虑这个问题。而对于一下规模较大且对性能非常敏感的程序,其实没有必要在程序执行一开始就记录执行轨迹状态信息,可以考虑分段记录,或者只记录自己感兴趣的程序段的状态信息。

最后留两个思考题:

  1. 逆向执行时,所有过去的状态信息都可以恢复吗?
  2. 我们前面通过反向调试定位调用栈全是问号的这个问题,在segmentation fault触发之后,我们直接在RSP地址处设置了数据断点,然后很轻松就定位到了root cause。 思考一下,这种方式在任何时候都是有效的吗?有哪些情况下,是不能直接在RSP地址处设置数据断点的,或者说在RSP地址处设置断点是无法直接定位到破坏栈数据的那一行代码的呢?如果出现这种情况,该怎么办呢?

如果有童鞋在实际项目中遇到类似的问题,欢迎留言讨论!

觉得有用的话,烦请点个赞!右上角关注!分享给其他小伙伴!

我之前写过多篇很实用的高级调试技术(本文是第9篇),感兴趣的朋友可以去翻看一下!

展开阅读全文

页面更新:2024-03-05

标签:断点   问号   指令   详解   函数   命令   准确   状态   快速   高级   代码   地址   技巧   程序   数据

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top