GDB高级技巧:同一个Bug,5种解决方案,不修改源码,不重新编译
1、引言
在《GDB高级技巧:边Debug边修复BUG,无需修改代码,无需重新编译》一文中,介绍了使用GDB breakpoint command lists的功能,可以在不修改源码、不重新编译的前提下,修复掉被调试程序中的BUG,从而避免反复修改代码和编译构建的过程,大大提高程序调试的效率。
文中提到了解决一个问题的几种不同的思路,但限于篇幅,只重点讲解了其中的一种。有童鞋希望能够把其它几种也讲解一下,于是便有了此文。
本文会介绍5种不同的方法,解决同一个Bug!
2、本文要解决的问题
如下示例程序中,存在两个BUG:
图1 示例程序
正常执行时,该程序将出现异常,如下图所示:
图2 原始程序执行异常
本文将介绍5种方法,在不修改源码、不重新编译的前提下,借助GDB解决掉示例程序中的两个BUG,使其能够正确执行,并得到预期结果。
其最终执行结果如下图所示:
图3 程序最终可以正常执行
先介绍一些必需的背景知识。
3、背景知识介绍
3.1、x64 CPU函数参数传递
对于C语言,不同的CPU/编译器,其参数传递方式也不尽相同。
我的测试环境是x64CPU,所以仅介绍x64 CPU的函数参数传递规则。
用GCC编译出来的程序,在x64 CPU上,如果程序中没有特殊指定,会优先使用寄存器传递参数,当寄存器不够用的时候,用栈传递。
默认情况下,前6个参数,从左到右,依次用寄存器RDI(EDI)、RSI(ESI)、RDX(EDX)、RCX(ECX)、R8、R9传递,从第七个函数开始,使用栈传递。
注:RDI、RSI、RDX、RCX是64位寄存器,EDI、ESI、EDX、ECX分别是它们低32位寄存器的表示。
如下图简单示例:
图4 参数传递示例
test()共有8个参数,我们在GDB中把main()函数反汇编一下,看下是如何传递参数的:
图5 main反汇编
通过反汇编,可以看到,前面6个参数是使用寄存器传递的,而第7、8两个参数是通过栈传递的。
3.2、GDB断点预置命令
注:已阅读过之前那篇文章的童鞋,可以直接跳过3.1小节。当然, 也可以再回顾一下,加深印象!
GDB提供了一种功能,对于指定的断点,GDB允许用户预设一组操作(通常是调试命令),当断点被触发时,GDB会自动执行这组预设的操作。
这个功能,具体用法如下:
1、先设置一个或多个断点,包括breakpoint、watchpoint、catchpoint等各种类型的断点。
2、然后,用commands命令针对一个或多个断点预设一组操作,最后以end结尾即可。格式如下:
commands [id...] command-listend
其中:
commands关键字标记开始预设命令。
id是断点的ID,也就是用info break命令查询出来的断点编号。可以省略,也可以是一个或多个断点号。当省略时,表示对最近一次设置的断点有效。
command-list是用户预设的一组操作,可以是任意一个或多个GDB支持的命令,甚至是用户自定义的命令。
end标记结尾。
设置完成之后,每当id指定的断点被触发时,GDB便会自动执行command-list所指定的一组命令。
是不是很简单呢?善用这个功能,可以完成一些非常强大的功能。本文将利用这个功能,介绍如何在不修改源码、不重新编译的前提下,解决程序中的BUG。
有了这些背景知识之后,下面正式进入正题!
4、解决思路
再看一下示例程序:
示例程序
包含两个BUG:
第15行和第17行均用sizeof取数组元素的个数,这明显是错误的。会造成do_stuff()中数组访问越界,进而破坏栈内数据,而且main()的for循环打印数组元素时,也会出错。
4.1、第15行的BUG
对于第15行的BUG,其实是传递给do_sutff()数组第二个参数错误。那么,解决这个问题,有两种思路:
在do_sutff()中处理。尽管在main()函数传递给do_stuff()的参数是sizeof(array),但是从do_stuff()的角度来看,要想让它正确执行,只需要在for循环时,迭代次数不要超过数组的实际大小就可以了,也就是说,只要保证i<10,就可以得到正确的结果。
在main()函数中处理。其实也就是,想办法把正确的参数传递给do_stuff()。
4.2、第17行的BUG
对于第17行的BUG,本质上只要保证for循环迭代次数不超过数组的实际大小就可以了,也就是必须要保证i<10。
下面我们根据这个思路,来解决这两个BUG。为了方便演示,我们先解决第17行的BUG。
5、解决第17行的BUG
上面已经分析过,只要能保证i<10就可以解决第17行的BUG。我们可以这样做:
在第18行设置条件断点,当i==10时触发。
在断点触发时,使用GDB的jump命令,退出循环,跳转到第20行继续执行。
命令如下:
b 18 if i==10commands jump 20 continueend
执行效果如下:
程序仍然执行异常,这是因为第15行的BUG还没解决。不过,它只打印出来了10个元素,说明第17行的BUG已经解决了。
接下来,解决第15行的BUG。
6、解决第15行的BUG
我们根据上面提到的两种思路,采用5种不同的方法来解决这个BUG。
6.1、在do_stuff()中处理
方法一:在do_stuff()中第一条指令执行前,修改ESI寄存器的值。
前面背景知识介绍过,x64上,优先采用寄存器传递参数,因此do_stuff()的第二个参数,按照顺序应该使用ESI来传递。如下图所示:
但是,do_stuff()的参数size,本质上也是一个局部变量,存放在栈中,它的初始值是从寄存器ESI中取得的。因此,只要在对变量size初始化前,把ESI寄存器的值修改掉,变量size就能被初始化成正确的值。
因此,我们把断点设置在do_stuff()入口第一条指令处,因为此时局部变量size还没有被初始化,然后把ESI寄存器的值修改为数组array的正确大小:10。
命令如下:
b *do_stuffcommands printf '\n ESI = %d\n',$esi set $esi=10 printf '\n ESI = %d\n',$esi continueend
注意,设置断点时,必须用*do_stuff,只有这样才能把断点设置在do_stuff()的第一条指令处。
另外,为了确定它是否正常工作,我们在修改ESI前后,把ESI的值打印出来。
看一下效果:
可以看出,ESI的值确实被修改为10,局部变量size也被初始化为10。
然后,对上面的命令作一些优化,并且和前面用来解决第17行BUG的命令结合起来,制作一个“热补丁”脚本文件:
vi test.fix.1
这样,GDB调试时,只需要加载test.fix.1就可以了,它的内容如下图:
test.fix.1
相比上面手动输入的命令,这里做了两点优化:
加入silent命令,屏蔽断点被触发时的打印信息,避免视觉干扰。
删除修改ESI前后的打印语句。
现在,我们用GDB重新调试运行test,并用-x参数加载“热补丁”文件test.fix.1,结果如下图:
test.fix.1执行结果
程序最终正常运行,并且得到预期结果!
方法二:在do_stuff()中for循环执行之前,修改size的值
我们只要保证,在第5行代码的for循环执行之前,把变量size的值,修改为数组的正确大小10就可以了。
可以在第5行设置断点,断点被触发时,把变量size的值改为10。结合上文修复第17行BUG的操作,完整命令如下:
test.fix.2
保存到test.fix.2文件中。执行结果如下:
test.fix.2执行结果
正常执行,得到预期结果!
方法三:在do_stuff()中循环中判断如果i==10,则跳出循环
这次,我们不去修改参数size的值,而是在for循环中,判断如果i==10,则跳出循环,对于do_stuff()来说,直接return出去就可以了。
在第7行设置条件断点,当i==10时触发,然后执行return命令,从do_stuff()中返回。
完整命令如下:
test.fix.3
保存为文件test.fix.3,执行结果如下图:
test.fix.3执行结果
从GDB中运行test,程序正常运行,得到预期结果!
6.2、在main()函数中处理
方法四:在main()调用do_stuff()函数前修改ESI寄存器的值
在方法一种,我们是在do_stuff()函数中修改了ESI寄存器的值,使得局部变量size被初始化成正确的数组大小。
这次,我们尝试在main()函数中调用do_stuff()函数的时候,就把ESI寄存器修改掉,从而确保最终传递给do_stuff()的参数是正确的。
首先,要确认main()函数中,真正调用do_stuff()函数的指令地址。
有些童鞋可能会有疑问,为什么一定要知道指令地址呢?不是可以直接用下面的命令来设置断点吗?
b 15
实际上,这样是不行的,我们在GDB中看一下:
main() 反汇编
从反汇编可以看到,如果我们直接在第15行设置断点,终止程序会断在偏移量为100的那条指令。但此时,main()还没有给ESI寄存器赋值,也就是还没有给do_stuff()开始传参数,因此,即便我们在这里把ESI的值改为10,等执行到偏移量为104的指令时,ESI寄存器仍然会被覆盖为0x28,也就是sizeof(array)。
从汇编看,真正调用do_stuff()的地方是偏移量为112的那条指令,所以,我们在那条指令执行前,把ESI寄存器的值设置为10,就可以了。
完整命令如下:
test.fix.4
其中,b *main+112,表示在以main函数为起始地址,向后偏移112的位置设置断点,也就是真正调用do_stuff()的那条callq指令。
保存为文件test.fix.4,执行效果如下:
test.fix.4执行结果
程序在GDB中正常执行,得到预期结果!
方法五:在main函数中跳过do_stuff()调用,然后用正确的参数手动执行do_stuff()
在main()函数第15行代码是这样调用do_stuff()的:
do_stuff(array, sizeof(array)); /* BUG: 取数组元素个数不能用sizeof */
我们知道它传递的参数是错误的。既然如此,干脆就不要让它执行这句代码,我们自己来手动调用do_stuff()函数,并且给它传递正确的参数就可以了:
call do_stuff(array, 10)
要达到这个目的,只需要在第15行代码处设置断点,等断点触发后,手动调用do_stuff(),然后跳转到第17行继续执行。
完整操作命令如下:
test.fix.5
保存到文件test.fix.5,然后执行,结果如下图:
test.fix.5执行结果
程序正常执行结束,得到预期结果!
结语
由于之前一篇文章已经介绍过GDB断点预设命令的用法,因此本文介绍相对简单,有不明白的童鞋,建议先去阅读一下那篇文章,或者留言讨论!
本文旨在介绍几种解决问题的思路,每种方法都有其各自适用的场景。