堆利用漏洞

本文阅读指南

本文主要使用尽可能新的版本的源码进行分析

对于内存示意图,符合以下标准:

  • 红色 内容被删除的内存区域
  • 黄色 内容被修改的内存区域
  • 蓝色 内容需要被关注的内存区域
  • 绿色 内容被写入的内存区域

此外 紫色 标注的代码是起关键作用的代码

基础知识

关键结构名称

arena

arena本意竞技场,是glibc堆管理器ptmalloc2用于管理堆内存的核心数据结构。它本质上是堆管理器向操作系统申请来的一大块连续内存(内存池),程序中的动态内存分配和释放请求最终都会在这块区域中进行划分和管理。

特性 main arena (主分配区) thread arena (线程分配区)
所属线程 主线程 子线程
创建方式 通过 brk()系统调用 通过 mmap()系统调用
malloc_state存储位置 存储在 glibc 的全局变量中 存储在该 arena 本身的内存区域中
数量限制 (举例) 唯一一个 有限 (例如 64 位系统上限一般为 CPU 核数的 8 倍)
堆内存扩展 通过 brk()扩展 通过 mmap()创建新的 sub-heap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct malloc_state
{
__libc_lock_define(, mutex);
int flags;
int have_fastchunks;
mfastbinptr fastbinsY[NFASTBINS];
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[NBINS * 2 - 2];
unsigned int binmap[BINMAPSIZE];
struct malloc_state *next;
struct malloc_state *next_free;
INTERNAL_SIZE_T attached_threads;
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

bins

管理结构 管理的内存大小范围 (字节) 管理算法 链表
tcache 0x20 - 0x410 LIFO 单链表
fastbin 0x20 - 0x80 LIFO 单链表
small bin 0x20 - 0x3f0 FIFO 双链表
large bin ≥ 0x400 (通常 ≥ 0x410) - 双链表(四链表)
unsorted bin - FIFO 双链表
  • largebin
    • 横向链表:通过fd和bk指针连接。

    • 纵向链表:通过fd_nextsize和bk_nextsize连接。

    • 插入规则:

      未命名绘图-第 2 页.drawio.png

利用方法

通用基础攻击方式

decrypt safe linking(glibc ≥ 2.32)

自glibc 2.32开始,glibc会通过safe_linking保护机制对堆块中的指针进行加密。但是仔细观察可以发现,此加密是可逆的。因为key的最高的12位(一个半字节)全是0,所以plaintext的最高12位就是cipher最高12位。这样已知的key就延长为最高的24位了,同理,我们一共只需要5轮解密就可以解出七个半个字节。至于最后的半个字节,由于内存对齐,一定为0,加密和解密脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
def encrypt(plaintext):
key = plaintext >> 12
cipher = plaintext ^ key
return cipher

def decrypt(cipher):
key = 0
plaintext = 0
for i in range(1, 6):
bits = 64 - 12 * i
plaintext = ((cipher ^ key) >> bits) << bits
key = plaintext >> 12
return plaintext

unsafe unlink(glibc 2.23~2.39+)

触发unsafe_unlink时需要提前做好如下布局(主要是绿色部分prev_size和prev_inuse要对应)

unsafe_unlink-Page-1.drawio.png

此时如果将chunk1 free,由于chunk0为free状态,会触发堆块合并,因此在合并前需要将chunk0脱链。当执行完FD→bk=BK, BK→fd=FD 的时候,原本指向chuck0的指针就会指向其向前0x18字节的位置。此时再向新的chunk0写入东西就可能会覆盖到指向chunk0的指针,那么我们就可以通过覆盖,把指针改到任意位置,实现任意地址写。

注意chunk0_ptr初始时的指向是chunk的元数据而不是用户数据。

unsafe_unlink-Page-2.drawio.png

简单地说,原理就是unsorted bin触发向前合并时,前一个chunk的fd和bk被伪造。

出现什么报错就在源码里找问题。


tcache attack

  • 安全发展历程

    glibc 2.26 引入tcache结构,几乎没有任何保护,可以轻松地进行double free。

    glibc 2.28 引入key字段以及count检查防止double free。

    glibc 2.34 key值随机化,提升绕过key值的难度。

    tcache在malloc的时候几乎不会检查size字段

  • Key字段检测double free的逻辑

    1. 标记Chunk:当一个chunk被释放并放入tcache时,它的key字段会被设置为指向线程的tcache_perthread_struct主结构体的地址。这相当于给该chunk打上了一个“我已缓存”的标记 。

    2. 检查标记:当程序再次尝试释放同一个chunk时,free函数会检查该chunk的key字段。如果key的值正好是tcache_perthread_struct的地址,则意味着这个chunk可能已经被存放在tcache中,从而触发进一步的检查 。

    3. 遍历确认:一旦key值匹配,glibc会遍历对应大小的整个tcache bin链表。如果在链表中找到了与当前正在被释放的chunk完全相同的地址,就可以确认发生了double free,程序会抛出”double free detected in tcache 2”的错误并终止 。

      这种设计巧妙地利用了一个概率极低的事件:一个未被放入tcache的chunk,其用户数据区(即bk位置)恰好存放着tcache_perthread_struct的地址。因此,只有当key值匹配时,才会进行开销较大的链表遍历检查,只有破坏了key值才能绕过检查 。

  • tcache house of spirit

    在目标地址的前方(通常是 -0x10-0x8 字节处)写入一个合法的 size 值。该 size 必须符合 tcache 的范围(通常在 0x20 到 0x410 字节之间)。与 Fastbin 不同,tcache 在释放时对 next chunk 的检查非常少,通常只需要保证 size 字段正确即可。

    将此chunk用free释放掉,则其会被放入tcache中,此时就可以正常将其申请出来以对其进行修改。

  • tcache metadata poisoning

    1. 利用漏洞,将数据直接覆盖到堆首的 tcache_perthread_struct 区域。

      1
      2
      3
      4
      5
      6
      7
      // /malloc/malloc.c

      typedef struct tcache_perthread_struct
      {
      uint16_t counts[TCACHE_MAX_BINS];
      tcache_entry *entries[TCACHE_MAX_BINS];
      } tcache_perthread_struct;
    2. 篡改元数据

      • 改指针: 将特定大小对应的链表头指针 entries[idx] 覆盖为目标攻击地址
      • 改计数: 将对应大小的计数器 counts[idx] 覆盖为 > 0 的值
    3. 执行 malloc(size),系统会直接将你设定的“目标攻击地址”当作合法 chunk 分配出来。

    4. 向新分配的 chunk 中写入 Payload(如 system 地址或 one_gadget),随后触发相应的函数指针执行代码。

  • tcache poisoning

    1. 先后释放两个相同大小的堆块Chunk A、Chunk B到Tcache中
    2. 利用堆溢出、UAF等漏洞将Chunk B的next指针修改为指向目标地址(要满足16字节对齐)
    3. 此时再连续申请两个该大小的堆块,即可控制目标地址

    tcache poisoning.drawio.png

  • tcache stashing unlink attack

    tcache stashing unlink attack 是一种针对 GLIBC 堆管理机制的高级利用技巧。它利用了当 tcache 未满时,从 smallbin 放入 tcache 的“搬运”机制。这种攻击的核心目标通常是 任意地址写入,或者更具体地,在任意地址分配出一个 chunk

    1. 首先,释放 7 个相同大小的 chunk 进入 tcache。这是为了后续清空 tcache 做准备。
    2. 释放两个(或更多)相同大小的 chunk(不同于第一步中的 chunk,但属于同一 size)到 unsorted bin,然后通过申请一个更大的内存触发整理过程,使它们进入 smallbin。此时 smallbin 结构:Head <-> Chunk A <-> Chunk B
    3. 利用漏洞篡改 smallbin 中最后一个 chunkbk 指针:将 Chunk B->bk 指向 目标伪造地址(Target Address - 0x10)
    4. 申请 2 个或更多该大小的 chunk,清空部分 tcache 空间,确保 tcache 至少有 2 个以上的空位。
    5. 再次申请一个该大小的 chunk。由于此时 tcache 为空,系统会去 smallbin 中寻找:系统从 smallbin 摘除 Chunk B 返回给用户。系统发现 tcache 还没满,且 smallbin 里还有剩余(Chunk A 以及被我们伪造的地址),它会尝试将 smallbin 中剩余的 chunk 全部“搬运”到 tcache
    6. 在搬运过程中,由于 Chunk B->bk 被篡改,系统会顺着 bk 指针找到我们的 目标伪造地址,并认为这是一个合法的 chunk,将其放入 tcache

fastbin attack

  • fastbin dup

    1. 首先申请两个相同大小的 Fastbin 范围内的堆块(例如 0x20 字节):
      ptr1 = malloc(0x20)
      ptr2 = malloc(0x20)
    2. Glibc 会检查当前释放的堆块是否与 Fastbin 链表顶部的堆块相同。为了绕过这个检查,我们需要在两次释放同一个堆块之间,插入另一个堆块的释放:
      free(ptr1)
      free(ptr2)
      free(ptr1) <– **关键点**:此时 Fastbin 链表结构变为 ptr1 -> ptr2 -> ptr1
    3. 现在,如果我们申请一个堆块,系统会返回 ptr1victim = malloc(0x20)
      victim 的数据区写入你想修改的目标地址。由于 ptr1 仍在链表中,这实际上修改了 ptr1fd 指针。此时链表变为:ptr2 -> ptr1 -> Target_Address
    4. 接下来连续进行三次 malloc
      malloc(0x20):返回 ptr2
      malloc(0x20):返回 ptr1
      malloc(0x20)返回 Target_Address
      此时,你已经获得了一个指向目标内存区域的指针,可以对其进行任意读写。

    在利用 Target_Address 时,glibc 会检查该位置的 Size 字段 是否符合当前 Fastbin 的大小范围。你需要在目标地址附近寻找一个“伪造的 size”。

  • fastbin dup consolidate

    1. 首先申请一个fastbin大小的chunk A,释放后A进入fastbin
    2. 然后申请一个largebin大小的chunk B
      chunk A被移入unsorted bin
    3. 再次释放A不会发生报错,此时A同时位于fastbinunsorted bin
  • fastbin reverse into tcache

    1. 连续释放7个相同大小的chunk填满tcache。再释放2个和上述大小相同的chunk A和chunk B后,会进入fastbin
    2. 利用漏洞修改 fastbin 中 Chunk A 的 fd 指针,使其指向你想要控制的目标地址 Target。此时 fastbin 链表被污染为:B -> A -> Target
    3. 连续申请7次内存,将刚才放入tcache中的chunk取出
    4. 再次申请一次该大小的内存。系统发现 tcache 为空但 fastbin 有货,于是把 B 分配给用户,并将剩下的 ATarget 倒序塞进 tcache 中。此时 tcache 链表变为:Target -> A

unsorted bin attack(glibc ≤ 2.27)

  • 安全发展历程

    glibc 2.28 增加了对 unsorted bin 链表完整性的检查,以防止链表指针被篡改。

    glibc 2.29 引入了对size的多项严格检查包括: 对下一个相邻 chunk 的 size 、对下一个相邻 chunk 的 prev_size 字段进行检查、对下一个 chunk 的 prev_inuse位进行检查。

    glibc 2.32 在 large bin 的攻击路径中增加了检查,如果fwd->bk_nextsize->fd_nextsize不等于fwd,则会报错,这限制了一些高版本的利用手法。

    实际上在glibc2.28及以后的版本中unsorted bin attack已经变得非常困难。glibc 2.27的unsorted bin的源码如下:

    • 源码

      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
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av))
      {
      ***bck = victim->bk;***
      if (__builtin_expect(chunksize_nomask(victim) <= 2 * SIZE_SZ, 0) || __builtin_expect(chunksize_nomask(victim) > av->system_mem, 0))
      malloc_printerr("malloc(): memory corruption");
      size = chunksize(victim);
      if (in_smallbin_range(nb) && bck == unsorted_chunks(av) && victim == av->last_remainder && (unsigned long)(size) > (unsigned long)(nb + MINSIZE))
      {
      remainder_size = size - nb;
      remainder = chunk_at_offset(victim, nb);
      unsorted_chunks(av)->bk = unsorted_chunks(av)->fd = remainder;
      av->last_remainder = remainder;
      remainder->bk = remainder->fd = unsorted_chunks(av);
      if (!in_smallbin_range(remainder_size))
      {
      remainder->fd_nextsize = NULL;
      remainder->bk_nextsize = NULL;
      }
      set_head(victim, nb | PREV_INUSE | (av != &main_arena ? NON_MAIN_ARENA : 0));
      set_head(remainder, remainder_size | PREV_INUSE);
      set_foot(remainder, remainder_size);
      check_malloced_chunk(av, victim, nb);
      void *p = chunk2mem(victim);
      alloc_perturb(p, bytes);
      return p;
      }
      ***unsorted_chunks(av)->bk = bck;
      bck->fd = unsorted_chunks(av);***
      if (size == nb)
      {
      set_inuse_bit_at_offset(victim, size);
      if (av != &main_arena)
      set_non_main_arena(victim);
      if (tcache_nb && tcache->counts[tc_idx] < mp_.tcache_count)
      {
      tcache_put(victim, tc_idx);
      return_cached = 1;
      continue;
      }
      else
      {
      check_malloced_chunk(av, victim, nb);
      void *p = chunk2mem(victim);
      alloc_perturb(p, bytes);
      return p;
      }
      }
      if (in_smallbin_range(size))
      {
      victim_index = smallbin_index(size);
      bck = bin_at(av, victim_index);
      fwd = bck->fd;
      }
      else
      {
      victim_index = largebin_index(size);
      bck = bin_at(av, victim_index);
      fwd = bck->fd;
      if (fwd != bck)
      {
      size |= PREV_INUSE;
      assert(chunk_main_arena(bck->bk));
      if ((unsigned long)(size) < (unsigned long)chunksize_nomask(bck->bk))
      {
      fwd = bck;
      bck = bck->bk;
      victim->fd_nextsize = fwd->fd;
      victim->bk_nextsize = fwd->fd->bk_nextsize;
      fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
      }
      else
      {
      assert(chunk_main_arena(fwd));
      while ((unsigned long)size < chunksize_nomask(fwd))
      {
      fwd = fwd->fd_nextsize;
      assert(chunk_main_arena(fwd));
      }

      if ((unsigned long)size == (unsigned long)chunksize_nomask(fwd))
      fwd = fwd->fd;
      else
      {
      victim->fd_nextsize = fwd;
      victim->bk_nextsize = fwd->bk_nextsize;
      fwd->bk_nextsize = victim;
      victim->bk_nextsize->fd_nextsize = victim;
      }
      bck = fwd->bk;
      }
      }
      else
      victim->fd_nextsize = victim->bk_nextsize = victim;
      }
      mark_bin(av, victim_index);
      victim->bk = bck;
      victim->fd = fwd;
      fwd->bk = victim;
      bck->fd = victim;
      ++tcache_unsorted_count;
      if (return_cached && mp_.tcache_unsorted_limit > 0 && tcache_unsorted_count > mp_.tcache_unsorted_limit)
      {
      return tcache_get(tc_idx);
      }
      #define MAX_ITERS 10000
      if (++iters >= MAX_ITERS)
      break;
      }

    其中标注的代码就是unsorted bin attack的关键。在unsorted bin中迭代获取需要的chunk的时候,会直接将其取出并作出决策:是取出作为可以分配的chunk,还是放入small bin或large bin(这不是讨论的重点)。要想执行重点的命令,就需要绕过之前切割remainder的操作

  1. 申请一个大于 Fastbin 范围的 Chunk(例如 0x400),防止被放入 Tcache(或填满 Tcache)。
  2. 将其释放,此时该 Chunk 的 fdbk 都会指向 main_arena
  3. 利用漏洞(如溢出)将该 Chunk 的 bk 修改为 &target - 0x10
  4. 申请一个同样大小或稍小的内存。此时 malloc 会遍历 Unsorted Bin,处理到该 Chunk 并将其移出。
  5. 在移出瞬间,目标位置被改写。

unsorted_bin_attack.drawio.png


small bin attack

我们需要连续申请几个特定大小的 Chunk 来布局:

1
2
3
4
5
# 假设我们申请以下 chunk
add(0, 0x18, 'A') # Chunk A: 溢出源
add(1, 0x208, 'B') # Chunk B: 被释放并被合并的受害者
add(2, 0xf8, 'C') # Chunk C: 用于触发向后合并 (off-by-null 的受害体)
add(3, 0x18, 'guard') # Chunk D: 防止与 top chunk 合并

在 Chunk B 中伪造 prev_size

由于之后触发 C 的释放时,glibc 会检查 C->prev_size 是否等于 B 的真正大小,所以我们要在 B 的末尾伪造好 prev_size


large bin attack

  • 安全发展历程

    glibc 2.30 在 large bin插入 chunk 的通用路径中,新增了两项针对双向链表完整性的检查

    glibc 2.31-2.35 在 2.30 版本的基础上,进一步强化了检查机制。尽管最初的攻击路径被有效封堵,但研究发现在特定条件分支下仍可能实现一次任意地址写 

  • glibc < 2.30

    利用条件

    1. 存在一个已经在 Largebin 中的堆块 A
    2. 存在一个位于 Unsorted Bin 中的堆块 B,且 size(B) > size(A)。
    3. 能够利用漏洞(如 UAF)修改堆块 A 的 bkbk_nextsize

    利用流程

    1. 将 Chunk A 放入 Largebin。
    2. 修改 A->bk = target1 - 0x10(针对 64 位系统)。
      修改 A->bk_nextsize = target2 - 0x20(针对 64 位系统)。
    3. 申请一个 Chunk(大小需要满足将 Unsorted Bin 中的 B 放入 Largebin,一般大于Largebin 中所有的堆块的大小即可)。
    4. malloc 将 B 放入 Largebin 的过程中,会执行:
      bck->fd = victim (即 *target1 = B)
      fwd->fd_nextsize = victim (即 *target2 = B)
      结果target1target2 两个位置都被写入了堆块 B 的地址。
  • glibc ≥ 2.30

    自 glibc 2.30 版本起,在对 large bin chunk 进行插入操作时,强制实施了两项新的检查,不过这两项检查都在一个分支里。

    1
    2
    3
    4
    5
    6
    //检查1
    if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
    malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");
    //检查2
    if (bck->fd != fwd)
    malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");

    malloc/malloc.c_int_malloc函数中存在将unsorted bin放入large bin的相关代码如下:

    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
    if (in_smallbin_range(size))
    {
    ...
    }
    else // in_largebin_range(size)
    {
    victim_index = largebin_index(size);
    bck = bin_at(av, victim_index);
    fwd = bck->fd;
    if (fwd != bck)
    {
    ...
    if ((unsigned long)(size) < (unsigned long)chunksize_nomask(bck->bk)) // size小于最小的堆块的大小
    {
    fwd = bck;
    bck = bck->bk;
    victim->fd_nextsize = fwd->fd;
    victim->bk_nextsize = fwd->fd->bk_nextsize;
    fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
    }
    else
    {
    //检查在这里面
    }
    }
    else
    victim->fd_nextsize = victim->bk_nextsize = victim;
    }

    ...
    victim->bk = bck;
    victim->fd = fwd;
    fwd->bk = victim;
    bck->fd = victim;

    利用条件

    • 存在一个已经在 Largebin 中的堆块 A
    • 存在一个位于 Unsorted Bin 中的堆块 B,且 size(B) 略小于 size(A)。
    • 能够利用漏洞修改堆块 A 的 bk_nextsize

    利用流程

    1. 释放 Chunk A(大),申请一个大块将其推入 Largebin。
      释放 Chunk B(小)(此时 B 在 Unsorted Bin 中)。

    2. 利用漏洞修改 A->bk_nextsize = target - 0x20
      注意:不要改 A 的 bk,否则会触发双向链表检查。

    3. 申请一个比 B 小的 Chunk,或者申请一个大于 A 的 Chunk。
      系统处理 Unsorted Bin 里的 B。由于 size(B) < size(A),B 会被插入到 A 的后面。

    4. 在插入逻辑中,由于 B 是新的“最小块”,代码会执行。

      1
      2
      3
      4
      5
      fwd = bck;
      bck = bck->bk;
      victim->fd_nextsize = fwd->fd;
      victim->bk_nextsize = fwd->fd->bk_nextsize;
      fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;

    large_bin_attack-第 2 页.drawio.png


off by one & off by null

  • off by null
    • glibc < 2.28

      未命名绘图.drawio.png

      在构造完堆叠后,记得修改ChunkB的size和下一个chunk对齐,prev_inuse为1。

    • glibc ≥ 2.29

house of系列

house of roman(glibc 2.23~2.24)

  • 简介

    House of Roman 是一种在无泄漏情况下实现 ROP 或劫持控制流的堆利用技术。它通过大量利用相对偏移和1/4096爆破来绕过ASLR。

  • 原理

    局部覆盖 (Partial Overwrite)

    在 64 位系统中,地址是 8 字节。ASLR 虽然随机化了地址的高位,但在同一页(Page)内的低 12 bit(即低 3 位十六进制数)通常是固定的。

    如果我们将一个指向 Libc 的指针的最后 2 个字节覆盖掉,我们就可以将它指向 Libc 附近的任意位置(例如 __malloc_hook),尽管中间的 4 bit 是随机的,需要爆破。

    最后将__malloc_hook修改为one_gadget的时候还需要再爆破一个字节,总爆破概率是

  • 流程

    1. 利用fastbin attack,分配到位于__malloc_hook附近的堆块
    2. 利用unsorted bin attack,向__malloc_hook填入main_arena+88地址(通常只需要修改低位)
    3. 利用分配到的fastbin修改填入的__malloc_hook的地址的低位为one_gadget,下次调用malloc就会弹出shell

house of einherjar(glibc 2.23~2.39+)

  1. 在Chunk A中伪造一个Fake Chunk。
  2. 利用Chunk B溢出等方式修改Chunk C的prev size和size,使其误认为前一个相邻Chunk是Fake Chunk
  3. 释放Chunk C后,Chunk C会跨过Chunk B与Fake Chunk合并
  4. 将合并后的Chunk 申请出来,称为Chunk D,这样Chunk D 就会与ChunkB形成堆叠

house of einherjar.drawio.png


house of force(glibc 2.23~2.28)

  1. 修改Top Chunk的size为一个极大的值,通常是-1
  2. 计算当前 Top Chunk 的位置到你目标地址之间的距离。申请跳板的大小$S=T-P_{top}-H$
  3. 再次申请一个堆块,其指针就会指向目标地址
1
2
3
4
// glibc 2.29 _int_malloc 中的新增检查
if (__glibc_unlikely (av->top->size > av->system_mem)) {
malloc_printerr ("malloc(): corrupted top size");
}

house of spirit(glibc 2.23~2.39+)


house of rabbit(glibc 2.23~2.26)

简介
House of Rabbit 核心思想是:将伪造的堆块挂入 Fastbin 链表,通过触发 malloc_consolidate 迫使 glibc 将该伪造块存入 Unsorted bin,并随后通过申请超大内存(类似 House of Force)或修改 Size 的方式实现对目标内存的控制。

它的独特优势在于:即使无法控制 Top chunk,只要能修改 Fastbin 的 fd 指针或 size 域,就能在内存的任何地方伪造一个“合法”的堆块。

原理

该漏洞利用了 ptmallocmalloc_consolidate 函数的一个特性:

  • 触发场景:当申请一个大于 Fastbin 范围的堆块(如 Large bin 大小)或发生特定的 free(如释放一个与 Top chunk 合并的堆块且总大小超过 64KB)时,会调用此函数。
  • 处理逻辑malloc_consolidate 会扫描所有的 Fastbin。它不会检查 Fastbin 中堆块的 size 是否符合该 bin 的标准,而是直接根据 fd 寻找下一个块,并尝试对每个块与其前后相邻的块进行合并(Consolidate)。
  • 利用点:如果我们在 Fastbin 链表中伪造一个 fd 指向目标地址(Fake Chunk),并给这个 Fake Chunk 设置一个极大的 size,当 malloc_consolidate 执行时,它会将这个 Fake Chunk 视为已释放的堆块并放入 Unsorted bin 中。

流程


house of storm(glibc 2.23~2.29)

原理

利用PIE下x64堆地址以0x55/0x56开头的特性,组合unsortedbin attack和largebin attack,在目标地址伪造size字段,使分配0x50 chunk时返回任意地址。

流程

  1. 申请两个堆块Chunk A(0x4e8), Chunk B(0x4d8),中间插入一个chunk防止合并
  2. 先释放小的一个堆块Chunk B再释放大的堆块 Chunk A,此时两个堆块都再unsorted bin中
  3. 将大的那个堆块申请出来Chunk A(0x4e8),这样由于LIFO,Chunk B会被放入large bin中
  4. 将Chunk A释放,这样就形成了Chunk A在unsorted bin,Chunk B在large bin的结构
  5. 利用漏洞修改
    A.bk = target-0x10
    B.fd = target+8
    B.bk_nextsize = target-0x18-5。
  6. malloc(0x48)

house of lore


house of gods

main_arena中有一个记录bins中是否有空闲chunk的结构binmap


house of corrosion


house of banana(glibc 2.23~2.39)

简介

“House of Banana” 主要通过篡改 GLIBC 内部的 _rtld_global 结构体,利用动态链接器(ld.so)在程序退出或执行 exit() 时的析构流程来控制程序流。

原理

在 GLIBC 中,_rtld_global 结构体包含一个名为 _dl_ns 的命名空间数组,其中_dl_ns[0]._ns_loaded 指向一个 link_map 链表。当程序正常退出时,会调用 _dl_fini 函数。该函数会遍历 link_map 链表,并执行每个库的 析构函数(Fini Functions)

流程

  1. 泄露ld.so、libc.so、堆的地址。

  2. **在堆上伪造一个 link_map 结构体。**你需要精心布局以下关键字段:
    l_next:指向下一个 link_map(或者设为 0 以终止链表)。
    l_real:通常指向结构体自身。
    l_info :这是一个索引数组。最关键的是 l_info[DT_FINI_ARRAY]l_info[DT_FINI]。你需要让它指向你伪造的动态节(Dynamic Section)。

    1
    2
    3
    4
    5
    6
    7
    struct link_map
    {
    ElfW(Addr) l_addr;
    char *l_name;
    ElfW(Dyn) * l_ld;
    struct link_map *l_next, *l_prev;
    };
  3. 利用堆漏洞(如 Large Bin Attack),将 _rtld_global._dl_ns[0]._ns_loaded 的值修改为指向你在堆上伪造的 link_map 地址。

  4. **在堆上伪造一个 ElfW(Dyn) 结构。**设置其标签为 DT_FINI_ARRAY。将其 d_un.d_ptr 指向你想要执行的指令地址(例如 one_gadgetsystem 函数的地址)。为了绕过一些内部检查,可能还需要伪造 l_info[DT_STRTAB] 等相关字段。

  5. **通过调用 exit() 或从 main 函数返回。**程序进入 _dl_fini_dl_fini 遍历 _ns_loaded 链表,找到了你伪造的堆上的 link_map。它根据 l_info 找到你设置的析构函数列表,最终执行你的恶意函数。

  • 源码 (由于原代码的SHARED在动态链接的时候是被定义的,因此此处将其去除了)

    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
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    void _dl_fini(void)
    {
    int do_audit = 0;
    again:
    for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns)
    {
    __rtld_lock_lock_recursive(GL(dl_load_lock));
    unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
    if (nloaded == 0 || GL(dl_ns)[ns]._ns_loaded->l_auditing != do_audit)
    __rtld_lock_unlock_recursive(GL(dl_load_lock));
    else
    {
    _dl_audit_activity_nsid(ns, LA_ACT_DELETE);
    struct link_map *maps[nloaded];
    unsigned int i;
    struct link_map *l;
    assert(nloaded != 0 || GL(dl_ns)[ns]._ns_loaded == NULL);
    for (l = GL(dl_ns)[ns]._ns_loaded, i = 0; l != NULL; l = l->l_next)
    if (l == l->l_real)
    {
    assert(i < nloaded);

    maps[i] = l;
    l->l_idx = i;
    ++i;

    ++l->l_direct_opencount;
    }
    assert(ns != LM_ID_BASE || i == nloaded);
    assert(ns == LM_ID_BASE || i == nloaded || i == nloaded - 1);
    unsigned int nmaps = i;
    _dl_sort_maps(maps, nmaps, (ns == LM_ID_BASE), true);
    __rtld_lock_unlock_recursive(GL(dl_load_lock));
    for (i = 0; i < nmaps; ++i)
    {
    struct link_map *l = maps[i];
    if (l->l_init_called)
    {
    _dl_call_fini(l);
    _dl_audit_objclose(l);
    }
    --l->l_direct_opencount;
    }
    _dl_audit_activity_nsid(ns, LA_ACT_CONSISTENT);
    }
    }
    if (!do_audit && GLRO(dl_naudit) > 0)
    {
    do_audit = 1;
    goto again;
    }
    if (__glibc_unlikely(GLRO(dl_debug_mask) & DL_DEBUG_STATISTICS))
    _dl_debug_printf("\nruntime linker statistics:\n"
    " final number of relocations: %lu\n"
    "final number of relocations from cache: %lu\n",
    GL(dl_num_relocations),
    GL(dl_num_cache_relocations));
    }

    void _dl_call_fini(void *closure_map)
    {
    struct link_map *map = closure_map;
    if (__glibc_unlikely(GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS))
    _dl_debug_printf("\ncalling fini: %s [%lu]\n\n", map->l_name, map->l_ns);
    map->l_init_called = 0;
    ElfW(Dyn) *fini_array = map->l_info[DT_FINI_ARRAY];
    if (fini_array != NULL)
    {
    ElfW(Addr) *array = (ElfW(Addr) *)(map->l_addr + fini_array->d_un.d_ptr);
    size_t sz = (map->l_info[DT_FINI_ARRAYSZ]->d_un.d_val / sizeof(ElfW(Addr)));
    while (sz-- > 0)
    ((fini_t)array[sz])();
    }
    ElfW(Dyn) *fini = map->l_info[DT_FINI];
    if (fini != NULL)
    DL_CALL_DT_FINI(map, ((void *)map->l_addr + fini->d_un.d_ptr));
    }

house of rust


house of muney


house of botcake(glibc ≥ 2.28)

  1. 先将tcache填满(大小要大于0x80,避免进入fastbin影响第二步堆块的合并)
  2. 再连续free连个连着的堆块Chunk A和Chunk B(Chunk A不能进入fastbin,且Chunk B的大小与第一步一致,A在B上面),确保二者会发生合并,进入unsortedbin
  3. 从刚刚的tcache中取出一个堆块,空出一个位置
  4. 再次释放Chunk B进入tcache,再申请回Chunk A&B(两个堆块合并后的堆块),完成利用。

此时我们通过修改Chunk A&B中的内容,修改Chunk B的fd指针,具体情形如下图所示

house_of_botcake.drawio.png


house of orange(glibc ≤ 2.23)

简介 House of orange常用于当程序中没有free函数的情况下,利用House of orange攻击能够释放一个unsortedbin 中的chunk,然后再结合unsortedbin attack和FSOP对_IO_FILE_plus.vtable进行攻击。

当我们申请了一个chunk后会检测top chunk的大小是否满足我们所需要的大小,若不满足则会将符合条件(如下)的old top chunk放入unsorted bin中,再重新映射一块top chunk。因此我们获得了unsorted bin。

  • 保证old top chunk的size > MINSIZE
  • 保证old top chunk的size < MINSIZE + 申请的大小
  • 保证old top chunk的prev_inuse为1
  • 保证old top chunk的结尾部分0x1000页对齐

(当对申请的chunk大小有限制的时候,可以通过修改top chunk的size完成house of orange)


house of pig


house of water


house of系列(IO_FILE相关)

house of husk


house of kiwi(glibc ≤ 2.35)

观察__malloc_assert 函数:

1
2
3
4
5
6
7
8
#define __assert_fail(assertion, file, line, function) __malloc_assert(assertion, file, line, function)
extern const char *__progname;
static void __malloc_assert(const char *assertion, const char *file, unsigned int line, const char *function)
{
(void)__fxprintf(NULL, "%s%s%s:%u: %s%sAssertion `%s' failed.\n", __progname, __progname[0] ? ": " : "", file, line, function ? function : "", function ? ": " : "", assertion);
fflush(stderr);
abort();
}

我们发现了我们熟悉的fflush函数,这个函数会调用_IO_file_jumps中的sync指针。

__assert_fail被分三种情况宏定义为了assert ,具体如下:

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
/* When possible, define assert so that it does not add extra
parentheses around EXPR. Otherwise, those added parentheses would
suppress warnings we'd expect to be detected by gcc's -Wparentheses. */
#if defined __cplusplus
#define assert(expr) \
(static_cast<bool>(expr) \
? void(0) \
: __assert_fail(#expr, __FILE__, __LINE__, __ASSERT_FUNCTION))
#elif !defined __GNUC__ || defined __STRICT_ANSI__
#define assert(expr) \
((expr) \
? __ASSERT_VOID_CAST(0) \
: __assert_fail(#expr, __FILE__, __LINE__, __ASSERT_FUNCTION))
#else
/* The first occurrence of EXPR is not evaluated due to the sizeof,
but will trigger any pedantic warnings masked by the __extension__
for the second occurrence. The ternary operator is required to
support function pointers and bit fields in this context, and to
suppress the evaluation of variable length arrays. */
#define assert(expr) \
((void)sizeof((expr) ? 1 : 0), __extension__({ \
if (expr) \
; /* empty */ \
else \
__assert_fail(#expr, __FILE__, __LINE__, __ASSERT_FUNCTION); \
}))
#endif

所以我们只需要触发assert函数即可。assert函数的简便的触发姿势:

  1. _int_malloc从large bin分配空间的时候,存在语句assert (chunk_main_arena (bck->bk)); 。该assert成立的条件是:让bck->bk指针指向一个伪造的堆块(fake chunk),并且这个伪造堆块的size字段的NON_MAIN_ARENA位被置位(即设置为1)
  2. _int_malloc中,当top chunk大小不足时,存在语句sysmalloc (nb, av); 。该assert成立的条件参考house of orange。

在glibc 2.36,__malloc_assert修改

在glibc 2.37,__malloc_assert完全删除,house_of_kiwi失效。


house of apple(glibc ≤ 2.39)

  • house of apple1
  • house of apple2
    1. 利用largebin attack向_IO_list_all写入 一个可控内存的地址
    2. 触发清理IO流,触发_IO_flush_all_lockp()
  • house of apple3

资料

https://github.com/shellphish/how2heap

https://elixir.bootlin.com/glibc/