应用程序逻辑层各式各样的错误可能会导致内存损坏。内存损坏的常见原因是问题代码访问的数据对象超出了内存管理器或者编译器分配的底层内存块的边界。下面列出各种在实践中经常看到的内存访问错误。相比于那些大型程序实际存在的bug,这些例子看上去可能简单和愚蠢,大型程序由于众多变量和复杂逻辑会隐晦难懂。因为例子中的元数据被损坏的方式是相似的,所以可以用相同的策略来攻克实际的问题。
内存溢出肯定是最常遇到的内存损坏之一。它发生在当用户代码访问的内存超出了内存管理器或者编译分配给用户内存块的最后1字节时。正如前面展示的,典型内存管理器的实现会在每个内存块开始处隐藏一个小的数据结构——块标签。这个数据结构包含了内存块的大小和它的状态信息(即空闲或者正在使用),以及其他的更多信息(取决于特定的实现)。
如果用户代码的写入超过了分配内存块的用户空间,它就会覆写下一个内存块的标签。这会损坏内存管理器的堆元数据结构并导致未定义行为。只有当下一个块被释放或者分配,也就是当下一个块的标签被内存管理器用来计算的时候,破坏才会表现出来或者往下游传播。
某些内存管理器不会在内存块镶嵌块标签,这时被损坏的内存会是下一个内存块里的应用数据。会导致的后果取决于该数据稍后是怎么被使用的。
内存管理器分配的内存块被覆写的代码示例如下:
在示例1中,用户代码没有考虑到字符串终止字符'\0',因而超出了内存块1字节。示例2往内存p[0]到p[N]写入总共N+1个整数,而不是被分配的N个整数。它将覆写分配内存块之后一个整数大小的内存。我们可以通过检查它的内容来更好地理解内存是如何被破坏的。下面的调试输出展示了示例1对ptmalloc元数据造成的破坏。
调用strcpy之前,变量newString被分配到地址为0x501030的内存块中。标签块位于该地址的8字节之前,即0x501028,值0x21意味着这个块的大小是32字节且正在使用中。下一个块的标签可以通过将当前块地址加上其大小来计算,即0x501048,它显示了下一个块的大小是48字节,并且也在使用中(0x31)。当函数strcpy被调用以后,内存被填充了传入的字符,这个块标签没有被改变,但是下一个块的标签被字符串终止字符抹掉了。之后,当下一个内存块被用户释放时,ptmalloc将会遇到问题。
值得一提的是,例子中的bug并不总是会损坏堆元数据。每个内存管理器有最小块大小和对齐的要求。如果用户请求的大小小于最小块大小,则请求会调整为最小块的大小;如果大小不是对齐的倍数,它会向上取整满足对齐要求。由于大小调整的结果,实际分配给用户的内存可能会比请求的更大。添加的字节填充拓展了用户可用的空间。
对于示例1,如果传入的字符串(包含8字节的块标签)小于32字节或者不是16字节的倍数(ptmalloc最小块大小和对齐要求),那么在分配的内存块中就会至少有1字节填充,在这种情况下,会默认覆写1字节的终止字符。难怪这个错误可以长时间休眠而不暴露,直到传入的字符串具有“正确”的长度。
这个例子中还有一个微妙的地方是字节序。因为测试是在小端机器上运行的,所以终止字符会覆写下一个块标记的最低有效字节。如果程序在大端机器上运行,则块标签的最高有效字节将被覆写。由于该字节很可能是0(对于小于65536 TB的块),因此溢出不会产生任何不良影响。
与内存溢出相反,内存块也可能被“下溢”,这意味着用户代码在可用字节之前修改了一个内存块。从先前的讨论中可以明显看出,当前块的标签将被破坏,而不是下一个块的标签。其后果与内存溢出类似,也是不可预测的,这取决于破坏的性质,比如写入的字节内容,以及内存块何时被用户释放。
另一种常见的内存损坏是非法访问已释放的内存,这通常发生在用户代码持有指向已释放内存块的悬空指针或引用时。当代码通过这样的指针修改内存值时,它会破坏底层数据。同样地,症状变化因许多因素而异。例如,释放的内存可能已经返回给内核,在这种情况下,当程序访问这个内存的时候它会立即崩溃;释放的内存可能被再一次分配给用户,用于其他数据对象,从而导致数据对象意外地被破坏;如果内存被内存管理器缓存,这块内存可能会被用于其内部数据结构,修改它可能会破坏堆元数据。
下面举一个这种内存损坏的例子。函数copyString从调用者处获取一个缓冲区,并将源字符串复制到缓冲区中。在这个例子中,笔者将一个已释放的内存块作为缓冲区传递。该块有16字节的用户空间,其起始地址为0x501030。在用户错误写入已释放内存之前,这16字节的空间被ptmalloc用作指向下一个和上一个空闲块的指针。这些指针将同一大小类别的空闲块链接在一起,并锚定到相应的bin中,细节可以参考2.1节中的讨论。在用户代码调用strcpy函数后,这两个指针被破坏了,指向下一个空闲块的指针0x00000036a59346b8被篡改成0x6620737365636361,后者显然不是一个可访问的地址。当ptmalloc稍后访问此空闲块时,它很可能会崩溃或继续损坏另一个数据对象。
一个未初始化的变量理论上具有随机和不可预测的值。根据它的使用方式,其不良影响也是不同的,可能恰巧是我们期望的缺省值(例如0),从而没有任何影响;也可能是以前变量留下的野指针,一旦使用就会导致内存损坏。
一个经常出现的谜团是,程序在调试版本上工作正常,产生正确的结果,但是在发行版本上,即使输入和运行环境完全相同,行为也变得奇怪,甚至是崩溃。未初始化变量是这种现象的常见原因。
如果未初始化变量位于堆中,则其后果跟内存管理器的实现有很大关系。调试版本的内存管理发行版本跟使用不一样的分配算法是很常见的。因此,内存的分配位置以及随机性会有区别。Windows C运行时内存管理器就是一个明显的例子:在调试模式,它会使用字节模式0xcd填充已分配的内存,但是在发行版本中则不会进行任何操作,意味着新分配的内存的字节是随机的。这就可以解释为什么未初始化内存的症状会如此不一样。
位于栈上的未初始化变量没有涉及内存管理器,而是通过编译器在编译时分配的。未初始化变量的内容取决于它的位置和底层内存的访问历史。因为栈随着控制流动态地扩展和收缩,栈内存不断变化。
有一种情况是未初始化的栈变量的值总是0,即在第一次访问栈内存时,未初始化的栈变量像未初始化的全局变量一样。这是因为出于安全的考虑,内核提供的物理内存页会在依附到进程虚拟空间时被置零,否则一个进程就有可能通过未清理的物理内存页读取另一个进程的数据。这可能也是有bug的程序在调试版本看起来工作正常的原因。尽管它没有初始化栈变量,但它的初始值是0,因此也能正常工作。发行版本可能会有所不同,因为编译器可能会选择寄存器来存储变量,而寄存器相对栈内存是真正的“随机”。这是调试版本和发行版本的行为有所区别的另外一个原因。
这种类型的内存错误的另一个观察是,不同的架构暴露这种问题的概率不一样。具有更多工作寄存器的架构(像x86_64)大概率会比那些具有更少寄存器的架构(像x86)更容易显现问题,这仅仅是因为编译器可以在优化代码时把更多的变量从栈移动到寄存器。