即时内存损坏检测
搜索并销毁不当的内存访问。
引言
本文介绍了一种检测代码中无效堆内存访问的方法。首先,我们将讨论进程的虚拟地址空间、内存分配的类型以及什么是无效内存访问。
熟悉这些主题的人可以跳过本节,直接进入即时内存损坏检测部分。
从操作系统的角度看进程内存
进程的虚拟地址空间由内存页组成。这些页面具有固定大小(Win32 上为 4K),并且每个页面都有其访问权限(读/写/执行)。当创建进程时,其地址空间最初是空的,其中的所有页面最初都无法访问。然后,在运行过程中,一些页面最终会被分配和访问。尝试访问属于某个页面且该页面的权限与请求不兼容的内存地址,将导致访问冲突异常。
应用程序可以通过 `VirtualAlloc`、`VirtualFree` 等虚拟内存函数直接分配/释放内存页面。但是,通常不需要显式分配 4KB 的内存块,应用程序通常需要大小小得多的可变大小内存块。为此,应用程序也可以使用传统的全局/堆栈/堆内存,但这些分配类型实际上也是通过虚拟内存机制实现的,并且可以看作是用于在进程地址空间内对内存页面进行分区的包装器。
因此,从操作系统的角度来看,进程分配内存页面,而它并不关心这些页面在进程内部实际是如何用于存储应用程序变量的。
进程内内存分配
已分配内存块一词指的是您拥有一块只有您才能访问的内存的情况。您可以随意读取/写入。这块内存保证在您释放它之前不会被分配用于其他任何用途。
有几种类型的内存分配
- 全局进程内存(实际上可能包含不同内容,例如代码、数据等)。特别是,所有全局变量都存储在那里。
- 堆栈内存。操作系统为每个线程分配自己的堆栈。特别是,自动变量和函数参数存储在那里。
- 堆内存。它通过适当的函数和运算符显式分配和释放。
- 您负责释放所有已分配的内存。否则,就会发生内存泄漏。
- 堆分配是一项相对复杂的操作。它比堆栈分配的开销大得多,因此除非确实需要,否则不应使用它。
- 堆分配具有显著的内存开销并产生内存碎片,而其他分配类型则将所有变量紧密打包。
当编译器和链接器构建可执行文件时,将计算所有全局变量的大小。此大小和初始化数据会写入可执行文件中,当加载它时,操作系统会分配足够数量的内存页面并对其进行适当的初始化。当您访问全局变量时,编译器实际上会生成访问该全局内存块某个部分的。代码。
对于每个线程,操作系统都会分配一部分虚拟地址空间(实际上,它是预留的,并且是按需分配的,但这并不重要)。每当线程进入一个带有参数和自动(在函数中局部定义的)变量的函数时,编译器生成的代码就会消耗更多的堆栈(移动堆栈指针)。当您访问函数参数或自动变量时,编译器实际上会生成访问当前堆栈位置并带有某些偏移的代码。
与全局内存和堆栈不同,堆内存可用于存储生命周期在编译时不可预测的对象。这些是所谓的动态分配。这些是通过 `new`/`delete` 运算符或 `malloc`/`free`、`HeapAlloc`/`HeapFree` 等函数完成的。
堆分配提供了最大的灵活性。但是,这种灵活性是有代价的
实际上,堆有各种实现,而且事实上,堆并不是 C/C++ 的一个组成部分。`new`/`delete` 运算符已被声明,但它们的实现并不固定。如果您链接了 CRT 库,它们将通过 CRT 堆代码(`malloc`/`free` 也使用)实现这些运算符。对于 MFC,则有另一种堆实现。而且,驱动程序编写者根本没有默认实现——他们必须自己定义它们。
无效内存访问
无效内存访问一词指的是某个代码试图访问(即读取或写入)它不允许访问的内存位置的情况。正如我们已经说过的,为了访问内存位置,它必须是已分配的。下面,我们将给出一些不当内存访问的例子
// Examples of improper memory access
char* sz = new char[20];
// 20 bytes allocated on the heap
sz[0] = 12;
// ok
sz[20] = 12;
// Error! We've written a value after the allocation end
char c = *(sz - 1);
// Error! We've read a value before the beginning of the allocation
delete[] sz;
char c = sz[2];
// Error! we're reading the memory after it was freed!
sz = (char*) 0x12345; // point to a random address
*sz = 2;
// Error! We're not allowed to write there
那么,无效内存访问会带来什么后果?实际上,可能会发生几件事
- 内存地址属于一个无法访问的内存页面。这种情况是最好的,因为您的程序会立即崩溃(除非您处理此异常),并且您会获得该非法访问被禁止内存的确切位置和调用堆栈。然后,您就会意识到问题所在(希望如此)并加以修复。
- 内存地址属于一个可访问的页面(该页面包含一些不属于您的数据)。
如果读取了该内存,它可能包含任何内容(即垃圾)。如果读取的值不会影响您的行为,则几乎没有问题。但是,如果读取的值很重要,那么从现在开始,您的程序将依赖于一些随机的垃圾。
如果写入了该内存,情况会糟糕得多,因为您刚刚破坏了某人的数据,而且您不知道是谁的。这种损坏是不可逆的,并且程序可能会随时崩溃或执行不当。
查找无效内存访问是一项艰巨的挑战。这主要是因为第一种情况(即时崩溃)相当罕见。通常,内存损坏的效果要到损坏的内存最终被使用时才会被看到。
由于这是一个已知问题,因此存在标准的内存损坏检测方法。特别是,我见过的所有堆实现(CRT、MFC、Win32)在堆损坏时都会给出指示。例如,Win32 堆函数(`HeapAlloc`/`HeapFree`)在损坏时会触发调试断点并发出以下消息
HEAP[strt.exe]: Heap block at ..... modified at ..... past requested size of .....
Windows has triggered a breakpoint in .....exe.
This may be due to a corruption of the heap, and indicates a bug in ...
所有这些堆损坏方法的工作方式都相同:当您分配内存块时,它们会在内部分配一个更大的块,并将您分配的区域包围在一些值周围。然后,当您释放此内存时,它们会验证装饰是否未损坏。
此方法可以很好地指示问题;但是,它对于查找问题并没有多大帮助。您只有在尝试释放内存时才看到内存已损坏,但您不知道谁是违规者,以及何时和如何发生了犯罪。
此外,您没有收到无效内存读取的指示。尽管破坏性不大,但这些也必须找到并修复。
即时无效内存访问检测
现在,我们将讨论我们的方法。顾名思义,它的目标是
- 检测任何无效内存访问,而不仅仅是写入访问。
- 提供对无效访问的即时检测。当场抓住罪犯。
此方法实现了堆,使得无效内存访问保证会导致访问冲突。因此,目标已经实现。其余的——理解和修复问题——取决于您。
我们直接通过 `VirtualAlloc`/`VirtualFree` 函数分配/释放内存。每个返回的内存地址都会进行填充,使得可以访问精确数量的字节;尝试读取一个额外的字节会导致访问冲突。分配可以有两种模式:一种模式保证在您尝试访问允许区域之后的内存时会发生访问冲突,另一种模式——当您尝试访问允许区域之前的内存时会发生访问冲突。
内存在这两种模式下的分配方式如下
![]() |
![]() |
可访问页面由绿色标记,不可访问页面由青色标记;已分配的内存块(返回指针)由蓝色标记。
如您所见,对于每次内存分配,无论其大小如何,我们都会在地址空间中保留至少两个内存页面。其中除一个外均已分配,并且返回地址已正确填充。此外,当您通过我们的堆释放内存时——它会取消分配所有内存页面,但不会立即释放它们。相反,它将它们保留在预留状态。在这种状态下,它们不可访问,并且不消耗物理内存,并且保证最终不会被分配,因此它们肯定会保持不可访问。当这些预留页面的虚拟大小超过某个数字(默认值为 50MB)时,堆将开始释放最近的页面。
嗯,不用说,这种方法以一种极其粗暴的方式浪费内存(物理内存和地址空间)。而且,它不能用于已发布给用户的应用程序。然而,它是调试时查找无效内存访问的绝佳工具。要启用它,请放置以下代码行
// Enable invalid memory access detection mode
DbgHeap g_Heap; // the global debug heap object.
void* operator new (size_t nSize)
{
// the last parameter - allocation alignment method
void* pPtr = g_Heap.Allocate(nSize, true);
// you may run your program with both methods to ensure everything's ok
if (!pPtr)
{
// do whatever you want if no memory. Either return NULL pointer
// or throw an exception. This is flexible.
}
return pPtr;
}
void operator delete (void* pPtr)
{
g_Heap.Free(pPtr);
}
同样,您可以破解其他堆函数,例如 `malloc`/`free`。您可能会遇到链接器错误,但经过一些操作,您就可以摆脱它。而且,使用高级破解技术(例如 Richter 的拦截 DLL 调用方法),您甚至可以尝试破解进程中的所有堆函数。
总而言之,这种方法检测
- 任何无效内存访问,并提供即时问题指示。
- 保证仅从一侧超出分配边界时发出警报。哪一侧——是参数。
此方法不检测
- 超出另一分配边界时的损坏。
- 全局内存、堆栈以及通过所有其他堆分配的内存中的无效访问。
结论
好吧,我认为这个方法很棒。寻找内存损坏的原因是一个非常令人头疼的问题,有时简直就是一场噩梦。而且,这种方法证明(至少对我来说)非常有用。您可以尝试使用这种方法,看看当您尝试做错事时,您的程序是如何神奇地崩溃的。
当程序在无效内存访问发生时立即崩溃,这真是奇迹!您应该祈祷这种情况发生。因为如果它没有发生,您就完蛋了。可能需要数天才能重现问题并找到其原因。
还有一件事:如果您打算查看此方法的代码级别,我建议阅读以下文章
欢迎批评和新想法。