C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931 最近在使用CSDN app时,app时常推荐我去搜索“内存越界一定会导致程序崩溃吗?”的话题,由于最近几年一直在负责排查部门多个C++软件异常排查的工作,期间遇到过很多因为内存越界引发软件异常的问题实例,在这方面也积累了大量经验,所以决定今天在这里做一个关于C++内存越界的总结,系统全面地介绍内存越界相关的内容,给大家提供一个借鉴或参考。希望大家在了解这些内容以后,能够更好地应对C++软件开发和维护过程中遇到的多种问题。
1、什么是内存越界?
内存越界是指代码操作某一变量时超出了该变量分配的内存范围,即操作了目标变量对应内存范围以外的内存区域。可能是读取越界的内存中的内容(即读越界),也可能是向越界的内存中写入了内容(即写越界)。下面我们来看几个内存越界的例子。
1.1、对数组的读越界
比如我们定义了一个:
int arr[10] = { 0 };
如果我们通过数组下标访问了arr[-1]或者arr[11],那么arr[-1]是向前越界了,arr[11]是向后越界了。一般越界是越到变量分配的内存区域的后面区域,很少越界到当前访问的变量内存前面去的。有人可能会说,怎么可能会越界到负的下标(arr[-1])上去了呢?我们还真遇到过,后面我们给大家专门讲一个向前越界的实例。
很多时候从我们的思维定势出发,很多异常在我们的认知中好像是不大可能出现的,但软件在实际运行时各种意外都可能出现,打破我们认知的事会时不时发生。
1.2、执行strcpy时的写越界
再比如我们调用C函数strcpy将一个字符串拷贝到目标buffer:
char szDstBuf[10] = { 0 };
char* pInfo = "this is a test";
strcpy(szDstBuf, pInfo);
显然这个"this is a test"字符串长度为14字节(加上\0结尾符则是15),已经超过了目标buffer szDstBuf的长度(10字节),所以执行strcpy后就发生内存越界了。你如果要将这段代码拷贝到visual studio中运行,这段代码执行后会报如下的错误:
即szDstBuf局部变量周边的栈内存被破坏了,为啥这么说呢?因为执行strcpy时内存越界了,篡改了szDstBuf变量后面的内存了。
1.3、执行memcpy时的写越界
再比如我们对某个结构体对象进行memcpy操作时,拷贝的内存大小超过了目标结构体对象的内存大小导致越界了:
struct tagDeviceInfo
{
char szDeviceName[64];
int nDeviceType;
char szDeviceIp[32];
int nDevicePort;
}TDeviceInfo;
TDeviceInfo tDstDeviceInfo;
memcpy(&tDstDeviceInfo, pSrcDeviceInfo, nSrcInfoLen );
此处的pSrcDeviceInfo是TDeviceInfo*指针类型,是从底层模块传上来的数据,nSrcInfoLen也是底层一起传上来的。那同样的结构体类型变量,怎么会出现长度不一致,致使memcpy时发生越界呢?这个我们后面会有专门的问题实例,暂时就不展开了。
2、内存越界一定会导致程序崩溃吗?
内存越界是否一定会引发程序崩溃呢?我之前做了个投票调查,大部分人都选择不一定会,还有部分朋友选择一定会导致崩溃。其实有些开发经验的人都知道,不是所有运行过程中发生的异常行为都会导致程序崩溃,内存越界也不例外,即内存越界不一定会引发程序崩溃。
3、有两种情况的内存操作是必然会导致程序崩溃的
下面讲的两种内存操作好像和内存越界话题没有直接的关系,之所以在此处详细讲述,一是因为要介绍的全面一些,二是引发这些内存访问问题可能是内存越界篡改了指针变量内存中的值(指针变量内存中存放的是要访问的目标内存地址)导致的。
3.1、访问小于0x00010000的内存地址(从0开始的64KB小内存地址)会触发崩溃
以Windows系统为例,从0开始的、小于0x00010000h的内存地址(64KB大小)区域被称为NULL地址内存区,这是系统故意设定的一块小地址内存区,是为了让程序员捕获对空指针(指向的内存区域)赋值。
任何试图来读取这个内存区域中的内容,或者向这个内存区域写入内容,都会触发内存访问违例,系统会强制将进程终止掉。**关于64KB禁止访问的小地址内存区域,在《Windows核心编程》一书中内存管理的章节,有专门的讲述**,相关截图如下所示:
在我们使用windbg分析dump文件时,如果发现崩溃的那条汇编指令中访问的内存地址是小于64KB的小地址内存区,则可以确定汇编指令崩溃原因是访问了不该访问的小地址内存区,触发了内存访问违例引发的崩溃。
** 引发这类内存访问违例一般有两个原因,一是访问了空指针,二是访问了内存被篡改的指针变量**(将指针变量中正确的内存地址值篡改成很小的值,比如将正常的0x12357889篡改成0x00000001)。
3.2、用户态的代码访问了内核态的内存地址会触发崩溃
出于安全考虑,操作系统明确将用户态内存与内核态内存隔离开来,**运行在用户态的代码不能访问内核态内存地址,运行在内核态的代码也是不能访问用户态内存地址的。**
一般情况下,软件中的业务模块都是运行在用户态中的,进程中加载的系统内核模块是运行进程的内核态中的,比如驱动程序就是运行在内核态的。
那到底哪些地址范围是属于用户态的,哪些内存地址范围是属于内核态的呢?以Windows中的32位程序程序为例,系统会给32程序进程分配4GB的虚拟地址空间,一般情况下,32位程序进程的内核态和用户态各占一半,即**用户态的内存地址范围为:0x00000000 - 0x7FFFFFFF**,**内核态的内存地址范围为:0x80000000 - 0xFFFFFFFF**。
在Linux系统中,32位程序的4GB总的虚拟内存空间,用户态占3GB,内核态占1GB。
我们在使用windbg分析dump文件时,如果发现崩溃的那条汇编指令中访问的内存地址是内核态地址范围的,则可以明确汇编指令崩溃原因是用户态的代码访问了内核态的内存,这种行为是被系统禁止的。至于为啥会出现用户态的代码去访问内核态内存地址呢?肯定是存放内存地址的指针变量的内存中值被越界篡改导致的。
4、内存越界的分类
从越界操作类型来看,可以分为从内存中读取内容时的读越界和向内存中写入内容时的写越界,这个比较简单,也好理解。这里我们主要讲述一下从被越界的变量所处的内存区域的类型来看的分类。在看这种内存越界分类之前,我们先来看看C++程序内存的5大分区。
4.1、C++程序的内存分区
如上图所示,C++程序的内存(这里讲的都是虚拟内存)主要分全局内存区、堆内存区、栈内存区、常量内存区和代码内存区:
1)全局内存区:全局变量和静态变量都是在该内存区上分配内存的。
2)堆内存区: 通过malloc和new动态申请的内存都是在该内存区分配的。
3)栈内存区:函数中的局部变量是在栈上分配内存的,函数调用时参数的传递也是通过栈进行传递的。
4)常量内存区:该分区是用来存放常量值,如常量字符串等。该部分内存中的内容是固定的常量,是不允许修改的,程序结束后由操作系统统一回收。
5)代码内存区:加载到进程空间中的所有二进制文件占用的内存区,叫做代码段内存区。前面四种都是数据段地址,此处的代码内存区是代码段地址。
在exe主程序启动时,系统会先把exe主程序依赖的各个dll库先加载到进程空间中(进程的虚拟地址空间中),最后再将exe主程序加载到进程空间中,exe主程序及其依赖的dll库加载到进程空间中占用的内存都是从进程的虚拟内存中分配的。这些二进制文件占用的内存区就叫代码内存区。
关于程序内存分区,可以参见之前写的文章:
实例详解C++程序的五大内存分区https://blog.csdn.net/chenlycly/article/details/120958761
4.2、内存越界的分类
内存越界一般会发生在全局内存区、堆内存区和栈内存区,所以内存越界按照越界内存的类型来分,可分为全局内存越界、堆内存越界和栈内存越界。不同区域的内存越界,影响是不尽相同的:
1)堆内存越界肯可能会破坏堆块的头信息和尾部信息,导致堆内存管理出现异常;
2)栈内存越界一般可能会越界到函数中其他局部变量的内存上,即可能篡改了其他局部变量的值。
一般栈内存越界比较容易排查。全局变量和静态变量占用的是全局内存,是在进入main函数之前就分配好内存的,全局内存区的越界相对难查一些。
堆内存的越界可能会破坏堆块的头信息和尾部信息,可能会导致系统的对堆内存的管理出问题,**在堆内存被越界破坏后,可能会导致后续的new或delete出现异常或者崩溃,引起程序“胡乱”崩溃,即每次崩溃时点都不太一样,崩溃时的函数调用堆栈有很大的不同。堆内存的排查相对要难很多。**这三类内存越界,我们在实际的项目中都多次遇到过。
5、内存越界的后果都有哪些?
内存越界发生在操作某一变量时,访问或操作了不属于该变量内存范围内的内存(内存越界了),即内存越界就是对越界的内存(本不应该访问的内存)进行读或者写操作。下面我们对于内存越界可能引发什么样的后果,我们需要从读越界和写越界两类别分别展开讨论。
5.1、内存读越界
所谓的读越界,就是要读取的内存不属于当前操作的变量的内存范围,即超出了目标变量的内存范围,有可能读到目标变量地址范围前面去了(这种情况相对较少),也有可能读到目标变量地址范围后面去了(这种情况居多)。
比如定义了数组:
int array[10] = { 0 }
如果使用数组下标读取array[-1]和array[11]的值,就是内存越界了,array[-1]属于前内存越界,array[11]属于后内存越界。
读越界是不会修改被越界的内存中的内容,只是去读取内存中的值,所以读越界相对于写越界,对程序的危害是要小很多的。**读越界是否会引发问题(比如引发内存访问违例,引发崩溃),这要看操作系统的“脸色”,系统觉得你可以访问这段你本不该访问的内存,那么就不会引发问题;系统觉得你是违规访问内存了,那么就会触发内存访问违例,引发崩溃。**
5.2、内存写越界
所谓的写越界,就是向不属于当前操作的变量的内存范围的内存写入内容,有可能写到目标变量地址范围前面的内存块中了(这种情况相对较少),也有可能写到目标变量地址范围后面的内存块中了(这种情况居多)。
相对于读越界,写越界要危险很多,写越界会直接篡改越界的内存区域中的内容,会引发一些unexpected未知的结果。
5.2.1、栈内存写越界
如果当前的写越界属于栈内存越界,则一般会越界到函数中其他局部变量的内存上,会篡改其他局部变量的值。如果后续代码访问到这些值被篡改的变量,会直接导致代码逻辑出异常。
如果内存被篡改的变量是一个结构体指针:
struct tagDeviceInfo
{
char szDeviceName[64];
int nDeviceType;
char szDeviceIp[32];
int nDevicePort;
}TDeviceInfo;
TDeviceInfo *pDeviceInfo;
本来这个指针中存放的是一个有效的TDeviceInfo结构体对象的地址:
1)如果pDeviceInfo指针值被篡改为0,那么后续代码使用这个指针去访问TDeviceInfo结构体中的成员变量的值,即到内存中去读取结构体成员变量的内存中的内容。因为pDeviceInfo等于0,即把0作为一个TDeviceInfo结构体对象的首地址,那么要访问结构体中的szDeviceIp成员变量时,szDeviceIp成员变量的首地址等于所在结构体对象的地址加上该成员相对其所在结构体的offset偏移,即此处的szDeviceIp成员变量的首地址 = 0 + sizeof(szDeviceName) + sizeof(nDeviceType),那么:
char* lpszIp = pDeviceInfo->szDeviceIp;
从汇编代码上看,就是szDeviceIp变量对应的内存地址中的内容,这样就访问了小于64KB的小地址内存区,这个内存区域是禁止访问的,就会触发内存访问违例,引发崩溃。
2)如果指针变量pDeviceInfo的值被篡改成一个很大的值,比如是一个大于0x80000000的值,这个地址是内核态的地址,这样如果使用该指针去访问TDeviceInfo结构体成员变量值,就会去访问内核态的地址,这是被严格禁止的,用户态的代码时禁止访问内核态地址的。
5.2.2、堆内存写越界
如果当前的写越界属于堆内存越界,对越界内存区域的写操作是否会引发违例,这同样要看系统的“脸色”,系统让你写,你越界的这一刻就不会引发违例;系统认为你此刻违例了,就会引发异常。
在大多数情况下,系统是感知不到当前执行的是越界操作,所以越界那一刻并没有报出异常,一般是越界篡改内容后后续代码在执行时才会产生异常。
**堆内存越界同样也可能越界到相邻堆上的其他变量的值**,上面讲的栈内存越界范例在此种情况下也是一样的。还有一种比较典型的情况,假设堆上有个存放回调函数地址的指针,如果这个函数指针变量的值被篡改成一个随机无效值,那么在通过该指针去call回调函数时(call的就是这个函数指针变量中的保存的函数地址(代码段地址)),程序就会跳转到一个随机的代码段地址上,跑到一个完全不相关的汇编代码处去继续,就会引发莫名其妙的异常问题。
** 堆内存的越界引发的另一个典型问题是,直接篡改了堆内存块的头信息或尾信息**。代码中使用malloc或new去申请一段堆内存时,系统会分配一块更大的堆内存,即在给用户的那段堆内存前后各增加一段额外的内存,这些额外的内存用来存放分配给用户的堆内存的头信息和尾信息,如下所示:
这些头信息和尾信息是系统用来管理这块分配给用户的堆内存的,系统有个堆管理模块,专门用来管理进程的堆内存的。
一旦发生堆内存越界,可能就会篡改掉堆内存块前后的头信息或尾信息(向前越界会篡改头信息,向后越界会篡改尾信息),就会对系统的堆内存管理模块产生直接的影响。接下来,程序每次使用malloc或new去申请堆内存时,调用free或delete去释放堆内存时,最终都要进程的堆内存管理模块去操作,**因为有堆内存的管理信息被破坏,可能就会导致后续的malloc或new出问题,程序可能会出现莫名其妙的崩溃。**
** **程序中主要是使用堆内存的,到处都在频繁的new(或malloc),到处都在频繁的delete(或free),那么执行这些操作的地方都可能出现异常出现崩溃,所以就会导致程序到处胡乱崩溃的问题,一会崩溃在这个模块中,一会崩溃在那个模块中。这个问题我们在实际项目中都遇到过。
6、内存越界实例分析
下面给出实际项目中遇到的几个内存越界的实例,并大概地讲述一下这些实例的分析与排查过程。
6.1、对堆内存向前写越界,篡改了堆内存的头信息部分,导致delete时产生崩溃
有次测试同事发现了一个掩藏很深的崩溃问题,**这个问题只有在特定的操作场景下才会出现,和测试数据及场景有强相关性。**我们取来了包含异常上下文的dump文件,查看到崩溃时的函数调用堆栈,发现是在delete一个堆内存时发生的崩溃。
于是查看了对应的源代码上下文:
先是new了一段堆内存,用完后用哪个delete将堆内存释放掉,整段代码没有明显的异常,但始终没有找到问题。
好在我们根据测试描述的操作步骤,我们找到了崩溃复现的办法,在该方法下是必现的!而且在Debug调试下也是必现的,于是用Visual Studio在Debug调试源代码,准备仔细的看看崩溃时到底发生了什么。按照复现的步骤,复现了崩溃,VS弹出了如下的报错提示框:
窗口中提示:HEAP CORRUPTION DETECTED,CRT detected that the application wrote to memory befroe the start of heap buffer,即检测到了堆内存被破坏,C运行时检测到程序向堆内存前面的一段内存写入了内容(内存越界越到堆内存的前面去了)。
点击重试按钮,VS中断在delete[] lpStr这句代码上。于是沿着代码向上看,查看操作lpStr变量的地方,居然发现这句代码:
0x2026对应省略号三个点的Unicode编码
lpStr[nCount-1] = (TCHAR)dwEllipsisUnicode;
此处nCount=0,出现了访问lpStr[-1]的情况,并且对lpStr[-1]进行了赋值操作,这和上面的错误弹框是相吻合的,即越界越到堆内存的前面去了。
** **我们前面讲过,系统在给用户分配堆内存时,会在堆内存的头部和尾部加上一小段额外的内存,用来存放管理堆内存的头部及尾部信息,因为对lpStr[-1]赋值,就篡改了堆内存的头部信息,即导致堆内存被破坏,导致后面执行delete时出现了崩溃。
所以,**在操作数组的时候,要添加控制代码,保证既不会访问负数下标(向前越界),也不会超过数组的长度(向后越界),特别是在使用表达式的值去访问数组下标时更是要注意!**
6.2、传入了错误的参数导致memcpy内存越界,篡改了相邻内存上其他变量的值,导致程序崩溃
在某次崩溃的案例中,我们通过事后使用windbg分析崩溃时捕捉到的dump文件得知,我们在上层dll模块中调用下层dll库的api接口时发生了崩溃,在windbg中查看到给下层dll库的api接口传入的参数在内存中的值为0,这个0值是非法值,所以导致了底层dll库产生了崩溃。
传给底层dll的api接口的参数值是保存在上层dll中某个类的一个数据成员中的:
DWORD m_dwConnectSID;
这个变量初始化为1的,然后代码中就没有修改过该变量的值,我们也是用这个变量作为下层dll库的api接口的传入参数的,为啥底层库中收到的值会变成0呢?
**没有修改m_dwConnectSID变量值的代码,难道是有内存越界,篡改了m_dwConnectSID变量在内存中的值?**好在这个问题时必现的,Debug下也是必现的,所以可以在使用Visual Studio调试Debug下代码时对该变量设置数据断点,一旦有内存越界篡改该变量内存中的内容,Visual Studio就会中断下来,查看此时的函数调用堆栈就知道是何处的内存越界修改的了。
发生内存越界时,触发了数据断点,Visual Studio中断了下来,查看此时的函数调用堆栈,发现是如下的memcpy导致的内存越界:
memcpy目标buffer是m_achAccountNO字符组,但给memcpy传递的第三个参数居然是sizeof(m_achCallId),应该传入的是sizeof(m_achAccountNO)。
按照当前的代码,m_achAccountNO字符数组长度是64字节,m_achAccountNO字符数组长度是128字节,所以执行memcpy时内存拷贝了128字节,显然超过了64字节长度的目标buffer m_achAccountNO,所以发生了内存越界。
而被越界的变量m_dwConnectSID,正好位于m_achAccountNO后面:
所以对achAccountNO的越界越到m_dwConnectSID内存中了,即篡改了m_dwConnectSID变量内存中的值,改成为0。这个应该是编写代码时手误导致的。
至于如何设置数据断点以及此问题排查的详细过程,可以参看我之前写的一篇文章:
巧用Visual Studio中的数据断点去排查C++内存越界问题https://blog.csdn.net/chenlycly/article/details/125626617
6.3、被调函数中执行memcpy时发生内存越界,直接越界到主调函数的栈内存上,导致主调函数中部分变量的值被篡改
在某次崩溃案例中,我们取来崩溃时捕获到的dump文件,然后用windbg打开,切换到异常上下文查看函数调用堆栈,然后查看调用堆栈中相关模块的时间戳取来了这些模块对应的pdb文件,并将pdb文件加载进来。
从崩溃的那条汇编指令来看:
指令中访问了小于64KB的内存地址,这个地址系统是禁止访问的,所以触发了内存访问违例,产生了崩溃。根据函数调用堆栈指示的行号,崩溃发生在如下的那行代码中:
代码中通过结构体指针变量ptErrorInfo去访问结构体对象的数据成员,结构体数据成员的首地址是相对结构体对象的首地址的偏移。崩溃的那行代码中,是先访问TErrorInfo_Api结构体中的dwServerErroce字段:
typedef struct tagTErrorInfo_Api
{
BOOL bSucess; // 登录是否成功
DWORD dwHttpErrcode; // http错误码,200表示链接服务器成功,其他表明连接服务器失败
DWORD dwServerErroce;// http错误码为200的情况下,服务器返回的错误码
tagTErrorInfo_Api() { memset(this, 0, sizeof(tagTErrorInfo_Api)); }
}*PTErrorInfo_Api,TErrorInfo_Api;
该字段相对结构体的地址偏移正好是前两个成员的sizeof值之和,即8,结合崩溃的那条指令,难道是ptErrorInfo指针变量的值为NULL?
从代码上看,ptErrorInfo的值就是当前函数OnValidateAccountNtf传入的wParam,点击函数调用堆栈中的第00帧展开,可以看到传给当前函数OnValidateAccountNtf的wParam和lParam参数都不为0:
这说明在代码刚进入函数入口处时ptErrorInfo指针值不为0。根据多年的经验推断,难道是接下来调用的下层库的GetUserPrevilegeInfo接口有问题?
难道是调用的GetUserPrevilegeInfo接口中有内存越界,直接越界到主调函数OnValidateAccountNtf中,把主调函数OnValidateAccountNtf中的ptErrorInfo指针值篡改了?于是查看GetUserPrevilegeInfo函数的声明,参数类型是引用,即传入的主调函数的栈内存地址:
void GetUserPrevilegeInfo(TUserPrevilege_Api& tUserPrevilege)
{
// memcpy(&tUserPrevilege, ..., ...) // 此处省略了部分代码
}
在主调函数OnValidateAccountNtf中传入的局部变量TUserPrevilege_Api tUserPrevilege,如果GetUserPrevilegeInfo内部直接操作的是主调函数的局部变量tUserPrevilege的内存,一旦操作时有越界,则会直接越界到主调函数OnValidateAccountNtf的栈内存上,很有可能将与tUserPrevilege相邻的变量ptErrorInfo的内存给篡改掉的!
** 那什么原因会导致GetUserPrevilegeInfo内部发生内存越界呢?变量tUserPrevilege对应的结构体TUserPrevilege_Api,是底层库定义并提供的,有可能底层库修改了头文件中的TUserPrevilege_Api结构体字段,新版本的dll库也发不过来了,新版本的dll用的是最新的结构体,但是这个最新的头文件没有发布到我们的代码中,导致我们的代码用的还是来的结构体。**
** **经和底层dll库的维护同事沟通,在svn上查看他们的修改记录,他们确实修改了TUserPrevilege_Api结构体,新增了一个字段,如下所示:
这样在GetUserPrevilegeInfo中执行memcpy操作就越界了,正好越到主调函数OnValidateAccountNtf的栈内存上,将主调函数中的局部变量ptErrorInfo内存中的值篡改了。至此终于搞清楚问题的原委了,终于真相大白了。
7、使用其他方法和专用的内存检测工具去排查内存越界问题
上述几个实例中的内存越界问题,都是比较简单的,排查起来也相对容易很多。但有些堆内存上越界,会导致软件出现胡乱的崩溃,基本无规律可循,根本没法下手排查的。这个时候就需要使用内存检测工具和其他排查手段来排查了。
常用的辅助排查方法有添加打印、分段注释代码等;常用的内存检测工具则有Valgrind和AddressSanitizer等。关于Windows和Linux下排查C++软件异常的常用调试器与内存检测工具,可以参见我之前写的文章:
Windows和Linux下排查C++软件异常的常用调试器与内存检测工具详细介绍https://blog.csdn.net/chenlycly/article/details/126381865
版权归原作者 dvlinker 所有, 如有侵权,请联系我们删除。