ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CTF实战:无Libc环境下格式化字符串漏洞的利用与防御

2026/8/11 5:52:46 拓冰建站 浏览量
CTF实战:无Libc环境下格式化字符串漏洞的利用与防御 1. 从一道CTF题看格式化字符串漏洞的实战利用最近在带新人入门二进制安全发现很多朋友在接触PWN题尤其是涉及格式化字符串漏洞的题目时会感到一头雾水。特别是当题目环境没有提供Libc库文件时那种“无从下手”的感觉会更加强烈。正好BugkuCTF上那道经典的pwn6-printf就是一个绝佳的教学案例。这道题不仅考察了格式化字符串漏洞的基本利用还额外增加了一个“未提供Libc”的挑战非常考验解题者对漏洞利用链路的整体构建能力。今天我就结合这道题以及大家常问的关于64位与32位参数传递、如何在没有Libc的情况下解题等核心问题来一次超详细的“庖丁解牛”。这道题的核心就是printf函数。很多人学C语言第一个接触的就是它但恰恰是这个最熟悉的函数如果使用不当就会成为最危险的漏洞源——格式化字符串漏洞。它的危害在于攻击者可以通过控制格式化字符串参数实现任意内存读、任意内存写甚至最终获取目标系统的完整控制权。而pwn6-printf这道题正是将我们置于一个“盲打”的环境没有给出服务器使用的Libc版本。这意味着我们无法直接计算诸如system、/bin/sh字符串等关键偏移。这听起来增加了难度但实际上它迫使我们去理解和运用更底层、更通用的利用技术比如通过泄露地址来“现场”计算所需偏移这才是实战中更常遇到的情况。2. 漏洞原理深度剖析为什么printf会成为突破口在深入解题之前我们必须彻底理解格式化字符串漏洞的根源。这不仅仅是知道%x能泄露数据更要明白数据从哪来、到哪去。2.1 printf函数调用约定与栈布局printf函数的函数签名是int printf(const char *format, ...)。这里的...表示可变参数。在程序执行时调用printf时参数是如何被压入栈中的呢这里就出现了32位和64位系统的根本性差异这也是很多初学者混淆的地方。在32位系统下参数传递主要依靠栈。当调用printf(“Hello %s”, buf)时参数从右向左压栈。假设buf的地址是0xffffd00c那么栈的布局大致如下高地址 ... (调用者栈帧) 返回地址 旧ebp值 - 当前函数的栈帧开始 ... 0xffffd00c (buf的地址即第二个参数) - 参数2 “Hello %s”的地址 (格式化字符串地址) - 参数1 ... (可能的局部变量) 低地址当printf开始解析格式化字符串“Hello %s”时它会期望在格式化字符串地址之后的位置找到对应的变量参数。对于%s它会将对应位置的内存值即0xffffd00c解释为一个指针然后去那个地址读取字符串内容。关键问题来了如果程序员错误地写成了printf(buf)即直接把用户输入的buf作为格式化字符串。那么printf会忠实地解析buf中的内容。如果buf中包含%x、%p等格式化符printf就会按照上述规则去栈上“预期”存放第一个变量参数的位置读取数据并打印出来。而这个位置很可能就包含着栈上的其他数据比如返回地址、栈帧指针、甚至是Libc的地址这就造成了信息泄露。在64位系统下规则发生了变化。前6个整型或指针参数通过寄存器传递RDI, RSI, RDX, RCX, R8, R9多余的参数才通过栈传递。对于printf(buf)这样的调用RDI寄存器存放的是buf的地址即格式化字符串。如果buf中包含%sprintf会去RSI寄存器中寻找对应的指针。但此时RSI里的值并不是我们输入的buf地址之后的内容而是调用前RSI寄存器的值可能毫无意义。但是当格式化参数超过6个时例如使用了%7$xprintf就会去栈上寻找第7个参数。这里有一个非常重要的技巧虽然前6个参数用寄存器传但调用函数时编译器仍然会为这6个参数在栈上预留空间称为“影子空间”或“参数备份区”。因此在64位下我们通过%p或%lx泄露数据时通常是从栈上“第7个参数”的位置开始计数。第一个%p对应的是栈上预留的RDI备份位置但里面可能不是RDI的值后续的%p才会逐步深入到调用者栈帧中从而可能泄露到返回地址、Libc地址等。注意上述栈布局是概念性的简化。实际布局会受到编译器优化、函数序言/尾声代码的影响。在解题时最可靠的方法是动态调试观察栈内存的具体内容。2.2 pwn6-printf题目场景还原与漏洞点定位回到pwn6-printf这道题。通常这类题目的代码结构类似下面这样#include stdio.h #include stdlib.h void vuln() { char buf[100]; printf(“Enter your data:\n”); fgets(buf, sizeof(buf), stdin); // 安全输入 printf(buf); // 漏洞点这里直接使用了用户输入作为格式化字符串 } int main() { setbuf(stdout, NULL); vuln(); return 0; }代码非常简单。fgets限制了输入长度看起来是安全的不会造成栈溢出。但祸根就种在了下一行的printf(buf)。用户如果输入%p.%p.%p程序就会打印出栈上的三个地址。我们的攻击目标很明确信息泄露利用%p、%s等格式化符泄露出栈上的关键地址例如Libc中某个函数的地址如__libc_start_main的地址。计算偏移根据泄露的地址计算出目标Libc版本中system函数和字符串“/bin/sh”的地址。篡改内存利用格式化字符串的%n或%hn、%hhn等写功能将某个函数的GOT表项例如printf或exit的GOT修改为system的地址。触发执行当程序再次调用被篡改的函数时实际上会跳转到system并且如果我们能控制此时传递给“函数”的参数就能执行任意命令。通常我们会让printf的GOT指向system然后在下一次输入时传入“/bin/sh”作为“格式化字符串”实际上成了system的参数。最大的挑战在于第2步题目没有提供Libc文件。我们无法预先知道system和“/bin/sh”相对于泄露点的偏移。3. 无Libc环境下的利用策略动态计算与远程爆破没有Libc并不意味着无法解题。我们有多种策略来应对核心思想都是动态获取关键信息。3.1 策略一泄露多个地址在线查询Libc数据库这是目前最主流、最高效的方法。我们并不需要知道服务器上具体的Libc文件名只需要知道其“指纹”。操作步骤如下构造泄露Payload编写一个循环或使用多个%p泄露出栈上大量的地址。例如payload b‘%p.’*40让程序打印出40个地址。筛选有效地址从输出中筛选出那些看起来像代码段的地址通常以0x7f或0x55/0x56开头且末三位是000的可能是函数地址。重点寻找Libc中的函数地址比如__libc_start_main240这是main函数返回后的地址经常出现在栈上、puts、printf、read等在程序中实际被调用过的函数的地址。计算偏移取泄露出的某个Libc函数地址的最后12位3个十六进制字节。因为Libc在内存中加载时其基地址的最后12位是固定的页对齐不同版本Libc中同一个函数的偏移量是固定的但地址值不同。我们取后12位实际上是在匹配函数在Libc内部的偏移量。在线查询将得到的后12位例如0x9a0提交到在线的Libc数据库如https://libc.blukat.me/或https://libc.rip/。这些网站维护了海量Libc版本及其函数偏移。输入后网站会返回可能匹配的Libc版本。确认版本网站通常会返回多个可能的结果。我们需要用泄露的另一个函数地址的后12位进行交叉验证。例如我们泄露了printf的地址尾缀是0x9a0__libc_start_main的尾缀是0x240。在查询结果中寻找一个Libc版本其printf偏移是0xxxxx9a0__libc_start_main偏移是0xxxxx240且两者之间的差值与我们计算的一致基本就能确定版本。获取关键偏移确定Libc版本后从数据库页面直接获取system和“/bin/sh”字符串相对于Libc基地址的偏移量。然后用泄露的任一Libc函数地址减去它在该版本中的偏移得到Libc基地址。最后system_addr libc_base system_offsetbinsh_addr libc_base binsh_offset。3.2 策略二Partial Overwrite与GOT表劫持在某些更受限或更简单的场景下我们可以采用更精巧的方法甚至不需要完整计算system的地址。思路是利用格式化字符串的“写”功能进行部分覆盖Partial Overwrite。例如我们通过泄露知道了printf函数在GOT表中的地址是0x7ffff7e26a0而通过某种方式比如观察本地测试或猜测发现system函数的地址可能是0x7ffff7e5230。这两个地址可能只在最后1-2个字节上有差异。我们可以使用%hhn写一个字节或%hn写两个字节来精确覆盖printfGOT表项的低位字节将其修改为system的低位。这要求printf和system的地址在高位上是相同的这通常发生在它们位于同一个Libc中且加载基址相同的情况下。虽然这种方法成功率受地址随机化ASLR影响较大但在一些简单的题目或特定条件下仍可一试。3.3 策略三栈数据直接利用与ROP链构建如果题目不仅没有Libc连栈地址随机化PIE都没开或者我们能够泄露出栈地址本身那么可能有更直接的利用方式。我们可以利用格式化字符串漏洞不仅泄露地址还可以向栈上写入数据。例如我们可以将一段简单的Shellcode写入到栈上的某个缓冲区前提是栈可执行即NX未开启但现在这种情况很少见。或者更常见的是我们可以向栈上写入一个ROP链返回导向编程。操作思路泄露栈地址计算出我们输入的缓冲区在栈上的确切位置。利用%n系列格式化符向栈上存放返回地址的位置写入一个pop rdi; ret的gadget地址后面再接上“/bin/sh”字符串的地址和system函数的地址。但这里又回到了老问题system地址未知。因此在无Libc情况下构建ROP链通常需要结合策略一先泄露Libc地址并计算出system然后再进行栈布局的篡改。这要求我们能够进行多次格式化字符串漏洞的触发比如程序在一个循环中或者一次性能写入足够多的数据。对于pwn6-printf策略一是最通用、最可靠的解法。下面我们就进入实战环节。4. 实战解题步骤详解从信息泄露到getshell假设我们通过反编译或动态调试已经知道了程序的基本信息它是一个64位程序开启了NX栈不可执行和ASLR但没有开启PIE程序本身基地址固定。这很常见因为如果开了PIE我们连GOT表的地址都不知道难度会更大。4.1 第一步建立连接与初步探测我们使用Python的pwntools库来编写利用脚本。首先进行基础连接和探测。from pwn import * context(os‘linux’, arch‘amd64’, log_level‘debug’) # 如果题目提供就用 remote(‘host’, port)本地调试用 process(‘./pwn6’) # io remote(‘xxx.xxx.xxx.xxx’, 9999) io process(‘./pwn6’) # 第一次交互发送一堆 %p 来探测栈布局 payload1 b‘%p.’ * 30 io.sendlineafter(b‘Enter your data:’, payload1) output1 io.recvline().decode().strip() log.info(f“Leaked addresses: {output1}“)运行后我们会得到一串由点号分隔的地址。看起来可能像这样0x7ffd12345678.0x1.0x7fabcdef1234.0x70252e70252e7025.(nil)...其中0x70252e70252e7025是%p.%p的ASCII编码说明我们输入的内容本身也在栈上。我们需要寻找那些看起来像代码段的地址。4.2 第二步筛选关键地址并确定Libc版本从输出中我们假设找到了两个有价值的地址leak1 0x7f12a34b6a0leak2 0x7f12a2f8c00我们需要判断它们是什么。通常__libc_start_main的地址会在栈的较深处。我们可以多试几次观察哪个地址相对稳定每次运行低12位不变高位变化这是ASLR导致的基址变化。假设我们认定leak2是__libc_start_main240。我们取leak2的低12位0xc00。 访问https://libc.rip/在搜索框输入__libc_start_main c00。网站可能会返回多个结果比如libc6_2.31-0ubuntu9.9_amd64。为了确认我们需要另一个函数的偏移。我们继续用格式化字符串泄露printf的地址。我们可以尝试用%s去“读”一个地址但这需要我们知道一个指向printf在GOT表中地址的指针在栈上的位置这通常需要调试。更简单的方法是程序本身可能会调用printf或puts来输出内容它们的地址也可能留在栈上。我们可以在之前的%p输出中寻找以0x7f开头且与leak2高位相同即同属一个Libc的地址假设找到leak3 0x7f12a34b29a0其低12位是0x9a0。在libc.rip的查询结果中查看libc6_2.31-0ubuntu9.9_amd64的详细信息确认其__libc_start_main偏移是否为0x26c000x...c00printf偏移是否为0x64a00x...9a0。如果匹配那么版本就确定了。从该页面我们查到system偏移0x55410str_bin_sh偏移0x1b75aa4.3 第三步计算关键地址并构造写Payload现在我们可以计算所有需要的地址了。# 假设我们确认 leak2 是 __libc_start_main240 libc_start_main_leak int(leak2_str, 16) # 0x7f12a2f8c00 # 从数据库得知该版本libc中 __libc_start_main 的偏移是 0x26c00 # 注意泄露的地址通常是 __libc_start_main240所以需要减去240得到函数实际地址 libc_start_main_addr libc_start_main_leak - 240 libc_base libc_start_main_addr - 0x26c00 system_addr libc_base 0x55410 binsh_addr libc_base 0x1b75aa log.success(f“Libc Base: {hex(libc_base)}“) log.success(f“System Addr: {hex(system_addr)}“) log.success(f“/bin/sh Addr: {hex(binsh_addr)}“)接下来是最关键的一步利用格式化字符串漏洞修改GOT表。我们需要知道要修改哪个函数的GOT表项。通常选择printf自身因为漏洞就在它身上修改后下一次调用printf就能触发。我们需要printf的GOT表地址。由于程序没开PIE这个地址是固定的可以通过objdump -R ./pwn6或readelf -r ./pwn6命令查看假设是0x601018。我们需要将0x601018处的内容原printf地址修改为system_addr。由于system_addr是一个8字节的大数直接一次性写入可能比较困难且会破坏栈。我们通常采用多次单字节或双字节写入%hhn或%hn。%n家族格式化符详解%n将目前已输出的字符数写入到对应参数指向的地址4字节。%hn写入2字节。%hhn写入1字节。%lln写入8字节64位。我们的策略是将system_addr这个8字节的值分4次每次2字节用%hn或8次每次1字节用%hhn写入到printf_got开始的连续内存中。这里以%hn为例我们需要控制每次printf输出的字符数恰好等于system_addr对应2字节的值。例如system_addr 0x7f12a2ff5410。我们计划写入到地址0x601018。第一次写入低16位0x5410到0x601018。第二次写入次低16位0xff54到0x60101a。第三次写入0xa2ff到0x60101c。注意这里0xff54和0xa2ff的划分取决于字节序小端序下低地址存低位字节第四次写入高16位0x7f12到0x60101e。这需要精密的构造。更简单的方法是使用pwntools的fmtstr_payload函数它能自动生成复杂的Payload。# 确定要写入的地址和值 printf_got 0x601018 # fmtstr_payload(offset, {addr: value}) # offset 是格式化字符串中我们控制的第一个参数在栈上的位置。 # 我们需要通过调试来确定这个offset。 # 通常我们发送类似‘AAAA%p%p%p%p%p%p’的字符串观察输出中‘AAAA’即0x41414141出现在第几个%p的位置。 # 假设‘AAAA’出现在第6个%p输出的位置那么offset就是664位下通常从栈上第7个‘参数’开始算但pwntools的offset计算已经考虑了这一点通常我们测试出的数字就是offset。 offset 6 # 这个值需要根据实际调试确定 # 生成payload payload2 fmtstr_payload(offset, {printf_got: system_addr}) io.sendlineafter(b‘Enter your data:’, payload2)fmtstr_payload会生成一个包含地址和大量填充字符的字符串利用%n一次性或分多次完成写入。4.4 第四步触发system并获取shell修改完GOT表后程序下一次调用printf时实际上会跳转到system。我们需要让这次“调用”的参数是“/bin/sh”。通常程序会在漏洞函数结束后再次读取用户输入并调用printf。我们只需要在修改GOT表之后发送字符串“/bin/sh”。# 发送 /bin/sh 作为下一次 printf 的“格式化字符串”实际上会成为 system 的参数 io.sendlineafter(b‘Enter your data:’, b‘/bin/sh\x00’)如果一切顺利system(“/bin/sh”)就会被执行我们将得到一个shell。io.interactive()5. 调试技巧与常见问题排查理论很美好但实战中总会遇到各种问题。下面分享一些调试技巧和常见坑点。5.1 如何确定格式化字符串的偏移offset这是利用成功的第一步。手动确定的方法如下发送一个包含唯一模式串和多个%p的字符串例如payload b‘ABCDEFGH%p.%p.%p.%p.%p.%p.%p.%p.%p.%p’。接收输出查看ABCDEFGH对应的十六进制值0x4847464544434241出现在第几个%p的输出中。注意64位下参数可能从第5、6或7个开始出现需要多试几次。pwntools的FmtStr类可以自动探测。from pwn import * io process(‘./pwn6’) def send_payload(payload): io.sendlineafter(b‘:’, payload) return io.recvline() fmtstr FmtStr(execute_fmtsend_payload) log.info(f“Offset found: {fmtstr.offset}“)5.2 泄露的地址看起来不对怎么办地址包含0x0a或0x00printf遇到\x00字符串终止符或\x0a换行符可能会提前停止输出。在构造泄露payload时尽量将地址放在格式化字符串的末尾或者使用%p而非%s来泄露因为%s遇到\x00会停止。地址全是(nil)或很小的数字可能偏移计算错了你泄露的是栈上更浅的位置那里可能存放的是局部变量或数字参数。尝试增加%p的数量或者使用%lx来泄露8字节数据。泄露出的地址每次运行都完全不同包括低12位这可能意味着程序开启了PIE位置无关执行。你需要先泄露程序自身的地址如main函数的返回地址它指向__libc_start_main但也在程序空间内计算出程序的基地址然后才能得到GOT表的实际地址。这需要更复杂的利用链。5.3 使用fmtstr_payload失败的可能原因偏移不对这是最常见的原因。务必用上述方法精确测定offset。写入长度限制fmtstr_payload生成的payload可能很长如果程序用fgets(buf, 100, stdin)那么缓冲区只有100字节。Payload过长会被截断。此时需要手动构造更精简的payload例如优先使用%hhn单字节写入或者选择只覆盖地址的低2字节Partial Overwrite如果system和原函数地址差距不大的话。栈对齐问题64位系统对栈地址有对齐要求通常是16字节对齐。如果payload中的地址没有正确对齐可能会导致程序崩溃。pwntools的fmtstr_payload通常会处理对齐但手动构造时需要注意。坏字符某些字符在输入中可能被过滤或转义比如空格 、换行\n、空字节\x00。printf的%n写入时如果输出的字符数包含坏字符可能会出问题。需要调整写入顺序和填充字符来绕过。5.4 修改GOT表后程序崩溃GOT表不可写检查程序是否开启了RELRO保护。RELRO分为Partial RELRO和Full RELRO。Partial RELRO下GOT表可写Full RELRO下GOT表只读。如果是Full RELRO则不能修改GOT表需要寻找其他利用点比如修改栈上的返回地址或函数指针。检查命令checksec ./pwn6覆盖了错误地址确保你写入的地址确实是printf的GOT表项。用调试器如gdb在发送payload前后查看0x601018地址处的内存值是否被修改。system地址计算错误这是无Libc题目中最容易出错的地方。务必确认泄露的地址确实是Libc中的函数地址。从数据库查到的偏移是正确的并且对应了正确的函数例如泄露的是__libc_start_main240查询时要用__libc_start_main的偏移并记得减去240。计算基地址的公式是libc_base leaked_func_addr - func_offset_in_libc。6. 总结与思维延伸通过pwn6-printf这道题我们完成了一次完整的、在无Libc环境下的格式化字符串漏洞利用。其核心思路可以概括为泄露 - 查询 - 计算 - 篡改 - 触发。这不仅是解一道CTF题更是理解现实世界中漏洞利用的缩影。在真实环境中攻击者同样面临未知的系统库版本。这道题带给我们的启示远不止于此。它强迫我们深入理解调用约定、栈布局、动态链接的过程GOT/PLT以及如何利用公开的信息库Libc数据库来弥补环境知识的不足。这种“信息搜集与拼图”的能力在高级漏洞利用中至关重要。在实际漏洞挖掘和利用中格式化字符串漏洞已经比较少见因为现代编译器和安全教条都会对printf等函数的使用发出严厉警告。但它的利用思想——通过控制输入来影响程序对数据的解释进而实现信息泄露和内存篡改——却渗透在许多其他漏洞类型中比如某些XML解析器、模板注入漏洞等。最后对于想深入学习PWN的朋友我的建议是不要只满足于跑通一个Exp脚本。一定要用调试器GDB配合PEDA或pwndbg一步步跟踪观察栈的变化理解每一个%p输出对应着内存中的哪个位置理解%n写入时究竟修改了哪里的值。这个过程虽然痛苦但却是从“脚本小子”走向真正理解者的必经之路。当你能够在不依赖自动化工具的情况下手工构造出利用payload时你对系统底层原理的理解将会达到一个新的高度。