格式化字符串漏洞
格式化字符串漏洞
初学pwn,学到了格式化字符串漏洞,总结一下。
格式化字符串函数:格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。
漏洞printf(s)
用 printf() 为例,它的第一个参数就是格式化字符串 :”Color %s,Number %d,Float %4.2f”
然后 printf 函数会根据这个格式化字符串来解析对应的其他参数
%d - 十进制 - 输出十进制整数
%s - 字符串 - 从内存中读取字符串
%x - 十六进制 - 输出十六进制数
%c - 字符 - 输出字符
%p - 指针 - 指针地址
%n - 到目前为止所写的字符数
%hhn - 写1字节
%hn - 写2字节
%ln - 写4个字节
%lln - 写8字节
格式转换
格式化字符串是由普通字符(包括%)和转换规则构成的字符序列。普通字符被原封不动地复制到输出流中。转换规则根据与实参对应的转换指示符对其进行转换,然后将结果写入到输出流中。
转换规则由可选的部分和必选部分组成。其中只有转换指示符type是必选部分,用来表示转换类型。
可选部分如下:
可选部分的 parameter比较特殊,他是一个POSIX扩展,不属于C99,用于指定某个参数,例如**%2$d**,表示输出后面的第二个参数。
标志(flags)用来调整输出和打赢的符号,空白,小数点等。
宽度(width)用来指定输出字符的最小个数。
精度(.precision)用来指示打印符号个数,小数点位数或者有效数字个数。
长度(length)用来指定参数的大小。
1 | %[parameter][flags][width][.precision][length]type |
漏洞原理
格式化字符串漏洞从2000年左右开始流行起来,几乎在各种软件中都能见到它的身影,随着技术的发展,软件的安全性的提升,现在在PC段已经比较少见了,但是在物联网设备上依然层出不穷。2001年USENIX security会议上发表的文章为glibc提供了一个对抗格式化字符串漏洞的patch,通过静态分析检查参数个数与格式化字符串是否匹配。另一项安全机制FORTIFY_SOURCE也让该漏洞的利用更加困难。
基本原理
在X86结构下,格式化字符串的参数是通过栈传递的。
1 | #include<stdio.h> |
根据cdecl的调用约定,在进入printf函数之前,程序将参数从右到左依次压栈。进入printf()之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是“%”,那么字符被直接复制到输出。否则,读取下一个非空字符,获取相应的参数并解析输出。
接下来我们修改上面的程序,给格式化字符串加上“%x %x %x %3$s”,使它出现格式化字符串漏洞。
1 | 0x565561f6 <main+41> lea edx, [eax - 0x1fce] |
从反汇编代码来看没有任何区别。所以我们重点关注参数传递。程序打印出来了四个值,参数只有三个。
如果我们将程序里面的格式化字符省略,转为由外部输入。
1 | 1 |
如果大家都正常输入字符,程序不会有问题,但如果我们在s里面输入一些转换指示符。那么printf()会把它当成格式化字符串解析,漏洞由此发生。
格式化字符串漏洞的发生条件就是格式化字符串要求的参数和实际上提供的参数不匹配。
漏洞利用原理
对于格式化字符串漏洞的利用主要有:使程序崩溃,栈数据泄露,任意地址内存泄露,栈数据覆盖,任意地址内存覆盖。
程序崩溃
这种攻击方法最简单,只需要输入一串 %s 就可以
1 | %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 来获取变量所对应地址的内容,只不过有零截断
利用 %n$x 来获取指定参数的值,利用 %n$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 fs1 查看一下 got 表
%s 是把地址指向的内存内容给打印出来,可以把 函数的地址给打印出来。
覆盖栈内存
%n,不输出字符,但是把已经成功输入的字符个数写入对应的整型指针参数所指的变量,只要变量对应的地址可写,就可以利用格式化字符串来改变其对应的值。
一般来说,利用分为以下的步骤:
确定覆盖地址
确定相对偏移
进行覆盖
源文件
1 |
|
关于覆盖偏移的话可以通过测试得出来:
1 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+%012d+%6$n
c_addr 再加上 12 之后才能凑够 16,这样就可以把 c 改成 16。
%n可以将对应参数地址存储的值给改写。
覆盖任意地址内存
覆盖小数字
如果想要将一个地方改为一个较小的数字,只需要 %n 是 数字 就可以了,如果想改成 2,可以用 aa%k$n,但是有个问题,之前我们是把地址放在前面,加上地址(4或8字节)之后就成了一个至少比 4 大的数
aa%k$nxx,如果用这样的方式,前面 aa%k 是第六个参数,$nxx 是第七个参数,后面在跟一个 我们想要修改的地址,那么这个地址就是第八个参数,只需要把 k 改成 8 就可以把这第八个参数改成 2,aa%8$nxx。
1 | from pwn import * |
这里掌握的小技巧:没有必要把地址放在最前面,只需要找到它对应的偏移就可以。
覆盖大数字
变量在内存中都是以字节的格式存储的,在 x86、x64 中是按照小端存储的,格式化字符串里面有两个标志用的上了:
h:对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数
hh:对于整型类型,printf 期待一个从 char 提升的 int 尺寸的整形参数
意思是说:hhn 写入的就是单字节,hn 写入的就是双字节。
1 | from pwn import * |
前面的那一串 p32(),每算是 4 字符,这样到 %6$hhn 前面就是:16+104=120,也就是 0x78
再加上 222 就是 342,也就是 0x156,然后依次是:0x234、0x312,又因为 hh 是写入单字节的,又是小端存储,也就是只能取后边两个,所以连起来就是 0x12345678
ps:
对于格式化字符串漏洞的题可以用pwntools的工具fatstr_payload()来简化构造payload。
fmtstr_payload(offset, writes, numbwritten=0, write_size=‘byte’)
第一个参数表示格式化字符串的偏移;
第二个参数表示需要利用%n写入的数据,采用字典形式,我们要将printf的GOT数据改为system函数地址,就写成{printfGOT:
systemAddress};本题是将0804a048处改为0x2223322
第三个参数表示已经输出的字符个数,这里没有,为0,采用默认值即可;
第四个参数表示写入方式,是按字节(byte)、按双字节(short)还是按四字节(int),对应着hhn、hn和n,默认值是byte,即按hhn写。
fmtstr_payload函数返回的就是payload
但是我们一般用的格式是
1 | fmtstr_payload(offset, {printf_got: system_addr})(偏移,{原地址:目的地址}) |
这是专门为32位格式化漏洞的函数。
下面是函数的源代码:
1 | def fmt(prev, word, index): |