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),指定具体的数据类型,有以下选择

字符 说明
%d 打印十进制整数(int)
%f 打印浮点数(float/double)
%.2f 打印浮点数,保留小数点后2位
%s 打印字符串(char*)
%c 打印单个字符(char)
%x 打印十六进制(小写)
%% 输出一个百分号 %

其中只有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.14;
char *str = "hello";

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

栈的大概结构:

1
2
3
4
5
6
7
8
9
10
11
+----------------------+
| 返回地址(printf结束后跳转) |
+----------------------+
| 格式字符串地址 | --> "Int: %d, Float: %f, String: %s\n"
+----------------------+
| 参数3(str) | --> 指向 "hello"
+----------------------+
| 参数2(b) | --> float/double 值:3.14
+----------------------+
| 参数1(a) | --> 整数值:10
+----------------------+

函数会从栈里正常读取指定的变量的值。

而当我们在使用格式化字符串函数但是并没有给定具体变量的情况下:

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

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

则会从栈中依次读取未定义的值作为参数进行格式化输出。

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

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

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

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

Exploit技巧

确定偏差

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

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

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

修改内存

可以通过上面提到的危险的%n来修改内存中的值:比如说

1
2
3
pwnme_addr = 0x0804A068

payload = p32(pwnme_addr) + b'aaaa%10$n'

可以将这个地址的值修改为8。

例题