0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

C++开发时遇到堆上的内存破坏怎么办

开关电源芯片 来源:一个程序员的修炼之路 作者: 河边一枝柳 2021-08-23 10:18 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

有一定C++开发经验的同学大多数踩过内存破坏的坑,有这么几种现象:

比如某个变量整形,在程序中只可能初始化或者赋值为1或者2, 但是在使用的时候却发现其为0或者其他的情况。对于其他类型,比如字符串等,可能出现了一种出乎意料的值!

程序在堆上申请内存或者释放内存的时候,在内存充足的情况下,居然出现了堆错误。

当出现以上场景的时候,你该思考一下,是不是出现了内存破坏的情况了。而本文主要通过展示和分析常见的三种内存破坏导致覆盖相邻变量的场景,让读者在碰到类似的场景,不至于束手无策。而对于堆上的内存破坏,很常见并且棘手的场景,本人将在后续的文章和大家分享。

1. 内存破坏之强制类型转换

大家都知道不匹配的类型强制转换会带来一些bug,比如int和unsigned int互相转换,又或者int和__int64强行转换。是不是每次当读起这类文章起来如雷贯耳,但是当自己去写代码的时候还是容易犯错?这也就是为什么C++容易写出坑的原因,明知可能有错,还难以避免。这往往是因为真实的项目中复杂程度,往往让人容易忽略这些细节。

不少老的工程代码还是采用VC6编译,为了安全问题或者使用C++新特性需要将VC6升级到更新的Visual Studio。接下来要介绍的一个样例程序,就是隐藏于代码中的一个问题,如果从VC6升级到VS2017的时候会带来问题吗?可以先找找看:

#include 《iostream》#include 《time.h》class DemoClass

{public:

DemoClass() : m_bInit(true), m_tRecordTime(0)

{

time((time_t *)(&m_tRecordTime));

};

void DoSomething()

{

if (m_bInit)

std::cout 《《 “Do Task!” 《《 std::endl;

}

private:

int m_tRecordTime;

bool m_bInit;

};

int main()

{

DemoClass testObj;

testObj.DoSomething();

return 0;

}

Do Task!这个字符串会不会打印出来呢? 可以发现这段程序在VC6中可以打印出来,但是在VS2017中却打印不出来了。那是因为如下原因:

函数原型time_t time( time_t *destTime );,在VC6中time_t默认是32位,而在VS2017中默认是64位。早期程序以为32位中表达最大的时间是2038年,那时候完全够用,但随着计算机本身的发展64位逐渐成为主流time_t在最新的编译器中也默认采用64位,这样时间完全够用以亿年为单位了,那时候计算机发展超出我们想象了。

程序的问题所在m_tRecordTime采用的是int类型,默认为32位,那么其地址作为time_t time( time_t *destTime );函数实参后,在VC6中time_t本身为32位自然也不会出错,但是在VS2017中因为time_t为64位,则time((time_t *)(&m_tRecordTime));后写入了一个64位的值。结合下图,看下这个对象的内存布局,m_bInit的值将会被覆盖,而这里原先的m_bInit的值为1,被覆盖为0,从而导致内存破坏,导致程序执行意想不到的结果。这里只是不输出,那在真实程序中,可能会导致某个逻辑错乱,发生严重的问题。

3783f2fc-02f6-11ec-9bcf-12bb97331649.png

这个问题修改自然比较简单,将m_tRecordTime定义为time_t类型就可以了。如果有类似的问题发生的时候,比如这个变量的可疑的发生了不该有的变化的时候,你可以查看下这个变量定义的附近是否有内存的操作可能产生溢出,找到问题所在。因为内存上溢的比较多,一般可以查看下定义在当前出现问题的变量的低地址出的变量操作,是否存在可疑的地方。最后,针对这种场景,我们是不是也可以得到一些收获呢,个人总结如下两点:

在定义类型的时候,尽量和原始类型一致,比如这里的time_t有些程序员可能惯性的认为就是32位,那就定义一个时间戳的时候就定义为int了,而我们要做的应该是和原始类型匹配(也就是函数的输入类型),将其定义为time_t,于此类似的还有size_t等,这样可以避免未来在数据集变化或者做平台迁移的时候造成不必要的麻烦。

在有一些复杂的场景的下,也许你不得不做类型转换,而这个时候就格外的需要注意或者了解清楚,转换带来的情况和后果,保持警惕,否则就可能是一个潜在的bug。这和开车一样,当你开车的时候如果看到前方车辆忽然产生一个不合常理的变道行为,首先要做的不是喷那辆车,而是集中注意力,看看是否更前方有障碍物或者事故放生,做出相应的反应。

2. 字符串拷贝溢出

这种情况应该是最常见了,我们来看一看样例程序:

#include 《iostream》#define BUFER_SIZE_STR_1 5#define BUFER_SIZE_STR_2 8class DemoClass

{public:

void DoSomething()

{

strcpy(m_str1, “Hi Coder!”);

std::cout 《《 m_str1 《《 std::endl;

std::cout 《《 m_str2 《《 std::endl;

}

private:

char m_str1[BUFER_SIZE_STR_1] = { 0 };

char m_str2[BUFER_SIZE_STR_2] = { 0 };

};

int main()

{

DemoClass testObj;

testObj.DoSomething();

return 0;

}

这种情况下肉眼可以分析的,输出结果为:

在m_str1的空间为5,但是Hi Coder!包含是10个字符,在调用strcpy(m_str1, “Hi Coder!”);的时候超过了m_str1的空间,于是覆盖了m_str2的内存,从而导致内存破坏。内存溢出这种尤其字符串溢出,程序崩溃可能是小事儿,如果是一个广为流传的软件,那么就很有可能会被黑客所利用。

这种字符串场景如何分析呢,如果程序崩溃了,可以收集Dump先看看被覆盖的地方是什么样的字符串,然后联想看看自己的程序哪里有可能对这个字符串的操作,从而找到原因。别小看这种方法,简单粗暴很有用,曾经就用这种方式分析过Linux驱动模块的内存泄露问题。

那如果还找不到问题呢?如果问题还能重现,那还是有调试手法的,下一节将会进行讲解。

当然最差最差的还是不要放弃代码审查。尤其在这个内存被破坏的附近的逻辑。对于这种场景的建议,比较简单就是使用微软安全函数strcpy_s,注意这里虽然列出了返回值errno_t不过对于微软的实现来说,如果是目标内存空间不够的情况下,在Relase版本下会调用TerminateProcess, 并且要注意的是这个时候抓Dump有时候并不是完整的Dump。

至于微软为什么要这样做,有可能是安全的考虑比崩溃优先级更高,于是在内存溢出不够的时候,直接让程序结束。

errno_t strcpy_s( char *dest, rsize_t dest_size, const char *src);

3. 随机性的内存被修改

这一个一听都快崩溃了,C++的坑能不能少一点呢。但是确实是会有各种各样的场景让你落入坑内。上一节的程序我稍作修改:

#include 《iostream》#define BUFER_SIZE_STR_1 5#define BUFER_SIZE_STR_2 8class DemoClass

{public:

void DoSomething()

{

strcpy_s(m_str2, BUFER_SIZE_STR_2, “Coder”);

strcpy_s(m_str1, BUFER_SIZE_STR_1, “Test”);

//Notice this line:

m_str1[BUFER_SIZE_STR_2 - 1] = ‘’;

std::cout 《《 m_str1 《《 std::endl;

std::cout 《《 m_str2 《《 std::endl;

}

private:

char m_str1[BUFER_SIZE_STR_1] = { 0 };

char m_str2[BUFER_SIZE_STR_2] = { 0 };

};

int main()

{

DemoClass testObj;

testObj.DoSomething();

return 0;

}

程序本意是m_str2赋值为Coder, m_str1赋值为Test, 在编程中很多字符串拷贝或者操作中有些是在字符串末尾补有的可能不补, 而在本例中实际上strcpy_s会自动补0,但是有的程序员防止万一,字符串靠背后,在数组的最后一位设置为’’。这种有时候就变成了好心办坏事。

比如这里的m_str1[BUFER_SIZE_STR_2 - 1] = ‘’; ,大家注意到没,这里应该改写为m_str1[BUFER_SIZE_STR_1 - 1] = ‘’; ,也就是说程序员可能拷贝代码或者不小心写错了BUFER_SIZE_STR_2和BUFER_SIZE_STR_1因为两者宏差不多。只要是人写代码,就有可能会犯这种错误。这个程序的输出变为:

这个程序是比较简单,一目了然,但是在大型程序中呢,这个数组的位置跳跃的访问到了其他变量的位置,你首先得判断这个被跳跃式修改的变量,是不是程序本意造成的,因为混合了这么多的猜想,可能会导致分析变的异常复杂。那么有什么好的方法吗?只要程序能偶尔重现这个问题,那就是有方法的。

通过Windbg调试命令ba可以在指定的内存地址做操作的时候进入断点。假设目前已经知道m_str2的第四个字符,总是被某个地方误写,那么我们可以在这个地址处设置一个ba命令: 当写的这个内存地址的时候进入断点。不过这样还是有个问题,那就是程序中有可能有很多次对这块内存的写操作,有时候是正常的写操作,如果一直进入断点,人工分析将会非常累,不现实。

这个时候有个方法,同时也是一个workaround,就是当你还没找到程序出错的根本原因的时候在被误踩的内存前面加上一个足够大的不使用的空间。比如下面的代码, m_str2总是被误写,于是在m_str2的前面加上一个100个字节的不使用的内存m_strUnused(因为一般程序内存溢出是上溢,当然也可以在m_str2的后面同样加上)。

这样我们被踩的内存就很容易落在m_strUnused空间里面了,这个时候我们在其空间里设置写内存操作的断点,就容易捕获到问题所在了。

#include 《iostream》#define BUFER_SIZE_STR_1 5#define BUFER_SIZE_STR_2 8#define BUFFER_SIZE_UNUSED 100class DemoClass

{public:

void DoSomething()

{

strcpy_s(m_str2, BUFER_SIZE_STR_2, “Coder”);

strcpy_s(m_str1, BUFER_SIZE_STR_1, “Test”);

//Notice this line:

m_str1[BUFER_SIZE_STR_2 - 1] = ‘’;

std::cout 《《 m_str1 《《 std::endl;

std::cout 《《 m_str2 《《 std::endl;

}

private:

char m_str1[BUFER_SIZE_STR_1] = { 0 };

char m_strUnused[BUFFER_SIZE_UNUSED] = { 0 };

char m_str2[BUFER_SIZE_STR_2] = { 0 };

};

int main()

{

DemoClass testObj;

testObj.DoSomething();

return 0;

}

下面完整的展示一下分析过程:

第一步 用Windbg启动(有的情况下可能是Attach,根据情况而定)到调试进程,设置main的断点

0:000》 bp ObjectMemberBufferOverFllow!main

*** WARNING: Unable to verify checksum for ObjectMemberBufferOverFllow.exe

0:000》 g

Breakpoint 0 hit

eax=010964c0 ebx=00e66000 ecx=00000000 edx=00000000 esi=75aae0b0 edi=0109b390

eip=003a1700 esp=00defa00 ebp=00defa44 iopl=0 nv up ei pl nz na pe nc

cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206

ObjectMemberBufferOverFllow!main:

003a1700 55 push ebp

第二步 使用p命令单步执行代码到testObj.DoSomething();

第三步 找到testObj的地址为00def984

0:000》 dv /t /v

00def984 class DemoClass testObj = class DemoClass

第四步 设置断点到testObj相对偏移的位置,这个位置即&m_str1+BUFER_SIZE_STR_2 - 1 = &m_str1+7。并且继续执行代码:

0:000》 ba w1 00def984+7

0:000》 g

第五步 你会发现程序运行进入断点,这个时候查看对应的函数调用栈即可。这个断点不一定在一个非常精确的位置,但是当你按照函数调用栈去阅读附近的代码,便比较容易找出问题所在了。

0:000》 k

# ChildEBP RetAddr

00 00def97c 003a1720 ObjectMemberBufferOverFllow!DemoClass::DoSomething+0x41 [。..。..strcpybufferoverflow.cpp @ 16]

01 00def9fc 003a1906 ObjectMemberBufferOverFllow!main+0x20 [。..。..strcpybufferoverflow.cpp @ 30]

02 (Inline) -------- ObjectMemberBufferOverFllow!invoke_main+0x1c [d:agent\_work3ssrcvctoolscrtvcstartupsrcstartupexe_common.inl @ 78]

03 00defa44 75818494 ObjectMemberBufferOverFllow!__scrt_common_main_seh+0xfa [d:agent\_work3ssrcvctoolscrtvcstartupsrcstartupexe_common.inl @ 288]

04 00defa58 770a40e8 KERNEL32!BaseThreadInitThunk+0x24

05 00defaa0 770a40b8 ntdll!__RtlUserThreadStart+0x2f

06 00defab0 00000000 ntdll!_RtlUserThreadStart+0x1b

总结

以上对三种内存破坏场景做了分析,在实际应用中将会变的更加复杂。在写代码的时候要注意避开其中的坑,有个叫做墨菲定律,你感觉可能会出问题的地方,那它一定会在某个时刻出现,当你对某个地方有所疑虑的时候一定要多加考虑,否则这个坑可能查找的时间,比写代码的时间要长的许多,更可怕的是可能会带来意想不到的后果。同样的分析问题要保持足够的耐心,相信真相总会出现,这样的底气也是来自于自己平时不断的学习和实践。

内存破坏问题不区分栈上还是堆上,我们在产品中离不开使用堆开间,而且由多个模块核心功能模块组成,而这些模块通常是公用一个进程默认堆的。所以也有人推荐在这些关键模块中,各自创建一个独立的堆,从而降低一个堆内存的使用对另一个堆中内存的影响。虽然不是完全隔离,但是也是一个聊胜于无的操作了。

责任编辑:haq

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 内存
    +关注

    关注

    9

    文章

    3174

    浏览量

    76164
  • C++
    C++
    +关注

    关注

    22

    文章

    2122

    浏览量

    76749

原文标题:C++常见的三种内存破坏场景和分析

文章出处:【微信号:gh_3980db2283cd,微信公众号:开关电源芯片】欢迎添加关注!文章转载请注明出处。

收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    CC++之间的联系

    ,后来才逐渐演变为一种成熟的面向对象编程语言。 总之,C语言和C++虽然有很多共同之处,但在编程范式、安全性、抽象层次等方面存在显著差异。开发者可以根据项目需求选择合适的语言,C语言更
    发表于 12-11 06:51

    C语言和C++之间的区别是什么

    函数重载,即在同一作用域内可以定义多个同名函数,只要它们的参数列表不同即可。 C语言不支持函数重载,每个函数必须具有唯一的标识符。 4、异常处理: C++内置了异常处理机制,允许程序在遇到无法正常
    发表于 12-11 06:23

    使用单片机系统时间不准怎么办

    使用单片机系统时间不准,怎么办?具体的是与晶振有关系还是MCU内部有关系,时间一长显示的时间就慢了
    发表于 12-05 07:51

    C++程序异常的处理机制

    1、什么是异常处理? 有经验的朋友应该知道,在正常的CC++编程过程中难免会碰到程序不按照原本设计运行的情况。 最常见的有除法分母为零,数组越界,内存分配失效、打开相应文件失败等等。 一个程序
    发表于 12-02 07:12

    电脑开机每次自动开启很多软件怎么办

    电脑开机每次自动开启很多软件怎么办
    发表于 07-17 06:40

    路由器能连上但是没有网络怎么办

    路由器能连上但是没有网络怎么办
    发表于 07-10 06:15

    使用英特尔® NPU 插件C++运行应用程序时出现错误:“std::Runtime_error at memory location”怎么解决?

    使用OpenVINO™工具套件版本 2024.4.0 构建C++应用程序 使用英特尔® NPU 插件运行了 C++ 应用程序 遇到的错误: Microsoft C++ excep
    发表于 06-25 08:01

    在OpenVINO™ C++代码中启用 AddressSanitizer 时的内存泄漏怎么解决?

    在 OpenVINO™ C++代码中启用 AddressSanitizer 时遇到内存泄漏: \"#0 0xaaaab8558370 in operator new(unsigned
    发表于 06-23 07:16

    主流的 MCU 开发语言为什么是 C 而不是 C++

    在单片机的地界儿里,C语言稳坐中军帐,C++想分杯羹?难喽。咱电子工程师天天跟那针尖大的内存空间较劲,C++那些花里胡哨的玩意儿,在这儿真玩不转。先说
    的头像 发表于 05-21 10:33 793次阅读
    主流的 MCU <b class='flag-5'>开发</b>语言为什么是 <b class='flag-5'>C</b> 而不是 <b class='flag-5'>C++</b>?

    使用C++中的CyAPI编写的应用程序上遇到了问题,求解决

    我在使用 C++ 中的 CyAPI 编写的应用程序上遇到了问题。 我将 XferData() 方法与其他所有端点类型一起使用,没有遇到任何问题。 但是,将其与 Endpoint0 一起使用会引发
    发表于 05-13 06:11

    FPGA的Jtag接口烧了,怎么办

    在展开今天的文章前,先来讨论一个问题:FPGA的jtag接口烧了怎么办?JTAG接口的输入引脚通常设计为高阻抗,这使得它们对静电电荷积累非常敏感,由于JTAG接口需要频繁连接调试器、下载线缆等外
    的头像 发表于 04-27 11:01 2187次阅读
    FPGA的Jtag接口烧了,<b class='flag-5'>怎么办</b>?

    C++学到什么程度可以找工作?

    C++学到什么程度可以找工作?要使用C++找到工作,特别是作为软件开发人员或相关职位,通常需要掌握以下几个方面: 1. **语言基础**:你需要对C++的核心概念有扎实的理解,包括但不
    发表于 03-13 10:19

    源代码加密、源代码防泄漏c/c++与git服务器开发环境

    源代码加密对于很多研发性单位来说是至关重要的,当然每家企业的业务需求不同所用的开发环境及开发语言也不尽相同,今天主要来讲一下c++及git开发环境的源代码防泄密保护方案。企业源代码泄密
    的头像 发表于 02-12 15:26 924次阅读
    源代码加密、源代码防泄漏<b class='flag-5'>c</b>/<b class='flag-5'>c++</b>与git服务器<b class='flag-5'>开发</b>环境

    基于OpenHarmony标准系统的C++公共基础类库案例:ThreadPoll

    。每个线程每秒打印1段字符串,10秒后停止。2、基础知识C++公共基础类库为标准系统提供了一些常用的C++开发工具类,包括:文件、路径、字符串相关操作的能力增强接口
    的头像 发表于 02-10 18:09 624次阅读
    基于OpenHarmony标准系统的<b class='flag-5'>C++</b>公共基础类库案例:ThreadPoll

    Spire.XLS for C++组件说明

    开发人员可以快速地在 C++ 平台上完成对 Excel 的各种编程操作,如根据模板创建新的 Excel 文档,编辑现有 Excel 文档,以及对 Excel 文档进行转换。 Spire.XLS
    的头像 发表于 01-14 09:40 1320次阅读
    Spire.XLS for <b class='flag-5'>C++</b>组件说明