首页
论坛
课程
招聘
[原创]极为详细的一次双重释放漏洞调试分析经历
2021-8-11 17:21 10334

[原创]极为详细的一次双重释放漏洞调试分析经历

2021-8-11 17:21
10334

1. 前言

本来这周按照计划应该是看CVE-2010-3974 Windows传真封面编辑器的双重释放漏洞的,但是《漏洞战争》这本书中对该漏洞并没有进行什么介绍,漏洞利用方法留在了下一章对于UAF漏洞的介绍中。因此本周文章没有针对具体某个漏洞的介绍,只针对双重释放漏洞本身进行了详细地调试学习。

 

在我执行书中示例代码生成的程序时,并没有如同书中介绍的那样触发异常,反而出现了程序卡住的情况,自己手动添加了第三次释放之后,程序触发异常。对于这种情况,我根据函数调用栈帧进行了详细地调试,分析了产生上述结果的原因。经过此次调试学习,对于heap的结构以及windows引入的安全机制有了更深的理解,同时也学习到了更多调试内存异常的方法,相信对于之后的漏洞调试分析会有很大帮助。

2. 一个思维误区

最初我在理解双重释放这个概念时,注意力主要集中在了指针上,心里一直在想释放两次指针会出什么问题。后来才意识到自己漏洞概念弄混了,双重释放释放的并不是指针,而是指针所指向的空间。

 

而需要进行释放的空间都是使用类似malloc这类的函数在堆上分配的空间,所以想要了解为什么双重释放会触发异常,就必须对堆结构,以及空间释放时发生的操作有所了解。

3. 双重释放为何会引发异常

3.1 实验代码

我使用下面的代码进行调试分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include "windows.h"
 
int main (int argc, char *argv[])
{
    void *p1,*p2,*p3;
    char* test_str = "aaaaaaaa";
 
    //__asm int 3
 
    p1 = (char *)malloc(100);   
    printf("Alloc p1:%p\n",p1);
    strncpy(p1, test_str, strlen(test_str));
 
    p2 = (char *)malloc(100);
    printf("Alloc p2:%p\n",p2);
    strncpy(p2, test_str, strlen(test_str));
 
    p3 = (char *)malloc(100);
    printf("Alloc p3:%p\n",p3);
    strncpy(p3, test_str, strlen(test_str));
 
    printf("Free p1\n");
    free(p1);
    printf("Free p3\n");
    free(p3);
    printf("Free p2\n");
    free(p2);
    printf("Double Free p2\n");
    free(p2);
    //printf("Triple Free p2\n");
    //free(p2);
 
    return 0;
}

注:在生成Release版本的可执行文件之前,根据参考资料2,设置生成对应的PDB文件,方便调试。

 

程序在执行后,并没有触发异常,有的时候会直接卡在了最后一步,有的时候会正常执行,所以最后又添加了第三次释放,一定会引发异常。下面看一下这期间究竟发生了什么,导致出现了这三种情况。

3.2 追踪

直接执行程序,触发异常,windbg打开,显示错误信息:

1
2
3
4
5
6
(b7c.830): Access violation - code c0000005 (!!! second chance !!!)
eax=00000dac ebx=002d0678 ecx=00000665 edx=002d0564 esi=002d0678 edi=002d0000
eip=7792434c esp=0018fdbc ebp=0018fde4 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
ntdll!RtlpCoalesceFreeBlocks+0x47c:
7792434c 66894cc604      mov     word ptr [esi+eax*8+4],cx ds:002b:002d73dc=????

此时的函数调用信息:

1
2
3
4
5
6
7
8
9
10
11
12
0:000> kb
ChildEBP RetAddr  Args to Child             
0018fde4 77923407 0000007f 002d06f0 0018feac ntdll!RtlpCoalesceFreeBlocks+0x47c
0018fedc 779232f2 002d06f0 002d06f8 002d06f8 ntdll!RtlpFreeHeap+0x1f4
0018fefc 772014d1 002d0000 00000000 002d06f8 ntdll!RtlFreeHeap+0x142
0018ff10 004011a7 002d0000 00000000 002d06f8 kernel32!HeapFree+0x14
0018ff2c 00401138 002d06f8 00408030 002d0770 double_free!free+0x66
0018ff48 004014c2 00000001 002d0e38 002d0e90 double_free!main+0x138 [C:\Users\test\Documents\ldzz\double_free\double_free.c @ 33]
0018ff88 77203677 7efde000 0018ffd4 77929d72 double_free!mainCRTStartup+0xb4
0018ff94 77929d72 7efde000 7fa803bd 00000000 kernel32!BaseThreadInitThunk+0xe
0018ffd4 77929d45 0040140e 7efde000 00000000 ntdll!__RtlUserThreadStart+0x70
0018ffec 00000000 0040140e 7efde000 00000000 ntdll!_RtlUserThreadStart+0x1b

可以看到,当尝试在002d06f8上调用free函数的时候,在RtlpCoalesceFreeBlocks函数处发生了异常。看一下002d06f8这个堆块的信息:

1
2
3
4
5
6
7
8
9
0:000> !heap -x 2d06f8
List corrupted: (Blink->Flink = 002d3178) != (Block = 002d0680)
HEAP 002d0000 (Seg 002d0000) At 002d0678 Error: block list entry corrupted
 
HEAP 002d0000 (Seg 002d0000) At 002d73d8 Error: invalid block size
 
Entry     User      Heap      Segment       Size  PrevSize  Unused    Flags
-----------------------------------------------------------------------------
002d0678  002d0680  002d0000  002d0000      6d60       108         0  free

第一个错误信息HEAP 002d0000 (Seg 002d0000) At 002d0678 Error: block list entry corrupted,这个错误信息在这里没什么用,因为现在正在调用HeapFree函数,由于会发生双向链表的链接操作,链表指针的数值可能会发生修改,出现不一致是正常的。

 

第二个错误信息HEAP 002d0000 (Seg 002d0000) At 002d73d8 Error: invalid block size需要注意一下,从上面的信息可以看到当前的堆块大小为6d60002d0678+6d60=2d73d8,也就是错误信息中的数值。看一下这个地址信息:

1
2
3
4
5
6
7
8
9
10
11
12
0:000> !address 2d73d8
 
 
Failed to map Heaps (error 80004005)
Usage:                  <unclassified>
Allocation Base:        002d0000
Base Address:           002d5000
End Address:            002e0000
Region Size:            0000b000
Type:                   00020000    MEM_PRIVATE
State:                  00002000    MEM_RESERVE
Protect:                00000000

这块地址是保留的,再看一下整个堆的信息:

1
2
3
4
5
6
7
8
9
10
11
0:000> !heap -a -h 2d0000
Index   Address  Name      Debugging options enabled
  2:   002d0000
    Segment at 002d0000 to 002e0000 (00005000 bytes committed)
    Flags:                00001003
    ForceFlags:           00000001
    Granularity:          8 bytes
    Segment Reserve:      00100000
    Segment Commit:       00002000
    DeCommit Block Thres: 00000200
......

我只截取了前面的一段信息,可以看到整个堆的范围在002d0000002e0000,但是最后有0x5000字节的内存无法使用(committed可以这样理解吗?)。

 

所以现在基本可以确定002d0678堆块中的size信息是错误的。在回顾一下发生异常的代码:

1
2
3
4
5
6
(b7c.830): Access violation - code c0000005 (!!! second chance !!!)
eax=00000dac ebx=002d0678 ecx=00000665 edx=002d0564 esi=002d0678 edi=002d0000
eip=7792434c esp=0018fdbc ebp=0018fde4 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
ntdll!RtlpCoalesceFreeBlocks+0x47c:
7792434c 66894cc604      mov     word ptr [esi+eax*8+4],cx ds:002b:002d73dc=????

在尝试向002d73dc写入信息,这个地址是通过esi+eax*8+4计算得到的,实际上就是在通过002d0678堆块的起始地址和大小计算下一个相邻堆块的地址。但是因为size信息有问题,导致计算得到的是一个无法访问的地址,从而出现了异常。

3.3 溯源

接下来看一下这个size数值是怎么来的。为了调试程序,在程序开头添加__asm int 3,重新启动程序。

 

直接步进到第二次释放p2之前,然后在RtlpCoalesceFreeBlocks上设置一个断点:

1
2
3
4
5
6
7
8
9
0:000> p
eax=000306f8 ebx=7efde000 ecx=0040a110 edx=0008e3b8 esi=00000000 edi=00000000
eip=0040111e esp=0018ff28 ebp=0018ff48 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
double_free!main+0x11e:
0040111e e825000000      call    double_free!free (00401148)
0:000> dd esp L1
0018ff28  000306f8
0:000> bp RtlpCoalesceFreeBlocks

继续运行:

1
2
3
4
5
6
7
8
9
10
0:000> g
Breakpoint 0 hit
eax=0018fea0 ebx=00000000 ecx=779bef0f edx=00000000 esi=000306f0 edi=00030000
eip=779230cf esp=0018fddc ebp=0018fed0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!RtlpCoalesceFreeBlocks:
779230cf 8bff            mov     edi,edi
0:000> dd esp L5
0018fddc  77923407 00030000 000306f0 0018fea0
0018fdec  00000000

RtlpCoalesceFreeBlocks的函数原型为:

1
2
3
4
5
6
7
PHEAP_FREE_ENTRY
RtlpCoalesceFreeBlocks(
    IN PHEAP Heap,
    IN PHEAP_FREE_ENTRY FreeBlock,
    IN OUT PULONG FreeSize,
    IN BOOLEAN RemoveFromFreeList
    );

可以根据函数原型对应到栈上的四个参数,其中第三个参数FreeSize是一个指针,真正指向的值为:

1
2
0:000> dd 18fea0 L1
0018fea0  0000000f

看一下306f0处的数据:

1
2
0:000> dd 306f0 L2
000306f0  d501000f 08000028

大小也是符合的。所以RtlpCoalesceFreeBlocks函数调用确实想要去合并306f0处的堆块,并从所谓的头部获取到了这个堆块的大小作为参数传入了函数中。

 

接下来跟一下RtlpCoalesceFreeBlocks函数的执行流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ntdll!RtlpCoalesceFreeBlocks:
779230cf 8bff            mov     edi,edi
779230d1 55              push    ebp
779230d2 8bec            mov     ebp,esp
779230d4 83ec1c          sub     esp,1Ch
779230d7 53              push    ebx
779230d8 8b5d0c          mov     ebx,dword ptr [ebp+0Ch]     // 第二个参数306f0 要释放的堆块
779230db 0fb74304        movzx   eax,word ptr [ebx+4]        // 相邻的上一个堆块的大小
779230df 56              push    esi
779230e0 57              push    edi
779230e1 8b7d08          mov     edi,dword ptr [ebp+8]       // 第一个参数 30000
779230e4 0fb74f54        movzx   ecx,word ptr [edi+54h]      // Heap->Encoding.PreviousSize 编码值
779230e8 33c1            xor     eax,ecx                     // Vista之后引入了对heap_entry的编码,这里在进行解码
779230ea c1e003          shl     eax,3                       // size是以8字节为单位的,这里*8,得到字节数0x78
779230ed 8bf3            mov     esi,ebx
779230ef 2bf0            sub     esi,eax                     // 0x306f0-0x78=0x30678,就是p1的位置

可以看到执行到这里,得到的要释放的堆块p2的前一个堆块的位置就是代码中分配得到的p1的位置。因为在第一次释放p2的时候,只是把p2和p1、p3合并到一起,只需要修改一些flag值,其他数据并没有改变,所以仍旧能够得到正确的前一个堆块的位置。

 

接下来一段代码检查前一个堆块是不是空闲的:

1
2
3
4
5
6
7
8
9
779230f1 3bf3            cmp     esi,ebx
779230f3 7417            je      ntdll!RtlpCoalesceFreeBlocks+0x481 (7792310c)
779230f5 8b474c          mov     eax,dword ptr [edi+4Ch]         // Heap->EncodeFlagMask
779230f8 8bc8            mov     ecx,eax
779230fa c1e914          shr     ecx,14h
779230fd 224f52          and     cl,byte ptr [edi+52h]           // Heap->Encoding.Flags
77923100 324e02          xor     cl,byte ptr [esi+2]             // pre_block->Flags 获得解码后的前一个堆块的Flags值
77923103 f6c101          test    cl,1                            // 检查是不是空闲
77923106 0f84f5100000    je      ntdll!RtlpCoalesceFreeBlocks+0x41 (77924201)

之后同样对前一个堆块的头部数据进行了解码,进行了堆块大小的判断,这里不再贴出来。继续看后面,进行了双向链表指针的判断:

1
2
3
4
5
6
7
8
77924225 8b560c          mov     edx,dword ptr [esi+0Ch]        // pre_block->FreeList.Blink 
77924228 8d4608          lea     eax,[esi+8]
7792422b 8b08            mov     ecx,dword ptr [eax]            // pre_block->FreeList.Flink
7792422d 894df4          mov     dword ptr [ebp-0Ch],ecx
77924230 8b4904          mov     ecx,dword ptr [ecx+4]          // pre_block->FreeList.Flink -> Blink
77924233 8955ec          mov     dword ptr [ebp-14h],edx
77924236 8b12            mov     edx,dword ptr [edx]            // pre_block->FreeList.Blink -> Flink
77924238 3bd1            cmp     edx,ecx                        // 这里正常应该是相等的,都指向前一个堆块

接下来有一大段的代码在检查和更新和BlocksIndex有关的内容,这部分知识和Low fragmentation heap有关,具体可以查看参考资料6以及7,我大致看了一下这部分内容,同时调试跟了一下这段代码,还是有一些概念不太清楚,不过大致判断和这次的漏洞分析关系不大,所以下面不再贴出这部分代码,直接步进到相关代码处:

1
2
3
4
5
6
7
8
0:000> bp 7792430b
0:000> g
Breakpoint 1 hit
eax=00030138 ebx=000306f0 ecx=00033178 edx=00030564 esi=00030678 edi=00030000
eip=7792430b esp=0018fdb0 ebp=0018fdd8 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!RtlpCoalesceFreeBlocks+0x334:
7792430b 8b45f4          mov     eax,dword ptr [ebp-0Ch] ss:002b:0018fdcc=00033178

7792430b这个地址是通过静态分析在IDA中获得的。

1
2
3
4
5
6
7
8
9
7792430b 8b45f4          mov     eax,dword ptr [ebp-0Ch]      // pre_block->FreeList.Flink
7792430e 8b4dec          mov     ecx,dword ptr [ebp-14h]      // pre_block->FreeList.Blink
77924311 8901            mov     dword ptr [ecx],eax
77924313 894804          mov     dword ptr [eax+4],ecx        // 上面这两句把上一堆块(p1)从双向链表卸下来了
77924316 f6460208        test    byte ptr [esi+2],8           // 检查p1的flags是否设置了0x8
7792431a 0f859ac10100    jne     ntdll!RtlpCoalesceFreeBlocks+0x349 (779404ba)   // 设置就跳转
77924320 8a4602          mov     al,byte ptr [esi+2]          // 未设置8,执行到这里
77924323 a804            test    al,4                         // 检查p1的flags值是否设置了0x4
77924325 0f859f5d0500    jne     ntdll!RtlpCoalesceFreeBlocks+0x3d9 (7797a0ca)   // 设置就跳转

这里程序检查了上一堆块,即p1这一堆块的flags值,根据参考资料8:

0x01 Indicates that the allocation is being used by the application or the heap manager
0x04 Indicates whether the heap block has a fill pattern associated with it
0x08 Indicates that the heap block was allocated directly from the virtual memory manager
0x10 Indicates that this is the last heap block prior to an uncommitted range

 

在此程序中,p1堆块的flags值为0,所以上面的跳转都没有执行。

 

接下来计算并更新堆块合并之后的大小:

1
2
3
4
5
6
7
7792432b 0fb70e          movzx   ecx,word ptr [esi]         // p1的大小
7792432e 8b4510          mov     eax,dword ptr [ebp+10h]    // 函数的第三个参数 *FreeSize 0018fea0,里面保存的是释放堆块的大小
77924331 c6460200        mov     byte ptr [esi+2],0         // pre_block->Flags = 0
77924335 c6460700        mov     byte ptr [esi+7],0         // pre_block->UnusedBytes = 0
77924339 0108            add     dword ptr [eax],ecx        // 释放堆块大小 + p1的大小,两个堆块合并后的大小
7792433b 668b08          mov     cx,word ptr [eax]         
7792433e 66890e          mov     word ptr [esi],cx          // 更新p1的大小为合并后的大小

这部分操作结束之后,p1堆块的大小增加了原本p2堆块的大小,但是要记得,p2这是第二次释放了,所以相当于p1加上了一个不存在的空间大小。

1
2
3
4
5
6
7
77924341 668b08          mov     cx,word ptr [eax]           
77924344 66334f54        xor     cx,word ptr [edi+54h]         // 这里在对大小的数值进行编码
77924348 8b00            mov     eax,dword ptr [eax]
7792434a 8bde            mov     ebx,esi
7792434c 66894cc604      mov     word ptr [esi+eax*8+4],cx     // 注意这里
                                                               // esi: 指向p1堆块
                                                               // eax: 更新后未编码的p1堆块大小

注意上面最后一行的代码,第三次释放p2的时候,异常就是发生在这里。esi+eax*8+4在尝试计算堆块合并后,p1相邻的下一个堆块的位置(esi+eax*8),并到达其Previous chunk size所在位置(+4),之后用编码后的大小数值更新该位置数据,即更新p1下一堆块中记录的上一堆块大小值。

3.4 总结一下

所以调试到这里,我们知道释放堆块的时候,程序会更新前一相邻堆块的大小为两堆块大小的和,同时根据这个更新后的大小,计算并更新后一堆块中记录的Previous chunk size数据。由于二次释放会导致相邻堆块大小的计算中包含一块不存在的空间,致使计算的大小大于实际大小,如果计算得到的大小超出了可访问内存范围,就会导致更新后一堆块中数据时发生内存访问错误。

4. 继续溯源 - 出现三种结果的原因

虽然知道了发生异常的原因,但是为什么有的时候会出现二次释放p2时卡住的情况呢?继续进行调试:

 

之后的一段代码程序对p1相邻的下一堆块进行了一些验证,由于这里并没有真的指向一个堆块,所以计算得到的结果也都是不正确的,最后因为验证失败而调用了RtlpLogHeapFailure函数。这部分代码和此次漏洞分析无关,这里不再贴出。

 

之后程序判断了p1相邻的下一堆块是否为空闲,从而判断是否需要进行合并:

1
2
3
4
5
6
7792313a 8b474c          mov     eax,dword ptr [edi+4Ch]     // Heap->EncodeFlagMask
7792313d c1e814          shr     eax,14h
77923140 224752          and     al,byte ptr [edi+52h]       // Heap->Encoding.Flags
77923143 324602          xor     al,byte ptr [esi+2]         // next_block->Flags
77923146 a801            test    al,1                        // 检查是不是空闲堆块
77923148 0f855f010000    jne     ntdll!RtlpCoalesceFreeBlocks+0x91d (779232ad)

要记得我们这里计算得到的p1相邻的下一堆块是一个不存在的假的堆块,而且它所在的位置一定已经超过了程序一开始自己申请的p1、p2、p3堆块的范围,这部分的数据是不可控的,所以得到的结果有一定随机性。

 

注:为什么超过了p3的范围?因为经过三次释放,p1、p2、p3已经p3后面的空闲堆块都合并在了一起,此时p1中保存的堆块大小就是这一整块合并之后的堆块大小。但由于p2的二次释放,这一堆块大小又加上了一个数值,所以所谓的p1相邻的下一堆块的位置一定是超过p3的范围的。

 

这次实验一开始没有跳转,继续往下执行,之后对该下一堆块的头部进行了一些检查,这里的检查比较有意思,它执行了一个这样的操作:next_block->InterceptorValue ^= Heap->Encoding.InterceptorValue;这个操作是在对堆块头部进行解码,所以会改变堆块前四个字节的值,这一点要注意!这里测试的时候失败了,所以调用了RtlpAnalyzeHeapFailure函数。

 

之后会检查该下一堆块中双向链表指针中的值是否正常,由于并不是一个真正的堆块,所以检查必然会失败(这里成功的可能性基本为零),调用RtlpLogHeapFailure函数。

 

然后的流程就比较有意思了,这里是一个while循环,程序会再次判断下一堆块是否空闲。

 

上面文字版的说明可能会比较乱,这里贴一下IDA中获得的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
while ( ((next_block->Flags ^ Heap->Encoding.Flags & (Heap->EncodeFlagMask >> 20)) & 1) == 0 )// 验证flags数值,这一数值位于next_block的前四个字节中
  {
    if ( Heap->EncodeFlagMask )
    {
      next_block->InterceptorValue ^= Heap->Encoding.InterceptorValue;// 解码操作,这里会改变next_block前四个字节的数据
      if ( next_block->SmallTagIndex != (next_block->Flags ^ (LOBYTE(next_block->Size) ^ HIBYTE(next_block->Size))) )
        RtlpAnalyzeHeapFailure(Heap, next_block, 0);
    }
    if ( !RemoveFromFreeList )
      goto LABEL_11;                             // 这里就跳转了
    v68 = pre_block_->FreeList.Flink;
    v52 = v68->Blink;
    v72 = pre_block_->FreeList.Blink;
    v53 = v72->Flink;
    if...                                       // 这里在进行堆块合并,程序执行不到这里
    RtlpLogHeapFailure(12, Heap, &pre_block_->FreeList, v52, v53, 0);
LABEL_186:
    RemoveFromFreeList = 0;
LABEL_11:
    v66 = next_block->FreeList.Flink;
    v11 = v66->Blink;
    v69 = next_block->FreeList.Blink;
    v12 = *v69;
    if...                                       // 验证双向链表指针,一定会失败
  }

也就是说,因为next_block在这里并不是一个真实的堆块,最后验证双向链表指针的时候一定会失败,程序在验证flags数值的时候,指向的堆块一直是同一堆块。如果堆块的前四个字节解码前后的数值在验证flags的阶段一直成功,程序就会陷在这个循环里面。

 

这就是我在实验的时候,为什么有的时候二次释放p2堆块能够成功执行,有的时候直接卡住的原因。

5. 关于p2 self size的疑问

根据之前的分析,如果二次释放的堆块足够大,那么在二次释放的时候就会发生异常,但是正如在此例中发生的情况一样,p2并不是特别大,所以二次释放的时候并没有触发异常。

 

第三次释放的时候,如果加上的释放堆块的大小仍旧是p2的大小的话,是不会超出可访问范围的。所以说程序之后一定还进行了一些操作,修改了p2堆块中self size所在位置的数值,导致第三次释放时加上了一个很大的数值。

 

接下来看一下程序是怎样修改p2堆块中self size所在位置的数值的。

5.1 调试

让程序回退到第一次释放p2之前(之前建立了快照),并在306f0处建立一个读写断点:

1
2
3
4
5
6
7
8
9
10
11
12
0:000> ba r4 306f0
0:000> dd 306f0 L2
000306f0  23300bc3 08000028
0:000> g
Breakpoint 0 hit
eax=2d310bcc ebx=00000000 ecx=00030000 edx=00000003 esi=000306f0 edi=00030000
eip=77928107 esp=0018fdf0 ebp=0018fed0 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!RtlpFreeHeap+0x166:
77928107 8a4602          mov     al,byte ptr [esi+2]        ds:002b:000306f2=01
0:000> dd 306f0 L2
000306f0  0e01000f 08000028

可以看到在第一次释放p2的时候,p2堆块头部在修改前后的数值变化,self size0xbc3变成了0xf,而0xf就是p2的真实大小(需要*8),所以RtlpFreeHeap函数在77928107这里对于堆块头部的修改,其实就在做一个解码操作(记得前面提到过,系统出于安全考虑,会对堆块头部进行编码)。

 

接下来继续运行,会遇到一些不太重要的读取过程,然后到达下面这段代码:

1
2
3
4
5
6
7
8
9
10
779233ef 0fb706          movzx   eax,word ptr [esi]                    // 获取RtlpCoalesceFreeBlocks 的第三个参数FreeSize
779233f2 8945d0          mov     dword ptr [ebp-30h],eax
779233f5 f6474080        test    byte ptr [edi+40h],80h
779233f9 7514            jne     ntdll!RtlpFreeHeap+0x1fc (7792340f)
779233fb 53              push    ebx
779233fc 8d45d0          lea     eax,[ebp-30h]
779233ff 50              push    eax
77923400 56              push    esi
77923401 57              push    edi
77923402 e8c8fcffff      call    ntdll!RtlpCoalesceFreeBlocks (779230cf)

可以看到RtlpCoalesceFreeBlocks中第三个参数Freesize是直接从Freeblock的头部,即306f0读取的,也就是读取的解码后的长度。

 

之后还要在free函数那里设置一个断点,避免在F5的过程中程序直接执行到了下一次p2释放。然后再继续执行,程序知道到达了第二次p2释放:

1
2
3
4
5
6
7
8
9
10
11
0:000> bp free
0:000> bl
 0 e 000306f0 r 4 0001 (00010:****
 1 e 00401148     0001 (00010:**** double_free!free
0:000> g
Breakpoint 1 hit
eax=000306f8 ebx=7efde000 ecx=0040a110 edx=0008e3b8 esi=00000000 edi=00000000
eip=00401148 esp=0018ff24 ebp=0018ff48 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
double_free!free:
00401148 55              push    ebp

self size的值会再次被“解码”,因为之前已经是解码之后的值了,这次的解码操作其实相当于做了一次编码,得到了0xbc3

1
2
3
4
5
6
7
8
9
0:000> g
Breakpoint 0 hit
eax=2d310bcc ebx=00000000 ecx=00030000 edx=00000003 esi=000306f0 edi=00030000
eip=77928107 esp=0018fdf0 ebp=0018fed0 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!RtlpFreeHeap+0x166:
77928107 8a4602          mov     al,byte ptr [esi+2]        ds:002b:000306f2=30
0:000> dd 306f0 L2
000306f0  23300bc3 08000028

在继续执行之前,看一下这段代码:

1
2
3
4
5
6
7
77928105 3106            xor     dword ptr [esi],eax                       // 这里在进行“解码”
77928107 8a4602          mov     al,byte ptr [esi+2]                       // FreeBlock->Flags
7792810a 324601          xor     al,byte ptr [esi+1]                       // HIBYTE(FreeBlock->Size)
7792810d 3206            xor     al,byte ptr [esi]                         // LOBYTE(FreeBlock->Size)
7792810f 384603          cmp     byte ptr [esi+3],al                       // FreeBlock->SmallTagIndex
77928112 0f84b9b2ffff    je      ntdll!RtlpFreeHeap+0x17b (779233d1)       // 验证通过,进行跳转
77928118 e987280500      jmp     ntdll!RtlpFreeHeap+0x173 (7797a9a4)

在对前四个字节进行了解码操作之后,进行了一个验证:

1
if ( FreeBlock->SmallTagIndex != (LOBYTE(FreeBlock->Size) ^ (HIBYTE(FreeBlock->Size) ^ FreeBlock->Flags)) )

如果这四个字节解码之后的数据是正常的,验证能够通过。而这里,由于是在进行二次释放,所谓的解码操作其实是在进行编码,得到的结果自然不正常。因此程序会调用RtlpAnalyzeHeapFailure函数。

 

RtlpAnalyzeHeapFailure函数中,程序又对306f0前四个字节的数值进行了恢复,看一下它的恢复过程:

1
2
3
4
5
6
779beea8 8a4802          mov     cl,byte ptr [eax+2]                              // FreeBlock->Flags 
779beeab 324801          xor     cl,byte ptr [eax+1]                              // HIBYTE(FreeBlock->Size)
779beeae 3208            xor     cl,byte ptr [eax]                                // LOBYTE(FreeBlock->Size)
779beeb0 884803          mov     byte ptr [eax+3],cl                              // 重新设置FreeBlock->SmallTagIndex
779beeb3 8b4f50          mov     ecx,dword ptr [edi+50h]
779beeb6 3108            xor     dword ptr [eax],ecx  ds:002b:000306f0=f8300bc3   // 重新对前四个字节进行编码

从上面的代码可以看出,函数不是直接通过编码进行恢复,它首先要确保这四个字节是能够通过验证的,所以它重新设置了SmallTagIndex字段的值,然后再进行编码。这样就能保证之后堆块的前四个字节在解码之后能够通过验证。

 

修正之后的数值,以及编码后结果为:

1
2
3
4
5
6
7
8
9
10
0:000> dd 306f0 L2
000306f0  f8300bc3 08000028
0:000> p
eax=000306f0 ebx=d90000d9 ecx=2d310bcc edx=00000003 esi=00000000 edi=00030000
eip=779beeb8 esp=0018fda4 ebp=0018fddc iopl=0         nv up ei ng nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000286
ntdll!RtlpAnalyzeHeapFailure+0x209:
779beeb8 8b4de4          mov     ecx,dword ptr [ebp-1Ch] ss:002b:0018fdc0=00000000
0:000> dd 306f0 L2
000306f0  d501000f 08000028

这也是为什么在二次释放p2时,得到的self size仍旧是正确的原因。

5.2 小总结

根据上面的调试流程,p2前四个字节的变化过程:

1
2
3
4
5
p2第一次释放:23300bc3 -> 0e01000f   原因:解码操作,可以通过验证         
p2第二次释放:0e01000f -> 23300bc3   原因:解码操作,无法通过验证         
             23300bc3 -> f8300bc3   原因:验证失败,调用RtlpAnalyzeHeapFailure,进行修复
             f8300bc3 -> d501000f   原因:RtlpAnalyzeHeapFailure在修复后重新进行了编码
p2第三次释放:d501000f -> f8300bc3   原因:解码操作,可以通过验证

知道了p2的self size的变化过程,我们就知道为什么第二次释放的时候p2的堆块大小是正常的,而第三次释放的时候p2的堆块大小就变得很大了。

6. 总结

下面总结一下此次双重释放实验触发异常的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
p2第一次释放:
        一切正常,p1, p2, p3以及后方空闲堆块合并成一个大的空闲堆块P,此时P具有正确的堆块大小S
p2第二次释放:
        RtlpFreeHeap:解码操作后,p2具有错误的self size,且前四个字节无法通过验证,调用->
        RtlpAnalyzeHeapFailure:修复前四个字节以通过验证,并重新进行了编码,并没有再次进行解码,直接调用->
        RtlpCoalesceFreeBlocks:该函数的FreeSize参数直接从p2起始位置获取,经过修正后,p2的self size正常
                                                        空闲堆块P具有了新的堆块大小 S' = S + p2.self_size
                                                        此时S'已经不正确,但并未超过可访问范围
        特殊情况:如果根据S'计算得到的堆块位置处,解码前后进行空闲堆块判断的结果都是“空闲”的,那么程序会陷入无限循环中。
p3第三次释放:
      RtlpFreeHeap:解码操作后,p2具有错误的self size。但由于之前的修正操作,前四个字节可以通过验证,调用->
        RtlpCoalesceFreeBlocks:此时p2的self size是一个很大的错误的值
                            空闲堆块P具有了新的堆块大小 S'' = S' + p2.self_size
                                                        由于p2.self_size过大,S''超过了可访问范围,触发异常

7. 一个不成熟的小想法

在进行p2的二次释放时,虽然没有触发异常,但是程序已经访问了本不应该访问的,位于p3之后的空间。在《漏洞战争》中,有提到可以通过“占坑”的方式将已释放的内存填充自己的代码,从而控制程序的执行流程。

 

如果在代码编写的时候,创建完p3堆块之后,再创建一个堆块p4,且该堆块始终未被释放。那么在二次释放p2的时候,就必然会访问到p4所在的空间,这部分空间内容是可控的(在代码编写阶段,或者加入一个用户输入的功能)。

 

回顾上面的调试过程,在分析程序为何会在二次释放过程中陷入循环时,由于验证双向链表指针失败,有很多在可控空间(即这里的p4)上面的操作都无法执行。

 

所以如果精心构造p4的内容,是否可以实现漏洞利用呢?下周会开始看UAF漏洞及其利用方式,双重释放漏洞是UAF漏洞的一个子集,届时就可以知道这样的漏洞究竟是如何实现漏洞利用的了。

8. 参考资料

  1. Doubly freeing memory
  2. VC6 Release下,生成pdb文件
  3. Heap Corruption: A Case Study (强烈推荐阅读!)
  4. Vista 数据结构
  5. heap.c源码
  6. Low-fragmentation Heap
  7. Understanding the Low Fragmentation Heap
  8. <Advanced windows debugging>
  9. 《漏洞战争》

【公告】欢迎大家踊跃尝试高研班11月试题,挑战自己的极限!

收藏
点赞3
打赏
分享
最新回复 (10)
雪    币: 6000
活跃值: 活跃值 (7068)
能力值: ( LV12,RANK:600 )
在线值:
发帖
回帖
粉丝
erfze 活跃值 11 2021-8-11 17:25
2
0

参考资料中“Heap Corruption: A Case Study”一文链接:

雪    币: 16152
活跃值: 活跃值 (7721)
能力值: ( LV13,RANK:640 )
在线值:
发帖
回帖
粉丝
LarryS 活跃值 13 2021-8-11 17:54
3
0
帖子编辑中显示的内容是正确的,但是发表出来就不对了
https://web.archive.org/web/20160126091336/http://blogs.msdn.com/b/carlos/archive/2008/12/10/heap-corruption-a-case-study.aspx
雪    币: 16152
活跃值: 活跃值 (7721)
能力值: ( LV13,RANK:640 )
在线值:
发帖
回帖
粉丝
LarryS 活跃值 13 2021-8-11 17:57
4
0
erfze 参考资料中“Heap Corruption: A Case Study”一文链接:
因为原文链接已经失效了,所以使用了web archive中的快照链接
雪    币: 6000
活跃值: 活跃值 (7068)
能力值: ( LV12,RANK:600 )
在线值:
发帖
回帖
粉丝
erfze 活跃值 11 2021-8-12 08:18
5
0
LarryS 因为原文链接已经失效了,所以使用了web archive中的快照链接
好的
雪    币: 932
活跃值: 活跃值 (400)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
jstqyymwy 活跃值 2021-8-12 09:23
6
0
确实很详细,谢谢
雪    币: 14285
活跃值: 活跃值 (11739)
能力值: (RANK:720 )
在线值:
发帖
回帖
粉丝
有毒 活跃值 10 2021-8-12 09:38
7
0
期待下一篇
雪    币: 5606
活跃值: 活跃值 (959)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
TopC 活跃值 2021-8-12 11:47
8
0
感谢分享
雪    币: 1579
活跃值: 活跃值 (1136)
能力值: ( LV8,RANK:121 )
在线值:
发帖
回帖
粉丝
coneco 活跃值 2 2021-8-12 14:24
9
0
感谢分享
雪    币: 276
活跃值: 活跃值 (485)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
Met三二 活跃值 2021-8-15 19:30
10
0
速更,哈哈  期待ing
雪    币: 390
活跃值: 活跃值 (228)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
sud0 活跃值 2021-8-28 22:12
11
0
游客
登录 | 注册 方可回帖
返回