优质C/C++学习网站:https://cppguide.com
大家好,我是小方。
周末的一学习群里有人抛出了一个C语言相关的问题:
先想一想,这段代码运行后会输出什么?
这道题我几年前在华为的面试题中也遇到过。
代码很简短,main函数定义了一个指针变量p,然后将其地址传递给fun函数,fun函数使用malloc函数在堆上分配了100个字节的空间,并把这块内存的地址赋值给了p。回到main函数中,紧接着调用free函数释放刚刚分配的内存。
随后来了一个if判断,如果指针p不等于NULL,则使用strcpy向p所在的内存拷贝一个"hello world"字符串,随后调用printf函数将其打印输出。
看到这里,你应该明白了,这是一道非常典型的悬空指针问题。注意,不是有些人认为的野指针,野指针是定义的指针变量未曾初始化赋值。而悬空指针才是上面这种,已经释放后,但又没有及时将其置为NULL。
C语言中的指针如果使用不当,经常容易出现这类指针的问题,这也是很多人觉得C语言指针难打交道的原因之一。
所以,从一开始学习C语言的时候,就会有人给你强调,刚刚定义的指针一定要赋值,释放后的指针一定要置为NULL。所以C语言中一般不推荐直接调用free函数,而是通过一个宏定义来把这个过程自动化,编程的时候通过这个宏来释放指针,一定程度上避免因为编程习惯引入的悬空指针问题。
#define FREE(p) free(p); \
p = NULL;
而在C++中,为了解决这个问题,引入了智能指针,把指针包在一个C++对象中,通过对象自动化析构的特点,从而完成上面的工作。
回到上面的题目中来,我们姑且不论malloc是否能成功分配到内存的问题,100个字节的空间,没有意外的情况下,99.99%的情况都能成功分配到。
而后通过free释放了内存,但指针变量p没有及时置空,仍然还是指向着这片内存地址,所以下面的if判断也一定是成立的,所以程序会进入到if中去。但p指向的这片内存已经被回收了,这时候使用strcpy向其写入数据,到底会造成什么后果就难以预料了。
运气好的话,字符串能够成功复制,也能成功打印出"hello world"字符串,比如我在VS2008下,用Debug模式运行:
运气不好,运行就会报错,什么也没有输出。比如同样在VS2008,换成Release模式:
现在你再猜一下,崩溃是在哪一行呢?
是strcpy写入数据的时候崩溃,还是printf打印输出的时候崩溃呢?
答案是printf的时候崩溃了,我们可以用WinDbg调试器来调试运行,发现strcpy运行并没有报错,成功把字符串完成了复制:
而通过查看崩溃时候的调用堆栈,实际是崩溃在了printf函数内部的调用链条上:
这是为什么呢?
实际上是这样的:虽然通过调用free把这块内存释放了,但要注意,这个释放只是C语言运行时库层面的释放(因为free函数是C语言的库函数),C语言运行时库里的算法把它回收回去,在编程语言的层面上,这块内存是不应该再访问的了。
但在操作系统的层面上,这块内存依然是可以访问的,它依然位于某个具有可读可写的4KB内存页中。因为C语言的堆内存分配算法,不会每次释放内存都调用系统级的函数(如VirtualFree)去真正释放内存页面,这是一个很重的操作。
这里所谓的free,仅仅是告诉C语言运行时库,这块内存我不用了,你回收回去统一管理吧。
所以,当调用strcpy的时候,是能够正常复制的。
但要注意,这块内存能写,不代表你能乱写。在操作系统层面上,内存页面可读可写,那你写没有问题。
但站在C语言运行时库的视角来看,这个地址的内容我已经回收了,现在这里面的内容对于我管理堆内存非常重要,你别乱写,乱写是要出乱子的。
这不,这样一strcpy,哦豁,堆内存里面的一些管理用的设施被破坏了(比如一些指针),等到后面调用printf的时候,里面同样要从堆分配内存,这个时候前面留下的问题就暴露出来了。
但如果你把printf换成MessageBox函数,还是能正常弹窗的:
这是因为MessageBox是Win32的API函数,它的调用不涉及到C语言运行时库的操作,C语言的堆被搞坏了,跟它没有关系。
不过,当你点击上面的弹窗消息后,程序依然会提示你报错。这是因为main函数返回后,程序的流程又会进入到C语言运行时库的地盘,堆内存被破坏的事情这个时候还是会被捅出来。
那为什么Debug模式下,程序又能够成功运行呢?这可能有两方面的原因:
1、Debug和Release模式下,C语言运行时库管理堆内存的方法有些差异。可能strcpy写入的内容并没有破坏堆管理算法的一些关键数据结构。
2、确实破坏了,但后面C语言运行时库工作的时候没有触发这个问题。
至于具体是哪一种原因,还得要深入研究C语言运行时库的堆内存管理算法,结合调试分析才能下结论了。
另外,这段代码在Linux上默认编译后,也是能够运行的:
所以总结来看,这段代码能不能正常工作,没有一个确定的说法,与不同的平台、不同的编译模式都有关系,它的运行结果是不确定的。
释放后使用攻击
说到悬空指针,顺便给大家延伸一点,来看下面这段代码:
我先给指针p分配了100个字节,里面填充了"hello, world"之后,打印输出,随后释放指针p的内存。
但要注意,我释放后,同样没有把p置空。
紧接着,我又调用malloc分配了100个字节给指针q,随后给它指向的内存填充了"hello, xuanyuan"。
但好玩的来了,我接下来还是打印p,不是打印q,居然把指针q的内容给我打印出来了。
打印了两次p,两次输出的内容居然不一样,这是为什么呢?
调试一下就会发现,现在p和q两个指针指向的地址是一样的,都指向了同一块内存:
这是利用了C语言运行时库堆内存分配算法的特点,把上面刚刚free归还的100个字节,又分配给新的q了,而p又还没有置空,就出现了p和q同时指向了这块内存。
而这个特性,常常被应用在在二进制安全攻击里面。有一种攻击手法叫做释放后使用攻击(Use After Free),简称UAF,就是用的这一招。
明明现在的内存是人家q的,但p也指向了它,会出什么事情呢?
假如p原来指向的是一个结构体,里面有个函数指针,通过p->fun()可以调用。
现在我通过这种方式创建了一个假的结构体,里面有恶意代码的函数指针,这样p->fun()调用的就是恶意代码了!
一个小小的指针,背后的故事可不简单哦!
今天的文章有收获吗,欢迎大家转发分享收藏,你的支持是我更新的动力哦!
相关阅读
小方目前在一家知名外企做 C++ 架构方面的工作。我建立 高质量 C++ 后端开发微信技术交流群,群里高手如云,不定期分享编程技术,也会提供一些求职和内推资源。
现在限时开放中,需要加群交流的同学可以加微信 cppxiaofang,备注“加群”。
关注我,更多的优质内容第一时间阅读