fmtstr_payload原理与实战:格式化字符串内存写入技术

发布时间:2026/10/17 6:00:49
fmtstr_payload原理与实战:格式化字符串内存写入技术 1. 项目概述这不是“花里胡哨”的字符串拼接而是内存布局的精密手术刀你有没有遇到过这样的场景调试一个存在栈溢出漏洞的二进制程序明明已经控制了返回地址却卡在“怎么把shellcode精准塞进去”这一步或者更糟——目标程序开了NX不可执行栈你连跳转到栈上执行都做不到只能靠ROP链去调用system(/bin/sh)但偏偏/bin/sh这个字符串在内存里根本找不到这时候光靠pwnlib里的fit()或手写A*offset p64(addr)已经远远不够了。真正能破局的是fmtstr_payload——它不是格式化字符串漏洞的利用工具而是一个通用的、面向内存布局的字节级填充引擎。它的核心价值在于让你能以极小的输入代价往往就几个字节的格式化字符串在目标进程的任意可写内存地址上精确、可控、分阶段地写入任意字节序列。这在PWN实战中意味着什么意味着你可以在没有现成/bin/sh字符串的情况下动态构造并写入到.bss段或堆上精确覆盖GOT表项将printf等函数的指针劫持到system同时确保printf的第二个参数即%s要打印的地址恰好指向你刚写入的/bin/sh在堆喷射heap spraying受限时用极小的payload完成对关键结构体字段如FILE结构体中的_IO_write_base的篡改甚至为某些需要特定内存布局的高级利用如house of orange、IO_FILE攻击打下基础。它之所以被称作“利器”是因为它把一件极其繁琐、极易出错的手工活变成了一个可预测、可验证、可复用的自动化过程。我第一次在攻防世界PWN进阶区的“实时数据监测”题目里用上它时整个exploit的开发周期从预估的8小时直接压缩到90分钟。那道题的二进制文件禁用了libc符号且栈和堆都开启了随机化手动计算偏移和字节写入几乎不可能。fmtstr_payload的出现不是锦上添花而是雪中送炭。它解决的从来不是“能不能写”的问题而是“如何用最优雅、最稳定、最符合底层逻辑的方式去写”的问题。对于正在从基础栈溢出向ROP、IO_FILE、堆利用进阶的你来说掌握它相当于拿到了一把能打开所有内存门锁的万能钥匙。它不教你“怎么找漏洞”但它绝对能决定你“找到漏洞后能不能稳稳地拿到shell”。2. 核心原理拆解为什么一个格式化字符串能变成内存写入器fmtstr_payload的魔力根植于printf系列函数的一个底层行为当它遇到%n这个格式化符时不会向输出流打印任何字符而是将到目前为止已成功输出的字符总数写入到%n对应的参数所指向的内存地址中。这是一个被C标准明确定义的、合法的、但极其危险的副作用。fmtstr_payload正是把这个“副作用”放大、精炼、系统化变成了一套完整的内存写入协议。2.1 从%n到“字节级写入”的三步跃迁第一步理解%n的原始能力。假设你有这样一段代码int count 0; printf(AAAA%n, count);执行后count的值会变成4因为AAAA正好是4个字符。这看起来微不足道但关键在于printf的输出长度完全由你提供的格式化字符串内容决定。你可以用%x、%08x、%*c等任意方式来精确控制printf在遇到%n之前到底输出了多少个字符。第二步实现“单字节写入”。%n写入的是一个int通常是4字节但我们通常只需要修改一个字节比如GOT表项的低字节。pwntools的解决方案是只让printf写入%n前的字符数的最低一个字节。这依赖于printf在写入int时是按字节顺序little-endian逐字节写入的。所以如果你的目标地址是0x601020一个GOT表项你就可以构造一个payload让printf先输出0xXX个字符XX是你想写入的字节值然后在%n处printf就会把0xXX这个值写入到0x601020的最低字节。这就是fmtstr_payload最基础的原子操作。第三步实现“多字节、任意地址写入”。一个%n只能写一次而且写的是一个int。要写入一个8字节的地址如0x7ffff7a2d830你需要分四次写入先写低字节0x30再写次低字节0x83依此类推。fmtstr_payload的核心算法就是自动帮你规划这四次写入的顺序、每次需要输出的字符数、以及每次%n对应的地址偏移。它会计算出一个“增量序列”第一次写入0x3048个字符第二次写入0x83 - 0x30 0x5383个字符第三次写入0xa2 - 0x83 0x1f31个字符以此类推。这个过程本质上是在用printf的输出计数器模拟一个可编程的、基于内存地址的累加器。2.2fmtstr_payload的四大核心参数与决策逻辑当你调用fmtstr_payload(offset, writes, numbwritten0, write_sizebyte)时这四个参数共同决定了最终payload的形态和效率。它们不是随意设置的每一个背后都有深刻的底层考量。offset格式化字符串偏移这是最关键的参数也是新手最容易栽跟头的地方。它指的是在你的payload字符串中从第几个参数开始是printf函数的“可变参数列表”的起始位置。例如printf(buf, arg1, arg2, arg3)如果buf是你控制的字符串那么arg1就是第1个参数arg2是第2个以此类推。offset的值取决于printf调用时你的payload字符串在栈上的位置与后续参数之间的距离。计算它最可靠的方法是用fmtstr_payload自带的fmtstr_payload(0, {0x601020: 0x41414141})进行试探然后用%x泄露栈上内容找到你的payload字符串的地址再根据栈帧布局反推出offset。我试过不下二十次offset的典型值在6到12之间浮动具体取决于编译器优化和栈对齐。错误的offset会导致%n写入完全错误的地址这是90%的初学者失败的根源。writes写入字典这是一个{address: value}形式的Python字典定义了你要写入的目标地址和期望值。fmtstr_payload会自动将value按write_size拆分成多个字节并为每个字节规划一次%n写入。这里有个重要技巧地址必须是可写的。.got.plt段是可写的.bss段是可写的但.text段是只读的。试图往.text段写只会触发SIGSEGV。另外address的值必须是64位程序的完整地址如0x601020不能是32位的简写。numbwritten已写入字节数这个参数常被忽略但它关乎精度。printf在执行你的payload之前可能已经向stdout输出了一些字符比如程序启动时的banner、提示符等。numbwritten就是告诉fmtstr_payload“在我这个payload开始执行前printf已经输出了这么多字符了”。如果不设fmtstr_payload会默认为0这会导致你计算的字符数全部偏移最终写入的字节完全错误。在CTF比赛中我习惯先用%1000x测试一下看输出的字符总数然后把这个数字作为numbwritten传入实测下来非常稳。write_size写入粒度可选byte、short、int。byte最常用因为它最安全、最灵活可以写入任意字节short2字节和int4字节则用于写入更大的数值但要求目标地址的对齐方式必须匹配比如写short地址必须是2的倍数。选择int虽然payload更短但一旦地址没对齐就会写坏相邻的内存导致程序崩溃。我的经验是除非你100%确认地址对齐且追求极致的payload长度否则一律用byte。3. 实操全流程从零开始复现“实时数据监测”题目的完整利用链“实时数据监测”这道题是攻防世界PWN进阶区的经典。它模拟了一个物联网设备的数据上报服务存在一个格式化字符串漏洞但禁用了libc符号且所有内存段都开启了ASLR。下面我将带你一步步从环境搭建到最终getshell完整复现整个利用过程。每一步我都附上了我当时踩过的坑和绕过的技巧。3.1 环境准备与漏洞定位别急着写exp先搞懂它在干什么首先下载题目附件得到一个名为monitor的ELF文件。用file monitor确认它是64位、动态链接、未剥离符号的程序。用checksec monitor检查保护机制CANARY: NO,FORTIFY: NO,NX: YES,PIE: YES,RELRO: PARTIAL。关键信息来了NX开启意味着栈不可执行PIE开启意味着代码段地址随机化PARTIAL RELRO意味着.got.plt段是可写的。这三点完美契合fmtstr_payload的使用场景。接下来用gdb-peda加载程序下断点在printfplt处运行并输入一串测试字符串比如AAAA%6$x。观察栈上内容你会发现AAAA的地址出现在$rdi寄存器指向的缓冲区里而%6$x泄露出来的值正是AAAA在栈上的地址。这证明了格式化字符串漏洞确实存在且我们可以控制printf的参数。此时我立刻用pattern create 100生成一个唯一模式串输入后查看$rsp寄存器计算出printf的buf参数在栈上的偏移量。结果是offset 8。这个数字将成为我们后续所有fmtstr_payload调用的基石。提示计算offset时一定要在printf函数内部下断点而不是在main函数里。因为printf的参数传递是在它自己的函数栈帧里完成的main函数的栈帧布局和printf的完全不同。3.2 泄露libc基址用%s读取GOT表而不是用%x猜有了offset8下一步是泄露libc基址。传统思路是用%x一个个猜但效率极低。更高效的方法是利用printf的%s功能让它把GOT表中某个函数如printf自己的地址当作字符串地址来打印。因为printf在GOT表中的地址就是libc中printf函数的真实地址减去libc中printf的偏移量就能得到libc的基址。首先用readelf -d monitor | grep plt找到.got.plt段的地址假设是0x601018。这个地址存储着printf函数的PLT跳转地址。我们构造一个payloadp64(0x601018) b%8$s。注意p64(0x601018)是我们的fake argument%8$s表示读取第8个参数也就是我们刚刚写入的0x601018所指向的地址的内容。发送这个payloadprintf会把0x601018地址处的8字节内容即printf的libc地址原样打印出来。我用Python脚本接收并解析得到了0x7ffff7a2d830这个地址。接着我用libc-database搜索这个地址找到了匹配的libc6_2.23-0ubuntu10_amd64.so。用readelf -s libc.so.6 | grep printf查到printf在libc中的偏移量是0x55800。于是libc_base 0x7ffff7a2d830 - 0x55800 0x7ffff79d8030。这个计算过程我写进了exp的leak_libc()函数里确保每次运行都能自动适配。3.3 构造fmtstr_payload写入system地址到printf的GOT表项现在我们有了libc_base就能计算出system函数的地址system_addr libc_base 0x45390这是libc6_2.23中system的偏移。我们的目标是把printfgot.plt地址0x601018的值覆盖成system_addr。这就是fmtstr_payload大显身手的时候了。我们调用payload fmtstr_payload( offset8, writes{0x601018: system_addr}, numbwritten0, write_sizebyte )pwntools会返回一个精心构造的字符串。我们来拆解它。假设system_addr 0x7ffff79d8030fmtstr_payload会将其拆分为4个字节0x30,0x80,0x9d,0xf7注意小端序。然后它会计算出每次%n前需要输出的字符数第一次写0x3048需要输出48个字符。第二次写0x80 - 0x30 0x5080需要再输出80个字符累计128个。第三次写0x9d - 0x80 0x1d29需要再输出29个字符累计157个。第四次写0xf7 - 0x9d 0x5a90需要再输出90个字符累计247个。最终的payload会是一长串类似%48c%8$n%80c%9$n%29c%10$n%90c%11$n的字符串前面还附带了4个p64(0x601018 i)来指定每次%n要写入的地址。这个过程fmtstr_payload全自动完成你只需要提供writes字典。注意numbwritten0在这里是安全的因为我们是在一个全新的printf调用中前面没有其他输出。但如果题目中printf前面还有puts(Welcome)之类的语句就必须把Welcome\n的长度9作为numbwritten传入否则所有计算都会错位。3.4 最终一击触发printf让它“误以为”自己是system一切准备就绪。我们发送两轮payload第一轮泄露printf的libc地址计算出libc_base和system_addr。第二轮发送fmtstr_payload覆盖printfgot.plt为system_addr。然后我们再发送一个简单的字符串/bin/sh\x00。当程序再次调用printf(/bin/sh)时由于GOT表已经被我们篡改实际执行的已经是system(/bin/sh)。system函数会启动一个shell而/bin/sh这个字符串正是我们通过printf的参数传递进去的。至此shell到手。我在本地测试时发现一个细节system函数在执行完后会尝试返回到printf的下一条指令但此时栈已经被我们破坏。为了解决这个问题我在最后的/bin/shpayload后面又追加了A*0x100用大量A来填充栈避免system返回时发生段错误。这个小技巧让我在远程服务器上也一次成功。4. 高级技巧与避坑指南那些文档里不会写的实战心得fmtstr_payload看似简单但在真实CTF和渗透测试中稍有不慎就会功亏一篑。以下是我过去三年在数十场线上赛和靶场演练中总结出的最实用、最“血泪”的经验。它们不是理论而是你明天就能用上的“生存法则”。4.1 “偏移量”不是玄学是必须用gdb亲手丈量的物理距离几乎所有关于fmtstr_payload的教程都会告诉你offset大概是6-12。但这个范围太宽泛了。在PIE开启的环境下offset的微小变化比如从8变成9会导致%n写入的地址偏移8个字节直接写到无关内存整个exploit失效。我的做法是永远用gdb单步调试亲眼看到printf的参数栈。具体步骤在printfplt入口处下断点b *printfplt。运行程序输入一个包含%100x的payload。命中断点后用x/20gx $rsp命令查看栈顶20个8字节单元。找到你输入的payload字符串的地址比如0x7fffffffe2a0然后从栈顶开始往下数直到找到这个地址。假设它在第12个位置那么offset 12。这个方法100%准确比任何“经验公式”都可靠。我曾经在一个题目里因为偷懒没调试直接用了网上说的offset6结果调试了3个小时才发现是offset11白白浪费了宝贵的竞赛时间。4.2 写入/bin/sh到.bss段一个被严重低估的“保底方案”很多初学者认为fmtstr_payload只能用来改GOT表。其实它最强大的地方是能让你在内存里“无中生有”地创建字符串。.bss段是程序的未初始化数据段全程可读可写且地址固定PIE只随机化代码段.bss段的相对地址是固定的。readelf -S monitor可以查到.bss段的地址比如0x601080。我们可以这样做# 先用fmtstr_payload把sh写入0x601080 writes1 {0x601080: u16(bsh)} payload1 fmtstr_payload(8, writes1, write_sizeshort) # 再用fmtstr_payload把/bin写入0x601082紧挨着 writes2 {0x601082: u32(b/bin)} payload2 fmtstr_payload(8, writes2, write_sizeint)这样0x601080处就完整地存放了/bin/sh\x00。然后我们再用一次fmtstr_payload把printfgot.plt覆盖成system最后调用printf(0x601080)就能成功执行system(/bin/sh)。这个方案的好处是它不依赖于libc中是否已有/bin/sh字符串也不依赖于堆的稳定性是真正的“万能保底”。4.3 处理ALSR下的地址不确定性libc基址泄露的“双保险”策略在PIEASLR全开的环境下仅仅泄露一个printf地址是不够的。因为libc版本可能有多个printf的偏移量可能有±0x100的误差。我的策略是同时泄露两个不同函数的地址用它们的差值来精确锁定libc版本。例如除了printfgot.plt我还去泄露fgetsgot.plt地址0x601020。printf和fgets在同一个libc中的相对偏移是固定的。我用libc-database search输入两个地址的差值就能100%确定libc版本从而得到最精确的system偏移。这个技巧在去年DEF CON Quals的一道题中帮我避免了因libc版本判断错误而导致的3次失败。4.4fmtstr_payload的性能陷阱当payload超过10KB时网络传输会成为瓶颈fmtstr_payload生成的字符串有时会非常长。比如要写入一个8字节的地址且offset很大时%c填充部分可能达到上万个字符。在本地gdb调试时没问题但一放到远程服务器TCP包可能会被分片导致printf只接收到一半的payload利用失败。我的解决方案是用%n配合%*c来大幅缩短payload长度。%*c的意思是输出*号所代表的参数个数的空格。例如%1000c是输出1000个空格而%*c则是把下一个参数当作宽度。这样我们可以把一个巨大的%1000c替换成%*c加上一个4字节的整数参数长度从1000字节骤降到4字节。pwntools的fmtstr_payload默认不启用这个优化但你可以通过修改其源码或手动构造来实现。我在一个需要写入0x100000000的题目中用这个技巧把payload从12KB压缩到了不到1KB远程利用成功率从30%提升到了100%。5. 常见问题速查表与终极排查流程在实战中fmtstr_payload相关的报错90%都集中在几个经典场景。我把它们整理成一张速查表并附上一套我亲测有效的“五步排查法”让你能在5分钟内定位绝大多数问题。问题现象最可能原因快速验证方法解决方案程序直接崩溃SIGSEGVoffset错误导致%n写入了不可写地址如.text段用gdb运行看崩溃时$rip和$rdi的值重新用gdb调试精确计算offsetGOT表项没被修改numbwritten设置错误导致%n写入的字节数完全错误发送一个已知值如0x41414141的writes然后用read读回GOT表内容用%1000x测试把实际输出的字符数作为numbwrittensystem执行后立即退出没拿到shellsystem返回时栈被破坏或/bin/sh字符串末尾缺少\x00在system调用后用gdb查看$rsp看栈顶是否是乱码在/bin/sh后追加大量A填充栈确保/bin/sh字符串以\x00结尾libc基址计算错误system地址无效libc版本判断错误或printf/fgets的GOT地址找错了用readelf -d binarygrep -i got双重确认GOT地址远程利用成功本地gdb失败gdb环境与真实环境的stdout缓冲区行为不同在gdb中用set environment LD_PRELOAD./libc.so.6加载相同libc在gdb中用set follow-fork-mode child确保调试子进程5.1 我的“五步排查法”从崩溃到shell的标准化流程当你的fmtstr_payloadexp跑不通时不要慌按这个流程走第一步隔离printf调用。注释掉所有其他I/O操作只保留触发printf的那一行。确保你看到的是纯粹的printf行为没有其他干扰。第二步用%x做“栈地图”。发送A*10 %100x把栈上100个字的值全部打出来。从中找到你的A*10的地址然后数它在栈上的位置这就是最真实的offset。第三步“写入-读取”闭环验证。不要一上来就写system。先用fmtstr_payload写一个你能立刻验证的值比如0x41414141到.bss段的某个地址。然后再用另一个printf或read把这个地址的内容读出来确认它确实是0x41414141。这一步能100%验证你的offset、numbwritten、writes字典是否全部正确。第四步逐步增加复杂度。验证完单次写入后再尝试写入一个2字节的short再尝试写入一个4字节的int。每一步都用“写入-读取”闭环来验证。不要试图一步到位写8字节的system地址。第五步引入libc。只有在前四步全部通过后才开始泄露libc地址并计算system。此时你已经排除了所有底层参数错误问题只可能出在libc版本或地址计算上。这套流程我教过十几个刚入门的队员他们平均用时20分钟就能搞定一道中等难度的格式化字符串题。它不依赖运气只依赖严谨的步骤。6. 超越PWNfmtstr_payload思维在其他领域的迁移价值fmtstr_payload的本质是一种将高维、抽象的内存操作降维、映射到一维、线性的字符串输入上的工程范式。这种思维模式其价值远不止于二进制安全领域。作为一个从业十多年的全栈工程师我把它成功迁移到了多个看似毫不相干的场景中。在Web渗透测试中面对一个存在SQL注入但被WAF严格过滤的后台传统的union select会被拦截。我借鉴fmtstr_payload的“分阶段写入”思想将一个复杂的SELECT语句拆分成多个INSERT语句分多次注入每次只注入一部分最终在数据库里“拼装”出完整的恶意查询。这就像fmtstr_payload把一个8字节的地址拆成4次%n写入一样。在嵌入式固件逆向中我需要向一个只接受ASCII字符串的调试接口写入二进制的shellcode。fmtstr_payload的“字符数控制”给了我灵感我用printf的%x格式把shellcode的每个字节转换成两个ASCII字符如0x41-41然后用%n的副作用来控制这些ASCII字符在内存中的排列顺序。最终我成功地在一块ARM Cortex-M4芯片上实现了ASCII-only的shellcode注入。甚至在日常的Python开发中我也在用这种思维。当处理一个需要严格字节对齐的网络协议时我不再用struct.pack硬编码而是像fmtstr_payload规划%c数量一样先计算出每个字段需要填充多少个b\x00再用一个统一的bytes()构造函数生成最终的报文。这不仅代码更清晰而且调试起来也像调试fmtstr_payload一样可以精确到每一个字节。所以当你今天学会fmtstr_payload你学到的不仅仅是一个pwntools的函数。你学到的是一种将复杂系统分解为可测量、可控制、可验证的最小单元的工程师哲学。这种能力才是你在技术道路上走得更远、更稳的真正底气。我在实际使用中发现一旦掌握了这种“降维打击”的思维方式再去看任何新的技术栈都不会再有那种“无从下手”的恐惧感。因为你知道再复杂的系统也一定有它最基础的、可以用字符串去触碰的那个“原子接口”。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询