发布时间:2023-09-18 15:30
目录
1、概述
2、异常实例描述
3、是底层的dll模块发生了崩溃
4、上层的m_dwConnectSID变量值被篡改,导致传给底层dll模块的函数参数有问题
5、给m_dwConnectSID变量设置数据断点,排查出问题
6、总结
C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931 在C++软件运行的过程中,有变量可能会因为内存越界导致其值被篡改,引发软件运行出现异常。今天我们就来讲一下如何使用Visual Studio中的数据断点去排查C++软件中的内存越界问题。
微软的Visual Studio开发工具提供了强大的调试功能,其中支持断点调试的断点类型就有几个,即普通的断点、条件断点和数据断点,其中数据断点比较高级一些,在某些场合下特别的有用。数据断点其实就是监控一段内存,一旦这段被监控的内存被修改,就会命中断点,调试器就会中断下来,然后查看此时的函数调用堆栈,就知道是哪个操作修改了这段内存了。
软件在运行的过程中,可能会因为内存越界导致变量值被无辜篡改,而变量值被篡改后可能会导致软件出现业务逻辑上的异常,甚至可能会导致软件异常崩溃。数据断点则可以很好地定位这类问题,只要给被篡改的变量的内存设置数据断点(通过数据断点去监控被篡改的变量的内存)就能快速地查出变量被篡改的原因。
常见的内存越界类型有栈内存越界、堆内存越界以及全局内存越界,这三类内存越界,我们在实际的C++项目中都遇到过。今天我们就通过一例memcpy使用不当导致的内存越界问题,来详细地说明一下如何巧妙地使用数据断点。
我们在排查C++软件异常问题时,仅凭肉眼去逐一排查代码,是比较费时费力的,特别是项目越来越大、项目代码越来越多的时候。有次我们在调试软件的某个功能时,遇到了一个很诡异的问题,最开始明明对目标类的一个成员变量m_dwConnectSID赋予了一个有效值,结果使用该成员变量调用底层的函数时,该成员变量的值竟然变成了无效值,导致底层模块发生了异常崩溃。
于是搜索查看了所有修改该成员变量的地方,都不会篡改变量m_dwConnectSID的值,这就奇怪了!难道是内存越界篡改了该变量内存中的内容?我们软件是由UI模块、业务组件模块、协议模块、网络模块、音视频编解码模块、公用组件模块、开源组件模块等构成的。由于当时处于各模块联调的初始阶段,问题比较多,查看我们上层的代码好像是没问题的,于是怀疑是不是底层模块的内存异常篡改的。
现在都成了惯性思维了,遇到问题时,总是第一时间怀疑是不是其他组维护的模块有问题引起的,总觉得不是我们自己维护的模块代码有问题。其实这是不科学的,因为往往很多时候,可能就是我们自己维护的模块代码出的问题。
怀疑归怀疑,现在问题出来了,还是得想办法去定位去解决的,还是要先从我们最上层的模块开始排查。以前接触过数据断点,比较适用于我们当前的问题场景,但是没实际使用过,这次正好成这个机会来尝试一把。
程序在调试运行的过程中,突然弹出如下的报错提示框:
提示发生了0xC0000005内存访问违例的异常,点击继续,VS自动跳到了如下的位置:
怎么会在调用一个函数时产生了崩溃呢?估计是崩溃发生在函数内部,而我们上层只有我们自己模块的代码,没有底层的代码,所以直接中断在上层的函数中。于是点开调用堆栈窗口中查看当前的函数调用堆栈,如下所示:
从调用堆栈上看,我们上面的假设是对的,确实是崩溃在底层的一个xxxapidll.dll中。为啥底层的dll模块会出现崩溃呢?难道是我们上层传下去的参数值有问题?或者是两层使用的库不匹配或者是头文件不匹配导致的库与库之间的不匹配问题?
我们只能先从最简单的查起,先检查传给底层库接口XXXX_LoginConnectCmd接口的参数值,先看看传入的参数是否有异常。结果无意中发现,此时传递给底层函数的m_dwConnectSID竟然等于0,而我们在初始化m_dwConnectSID变量时给其设定的是固定值1,如下所示:
于是我查找了一下所有操作m_dwConnectSID的地方,其他代码并没有再修改该默认值,怎么运行过程中就变成0了呢?百思不得其解,确实很奇怪!
是内存被篡改了?是被其他模块篡改的还是自己的模块篡改的呢?此时已经确定是变量m_dwConnectSID的内存中的内容被无故篡改了,于是想到了数据断点,可以尝试使用数据断点,看看能不能定位出篡改该成员变量的地方。只要给该变量的内存设置数据断点,一旦有代码篡改了该变量的内存中的内容,就会命中该数据断点,VS调试器就会中断下来,查看中断时的函数调用堆栈就能看出是哪个函数篡改的了。
接下来我们就来给m_dwConnectSID变量设置数据断点。要给变量dwConnectSID设置数据断点,就是通过该断点来监控dwConnectSID变量的内存是否被修改的,所以首先要在变量dwConnectSID被分配内存后去设置数据断点!
那我们应该选择什么时机去给dwConnectSID变量设置数据断点呢?可以在CWhiteBoard类的构造函数中打一个普通的断点,作为成员变量的dwConnectSID就是在类的构造函数中被分配内存的。我们直接对变量m_dwConnectSID取址,就能得到m_dwConnectSID的内存地址了。
我们调试运行当前软件,假设当前已经命中CWhiteBoard类的构造函数中的断点,点击VS菜单栏中的“调试 ”,在弹出的菜单中选择“新建断点”,然后在子菜单中点击“新建数据断点”,如下所示:
然后输入&m_dwConnectSID获取m_dwConnectSID变量的地址,监控字节数设置为4即可,如下所示:
点击确定,即对dwConnectSID变量的内存设置了数据断点,设置的数据断点如下所示:
注意,在CWhiteBoard类的构造函数会对m_dwConnectSID变量的值初始化为1,因为修改了该变量内存中的内容,所以也会命中数据断点,如下:
初始化时命中数据断点是正常的,我们直接按下F5让软件继续运行即可。
直到遇到了一个可疑的数据断点中断,查看当前的堆栈列表,看到了当前的中断操作是谁触发,如下所示:
首先最上面是memcpy函数,它是系统函数,我们向下看,可以看到是CWhiteBoard::SetAccountE164函数调用的memcpy系统函数,于是双击查看CWhiteBoard::SetAccountE164函数的内部实现,如下:
果然是有问题的,我们对照着memcpy的参数声明看:
void * __cdecl memcpy ( void * dst, const void * src, size_t count )
执行memcpy时,第三个参数传入的是内存拷贝的长度值,是m_achP2PCallId数组的长度,问题就处在这儿了!我们要将数据拷贝到m_achSelfE164(目标buffer)中,所以第三个参数应该是m_achSelfE164数组本身的长度!估计是写代码时手误导致的!
查阅代码,m_achP2PCallId数组的长度,是大于m_achSelfE164数组的长度的,所以是在执行上述memcpy操作时 m_achSelfE164数组被越界了。而m_dwConnectSID正好位于内存被越界的 m_achSelfE164数组的后面,如下所示:
所以越界越到m_dwConnectSID内存上了,所以导致m_dwConnectSID的值被篡改了。
至此,篡改内存的真相大白了!这种内存越界的问题掩藏的太深了,这恰恰能说明数据断点的强大的作用。如果仅凭肉眼去逐句排查,既费时又费力,可能都找不到问题,特别是在代码量特别大的时候!
通过上述实例,我们可以看出,数据断点确实很好用,很快就定位出了内存越界的问题,有力的简化了我们排查问题的过程!所以,大家后面在遇到类似的因为内存越界导致内存被篡改的问题时,可以尝试使用数据断点去监测一下。