0%

Apache Struts2 S2-066 任意文件上传漏洞(CVE-2023-50164)

漏洞描述

由于文件上传逻辑存在缺陷,攻击者可以操纵文件上传参数来实现路径穿越,在某些情况下,通过上传的恶意文件可实现远程代码执行。

影响版本

2.5.0 <= Apache Struts <= 2.5.32
6.0.0 <= Apache Struts <= 6.3.0

环境搭建

使用idea创建一个maven-web项目,修改pom.xml文件添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<!-- Struts2 核心库 -->
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>2.5.30</version>
<!-- <version>2.5.33</version>-->
</dependency>

<!-- Servlet API -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>

编辑web.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
<display-name>Archetype Created Web Application</display-name>
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>

<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
</web-app>

在webapp目录下创建upload.jsp文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<%--
Created by IntelliJ IDEA.
User: hejixiong
Date: 2025/4/25
Time: 10:49
To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="upload">
<input type="text" name="uploadFileName" value="../../test.jsp">
<input type="submit" value="上传">
</form>
</body>
</html>

resources目录下创建struts.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.5//EN"
"http://struts.apache.org/dtds/struts-2.5.dtd">

<struts>
<package name="default" extends="struts-default">
<action name="upload" class="org.example.demo.UploadAction">
<interceptor-ref name="fileUpload">
<param name="maximumSize">52428800</param> <!-- 限制文件大小为50MB -->
</interceptor-ref>
<interceptor-ref name="defaultStack" />
<result name="success">/success.jsp</result>
<result name="error">/error.jsp</result>
</action>
</package>
</struts>

java目录下创建UploadAction.java

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
package org.example.demo;


import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.dispatcher.multipart.UploadedFile;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.file.Files;

public class UploadAction extends ActionSupport {
private UploadedFile upload;
private String uploadFileName;
private String uploadContentType;

public UploadedFile getUpload() {
return upload;
}

public void setUpload(UploadedFile upload) {
this.upload = upload;
}

public String getUploadFileName() {
return uploadFileName;
}

public void setUploadFileName(String uploadFileName) {
this.uploadFileName = uploadFileName;
}

public String getUploadContentType() {
return uploadContentType;
}

public void setUploadContentType(String uploadContentType) {
this.uploadContentType = uploadContentType;
}

@Override
public String execute() throws Exception {
System.out.println("uploadFileName:" + uploadFileName);
File file = new File(uploadFileName);
FileOutputStream fileOutputStream = new FileOutputStream(file);
File content = (File) upload.getContent();
FileInputStream fileInputStream = new FileInputStream(content);
byte[] fileContent = Files.readAllBytes(content.toPath());
fileOutputStream.write(fileContent);
fileOutputStream.flush();
fileOutputStream.close();

return super.execute();
}
}

配置调试环境
在idea高级版中由内置的tomcat调试器,选择添加后按照下图配置
img.png
配置应用上下文为/
img_1.png

漏洞成因

该漏洞因org.apache.struts2.interceptor.FileUploadInterceptor而起,我们也从这个类开始分析

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
public String intercept(ActionInvocation invocation) throws Exception {
ActionContext ac = invocation.getInvocationContext();

HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);

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(); // 这里需要将request包装成MultiPartRequestWrapper对象 这样在获取请求参数的时候参能获取到form表单里的参数
}

ValidationAware validation = null;

Object action = invocation.getAction(); // 得到UploadAction

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);
}
}

// bind allowed Files
Enumeration fileParameterNames = multiWrapper.getFileParameterNames(); // 文件参数 我们只上传了一个文件 所以这里只有一个
while (fileParameterNames != null && fileParameterNames.hasMoreElements()) {
// get the value of this input tag
String inputName = (String) fileParameterNames.nextElement(); // inputname即我们form表单类型为file的input标签的name属性值

// get the content type
String[] contentType = multiWrapper.getContentTypes(inputName);

if (isNonEmpty(contentType)) {
// get the name of the file from the input tag
// 这里会将multipart文件的那部分的filename属性的值进行规范化,检查是否存在`/`与`\`,
// 直接通过这个参数进行路径穿越是不可行的
String[] fileName = multiWrapper.getFileNames(inputName);

if (isNonEmpty(fileName)) {
// get a File object for the uploaded File
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);
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()])));
// 将文件内容 文件类型 文件名放到 newParams 里后又将器添加到actionContext的params参数中
// 漏洞的关键也在这里
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();
}

我们调试到appendAll方法里面查看各个变量的值

1
2
3
4
public HttpParameters appendAll(Map<String, Parameter> newParams) {
parameters.putAll(newParams);
return this;
}

img_2.png
通过观察可知,ActionContext的parameters变量在追加前就已经又一个元素了,这个元素来自我们文件上传时添加的一个input标签其name为uploadFileName,value为../../test.jsp
这个元素与UploadFileName只有首字母大小不一样。
当追加完成后parameters拥有了4个元素,且key为uploadFileName的元素位于第一个
img_5.png
关于FileUploadInterceptor拦截器的代码分析这里就告一段落了,当程序继续运行会运行到另一个拦截器ParametersInterceptor
该拦截器为当前请求对应的Action对象设置属性。 设置属性的方式是通过ActionContextparameters属性的值将首字母大写后与set拼接作为方法名进行发射调用。
如:属性uploadFileName对应的方法为setUploadFileNameUploadFileName对应的方法为setUploadFileName,这两个属性对应的方法相同,
那么调用的先后顺序是什么,调用是否会出现短路,选择哪一个进行调用都有讲究。

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
public String doIntercept(ActionInvocation invocation) throws Exception {
Object action = invocation.getAction();
if (!(action instanceof NoParameters)) {
ActionContext ac = invocation.getInvocationContext();
HttpParameters parameters = retrieveParameters(ac);

if (LOG.isDebugEnabled()) {
LOG.debug("Setting params {}", getParameterLogMap(parameters));
}

if (parameters != null) {
Map<String, Object> contextMap = ac.getContextMap();
try {
ReflectionContextState.setCreatingNullObjects(contextMap, true);
ReflectionContextState.setDenyMethodExecution(contextMap, true);
ReflectionContextState.setReportingConversionErrors(contextMap, true);

ValueStack stack = ac.getValueStack();
setParameters(action, stack, parameters);
} finally {
ReflectionContextState.setCreatingNullObjects(contextMap, false);
ReflectionContextState.setDenyMethodExecution(contextMap, false);
ReflectionContextState.setReportingConversionErrors(contextMap, false);
}
}
}
return invocation.invoke();
}

setParameters方法中parameters被依次遍历检查参数名是否符合约定,并将结果存储到一个新的treeMap中,此时4个参数的先后数据发生了变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected void setParameters(final Object action, ValueStack stack, HttpParameters parameters) {
HttpParameters params;
Map<String, Parameter> acceptableParameters;
if (ordered) {
params = HttpParameters.create().withComparator(getOrderedComparator()).withParent(parameters).build();
acceptableParameters = new TreeMap<>(getOrderedComparator());
} else {
params = HttpParameters.create().withParent(parameters).build();
acceptableParameters = new TreeMap<>();
}

for (Map.Entry<String, Parameter> entry : params.entrySet()) {
String parameterName = entry.getKey();

if (isAcceptableParameter(parameterName, action)) {
acceptableParameters.put(parameterName, entry.getValue());
}
}
...
}

这时我们发现uploadFileName 排在了UploadFileName的后面,这是不是意味着uploadFileName对应的setUploadFileName方法将UploadFilename设置的值覆盖掉呢?
img_6.png
setParameters方法中有一段代码是这样的,它将acceptableParameters的每个元素依次遍历出来然后调用newStacksetParameter方法。
这里在进行treeMap遍历的时候是有固定的先后顺序,UploadFileName会被uploadFileName先遍历。

1
2
3
4
5
6
7
8
9
10
11
for (Map.Entry<String, Parameter> entry : acceptableParameters.entrySet()) {
String name = entry.getKey();
Parameter value = entry.getValue();
try {
newStack.setParameter(name, value.getObject());
} catch (RuntimeException e) {
if (devMode) {
notifyDeveloperParameterException(action, name, e.getMessage());
}
}
}

而这个 newStack.setParameter最终会导致Action的属性setter的调用。
那么参数到方法名的转化是什么样的呢,我们直接到关键的地方,下面给出从setParameters方法到getDeclaredMethods之间的调用堆栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
getDeclaredMethods:2680, OgnlRuntime (ognl)
_getSetMethod:2912, OgnlRuntime (ognl)
getSetMethod:2881, OgnlRuntime (ognl)
hasSetMethod:2952, OgnlRuntime (ognl)
hasSetProperty:2970, OgnlRuntime (ognl)
setProperty:83, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3356, OgnlRuntime (ognl)
setValueBody:134, ASTProperty (ognl)
evaluateSetValueBody:220, SimpleNode (ognl)
setValue:308, SimpleNode (ognl)
setValue:780, Ognl (ognl)
execute:436, OgnlUtil$1 (com.opensymphony.xwork2.ognl)
execute:428, OgnlUtil$1 (com.opensymphony.xwork2.ognl)
compileAndExecute:523, OgnlUtil (com.opensymphony.xwork2.ognl)
setValue:428, OgnlUtil (com.opensymphony.xwork2.ognl)
trySetValue:186, OgnlValueStack (com.opensymphony.xwork2.ognl)
setValue:173, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameter:157, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameters:214, ParametersInterceptor (com.opensymphony.xwork2.interceptor)

OgnlRuntime#getDeclaredMethods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static List getDeclaredMethods(Class targetClass, String propertyName, boolean findSets) {
List result = null;
ClassCache cache = _declaredMethods[findSets ? 0 : 1];
Map propertyCache = (Map)cache.get(targetClass);
if (propertyCache == null || (result = (List)propertyCache.get(propertyName)) == null) {
synchronized(cache) {
Map propertyCache = (Map)cache.get(targetClass);
if (propertyCache == null || (result = (List)((Map)propertyCache).get(propertyName)) == null) {
String baseName = capitalizeBeanPropertyName(propertyName);
List result = new ArrayList();
collectAccessors(targetClass, baseName, result, findSets);
if (propertyCache == null) {
cache.put(targetClass, propertyCache = new HashMap(101));
}

((Map)propertyCache).put(propertyName, result.isEmpty() ? NotFoundList : result);
return result.isEmpty() ? null : result;
}
}
}

return result == NotFoundList ? null : result;
}

capitalizeBeanPropertyName 方法会对属性名进行处理,如uploadFileName会被处理为UploadFileName
img_7.png
然后调用 collectAccessors 查找对应的方法添加到result中。
img_8.png
最终在ognl.OgnlRuntime.invokeMethodInsideSandbox方法中完成Action属性的设置。
img_9.png
这样便完成了UploadActionuploadFileName属性的覆盖。
img_10.png
当用户程序未对uploadFileName的值进行检查的时候可能会导致可上传任意文件到任意目录,通过上传.jsp文件的方式便有可能造成代码执行漏洞,或者上传ssh密钥等。

修复方案

HTTPParameters中添加remove方法以删除通过表单上传的参数。
img_11.png
img_12.png

参考链接

Buy me a coffee.

欢迎关注我的其它发布渠道