最近一次升级以后,杀毒软件经常莫名其妙的崩溃。
把crash dump,问题描述和环境描述反馈给客服。客服回复称他们认为该崩溃是和Citrix冲突导致的,Citrix 把一些待扫描文件移到了新的内存地址,使得SEP无法继续跟踪这些文件,导致了崩溃。
虽然Citrix的确和很多杀毒软件有诸多冲突,可是……我这台机器根本没有使用任何Citrix产品阿。Citrix这次应该是躺枪了吧。不过既然客服这么说,那我就先自己检查一下crash dump。
用windbg打开hdmp文件,hdmp文件是windows中程序出现错误或崩溃时产生的错误文件,用于记录程序什么时候发生错误,以及错误产生时相关的系统数据,比如栈和堆里的数据。
先用kb命令看一下崩溃发生前一刻栈里的数据,主要是调用了哪些函数以及相应的参数
0:071> kb
ChildEBP RetAddr Args to Child
…
06e2ebb8 7752e36b 00e2ebd0 06e2ec20 06e2ebd0 ntdll!KiUserExceptionDispatcher+0xf
06e2ef3c 7752df73 00000000 00000800 0444e0d8 ntdll!RtlpLowFragHeapFree+0xc5
06e2ef54 758614ad 00310000 00000000 0444e0d8 ntdll!RtlFreeHeap+0x105
06e2ef68 7384016a 00310000 00000000 0444e0d8 kernel32!HeapFree+0x14
06e2ef7c 727f1a51 0444e0d8 00000800 06e2f2d0 msvcr100!free+0x1c
WARNING: Stack unwind information not available. Following frames may be wrong.
06e2f0b8 727f184a 06e2f25c 06e2f2b0 06e2f2d0 AVHOSTPLUGIN!GetFactory+0x640c1
…
每一行记录一次函数调用。第1列是栈贞,第2列是压入的返回地址,第3-5列是栈里的前3个32位数据,第6列依次显示了被调用的函数。在第6列,我们可以发现异常(Exception)发生前调用了RtlpLowFragHeapFree(),因此推断是RtlpLowFragHeapFree导致了异常.,并且RtlpLowFragHeapFree之前依次调用了RtlFreeHeap(), HeapFree(), free()。进一步发现KiUserExceptionDispatcher的返回地址是7752e36b,说明问题发生在7752e36b附近。
既然问题归根结底是由free引起的,那么我们可以确定问题发生在堆上。马上开始联想堆上可能发生的各种问题,溢出,Use-After-Free,double Free… 但是首先我们还是得先找到free的是 哪个地址。
再看一下kb的输出,Free的那一行中间有3个数据(0444e0d8 00000800 06e2f2d0),我们知道free只有一个参数,即要被free的内存地址。而调用函数前系统会把传递的参数压入栈。因此,要被free的内存地址就是0444e0d8.
用 !heap –x 命令来查看一下0444e0d8到底是什么情况。
0:071> !heap -x 0444e0d8
Entry User Heap Segment Size PrevSize Unused Flags
-----------------------------------------------------------------------------
0444e0c8 0444e0d0 00310000 065283b0 808 - 0 LFH;free
最右边的flag说明0444e0d8属于LFH (Low Fragment Heap, 低碎片堆)。LFH用于解决堆内存碎片问题。很多程序需要频繁申请和释放小块内存(通常小于1MB)。而堆管理的策略往往很吝啬:你要多少,我就切给你多少,把剩余的还给系统。这样频繁的申请和释放把堆内存切成大量不连续的小块,称为碎片。由于碎片是不连续的,所以这个时候再申请较大内存块可能会失败。因此碎片的可重用率非常低,从而造成了极大的浪费。当然系统可以整理碎片把它们合并成一个连续的大块,但是频繁的合并肯定会影响效率和稳定(尤其在多处理器的情况下)。
这种情况下就要用到LFH。在LFH机制下,每个堆有128种预先划分好的内存块(称为chunk 或者bin)让进程申请。每种chunk有固定的大小,从1 block 到 2048 block 不等 (block 是一种单位,一个block等于8字节)。在这种情况下,系统可以根据内存请求的大小先找到最合适的chunk,并把整个chunk分配出去(不再把剩余的部分切下来)。释放的时候,再把整个chunk归还给系统。因此LFH避免了碎片的产生。
Windows的堆管理器主要分为前端管理(front-end)和后端管理(back-end),限于篇幅,本文不讨论前端管理和后端管理论的区别。本文的例子只用到前端管理,大家只需要知道从win7开始前端管理只有LFH一种方式。
下图描述了LFH的一些基本数据结构以及它们之间的关系。
1. _HEAP: 一个堆的基本结构,称为heapbase,描述该堆的基本信息。当我们在一个堆上分配内存时,heapbase就是入口点。注意一个进程可能有多个堆,因此也有多个heapbase。
2. _LFH_HEAP: 管理LFH的入口。包含了一个有128个_HEAP_BUCKET对象的数组。
3. _HEAP_BUCKET: 每个_HEAP_BUCKET对应一个大小,从1 block 到 2048 block 不等。通过sizeindex和_HEAP_LOCAL_SEGMENT_INFO相关联。
4. _HEAP_LOCAL_DATA:包含了一个有128个_HEAP_LOCAL_SEGMENT_INFO对象的数组。_HEAP_LOCAL_SEGMENT_INFO和_HEAP_BUCKET通过BucketIndex相关联,并且指向一个_Heap_SUBSEGMENT对象。
5. _Heap_SUBSEGMENT:这个结构管理和维护用户使用的堆内存。并指向一个_HEAP_USERDATA对象和一个_INTERLOCK_SEQ对象。
6. _HEAP_USERDATA:一个_HEAP_USERDATA对象包含n个相同大小的chunk。chunk就是分配给用户进程的内存块了。用户使用完以后应该释放掉,把chunk归还给_HEAP_USERDATA对象。
7. _INTERLOCK_SEQ:记录了当前_HEAP_USERDATA里chunk的使用情况。比如还有哪些chunk没有被分配的,下一个可供分配的chunk是哪一个,等等。因此,每次用户得到或者归还chunk的时候,系统都要更新_INTERLOCK_SEQ对象。
还是举个例子直观一点。假设进程用malloc在堆上申请大小为101个block的内存 。那么系统通过该进程的_HEAP找到_LFH_HEAP,在Buckets[128]里找到BlocksUnits为101的sizeIndex,假设为50。从而找到相对应的SegmentInfo (其BucketIndex也为50)。SegmentInfo的成员指向一个_HEAP_SUBSEGMENT对象,进一步定位到_HEAP_USERDATA对象。这个_HEAP_USERDATA里面有若干个预留好的大小为101 block的连续内存块(chunk)。根据_INTERLOCK_SEQ的信息,系统从中选出一块chunk分配给用户。
再回顾一下 !heap –x 命令的结果。
0:071> !heap -x 0444e0d8
Entry User Heap Segment Size PrevSize Unused Flags
-----------------------------------------------------------------------------
0444e0c8 0444e0d0 00310000 065283b0 808 - 0 LFH;free
00310000 就是_HEAP的地址,065283b0是_HEAP_SUBSEGMENT的地址,0444e0d0 是_HEAP_USERDATA的地址。0444e0c8是chunk的地址,大小为808字节,即101个block。
Chunk的开头8字节用来存放元数据,称为chunk header (也可称为_HEAP_ENTRY)。当chunk被malloc分配给进程的时候,返回的并不是chunk header的地址,而是紧随其后的地址,即chunk header +8。
0:071> dd 0444e0c8
0444e0c8 71646efc 80000000 00000608 80000000
0444e0d8 6767b024 00006465 00000000 00000000
0444e0e8 00000000 00000000 00000000 00000000
0444e0f8 00000000 00000000 00000000 00000000
0444e108 00000000 00000000 00000000 00000000
0444e118 00000000 00000000 00000000 00000000
0444e128 00000000 00000000 00000000 00000000
0444e138 00000000 00000000 00000000 00000000
dd的输出显示71646efc 80000000是该chunk的chunk header。那么在分配时返回给进程的地址应该就是0444e0c8+8 =0444e0d0。
p = malloc(…); //p=0444e0d0,但是实际得到的chunk是从0444e0c8开始的
而释放时,系统需要chunk header的信息,很简单,只要将free的参数里的地址减去8字节就可以了
free(p); // p=0444e0d0,把这个地址减8,得到chunk header 0444e0c8。
再结合前面kb的输出,也许你已经发现问题了,既然0444e0c8是chunk header,那么malloc返回的地址应该是0444e0d0。而free的参数也应该是0444e0d0。可是事实上free的参数却是0444e0d8!
用vs打开hdmp文件,发现程序崩溃在7752E36B,此处试图把esi的值传给某个地址,(地址为eax的值)。错误信息是地址,7736eb47无法访问。注意此时esi的值为7736eb47。
用dd看一下7736eb47
0:071> dd 7736eb47
7736eb47 ???????? ???????? ???????? ????????
7736eb57 ???????? ???????? ???????? ????????
7736eb67 ???????? ???????? ???????? ????????
7736eb77 ???????? ???????? ???????? ????????
7736eb87 ???????? ???????? ???????? ????????
7736eb97 ???????? ???????? ???????? ????????
7736eba7 ???????? ???????? ???????? ????????
7736ebb7 ???????? ???????? ???????? ????????
看来是一块没有初始化的内存。那为什么free的时候系统试图访问这个地址呢?这个还是和LFH的特性有关。前面说过,当free把chunk归还给系统时,系统会更新_INTERLOCK_SEQ对象,记录有一块新的chunk可以用了。而从前面的堆结构关系图看,你会发现,chunk 和_INTERLOCK_SEQ对象并没有直接的对应关系:没有指针从chunk指向对应的_INTERLOCK_SEQ对象。怎么解决这个问题呢?两个步骤
第一步:用下面这个公式找到其对应的_Heap_SUBSEGMENT对象。
Subsegment = *(DWORD)chunk header ^(chunk header/8)^heap^RTLpLFHKey
还是拿0444e0c8举例子,这个chunk的第一个DWORD内容是71646efc,而heap的地址为00310000 ,最后RTLpLFHKey可以用!heap -s找到。
0:071> !heap -s
LFH Key : 0x778f7155
…
因此Subsegment = 71646efc ^ (0444e0c8/8) ^ 00310000 ^ 778f7155 = 65283b0
这个正好是!heap –x 列出来的subsegment地址。
第二步:通过_Heap_SUBSEGMENT对象的AggregateExchg指针定位到_INTERLOCK_SEQ对象.
然而正因为free的参数错误的变成了0444e0d8,从而系统误认为chunk header是0444e0d0,而0444e0d0里的数据是608,因此
Subsegment = 608 ^ (0444e0d0/8) ^ 00310000 ^ 778f7155 = 7736eb47
这正好是之前ESI里的值。
因此,结论是由于某种原因,free的参数不正确,导致了subsegment的地址计算错误,结果试图读取为初始化的内存,发生了异常。
_HEAP_USERDATA对象是一块连续的内存空间,包含n个相同大小的chunk,就像等分的巧克力。分配的时候掰下一块给用户,释放的时候再拿回来拼进去。为了有效的管理,chunk用offset来作为自己的惟一标识。offset记录着_HEAP_USERDATA与该chunk之间距离(间隔了多少个block)。比如第一个chunk的offset为2,因为_HEAP_USERDATA_header的长度为2 block。
为了维持各个chunk之间的先后关系,未被分配的chunk在其chunk header后有一个2字节值,用来表示后继chunk的offset。
0:071> dd 0444e0c8
0444e0c8 71646efc 80000000 00000608 80000000
0444e0d8 6767b024 00006465 00000000 00000000
0444e0e8 00000000 00000000 00000000 00000000
这里例子里,0608就是offset。说明后继chunk的offset为608。既然已经知道这里chunk的大小是101 block (也就是808字节),那么看一看前后chunk长得什么样。
0:071> dd 0444e0c8-808
0444d8c0 716469fd 80000000 00000507 00000000
0444d8d0 00000000 00000000 00000000 00000000
0444d8e0 00000000 00000000 00000000 00000000
前一个chunk的chunk header是716469fd 80000000, 其后继offset为507, 看起来很正常。
0:071> dd 0444e0c8+808
0444e8d0 00000000 00000000 00000709 00000000
0444e8e0 00000000 00000000 00000000 00000000
0444e8f0 00000000 00000000 00000000 00000000
后一个chunk的后继offset为709,但是chunk header竟然为00000000 00000000。这里有问题,可能是某个溢出把chunk header给覆盖了。
然而下一个chunk又恢复正常了
0:071> dd 0444e0c8+808*2
0444f0d8 71646cfe 80000000 0000080a 00000000
0444f0e8 00000000 00000000 00000000 00000000
0444f0f8 00000000 00000000 00000000 00000000
把分析的结果发给客服,这次客服终于承认了问题,一个月后,我们收到了测试补丁。
[1] Chris Valasek, Understanding the Low Fragmentation Heap, http://illmatics.com/Understanding_the_LFH.pdf
[2] Mark Russinovich, Windows Internals Part2
* 作者:nickchang,本文属FreeBuf原创奖励计划文章,未经许可禁止转载