不介意转载,转载请注明来自 boxcounter.org
前阵子遇到个堆破坏的BUG,花了一个早上才找到根源,一个低级错误:
首先我定义了一个变量用于存储进程完整路径:
UNICODE_STRING usProcessPath = {0, MAX_PATH * sizeof(WCHAR), NULL};
然后,为它从 look-aside 表里申请了一块内存:
usProcessPath.Buffer = LsaCreateFilePath(usProcessPath.MaximumLength);
之后用这块 buffer 来存储进程完整路径,进行一些处理等等。最开始运行的挺正常的,后来某天进行代码优化,结果给优化出了个低级错误:
为了比较某个进程是不是我要关注的进程,我调整了usProcess.Buffer,之后进行一些列的比较等等,然后释放的时候没有调整回去,结果导致堆崩溃。
很郁闷,一个鸟问题耗费了一个上午。堆破坏这种BUG,最让人头疼,因为它很难追溯到问题的根源。于是干脆又多花了点时间琢磨了下解决方案,希望能通过这一个BUG,解决一系列此类BUG。
首先,考虑下堆破坏,最有可能导致堆破坏BUG的原因有3:
1. 申请了内存,结果却释放错了(比如上面我说的那个问题)。
2. 重复释放同一块内存。
3. 溢出。
上述三者,其实再仔细想想,可以把1、2合成一条,统一视为“错误释放”。
对于这种堆错误,可以采用一个简单的方法来解决:
建立一个链表(或者类似的数据结构,只要方便插入、摘除、查询就OK),每申请一块内存,就将内存的信息,比如内存起始地址,申请代码的行数(__LINE__)、文件路径(__FILE__)、tag(如果有的话)等信息组成一个节点,插入链表中。如果申请内存的方法不一样,最好能为每种方法都单独建立一个链表。以我的代码为例:我为从 look-aside 表中申请的内存,和用 ExAllocXXXX 申请内存各准备了一个链表。
然后释放内存的时候,去查询链表,看该内存地址是否在链表中,如果不在,则说明释放有问题。
对于另外一种破坏方式,“溢出”,则需要借助工具 - Driver Verifier,这是微软提供的一个利器。该工具的具体作用、使用方法等,我就不啰嗦了,需要的朋友请自行google,《windows internals》里也有说明。需要多嘴一句的是,这个工具已经整合在系统里了,至少我用的 xp、2003、7 里都已整合了,存放在 %systemroot%\system32 下,文件名是 verifier.exe。
Verifier 可以设置 Special pool,这个功能可以帮助我们减少“溢出”的问题。
首先,啰嗦下“Special pool”,
这种特殊的内存池,特殊之处就在于它的结构:

比如,咱们申请一块0x208字节的内存,系统实际会给咱们分配3个内存页(大方吧~),其中一头一尾两个是无效页(图中的“无效页1”和“无效页2”),中间这个页的最底部就是咱们的0x208个字节,从这也的页首部到咱们分配的0x208个字节的首地址之间的这块区域被打上了特殊标记(这个标记的作用,我还没继续琢磨,有空补上)。
通过 windbg,咱们可以验证一下:
kd> !pool 0x857c6df8 << 0x857c6df8 是我申请到的一块内存的首地址
Pool page 857c6df8 region is Special pool << 确认是从 special pool 中分配的
857c6000: Unable to get contents of special pool block
kd> db 0x857c6df8 0x857c6df8+400
857c6df8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6e08 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6e18 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6e28 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
...... << 这里省略若干完全一样的输出
857c6fd8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6fe8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6ff8 87 87 87 87 87 87 87 87-?? ?? ?? ?? ?? ?? ?? ?? ........???????? << 结束地址
857c7008 ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c7018 ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ??
从上面可以看到这块内存的结尾是857c7fff,正好是一个内存页的末尾(我的机器x86上,一个内存页为0x1000个字节)。而紧接着的一个页都是无效页(之前说到的“无效页面2”。另,windbg 中将无效页的内容表示为 ?),我们可以继续验证这整个页是否都是无效,这里就不浪费篇章了。
我们再来看看 0x857c6df8 之前的内存是不是都被打上了特殊标记
kd> db 0x857c6df8-100 0x857c6df8
857c6cf8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d08 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d18 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d28 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d38 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d48 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d58 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d68 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d78 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d88 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6d98 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6da8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6db8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6dc8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6dd8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6de8 87 87 87 87 87 87 87 87-87 87 87 87 87 87 87 87 ................
857c6df8 87
从这里看来,这些'87'就是所谓的特殊标记。再往前看,可以发现,这一页的最开始有一些特殊标识:
857c5ff8 ?? ?? ?? ?? ?? ?? ?? ??-08 42 87 00 78 44 62 67 ????????.B..xDbg
857c6008 b0 fc cb 84 87 87 87 87-87 87 87 87 87 87 87 87 ................
这应该是特殊标志的标志头。(关于特殊标记,由于我没有关注,所以这里只能通过现象来验证,也只能用“看来”、“应该”这样的词来说明 :-))
OK,咱们最后来看一下再往前的一个页面,上面说了,咱们申请的内存所在的页的尾巴是 857c7fff,那我们就往前看0x1000个字节:
kd> db 857c7fff-0x1000 857c7fff
857c6fff 87 ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? .???????????????
857c700f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c701f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c702f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c703f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c704f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
857c705f ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
...... << 之后的部分全部省略
上面显示的第一个字节实际上是属于更前面的一个内存页,除它以外,这一页全显示为 ?,也就验证了这一个页是无效页,也就是之前说的“无效页面1”。
OK,咱们完整了验证了 Special pool 的 Special 之处。咱们可以继续讨论“溢出”的问题了。
其实,也没什么好多说的了,最常见的“溢出”是向后溢出,而上面说描述的 Special pool 的结构,就可以保证一旦驱动发生向后的“溢出”,调试器就捕获到异常,中断下来。那向前溢出呢? verifier 也提供了相应的方法,实际上 verifier 准备了两种 Special pool 的结构,上面咱们看到的是其中一种,也是默认的一种,被称为"Verify End"。与之相对应的是"Verify Start",对于这种方式,Special pool 的总体结构没有变,仍然是两个无效页面夹着一个有效页面,但是这个有效页面就有变化:咱们申请的内存被安置在这一页的页首,也就是这个样子:

可见,这种情况下,一旦发生向前溢出,调试器也会立刻就捕捉到。
这样看来,维护一套自己的内存信息链表,然后再启用 special pool,几乎就完美解决了堆破坏。但是,(转折鸟~,很多时候,这种转折最扫人兴)我查到的资料上说, special pool 只针对少量的内存的申请,多少是“少量”呢?不超过一个页面大小。我就不继续验证了。
另说一句:对于较新的系统,如vista,7等,系统将 special pool 机制应用到了更多的函数上,比如 IoAllocateMdl、IoAllocateIrp、RtlAnsiStringToUnicodeString等。但是我还没仔细查 xp、2003 上有哪些函数是被支持的,目前只知道 ExAllocateFromNPagedLookasideList 和 ExAllocXXX 是肯定支持的。
OK,暂时说这么多,收工。
参考资料[1]:http://msdn.microsoft.com/en-us/library/ms792863.aspx
参考资料[2]: 《Microsoft Windows internals》


