Skip to content

lab-03: 堆上漏洞及其利用

:material-circle-edit-outline: 约 4177 个字 :fontawesome-solid-code: 41 行代码 :material-clock-time-two-outline: 预计阅读时间 14 分钟

Gamma

与栈上缓冲区溢出以及格式化字符串漏洞不同,堆漏洞无论是在检测、利用、还是防御上都更显复杂。在本次作业上,我们将以简单的堆场景“管中窥豹“地学习堆相关的知识,包括堆块的结构,堆上常见的漏洞成因以及复现。为了顺利完成本次作业,你需要掌握:

  1. 堆块的基本结构,其中的申请与释放
  2. 堆上常见的漏洞类型
  3. 堆上漏洞构建的原语 (primitive) 以及漏洞利用
  4. (可选) 堆上漏洞的防御

堆管理器基础 (40 points)

如课堂上介绍的,堆 (heap) 相比于栈而言,提供了动态内存管理的能力,其中动态表现在:

  1. 空间大小的动态。栈的大小在程序被编译时就已经划分,无法处理可变长度的数据对象。
  2. 生命周期的动态。栈上空间用于管理临时变量,当脱离对应的作用域时,栈上的空间即被回收,而堆则允许开发较为自由的申请和释放内存空间。

对于特定场景,只需要 (1) 的动态而不需要 (2) 中管理周期,堆的实现可以做的非常简单,如 Linux 内核启动过程中的简单,就只需要支持内存申请操作,而无需去考虑释放和管理。在多数一般场景下,往往存在大量的堆块申请和释放,如何使得这些堆操作高效便是堆管理器 (heap manager/allocator) 负责的任务。

不同的软件系统都可能会依赖不同的堆管理器,进而表现出不同的堆操作行为。例如

  • dlmalloc (Doug Lea's Malloc): 最早的 C 程序堆管理器之一,ptmalloc 的前身。
  • ptmalloc (Pthread Malloc): GNU Libc 的默认堆管理器。
  • mallocng (Malloc Next Generation) Musl Libc 自 1.2.1 后的默认堆管理器。
  • jemalloc: FreeBSD 下最早使用,也被 Android 5-7 版本下使用(自 Android 8 开始,scudo 成为默认堆管理器)
  • ......

堆的维护和管理,效率以及安全性的兼顾都相当复杂,使得任何一个堆管理器本身就成为了「复杂的系统」,再加上各类堆管理器玲琅满目,多种多样,堆的知识并非几堂课几次作业就能深入的。本章作业将主要以多数 Linux 发行版使用的 ptmalloc 作为研究对象,尽可能通用的阐述堆的内容。如果同学们对其他类型的堆管理器感兴趣,也可以找到相关代码进行学习。

本次作业主要以 libc-2.31 版本的代码作为分析。源代码可以在该链接下载

示例

请查看仓库目录 lab-03/basic,阅读 Makefile 以及 example.c,该程序展现了一个典型的「目录」利用(相比于栈题而言)。此外,程序还应用了 glibc 中的 __malloc_hook__free_hook 来记录堆操作。特别的,程序提供了函数 heap_debug 来把堆内容的数据转储。

进入到该目录,通过 make build,可以完成对于这个程序的编译(注意,由于高版本的 glibc 已经移除了堆上的 hooking,所以 Makefile 中通过旧版本的 gcc docker 进行编译)。并且还使用了 patchelf 将目标程序默认的装载器路径与 glibc 路径都修改到了提供到的 2.31 版本上。

构建完后,执行 ldd 命令将看到程序使用了同级目录下的装载器和库代码

$ ldd example
    linux-vdso.so.1 (0x00007ffdffb8a000)
    ./libc-2.31.so (0x000078bb465e7000)
    ./ld-2.31.so => /lib64/ld-linux-x86-64.so.2 (0x000078bb467db000)

如果使用宿主机本身的编译链 + 不做 patchelf,因为 glibc 版本的不同,后续的堆分析可能也会跟示例的不一致。

test.py,其通过 pwntools 工具与编译好的目标程序交互。脚本中简单定义了与目标堆目录程序操作一致的函数,如 handle_add 就对应了目标 C 程序中的 user_add,编写此类交互程序中请特别注意 IO 上的长度,需不需要换行符等,并且通过合适使用 recvuntil 或者 sendafter 来保证发送的数据被预期的逻辑消耗。

运行 test.py,脚本发送的数据将调用数次 user_add 以及 user_del,并激活 heap_debug 打印了堆上数据到 dump*.bin 文件中。

$ python3 test.py
[DEBUG] Sent 0x2 bytes:
    b'5\n'
[*] Process '.../example' stopped with exit code 0 (pid 28518)

$ ls
dump_772000_0.bin  example  example.c  ld-2.31.so  libc-2.31.so  Makefile  test.py

这里将示例分析这段 dump 内容的头部,可以用各自熟悉的二进制阅读工具

  # NOTE: 由于程序是 docker 内构建的,所以转储的内容可能无读权限
$ sudo chmod 444 dump_772000_0.bin
$ hexdump -C dump_772000_0.bin | less
00000000  00 00 00 00 00 00 00 00  91 02 00 00 00 00 00 00  |................|
00000010  00 00 00 00 00 00 07 00  00 00 07 00 00 00 00 00  |................|
00000020  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
000000a0  00 00 00 00 00 00 00 00  10 23 77 00 00 00 00 00  |.........#w.....|
000000b0  00 00 00 00 00 00 00 00  a0 22 77 00 00 00 00 00  |........."w.....|
000000c0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000290  00 00 00 00 00 00 00 00  71 00 00 00 00 00 00 00  |........q.......|
000002a0  60 23 77 00 00 00 00 00  10 20 77 00 00 00 00 00  |`#w...... w.....|
000002b0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
000002c0  31 32 33 34 35 36 00 00  00 00 00 00 00 00 00 00  |123456..........|
000002d0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
000002e0  10 23 77 00 00 00 00 00  74 72 79 20 65 76 65 72  |.#w.....try ever|
000002f0  79 74 68 69 6e 67 00 00  00 00 00 00 00 00 00 00  |ything..........|
00000300  00 00 00 00 00 00 00 00  51 00 00 00 00 00 00 00  |........Q.......|
00000310  d0 23 77 00 00 00 00 00  10 20 77 00 00 00 00 00  |.#w...... w.....|
00000320  6d 20 62 6f 62 00 00 00  00 00 00 00 00 00 00 00  |m bob...........|
00000330  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

分析之前,复习一下课堂上已经学习的内容:

  • ptmalloc2 下的堆块,都通过结构 malloc_chunk 进行表述,后文简称 chunk,又或称其为元数据
/*
  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      mchunk_prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      mchunk_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;
};

// ...

如源代码中的注释所述,这个结构其实容易让人误解,建议同学阅读这个结构定义下 Colin Plumb 陈述的注释,这里只做简单的叙述。

    • chunk 的结构基于 boundary tag 设计,及包含了本 chunk 的大小以及上一个 chunk 大小,这样的设计允许开发者可以高效的向上或向下遍历堆中目前存在的 chunks。
    • 一个申请出来的 chunk 如下结构( fd, bk, fd_nextsize, bk_nextsize 都是给释放后的 chunk 维护使用(这种元数据的空间复用可以减少存储代价,但也带来了安全风险)

    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)                      .
            .                                                               |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             (size of chunk, but used for application data)    |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             Size of next chunk, in bytes                |A|0|1|
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- - 注意,用户从 malloc 拿到的指针,是上图中的 mem 而不是 chunkmchunk_prev_sizemchunk_size 竭力做到了对用户透明。 - - 在 64 位下,malloc 申请到的实际内存大小为 chunk_size = max(( request_size + 8 ) # 16, 32)即请求的大小加上 8 然后再向上对齐到 16 的倍数 (但一定要大于等于 32 字节的元数据)。

OK,有了这些知识,我们可以将之前 hexdump 的内容先划分出三个堆块,可以简单的通过下面的 excel 图呈现

exceldraw

根据遗留的数据,我们可以判断出,位于 0x290 偏移出的堆块是已经被释放掉的属于 Bobuser_info (通过 123456password 成员反推)。与其相邻的则是堆上申请的属于 Bobintroduction 堆块(统括 m bob 的内容反推)。通过红色箭头,我们可以将 metadata 中的指针指向关系表征出来,而绿色箭头则表示了用户程序逻辑中的堆上指针。

但将图上数据,套用到前文中提到的 malloc_chunk ,会发现有些地方不符预期:

  1. 0x300 偏移处的 prev_size 本应该记录上个被释放堆块的大小的,但这里却仍然为 0
  2. 即使 fd 指针看起来没啥毛病,但原本 bk 指针的位置,却不像是构建了双向链表:可以看到偏移 0x2a80x318 处的 bk 都指向了 heap 顶部一个 0x290 大小的,并不是由我们程序申请的一个 chunk

这些差异来自于 ptmalloc 堆上名为 tcache 的优化,该优化自 glib 2.26 引入,默认启用。请回顾课上内容,或者阅读文档末的拓展链接了解 tcache 的细节,这里仅简单的展示:

  • 堆顶上 0x290 的堆块是堆管理器为主线程分配的,类型为 tcache_perthread_struct
# define TCACHE_MAX_BINS                64

typedef struct tcache_entry
{
  struct tcache_entry *next;
  /* This field exists to detect double frees.  */
  struct tcache_perthread_struct *key;
} tcache_entry;

typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
  • tcache 为从 24 字节用户数据到 1032 字节的 64 类不同确定大小的堆块提供了一个线程粒度的「单向链表」的无锁快速缓存,每个缓存最大容纳 7 个 free 的结构。
    • 图中相当于有 tcache_perthread_struct.counts[3] = 7 & tcache_perthread_struct.entries[3] = 0x772310; tcache_perthread_struct.counts[5] = 7 & tcache_perthread_struct.enries[5] = 0x7720a0;
  • 对于每一个释放后存储在 tcache 链表的对象,不用 fdbk ,而是用 tcache_entry 中的 nextkey ,其中 key 指向 tcache 管理结构 tcache_perthread_struct 的堆块,即图中最上部的 0x772110

如此一来,我们就理解了 dump*.bin 中所有释放对象的内容元数据所代表的含义。

实践

  1. 阅读 example.c 代码,在报告中简述这个目录程序的逻辑;通过 make build 完成对程序的编译和 patch,提供 ldd 执行后的截图;(10 points)
  2. 阅读和运行 test.py 代码,分析打印的 dump*.bin 的内容。要求类似示例图一样将所有申请和释放的对象标记出来,特别标注出 tcache 单向链表管理的对象);(20 points)
  3. test.py 中注释的两行 handle_del 取消注释,再次运行,新产生的 dump*.bin 和之前的相比有何变化?多释放的属于 WilliamJoseph 的堆块由什么结构管理,还位于 tcache 链表上么?请复习课堂上的内容,在报告中进行回答;(10 points)

堆上常见漏洞 (30 points)

简单分类,我们可以将堆上常见的漏洞划分为如下三种类型

1. 未初始化读

lab-03/uninit 目录下的 uninit ,对比之前的 example.c 可以找到其中的未初始化读问题。

2. 堆溢出(读写)

lab-03/overflow 目录下的 overflow.c ,对比之前的 example.c 可以找到其中的堆溢出问题。

特别注意,堆溢出根据能够溢出到的区域,可以分为

  • 堆块内溢出,这类溢出能力较弱,但往往难以检测,其利用需要对相应结构体内不同成员有关的代码逻辑进行分析。
  • 跨堆块溢出,借用该溢出,我们往往可以获取/破坏堆管理器有关的数据。

在此基础上,有两类特殊的溢出,称为 off-by-oneoff-by-null ,表述仅仅能进行一字节溢出的场景(实际上,因为编程中“从0开始数”的特性,这种一单元的溢出是非常常见的。

3. 释放后使用 (use-after-free)

lab-03/uaf 目录下的 uaf.c,对比之前的 example.c 可以找到其中的释放后使用问题。

特别注意,释放后使用往往也可以根据“使用”的不同进行如下划分

  • 释放后使用读(use-after-free read),类似未初始化读,这类读能力可以进行信息泄露,如果目标对象是已释放的堆块,则可以读取到如果 fd 指针所代表的堆地址等信息,用于构建更强的攻击原语。
  • 释放后使用写(use-after-free write),如果目标对象是已释放的堆块,则可以破坏堆管理器为 free 堆块所布置的元数据,进而构建更强的攻击原语。
  • 两次释放(double free),在读写之上,如果攻击者能再次释放已经被释放的堆块,则可以完全破坏堆管理器对于 free 堆块的管理。

这里提到的攻击原语(primitives),将在后文进行简单的介绍

实践

  1. 找到 uninit/uninit 中的未初始化读漏洞,在报告中给出分析;编写攻击脚本 ,完成对于堆上 flag 内容的窃取; (10 points)
    • 远程环境位于 IP: 8.154.20.109, PORT: 10400
  2. 找到 overflow/overflow.c 中的堆溢出漏洞,编写攻击脚本触发该漏洞;(10 points)
  3. 找到 uaf/uaf 中的释放后使用漏洞,编写攻击脚本触发该漏洞;(10 points)

堆上漏洞的利用 (30 points)

堆管理器的复杂性在前文已经有所讨论,那么,不同的堆管理器自然也会有不同的利用技巧,更精确的,通用堆管理器下,针对不同的管理结构,如 ptmalloc2 下的 tcache 和各类 bin 上的技巧也纷繁复杂。本次作业中,将简单介绍两类在多数堆管理器上都可以构建的利用原语:

劫持 freelist 实现恶意地址分配

根据前文介绍,我们知道多数堆管理器都通过链表(单链表/循环链表)来维护已经释放的堆块,这类链表统称为 freelist,当 freelist 中存在合适大小的堆块时,管理器就无需去堆顶上再申请更多内存,返回链表上已有的堆块即可。那么,假设攻击者可以通过堆上漏洞(跨堆块溢出或者释放后使用写)去破坏 freelist 链表上的指针,如此这般,就可以将下个要申请的堆块布置到攻击者计划的地址上。该攻击流程可以通过如下图展示:

freelist

图中假设攻击者能将链表上最后一个成员的指针从 NULL 修改到目标 exit$GOT ,那么,接着连续进行分配,就可以获得一个指向目标 GOT 的堆块。恶意地址分配原语往往用于实现任意地址的读写。

类型混淆

针对 freelist 破坏的保护措施现在已经层出不穷,常见的方式包含

  • 将 FD 指针放置到不那么容易被破坏的位置。如内核堆中,freelist 指针就被放在对象的“中间”而不是开头;
  • 将 FD 指针进行“加密”保护,如在 glibc 2.32 版本后,堆上无论是 freelist 的指针,还是一些其他的指向堆块的指针(如 key 指针),都会有一层异或加密。这使得攻击者想伪造一个 freelist 指针的难度大大提升;

因此,目前堆上攻击最主流的方式是通过 UAF 构建类型混淆原语。该类攻击方式往往不用去管堆管理器实现的细节,而只需要考虑用于做混淆的堆块上具备的能力。

比如有堆结构 A 可以提供对自身堆块数据的读写能力;而堆结构 B 中会将自身某个成员指针解引用做数据读,那么我们就可以通过如下图所示的混淆,构建一个任意地址读的能力:

confusion

实践

  1. 利用 overflow/overflow.c 中的堆溢出漏洞,通过劫持 freelist 的方式(10 points),写 exit GOT 表数据将执行流劫持到 backdoor 函数,从而完成弹 shell,执行 flag.exe 取得 flag(5 points)
    • 远程环境位于 IP: 8.154.20.109, PORT: 10401
  2. 利用 uaf/uaf 中的释放后使用漏洞,通过类型混淆的利用方式构建任意地址读写原语(10 points),进而通过内存破坏实现弹 shell,执行 flag.exe 取得 flag;(5 points)
    • 远程环境位于 IP: 8.154.20.109, PORT: 10402
    • 注:相比于上个目标,这个程序开启了 RELRO 保护,故无法破坏 GOT 表内容;
    • 提示:回到最开始的 example.c,位于 libc 内存中有其他的攻击目标可以作为写的对象来实现控制流劫持。

Bonus - 堆上保护机制 (extra 20 points)

更高版本的 glibc,对于 freelist 进行了指针上的保护,见目录 protect 下的代码和目标程序。在这个情况下,是否还能通过类似的方式完成漏洞利用呢?

  • 远程环境位于 IP: 8.154.20.109, PORT: 10403

拓展阅读

堆有关的攻击丰富多样,针对 glibc 的不同版本和不同 bin 前人已经总结出大量的 House Of XXX 原语。本次作业仅简单的展现了其中几个,感兴趣的同学可以通过更过竞赛题目和 writeups 进行了解