1. 从一道2016年的老题说起:house of force到底值不值得重新调一遍
这几年CTF赛题越来越依赖复杂利用链,动不动就是tcache double free、large bin attack、io_file利用,很多人反而把一类经典堆利用方式给忽略了。bcloud这道题,出题年份是2016年,考察的就是house of force。我第一次接触这个题的时候,对top chunk的理解只停留在"分配内存时会自动往前切"这种程度,结果调试了三四个小时才把一个-1的size字段和一次大malloc串成完整利用链。说实话,把这道题手动调通之后,再去读glibc malloc源码里关于top chunk的路径,顺滑得像换了个脑子。
bcloud是BCTF 2016的一道pwn题,漏洞点很典型:程序在初始化时用gets读取name和org,这两个输入会覆盖到堆上后续的chunk头。利用方式是典型的house of force:先改掉top chunk的size,再利用malloc的分配逻辑把top指针推到任意目标地址附近,最后通过一次普通malloc拿到指向__free_hook的chunk,把free_hook改成system,实现任意命令执行。整个过程不涉及复杂的UAF链,也没有tcache,靠的是对malloc分配器内部计算的精确控制。
为什么到现在还有人在翻这道题?因为house of force虽然在libc 2.29以后被加了校验卡得死死的,但它的思维模型是理解现代堆利用的地基。你能解释清楚为什么改掉top chunk的size之后,一次巨大的malloc就能让后续malloc返回任意地址吗?你能在不知道目标地址时,仅凭一次unsorted bin泄漏就算出top chunk的新落点吗?如果能,说明malloc底层逻辑你已经吃透了;如果不能,bcloud就是最好的补课题。
2. top chunk与malloc的"算账"逻辑
2.1 top chunk到底是什么
从操作系统的角度看,进程通过brk或mmap向内核申请内存。glibc malloc维护的arena中,有一段被映射进来但还没有分割给用户的内存,这段内存就是top chunk。top chunk相当于malloc手里的一整块"待切割的原料",每次用户malloc一小块,malloc就从top chunk顶部切一小块下来,把剩下的继续当作新的top chunk。
正常情况下的切割流程是这样的:假设top chunk从地址T开始,size字段为S,用户请求大小为R(内部会加上chunk头的16字节并对齐,记作nb)。malloc检查后发现nb小于等于S,就从T处切出nb字节,新的top chunk地址变成T + nb,size字段变成S - nb。整个公式简单说就是:新top地址 = 原top地址 + 请求块的真实大小。
这里有个容易被忽略的细节:top chunk的size字段可以直接读写吗?正常情况下不能,因为堆布局里top chunk紧跟最后一个已分配chunk,而前一个chunk的data区域通常由用户的输入控制。只要存在堆溢出,尤其是有gets这种无边界输入,就能越过当前用户块的数据区,改写下一块chunk的header。一旦改写的是top chunk的size字段,上面那个切割公式就会出现完全不同的走向。
2.2 改掉size后,malloc会怎么算
house of force的核心就是把top chunk的size字段直接改成一个巨大的值,比如0xffffffffffffffff。这样无论用户请求多大的nb,检查nb小于等于S都会通过,因为-1在无符号比较里是最大的数。
此时,如果我们故意调用一次malloc申请一个极大值,让nb等于"某个目标地址与控制top chunk地址的差值",malloc就会忠实地执行:
新top地址 = 原top地址 + nb
结果就是,一次大malloc之后,新top chunk的地址被我们精确挪到了目标地址附近的某个位置。接下来再调用一次malloc申请一个小请求,返回的chunk地址就会落在目标地址上,于是我们就拿到了一个指向任意地址的读写窗口。
这里可能有人会问:目标地址要是在当前top chunk的下方怎么办?比如top chunk在堆里,目标地址在libc的BSS段,而且libc加载地址高于堆。其实glibc的size计算全部用的是无符号数,我们传入的"目标地址 - 原top地址 - 0x10"得到负数时,会被当作一个巨大的无符号数。这个巨大值在malloc看来依然是合法的nb,切割公式照样执行,最后top指针同样能落到目标地址附近。不要被有符号/无符号的直觉骗了,这也是house of force最反直觉的地方。
2.3 为什么libc 2.29之后这招不好使了
house of force之所以在2016到2018年称霸,是因为当年的glibc很少对top chunk做校验。但在较新版本的malloc源码中,分配路径里加入了一个检查条件,大概逻辑是:如果top chunk的size大于系统映射的总内存大小,就认为top chunk被破坏了,从而改用sysmalloc重新申请内存。这个检查直接堵死了改-1这条路。
所以做bcloud时,一定要选择正确的环境。推荐使用Ubuntu 16.04自带的glibc 2.23,或者手动切换到一个2.23的libc,配合对应版本的ld。用新版系统直接跑老题,大部分情况连house of force的第一步都会崩,因为系统分配器变了,堆布局和校验逻辑完全不同。
3. bcloud的漏洞点定位与堆布局构造
3.1 入口的两个gets是整道题的钥匙
bcloud的main函数一开始会先读取name和org,这两个读入用的都是gets。gets的终止符是换行或者EOF,完全不限制长度,这就为堆溢出提供了入口。
初始化时的分配顺序是name先malloc一个块,org再malloc一个块,两者在堆上是相邻的。之后用户每次创建note,程序会再malloc一个新块。如果我们把org填满,再继续输入超过org块长度的内容,就能直接覆盖到下一个chunk的header。当这个chunk恰好是top chunk时,我们写入的size字段就会覆盖掉真正的top size。
这个漏洞点设计得其实很巧妙:它不需要你先泄漏堆地址,也不需要你精确计算偏移,只需要把org输入长度控制在一个合理的范围,覆盖到top chunk的size字段即可。真正需要花心思的是,如何在该改的地方改,不该碰的地方不要碰。
3.2 怎么摆布局,才够得着top chunk的size字段
直接上来就在初始化阶段覆盖top chunk size是可行的,但更稳妥的做法是先分配几个note,然后把其中一两个free掉,制造出空闲chunk,再在适当时候触发溢出。
我个人习惯的布局是:
- 先分配一个0x40的note,让它紧挨着org块。
- 再分配一个0x40的note,让它作为填充。
- 通过org的gets溢出,伪造top chunk的size为0xffffffffffffffff。
- 后续create一个新的note,在house of force路径中作为"推动top指针"的请求。
注意,覆盖top chunk size的偏移要拿本地调试器确认。不要在没开调试的情况下凭空猜偏移,不同libc版本、不同malloc策略下,相邻chunk的padding和复用情况都有差异。
3.3 泄漏libc地址的稳定线路
bcloud的print功能会把note内容区的数据原样打印出来,而这个题在某些操作路径下会让note的内容区残留unsorted bin的fd指针。具体做法是:先构造一个note,把它释放掉,让这个空闲chunk进入unsorted bin。由于unsorted bin的fd、bk指针会被写入chunk的user data区域,而释放后这个数据区域并不会被清空,再次调用print就能把这些指针读出来。
读到的fd指针指向main_arena+88的位置。拿到这个地址后,用libc基址 = 泄漏地址 - offset就能算出libc的加载基址。offset怎么确定?最简单的方法是用本地的libc.so.6开一个gdb,在main_arena附近看一眼偏移;或者直接使用libc-database。bcloud这道题在Ubuntu 16.04默认环境下的偏移是0x3c4b78,但我建议还是自己算一遍,不要死记这个数,因为换libc版本就废了。
这里涉及一个关键的技巧:house of force的目标地址往往需要精确到__free_hook的偏移,而__free_hook的偏移只能通过libc基址加符号偏移得到。没有泄漏这一步,house of force顶多算一个"能把堆打飞"的堆风水题,有了libc地址,它才真正升级为可以任意地址写的漏洞利用链。
4. 从理论到shell:bcloud的完整利用链与调试记录
4.1 环境准备和工具
做这道题我建议的环境组合是Ubuntu 16.04虚拟机(glibc 2.23),配合pwntools和gdb插件。
调试之前先把ASLR的情况确认好。本地调试时可以关闭ASLR省心一点,但最终exp不能依赖ASLR关闭,因为我们的利用链里有unsorted bin泄漏,拿到了实际libc基址,所以开着ASLR也能稳定打通。
工具方面,pwndbg对堆块查看比较友好,输入heap、bins、top三个命令就能把当前堆布局、bin链表和top chunk的size全部看清楚。遇到段错误时,千万不要急着改exp,先用x/50gx 目标地址把目标内存区域的内容拉出来看,很多问题一眼就能定位。
4.2 第一步:把top chunk的size改成-1
初始化时,我们通过org输入写一个足够长的字符串,把top chunk的size位置覆盖成0xffffffffffffffff。
这一步最容易把size字段前面的padding和prev_size也一起改掉,不过对house of force来说问题不大,因为top chunk是堆末尾的特殊chunk,它前面的物理相邻chunk会被我们在布局阶段释放掉,malloc不会对它做严格的consolidate检查。下面是核心代码的骨架:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' def create(size, data): p.sendlineafter('choice:', '1') p.sendlineafter('size:', str(size)) p.sendafter('content:', data) def edit(idx, data): p.sendlineafter('choice:', '3') p.sendlineafter('index:', str(idx)) p.sendafter('content:', data) def delete(idx): p.sendlineafter('choice:', '2') p.sendlineafter('index:', str(idx)) def show(idx): p.sendlineafter('choice:', '4') p.sendlineafter('index:', str(idx)) p.recvuntil(b'content:') return p.recv(0x30)在调试器里改完size之后,记得看一次top命令。如果显示的size是一个巨大的数,说明覆盖成功。如果显示的是正常的0x210之类的数,说明输入长度不够或者偏移算错了,需要从偏移量开始排查。
4.3 第二步:越界分配,拿到__free_hook附近的chunk
这一步是整个利用链的核心,也是最容易算错的地方。
假设当前top chunk地址为top_addr,我们先请求一个大小为adjust_size的块,之后再请求一个0x20的小块。目标是小块返回的地址正好对齐到__free_hook - 0x10的位置,这样我们写入的数据就从chunk头后面的user data开始,正好覆盖__free_hook。
计算公式是:
adjust_size = (target_addr - 0x10) - top_addr - 0x10
也就是要分配的块,真实占用为adjust_size + 0x10,切割后新的top chunk地址指向target_addr - 0x10。这样下一次malloc(0x20)返回user data的地址恰好是target_addr。
为什么这里要减掉0x10?因为malloc返回的指针永远指向chunk头的后面16字节,而chunk头要占16字节。目标地址是__free_hook本身,我们希望chunk的user data起点正好是__free_hook,那么chunk头就得从__free_hook - 0x10开始。
# 假设已经通过show泄漏得到libc_base libc.address = libc_base free_hook = libc.sym['__free_hook'] system_addr = libc.sym['system'] top_addr = 0x602000 + 0x... # 需要实际泄漏或由堆布局推算 adjust_size = (free_hook - 0x10 - top_addr - 0x10) & 0xffffffffffffffff create(adjust_size, b'a') create(0x20, p64(system_addr)) # 此时返回的就是free_hook的地址注意,adjust_size可能是一个很大的值,甚至超过2的31次方,程序里读取size如果用了int类型,最好直接传负数的字符串形式,因为无符号转换后等价。比如在Python里计算完再转成有符号负值传给程序,或者直接在create里传str(adjust_size)让底层atoi把它解析成无符号大数,常见实现都没问题。
实际调试中发现,很多人卡在这一步不是因为公式错,而是因为top_addr没有更新。第一次大malloc之后,当前top chunk已经变成目标地址了,很多人还用原来的top_addr计算下一个请求的size,结果malloc直接申请失败。
4.4 第三步:写__free_hook,拿shell
当第二次create返回的chunk指向__free_hook时,我们就有了一个对__free_hook的任意写窗口。直接把system的地址写进去,然后找一个内容为"/bin/sh"的note,调用delete,free就会执行system("/bin/sh")。
这里有一个细节:为了确保触发时传入的字符串正好是"/bin/sh",可以创建一个小note,内容写b'/bin/sh\x00',并且保证这个note的指针不会因为后续house of force的布局而被覆盖。最好在完成house of force布局之前就创建好这个note,留到最后一步释放。
# 在修改free_hook之前就创建好 create(0x20, b'/bin/sh\x00') # 修改free_hook create(0x20, p64(system_addr)) # 触发 delete(1) # 假设笔记1的内容区是/bin/sh p.interactive()如果在写free_hook之后还有别的chunk操作,容易导致malloc内部状态出错,特别是新top chunk落在libc地址区域,后续malloc会被系统判定为异常。所以我的建议是:完成free_hook覆盖后,立刻触发,不要做任何多余操作。
4.5 实战调试中容易翻车的三处细节
第一,gets读入时如果本地测试环境和远程环境libc不同,堆分配的chunk大小可能差好几个字节,导致org溢出覆盖不到top size。遇到这情况不要硬调偏移,直接把远程libc和ld的patchelf环境搭好再调试。
第二,house of force里那个巨大的malloc请求,有些题目在create函数内部会对size做二次对齐或者加一个固定偏移。写exp之前先看反汇编,确认用户传入的size和malloc真正的参数之间的关系。如果程序内部写死了chunk大小,那就不能直接传adjust_size,得用text的预留字段做转换。
第三,在2.23的malloc里,大malloc请求有可能因为"nb >= MINSIZE"之外的其他限制被拒绝,比如MMAP_THRESHOLD。这里的house of force之所以能成功,是因为nb在无符号数上虽然很大,但在有符号比较时可能会走mmap路径。如果你的exp在create(adjust_size, ...)这一步触发mmap,而不是推进top指针,说明数值解析出了问题,可以尝试用负数形式传入,或者确认程序的read_line会不会对负数做额外处理。
5. house of force在今天还能怎么用
5.1 场景一:改hook函数指针
__free_hook和__malloc_hook是这类利用的首选目标,因为它们在libc的数据段,地址固定,计算方便。只要泄漏一次libc基址,就能算出精确落点。bcloud用__free_hook改system是教科书打法,代码简单,稳定性高。
但要注意,新版glibc已经将__free_hook等符号从动态符号表里弱化或者改用其他机制,如果你在自己的环境里用pwnlib的libc.sym查不到对应符号,就先更换libc版本,不要在旧题上强行适配新环境。
5.2 场景二:配合堆地址泄漏做精准落点
如果目标不是libc的hook,而是某个堆上的结构体,比如note的指针数组,那house of force同样可以把top指针推到note数组地址附近。这种方式在2016年的题目里也出现过,它的好处是不需要libc泄漏,只要一个堆地址就能完成任意写。虽然bcloud不强制走这条路,但理解了这种变体,你在遇到其他house of force题目时会灵活很多。
5.3 新版本下的绕过思路与练习建议
libc 2.29和2.31分别在malloc的top chunk路径里加了system_mem校验,house of force的传统玩法基本失效。如今还能见到的变体包括:利用large bin里的堆地址残留做unsorted bin attack,或者通过House of Spirit将空闲chunk放在可控位置,配合tcache poisoning让后续malloc返回任意地址。这些利用方式本质都是在回答同一个问题:你能不能伪造一个让malloc相信的chunk头。
练习顺序上,我建议先做bcloud,再找几道house of force变种题练手,然后回头读glibc 2.23的malloc.c源码,重点看_int_malloc、sysmalloc和_consolidate三个函数。把bcloud手动调通一次之后,你会发现后续学large bin attack、tcache stashing unlink attack时会轻松很多,因为这些技术都是在同一个分配器内部计算模型上做文章。
从个人经验来说,bcloud这道题真正有价值的地方不在house of force本身,而在于它逼着你把malloc的切割逻辑拆开揉碎。调通它之后,再看到"new_top = old_top + nb"这行代码,你脑子里浮现的不是公式,而是整个堆从分配到释放的生命周期。这就是经典题目的意义。