1. 从一道CTF题看堆利用的三种经典路径
最近在复盘一些经典的CTF堆利用题目,BUUCTF平台上的“网鼎杯 2018 第三场”的wdb_2018_3rd_pesp这道题,可以说是一个绝佳的堆漏洞利用“演武场”。这道题本身是一个标准的菜单堆题,漏洞点也很直接,但它的价值在于,它几乎完美地串联起了三种在CTF和实际漏洞利用中极为关键的堆利用技术:利用__realloc_hook劫持控制流、利用unlink攻击进行任意地址写,以及通过堆布局向BSS段写入数据。很多初学者在接触堆利用时,往往觉得这些技术点孤立且复杂,而这道题恰恰提供了一个将它们融会贯通的视角。今天,我就结合这道题的具体场景,把这三种方法的原理、操作细节和背后的“为什么”彻底拆解清楚,希望能帮你建立起一个更立体的堆利用知识框架。
这道题的程序逻辑并不复杂,提供了常见的malloc、free、edit和show功能。漏洞存在于edit函数中,存在一个典型的堆溢出漏洞,允许我们向一个堆块写入超出其本身大小的数据。这个漏洞就是我们发起所有攻击的起点。我们的最终目标通常是执行system(“/bin/sh”)来获取shell。为了实现这个目标,我们需要找到一个方法,将system函数的地址写入到某个能够被程序执行的地方,比如__malloc_hook或__free_hook。这道题的巧妙之处在于,它限制了我们malloc的大小,使得直接通过fastbin attack攻击__malloc_hook变得困难,从而迫使我们思考更多样化的利用路径。下面,我们就逐一深入这三种方法。
2. 方法一:迂回攻击——利用__realloc_hook作为跳板
当我们无法直接向__malloc_hook写入system地址时,__realloc_hook就成了一个非常理想的跳板。这里首先要理解__realloc_hook和__malloc_hook的关系。在glibc的内存分配器中,malloc函数在入口处会检查__malloc_hook是否为空,如果不为空,则直接跳转到__malloc_hook指向的函数去执行。而__realloc_hook是realloc函数内部的一个钩子。关键在于,realloc函数在开始执行时,会先检查__realloc_hook,如果它不为空,同样会跳转执行。更重要的是,realloc函数内部在特定路径下会调用malloc。这就为我们提供了一个链式调用的机会。
注意:在较新版本的glibc(2.34之后)中,这些
_hook变量已被移除,这种利用方式随之失效。但在2.23-2.32等经典版本中,这仍是核心技巧。
攻击链路的构建逻辑如下:
- 目标:我们最终希望执行
malloc时能触发system(“/bin/sh”)。 - 直接障碍:无法直接向
__malloc_hook写入system地址(例如由于size限制无法构造合适的fake chunk)。 - 迂回策略:我们将
__realloc_hook覆盖为system函数的地址。同时,将__malloc_hook覆盖为realloc函数内部某个特定偏移的地址。 - 触发流程:当我们调用
malloc(size)时,程序首先跳转到__malloc_hook,即realloc+某偏移。从这个偏移开始执行realloc代码,realloc一开始就检查__realloc_hook,发现其指向system,于是跳转去执行system。此时,realloc函数的第一个参数(通常存储在rdi寄存器)会作为system的参数。如果我们能控制这个参数为/bin/sh的地址,就能成功getshell。
那么,realloc的第一个参数是什么?是realloc原本要处理的原内存地址。但当我们是从malloc“拐”进来的,并没有原指针。实际上,此时rdi寄存器中的值,很大程度上取决于我们调用malloc时传入的size参数以及__malloc_hook被覆盖为realloc的具体偏移。经过调试可以发现,存在某个偏移,使得此时rdi寄存器恰好指向我们即将通过malloc申请到的chunk的用户数据区。因此,我们只需要提前在这个chunk的用户数据区布置好字符串/bin/sh,再触发这个精心构造的malloc调用,就能让system(“/bin/sh”)成功执行。
在这道题中的实操要点:
- 首先利用堆溢出,通过
fastbin attack或unsorted bin attack等手法,实现向__malloc_hook和__realloc_hook所在的内存区域写地址的能力。由于题目限制,直接攻击__malloc_hook可能困难,但攻击它附近的__realloc_hook区域可能size条件更宽松。 - 计算正确的
realloc偏移。这个偏移不是固定的,需要根据本地的glibc版本通过调试确定。通常我们会尝试realloc函数开头往后的一些偏移,观察哪个偏移能让rdi指向可控的内存区域。 - 将
/bin/sh字符串写入一个即将被申请的chunk中。 - 覆盖
__malloc_hook为realloc+offset,覆盖__realloc_hook为system地址。 - 调用
malloc申请那个存有/bin/sh的chunk,触发攻击链。
这种方法的核心思想是“借道而行”,利用内存分配器内部函数的调用关系,将一个难以直接利用的钩子(__malloc_hook)转换成一个容易利用的钩子(__realloc_hook),体现了在限制条件下灵活寻找攻击面的思路。
3. 方法二:釜底抽薪——利用unlink实现任意地址写
如果说_hook攻击是“劫持流程”,那么unlink攻击就是“篡改数据”。它的威力在于,一旦成功,可以让我们向任意地址写入一个任意值(通常是一个堆地址),这为后续的利用打开了无限可能。unlink是glibc中用于从双向链表(如smallbin或largebin)中摘除一个空闲chunk的操作。攻击的关键在于伪造一个空闲chunk,并诱使程序对这个fake chunk执行unlink操作。
unlink宏的核心代码(glibc 2.23)如下:
#define unlink(AV, P, BK, FD) { FD = P->fd; BK = P->bk; if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) malloc_printerr (“check_action”); else { FD->bk = BK; BK->fd = FD; ... } }这段代码看起来只是简单的链表节点删除操作:P->fd->bk = P->bk;和P->bk->fd = P->fd;。但正是这两个赋值语句,如果P、fd、bk都是我们可控的,就能实现任意地址写。
攻击条件与伪造chunk的构造:要成功利用unlink,我们需要伪造一个free状态的chunk(P),并让它看起来处于一个双向链表中。这意味着我们需要控制P的fd和bk指针。同时,为了通过unlink时的安全检查FD->bk == P && BK->fd == P,我们需要让FD->bk和BK->fd都指向P。这听起来像是一个“先有鸡还是先有蛋”的问题,但解决方案很巧妙:我们让FD和BK指向P附近的内存地址。
假设我们在一个可控的堆块(记为chunk0)里伪造了一个free的chunk结构P,P的fd指针指向(目标写地址 - 0x18),bk指针指向(目标写地址 - 0x10)。同时,我们确保目标写地址处的内容(即FD->bk和BK->fd)恰好是P的地址。这通常通过堆布局,让另一个堆块(chunk1)的prev_size和size域被我们控制,从而在free(chunk1)时,glibc会认为前面的chunk0(即我们的fake chunkP)是空闲的,并尝试将其从所谓的“链表”中unlink出来,从而触发我们的恶意逻辑。
在这道题中的具体步骤:
- 布局堆内存:申请两个相邻的chunk,
chunk0和chunk1。chunk0的大小要足以让我们在里面伪造一个完整的chunk结构(含prev_size,size,fd,bk等)。 - 伪造
freechunk:在chunk0的数据区构造一个fake chunk。设置其size域,并将PREV_INUSE位设为0,表示前一个chunk是空闲的(这里就是它自己,这是一种欺骗)。最关键的是设置fd和bk,使其满足unlink检查条件,并指向我们想要写入的目标地址,例如global_max_fast(一个全局变量)或BSS段上的一个函数指针。 - 触发
unlink:通过堆溢出,修改chunk1的prev_size为chunk0中fake chunk的大小,并将chunk1的size域的PREV_INUSE位清零。然后,调用free(chunk1)。free操作会检查前一个chunk是否空闲(根据chunk1的PREV_INUSE位),发现是“空闲”后,会尝试将前一个chunk(即我们伪造的chunkP)与chunk1合并。合并前需要将fake chunkP从它所在的“链表”中unlink出来,从而执行我们预设的写操作。 - 利用写入结果:
unlink操作最终会执行FD->bk = BK,即*(目标写地址) = BK。BK是我们伪造的bk指针,通常指向一个堆地址。这意味着我们成功向目标写地址写入了一个堆地址。这个堆地址可以用来做什么?我们可以进一步利用这个写入的地址,通过编辑chunk0的内容(因为写入的地址可能指向chunk0附近),来实现对目标写地址处的值进行二次修改,例如将其改为system地址。
unlink攻击是一种相对古典但非常强大的技术,它不依赖于任何特定的钩子函数,而是直接利用堆管理器的内部操作逻辑来实现内存篡改。理解unlink是深入理解glibc堆管理机制的重要一环。
4. 方法三:直击要害——向BSS段写入可控数据
BSS段通常存储着程序的全局变量和静态变量。如果程序在BSS段上存储了函数指针(比如printf的got表项虽然不在BSS段,但一些程序自定义的函数指针或atexit链表可能在BSS段),那么向BSS段写入system地址就是一种直接的攻击方式。即使没有现成的函数指针,向BSS段写入可控数据也能为后续利用创造条件,比如在BSS段上构造一个fakeFILE结构体进行FSOP攻击。
那么,如何从堆区“穿越”到BSS段进行写操作呢?这通常需要一种能够实现任意地址写的原语。而这道题展示的,正是如何利用堆漏洞培育出这样一个原语。
常见的培育路径:
- 制造一个“野指针”:首先,我们需要一个指向我们想要修改的BSS段地址的指针。这个指针本身可能存在于堆上、栈上或者BSS段自身。通过
unlink攻击(如上所述),我们可以向BSS段的某个地址(比如一个全局指针变量global_ptr)写入一个堆地址。现在,global_ptr就指向了堆上的某个位置(例如chunk0的fd指针所在处)。 - 通过编辑“野指针”指向的内容,实现任意写:由于
global_ptr现在指向堆上的可控数据区,程序如果提供了基于global_ptr的编辑功能(或者我们能通过其他漏洞触发编辑),我们就可以修改global_ptr所指向的内存内容。但我们的目标是修改BSS段的其他地方,比如一个函数指针func_ptr。 - 将“任意写”能力进行转移:关键在于,我们可以在堆上伪造一个数据结构。例如,我们在
chunk0中布置一个fake_chunk,并将其fd指针设置为&func_ptr - 0x18(为后续可能的unlink或类似操作做准备)。然后,我们通过编辑global_ptr指向的内存(即chunk0的开头),将global_ptr本身的值覆盖为fake_chunk的地址。现在,global_ptr就指向了我们伪造的fake_chunk。 - 触发最终写入:程序再次使用
global_ptr进行写操作时(例如调用一个以global_ptr为参数的edit函数),写入的数据就会落到fake_chunk的fd指针等位置上。如果我们能进一步触发某个操作(比如再次unlink,或者程序逻辑会解引用fake_chunk->fd并写入),就有可能将system地址写入func_ptr。
在这道题中的串联应用:在这道wdb_2018_3rd_pesp中,BSS段上可能就存在一些有用的全局变量。解题思路往往是复合式的:
- 首先,利用
unlink攻击,向BSS段上的一个全局指针(比如ptr_array[0])写入一个堆地址。这建立了堆到BSS段的初步联系。 - 然后,利用程序的
edit功能,通过这个已被篡改的ptr_array[0]去修改堆上的数据,在堆上精心布置一个fake fastbin chunk,其fd指针指向__malloc_hook附近的一个合法可分配内存地址。 - 接着,通过
malloc申请到这个fake chunk,从而获得一个指向__malloc_hook附近区域的指针。 - 最后,再利用
edit功能,通过这个指针,直接覆写__malloc_hook为one_gadget或system地址。
这种方法体现了堆利用中“步步为营”的思想。从一个简单的堆溢出开始,通过一系列精巧的内存布局和操作链,将漏洞的破坏力逐步传导至关键的内存位置。向BSS段写入只是中间环节,目的是为了搭建一个跳板,最终目标仍然是劫持控制流。
5. 三种方法的对比与实战选择
| 方法 | 核心原理 | 优势 | 劣势/条件 | 适用场景 |
|---|---|---|---|---|
__realloc_hook跳板 | 利用realloc内部调用malloc及钩子检查机制,形成调用链劫持控制流。 | 1. 不依赖unlink等复杂构造。2. 只需覆盖两个钩子,触发简单。 3. 是绕过 __malloc_hook直接攻击限制的有效方法。 | 1. 依赖特定glibc版本(<2.34)。 2. 需要精确计算 realloc偏移,可能因环境而异。3. 需要能在 __malloc_hook附近进行写操作。 | 存在堆溢出且能攻击_hook附近内存,但直接写__malloc_hook受阻时。 |
unlink攻击 | 伪造空闲chunk,利用unlink宏的安全检查漏洞实现任意地址写。 | 1. 实现真正的任意地址写,能力强大。 2. 不依赖特定函数指针或钩子,通用性较强。 3. 是理解glibc堆管理机制的必修课。 | 1. 构造复杂,需精心布局满足FD->bk==P && BK->fd==P。2. 通常需要堆溢出能修改下一个chunk的头部信息。 3. 写入的值通常是一个堆地址,需二次利用。 | 存在堆溢出且能修改相邻chunk的size/prev_size,程序存在可被篡改的全局指针。 |
| 写入BSS段 | 通过堆漏洞培育出的任意写能力,修改BSS段上的关键数据(如指针、函数地址)。 | 1. BSS段地址通常固定且已知,目标明确。 2. 可为后续更复杂的利用(如FSOP)奠定基础。 3. 常作为利用链中的关键中间步骤。 | 1. 通常不是最终利用,需要结合其他技术(如unlink)获得初始写能力。2. 需要BSS段上存在有价值的目标。 | 程序BSS段存在全局指针或函数指针,且能通过堆漏洞获得一次写任意地址的机会。 |
在实际解题或漏洞利用中,选择哪种方法并非一成不变,而是需要根据题目给出的具体条件进行判断:
- 检查保护机制:首先看
RELRO保护。如果是Partial RELRO,GOT表可写,那么直接通过堆漏洞写GOT表可能是最直接的。如果是Full RELRO,则需转向_hook或FSOP。 - 评估漏洞能力:你的溢出能写多远?能修改哪些元数据?这决定了你能发起哪种攻击的初始条件。
- 观察程序特性:BSS段有没有大的数组?程序是否使用
malloc_trim或exit?这些都可能提示不同的利用路径。 - 灵活组合:很多时候,单一方法不足以完成利用。例如,先用
unlink在BSS段创造一个可控指针,再用这个指针去修改_hook,这就是两种方法的组合。
wdb_2018_3rd_pesp这道题之所以经典,就是因为它给出的条件(如malloc大小限制)迫使你不得不去思考和尝试这些组合技。它像是一个微型的“漏洞利用实验室”,让你在安全的环境中实践这些高级技术。
6. 从理论到实践:解题过程中的关键调试技巧
理解了原理,实战中依然会踩坑。调试是堆利用不可或缺的一环。以下是一些在实践这三种方法时,至关重要的调试心得:
1. 精确计算偏移与地址:
realloc偏移:不要死记硬背偏移量。在你的调试环境中(gdb-peda/pwndbg),通过p &__realloc_hook和disas realloc找到__realloc_hook被调用的指令地址。__malloc_hook需要填入的地址通常是这条指令之前、能正确设置rdi的某个地址。多次尝试,观察调用malloc时rdi寄存器的值是否指向你布置的/bin/sh字符串。unlink检查:在伪造fd/bk时,牢记公式:FD = &目标地址 - 0x18,BK = &目标地址 - 0x10。并在执行free之前,用调试器查看FD->bk和BK->fd的值是否确实等于fake chunk的地址P。这是unlink成功的关键。- BSS段地址:使用
readelf -S ./pwn | grep .bss或objdump -t ./pwn | grep “\.bss”来获取准确的BSS段起始地址。结合反汇编(objdump -d)或gdb中的info variables来定位具体的全局变量地址。
2. 堆布局可视化:单纯看内存十六进制是痛苦的。善用pwndbg的堆命令:
heap:查看所有堆块的基本信息。bins:查看各个bin的状态,这是判断unsorted bin attack或fastbin attack是否成功的关键。vis_heap_chunk:图形化查看堆块布局,对于检查伪造的chunk结构、prev_size和size域是否正确设置非常直观。 在布局unlink时,我习惯在关键操作(如修改chunk1的prev_size、执行free)前后都执行vis_heap_chunk,确保内存状态符合预期。
3. 利用脚本的模块化与断点调试:不要试图一口气写出完整的exp。应该模块化编写和测试。
def create_overflow_setup(): # 1. 布置初始堆块 ... def trigger_unlink(): # 2. 触发unlink,并验证是否向目标地址写入了堆地址 ... def achieve_arbitrary_write(): # 3. 利用unlink的结果,实现真正的任意写 ...每完成一个函数,就在gdb中运行到相应位置,检查内存是否达到预期状态。在关键函数(如free、malloc)和unlink宏内部设置断点,单步跟踪程序流和内存变化。例如,在free函数入口和unlink宏处设断,可以清晰看到整个合并与解链过程。
4. 应对环境差异:CTF题目提供的libc可能与你本地环境不同。务必使用题目提供的libc文件。在gdb中,使用set debug-file-directory和file命令加载带符号的libc。计算偏移时,使用pwntools的LibcSearcher或手动计算:system_addr = libc_base + libc.symbols[‘system’]。one_gadget工具可以帮助你快速找到执行execve(“/bin/sh”, NULL, NULL)的gadget地址,有时比system更稳定。
5. 一个常见的“坑”:size与prev_size的混淆在构造fake chunk或修改下一个chunk头部时,最容易出错的就是size域的计算。记住,size是整个chunk的大小,包括头部(prev_size和size本身)和用户数据区,并且必须是2*SIZE_SZ的整数倍(64位下通常是0x10的倍数)。prev_size只有在前一个chunk空闲时才有意义,且必须等于前一个chunk的size。在unlink攻击中,我们通过溢出修改chunk1的prev_size为fake chunk的大小,并将chunk1的size的PREV_INUSE位清零,以此欺骗glibc认为chunk0(fake chunk)是空闲的。如果这两个值对不上,free时会直接抛出“corrupted size vs. prev_size”错误。
7. 举一反三:技术变种与防御演进
通过解剖这道题,我们掌握了三种经典技术。但漏洞利用技术是“道高一尺,魔高一丈”的博弈。了解这些技术的变种和相应的防御措施,能让我们理解更深刻。
技术变种:
house of系列:这些都是基于特定堆场景的高级利用技术。例如,house of spirit是在栈或BSS段伪造fastbin chunk;house of force是通过溢出修改top chunk的size,实现近乎任意地址的malloc;house of orange则利用_IO_FILE结构进行攻击。它们可以看作是本文所述基础技术的组合与延伸。tcache攻击:在引入tcache(glibc 2.26+)后,出现了新的攻击面,如tcache poisoning(直接修改tcache链上的next指针)和tcache stashing unlink attack(结合smallbin和tcache)。其思路与fastbin attack类似,但约束更少,利用更容易。FSOP(File Stream Oriented Programming):当_hook不可用时,攻击_IO_FILE结构(如stdout、stderr)成为主流。其核心也是通过堆漏洞在内存中伪造一个_IO_FILE结构,并利用vtable劫持控制流。这通常需要结合向BSS段或堆写的能力来修改_IO_list_all等全局指针。
防御措施与绕过:
unlink检查强化:现代glibc对unlink的检查更加严格(如FD->bk == P && BK->fd == P),但通过精心构造fd/bk依然可以满足,只是条件更苛刻。tcache双指针保护:某些版本的tcache对next指针有简单的异或加密,增加了利用难度,但并非不可破。safe-linking:glibc 2.32引入的tcache和fastbin单链表指针加密机制,显著增加了利用难度,需要结合信息泄露才能破解。- 移除
_hook:glibc 2.34及以上版本移除了__malloc_hook、__free_hook等,封堵了这条直接路径,迫使攻击者转向FSOP或其他更复杂的方法。
作为攻击者,我们需要持续关注这些变化,理解新机制的弱点;作为防御者或安全研究者,理解这些攻击原理是设计更安全的内存管理器和编写更健壮代码的基础。这道wdb_2018_3rd_pesp题,就像一本旧但并未过时的教科书,它所传授的“任意地址写”的核心思想,在今天的漏洞利用中依然闪烁着光芒。