L1E6N0A2

监督自己不断学习

0%

CTF基础知识之堆溢出

堆溢出

本文章首先介绍堆的基本单位的结构(chunk),之后介绍了堆的分配和释放函数,通过一个实例分析了堆溢出漏洞的危害(DWORD SHOOT),最后介绍了另外两种利用堆溢出的漏洞(Double free和Unlink)

1.堆的结构

堆的基本单位是chunk,堆从低地址向高地址,需要用户自己分配。

1

堆、栈区别总结

1.堆栈空间分配

①栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

②堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。

2.堆栈缓存方式

①栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。

②堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。

3.堆栈数据结构区别

①堆(数据结构):堆可以被看成是一棵树,如:堆排序。

②栈(数据结构):一种先进后出的数据结构。

chunk的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
This struct declaration is misleading (but accurate and necessary).
It declares a "view" into memory allowing access to necessary
fields at known offsets from a given base. See explanation below.
*/
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

每个字段的具体的解释如下

  • prev_size, 如果该 chunk 的物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk 。

  • size,该 chunk 的大小,包括用户申请的大小和头部的大小。 该字段的低三个比特位对 chunk 的大小没有影响,是三个标志位,它们从高到低分别表示:(重点关注PREV_INUSE位)

    • NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
    • IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的。
    • PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
  • fd,bk, chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下

    • fd 指向下一个(非物理相邻)空闲的 chunk
    • bk 指向上一个(非物理相邻)空闲的 chunk
    • 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
  • fd_nextsize,bk_nextsize, 也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。

    • fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。

    • bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。

      一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。

一个已经分配的 chunk 的样子如下。我们称前两个字段称为 chunk header,pre_size与size都是4字节数据。后面的部分称为 user data。每次 malloc 申请得到的内存指针,其实指向 user data 的起始处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。

bin链的分类与构成

堆块释放之后会添加到相应的bins中进行管理

https://blog.csdn.net/qq_41453285/article/details/96865321

根据bin链成员的大小不同,分为以下几类:

fast bin是单链表,其他都是双向链表

  • Fast bin:拥有10个元素的数组,用户存放每个fast chunk的头指针。fastbin是最多包含十个fast chunk的单向链表。主要是为了提高小内存的分配效率,对于size_sz为8B的平台,小于128B的chunk分配,首先会在fastbin中查找是否有所需大小的chunk存在。

    2

  • Unsorted bin:1个。是smallbin和largebin的cache,所有空闲的chunk在回收时都要先放到unsortbin中,分配是如果unsortbin中没有合适的chunk,就会把unsortbin中所有的chunk加入到所属的bin中,在从bin中分配出合适的chunk。

  • Small bin:62个

  • Large bin:63个

3

2. 堆的分配与释放

  • 分配:malloc函数
  • 释放:free函数

17

上图的情形是,当前chunk的上一chunk被free()释放,容易发现,当前chunk的PREV_ISUSE标志位置0,表示前一chunk已经被释放。

被释放的chunk中,原先data的位置的低地址处被填入两个指针,分别是fd和bk,分别表示前一个free chunk和后一个free chunk的地址。这样所有通过free()释放的内存chunk会组成一个双向链表。也因此一个chunk最小长度为16字节:2个size和2个指针。

当一个chunk被释放时,还有一件事情要做,就是检查相邻chunk的是否处于释放状态,如果相邻chunk空闲的话,就会进行chunk合并操作。由于每个chunk中都存放了size信息,所以很容易就找到当前chunk前后chunk的状态。

18

3. 堆溢出漏洞利用

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
main()
{
HLOCAL h1, h2,h3,h4,h5,h6;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
_asm int 3//break the process

HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5);
_asm int 3

h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
return 0;
}

流程分析

(1)程序首先创建了一个大小为 0x1000 的堆区,并从其中连续申请了 6 个大小为 8 字节的堆块(加上块首实际上是 16 字节),这应
(2)释放奇数次申请的堆块是为了防止堆块合并的发生。
(3)三次释放结束后,freelist[2]所标识的空表中应该链入了 3 个空闲堆块,它们依次是 h1、 h3、h5。
(4)再次申请 8 字节的堆块,应该从 freelist[2]所标识的空表中分配,这意味着第一个堆块 h1 被从空表中“拆下”。(下面解释)
(5)如果我们手动修改 h1 块首中的指针,应该能够观察到 DWORD SHOOT 的发生。

分配

ollydbg调试,在第一个断点处中断,即分配完成。发现pmemoey的值为00390688,该值是堆分配函数的返回值,代表的是块头下用户可用区域的开始地址,因此分配的第一个堆块的地址为0x00390680。查看该地址的值:

4

下图显示了分配的6个块,例如0x390680-0x390690是一个块(两行是一个块)。注意到出了堆结构外还有16字节(ABAB… 0000…)这是调试堆的特征,增加16字节数据防止程序溢出,分析时可忽略。

5

释放

接下来程序继续运行,释放三块h1,h3,h5,释放之后堆中内存值如下图,可以看出h1,h3,h5的fd和bk指针均发生了改变,不再是0。

6

将fd(向前指针),bk(向后指针)的值列出,可以看出h1,h3,h5形成了一条双向链表,链在freelist[2],因为块大小是16比特,freelist[2]管理所有大小为16比特的空闲块。

NAME Flag 向前指针 向后指针
h1 1 占用态 0x003906c8 (h3) 0x00390198(Freelist[2])
h2 0 空闲态 NULL NULL
h3 1 占用态 0x00390708 (h5) 0x00390688(h1)
h4 0 空闲态 NULL NULL
h5 1 占用态 0x00390198(Freelist[2]) 0x003906c8 (h3)
h6 0 空闲态 NULL NULL

freelist

0x00390680-0x00390730是分配的6个堆块,下面0x00390040开始是未分配的空堆,fd,bk的值相同,指向的是freelist[0],根据上表可知0x00390198是freelist[2]的地址

7

freelist的值如下如图:除了 freelist[0]和 freelist[2]之外,所有的空表索引都为空(指向自身)

8

再分配

最后一次 8 字节的内存请求会把 freelist[2]的第一项(原来的 h1)分配出去,这意味着将第一个结点从双向链表中“卸下”。为什么是第一个而不是最后一个呢,因为unsortbin采用先进先出的原则,添加/取走bin链上的一个freechunk时,是通过bin头的bk指针所操作的。

10

由上图可以看出0x00390198处链接的第一个块是0x00390688,即h1。观察最后一次申请内存时h1前后向指针的变化 非常重要。未申请内存之前 h1处的前向指针指向h3, 后向指针指向指向freelist[2]。当h1被卸下后,h3的前向指针不变为h5-0x390708 ,后向指针变为freelist[2]-0x390198(也就是将原来h1的后向指针的值,写入(h1前向指针地址+0x04)的地址的相对应的值中)

????:教程写的都是去掉左后一个h5,然后相当于将原来h5的前向指针的值,写入h5后向指针地址的相对应的值中

截图来看确实是去掉了h1,h1的fd,bk的值都被置为0

9

如果在程序中修改上述的 h1的前向指针为一个合法地址的时候,h1的后向指针就会被写入这个合法的地址当中,那么应该能够观察到 DWORD SHOOT 现象。所以可以直接在调试器中手动将h1(0x00390688) 处的后向指针改为shellcode的地址,前向指针改为任意地址-0x04,可以是系统调用。当最后一个分配函数被调用后,这条指令执行后,shellcode 将会被写入目标地址。

上面的例子只是引发 DWORD SHOOT 的一种情况。堆块的分配、释放、合并操作都能引发 DWORD SHOOT(因为都涉及链表操作)。

攻击失败分析

DWORD SHOOT攻击需要利用一个正常的调用,以实现恶意代码的执行,一般是利用RtlEnterCriticalSection() 和RtLeaveCritcalSection()。每个进程的PEB 中都存放这一对线程同步函数指针,指向RtlEnterCriticalSection() 和RtLeaveCritcalSection(),并且在进程退出的时候会被ExitProcess()调用,如果能够通过DWORD SHOOT 修改这对指针中的任意一个,那么在程序退出的时候ExitProcess()将会被骗去执行我们的shellcode。由于PEB的位置始终不会变化,所以这对指针相对于peb的偏移之中不变,这使得开发通用的exploit成为可能。

本次采用peb中线程同步函数的入口地址 即 PEB(进程环境块) RtlEnterCriticalSection()。ExitProcess()函数在程序退出的时候会调用临界区函数,但是调用方法独特,是通过线程环境块中偏移 0x20处存放的函数指针来间接完成的。也就是偏移 0x7ffdf020处存放的指向RtlEnterCriticalSection()的指针。在0x7ffdf024处存放着RtlCriticalSection()的指针。但是Windows XP SP2 之后改写了实现方法,对这种攻击进行了防护。微软不再使用固定的 PEB 基址 0x7FFDF000,而是使用具有一定随机性的基址,从而影响了 DWORD SHOOT 对 PEB 中函数的攻击,无法准确利用该地址进行攻击,这种防御措施称为PEB Random。因此该种攻击方式仅可以在windows2000版本下进行复现,存在一定的局限性。

此外微软也改写了操作双向链表的代码,在卸载 free list 中的堆块时更加小心。SP2 在进行删除操作时,提前验证堆块的完整性,以防止 DWORD SHOOT,称为Safe Unlink。

4. 其他堆溢出漏洞利用

常见漏洞

  • 堆溢出:chunk1的数据溢出到chunk2,典型的攻击方式就是DWORD SHOOT(上面已经详细介绍)利用漏洞获取一个可以任意写的chunk,修改下一个chunk的关键信息如chunk头中的信息,如果下一个chunk是free状态,还可以修改BK和FD字段进行攻击。

  • double free:在释放chunk之后,没有对chunk指针清零。对同一地址调用两次free()。潜在导致对未知内存位置的修改。double free的原理其实和堆溢出的原理差不多,都是通过unlink这个双向链表删除的宏来利用的。只是double free需要由自己来伪造整个chunk并且欺骗操作系统。为了让系统进行unlink的操作,达到篡改指针的目的。但是一般的情况下,我们两次释放同一块内存会被操作系统给检测出来,怎么欺骗过操作系统才是最重要的。

    分配两个 chunk, chunk1 和 chunk2,然后free(chunk1);free(chunk2);free(chunk1),假设进入fastbin管理,结构如下图:

    11

    表头指向chunk1,chunk1指向chunk2,chunk2又指回chunk1。那么这时按照如下步骤:

    • 申请一个new chunk 1:

    12

    • 将new chunk 1的指针域修改为要修改的地址-0x10以上:

      13

    • 申请一个new chunk 2:

      14

    • 再申请一个new chunk 3(new chunk 3是和new chunk 1重合的)

      16

    这时可以发现,fastbin链表已经指向了我们想要修改的地址了,只要再申请一个堆块就会申请到想要修改的地址,然后只要编辑这个堆块便可完成任意地址写。

  • unlink:低版本libc2.23以下存在。使得攻击者拥有可以在任意可读写地址进行读写的能力。

    原理概述

    当堆块free时,会检查相邻的后面的堆块,或者前面的堆块,是否空闲,如果空闲,那么需要进行堆块合并操作。空闲的堆块一般以双向链表的形式组织(fast bin是单向链表,此攻击不适用),如果刚刚释放的堆块要与前面或者后面空闲的堆块进行合并操作,那么需要将前或后的堆块从双向链表中摘下来,合并成更大的堆块插入到unsort bin链表中。空闲堆块从(small bin)双向链表中摘下来的操作就是unlink。

    首先需要两个相邻的堆块,其中一个堆块空闲,一个堆块占用,释放占用的堆块,引发两个堆块合并。正常的空闲堆块链接在空闲链表中,我们无法控制其中的fd和bk指针,所以方法是伪造一个空闲的堆块。

    伪造空闲堆块:libc判断相邻堆块空闲的方法是通过本堆块的size字段,size字段最后3位是标志位。其中最后一位如果为0,说明相邻的堆块是free的,为1说明正在使用。pre_size字段指明前一个堆块的起始位置。这两个字段可以判断相邻的后面堆块是否分配和堆块的位置。

    ps. 如果系统还会判断这个相邻的堆块是否在某个未分配的链表中,那么unlink攻击便实现不了。因为如果相邻堆块在某个空闲链表中,那么就无法修改其中的bk和fd指针。所以我们需要利用一个分配的堆块,来伪造出一个空闲堆块。

    具体操作

    分配两个堆块,但不要过小,大于fastbin的限制。

    示意图:chunkA->chunkB

    ChunkA = prev_size | chunksize&flag | content

    ChunkB = prev_size | chunkSize&flag | content

    如果此时chunkA存在堆溢出漏洞,那么我们便可将精心构造的数据写入chunkB. 或者直接修改后面分配的堆块的头部,即pre_size和size字段,pre_size需要设置成前面分配的堆块的用户区大小,并且设置size字段最后一位标志位为0,表示chunkA伪造堆块是空闲的。

    那么在我们free(chunkB)时,堆管理器就会将chunkB和我们的fakeChunk合并。这是unlink会摘除chunkA,因此chunkA的fd,bk需要被伪造。

    相应的也可以通过堆溢出,构造chunkB为伪造的空闲堆块。

    假设被覆盖后的chunk header相关数据如下:

    1
    2
    3
    4
    1) prev_size = 一个偶数,这样其PREV_INUSE 位就是0 了,即表示前一个chunk为free。
    2) size = -4
    3) fd = target addr – 24;(后文统一简称为“free addr – 24”)
    4) bk = shellcode的地址

    这时free(chunkA),记nextchunk为chunkB。从上面代码可以知道,它是通过将nextchunk + nextsize计算得到指向下下一个chunk的指针,然后判断下下个chunk的size的PREV_INUSE标记位。在本例中,此时nextsize被我们设置为了-4,这样glibc malloc就会将next chunk的prev_size字段看做是next-next chunk的size字段,而我们已经将next chunk的prev_size字段设置为了一个偶数,因此此时通过inuse_bit_at_offset宏获取到的nextinuse为0,即next chunk为free!既然next chunk为free,那么就需要进行和chunkA合并,所以就会调用unlink(nextchunk, bck, fwd);函数,将chunkB取下。

    绕过glibc的安全防护

    unlink之前需要进行一些简单的检查,设伪空闲堆块的堆块头指针是p,那么需要检查:

    1
    p->bk->fd==p && p->fd->bk==p

    这个检查是可以欺骗的:fd的偏移是3个机器位数,bk的偏移是4个机器位数。即在64位机器上,fd是8*3=24字节,bk是8*4=32字节;进行以下构造:

    1
    fd = ptr - 3*size(int); bk = ptr - 2*size(int) 
    1
    2
    3
    4
    5
    6
    7
    8
    因为,FD = p-->fd = ptr - 24; BK =  p-->bk = ptr - 16,
    所以,FD-->bk = FD + 3*8 = ptr - 24 + 24 = p,
    BK-->fd = BK + 16 = ptr - 16 + 16 = p.

    FD->bk = BK; ==> p = ptr-16
    BK->fd = FD; ==> p = ptr-24
    ==> p=ptr - 3*size(int)
    即P 的指针指向了比自己低 12 的地址处

    恶意代码的构造

    构造恶意的fd与bk实现攻击,根据上面chunkB的构造,关注fd和bk

    1
    2
    3
    4
    FD=P->fd = target addr -24
    BK=P->bk = shellcode起始地址
    FD->bk = BK,即 *(target addr-24 + 24)=BK=shellcode起始地址
    BK->fd = FD,即 *(shellcode起始地址 +16) = FD = target addr-24

    15

    (图中的12是32位系统下,4*3=12,与上文保持统一的话,12就是24)

    这样,通过 unlink 直接实现任意地址读写的目的,但是我们还是需要确保 shellcode起始地址 +16地址具有可写的权限。

    比如说我们将 target addr 设置为某个 got 表项,那么当程序调用对应的 libc 函数时,就会直接执行我们设置的值shellcode处的代码。需要注意的是,shellcode+16 处的值被破坏了,需要想办法绕过.

参考资料:

https://www.jianshu.com/p/484926468136

https://www.cnblogs.com/gm-201705/p/9901548.html

https://zhuanlan.zhihu.com/p/26981039

https://blog.csdn.net/weixin_50287973/article/details/108438564

https://blog.csdn.net/zh_explorer/article/details/80307005

https://blog.csdn.net/qq_25201379/article/details/81545128

https://www.dazhuanlan.com/2019/12/24/5e01fc2754796/

https://blog.csdn.net/qq_41453285/article/details/97613588

https://blog.csdn.net/Breeze_CAT/article/details/103788698