Apache Struts2 文件上传逻辑绕过(CVE-2024-53677)(S2-067)

Apache Struts2 文件上传逻辑绕过(CVE-2024-53677)(S2-067)

前言

Apache官方公告又更新了一个Struts2的漏洞,考虑到很久没有发无密码的博客了,再加上漏洞的影响并不严重,因此公开分享利用的思路。

分析

影响版本

Struts 2.0.0 - Struts 2.3.37 (EOL), Struts 2.5.0 - Struts 2.5.33, Struts 6.0.0 - Struts 6.3.0.2

环境搭建

Struts2的环境搭建比较简单,分析时使用了两种不同漏洞场景的代码

UploadsAction对应多文件上传的场景,也是最简单的场景,不需要任何其他背景知识方便理解

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

import com.opensymphony.xwork2.ActionSupport;
import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class UploadsAction extends ActionSupport {

private static final long serialVersionUID = 1L;
private List<File> upload;
private List<String> uploadContentType;
private List<String> uploadFileName;
private List<String> uploadedFileNames = new ArrayList<String>();

public List<File> getUpload() {
return upload;
}

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

public List<String> getUploadContentType() {
return uploadContentType;
}

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

public List<String> getUploadFileName() {
return uploadFileName;
}

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

public List<String> getUploadedFileNames() {
return uploadedFileNames;
}

public String doUpload() {
for (int i = 0; i < uploadFileName.size(); i++) {
uploadedFileNames.add(uploadFileName.get(i));
}
return SUCCESS;
}

}

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

import com.opensymphony.xwork2.ActionSupport;
import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class UploadAction extends ActionSupport {

private static final long serialVersionUID = 1L;


private File upload;
private String uploadContentType;
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() {
return SUCCESS;
}

}

struts.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?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="">/file.jsp</result>
</action>
</package>
<package name="uploads" extends="struts-default">
<action name="uploads" class="com.struts2.UploadsAction" method="doUpload">
<result name="success" type="">/files.jsp</result>
</action>
</package>
</struts>

file.jsp

1
2
3
<%@page contentType="text/html; charset=UTF-8" language="java" %>
<%@ taglib prefix="y4tacker" uri="/struts-tags"%>
上传的文件名是:<y4tacker:property value="uploadFileName" />

files.jsp

1
2
3
4
5
6
7
8
9
10
11
<%@page contentType="text/html; charset=UTF-8" language="java" %>
<%@ taglib prefix="y4tacker" uri="/struts-tags"%>
<y4tacker:if test="uploadedFileNames.size() > 0">
文件上传成功:
<y4tacker:iterator value="uploadedFileNames">
<li><y4tacker:property /></li>
</y4tacker:iterator>
</y4tacker:if>
<y4tacker:else>
no files.
</y4tacker:else>

web.xml

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
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0"
metadata-complete="true">

<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>

</web-app>

目录结构如下

image-20241216204105946

前置知识

由于是S2-066的绕过,所以需要对上一个漏洞的原理有所了解,在我上一篇文章中Apache Struts2 文件上传分析(S2-066)对此有详细的介绍,这里就不详细描述了,对于上一个漏洞,官方的修复也很暴力,在FileUploadInterceptor中设置参数时,忽略大小写遍历删除同名参数再做添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public HttpParameters appendAll(Map<String, Parameter> newParams) {
this.remove(newParams.keySet());
this.parameters.putAll(newParams);
return this;
}

public HttpParameters remove(Set<String> paramsToRemove) {
Iterator var2 = paramsToRemove.iterator();

while(var2.hasNext()) {
String paramName = (String)var2.next();
this.parameters.entrySet().removeIf((p) -> {
return ((String)p.getKey()).equalsIgnoreCase(paramName);
});
}

return this;
}

S2-067,不同于以往的漏洞分析,这一次不能通过官方的commits对比快速定位漏洞原因

image-20241216205557836

原因是官方直接使用了一个新的类,在官方文档中,告诉我们在处理上传时推荐使用新的拦截器org.apache.struts2.interceptor.ActionFileUploadInterceptor

简单分析不难看到,其与之前的org.apache.struts2.interceptor.FileUploadInterceptor最大的区别在于,这一次并没有参数存储的过程,因此也不存在变量覆盖的问题

image-20241216210259192

失败的尝试

在一开始,没有其他背景知识的情况下,我的第一个思路是java.lang.String#equalsIgnoreCase是否安全?

查看Java的实现可以看到,在regionMatches中对于每个字符的比较过程中都是同时转小写以及大写做比较

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
public boolean equalsIgnoreCase(String anotherString) {
return (this == anotherString) ? true
: (anotherString != null)
&& (anotherString.value.length == value.length)
&& regionMatches(true, 0, anotherString, 0, value.length);
}

public boolean regionMatches(boolean ignoreCase, int toffset,
String other, int ooffset, int len) {
char ta[] = value;
int to = toffset;
char pa[] = other.value;
int po = ooffset;
// Note: toffset, ooffset, or len might be near -1>>>1.
if ((ooffset < 0) || (toffset < 0)
|| (toffset > (long)value.length - len)
|| (ooffset > (long)other.value.length - len)) {
return false;
}
while (len-- > 0) {
char c1 = ta[to++];
char c2 = pa[po++];
if (c1 == c2) {
continue;
}
if (ignoreCase) {
// If characters don't match but case may be ignored,
// try converting both characters to uppercase.
// If the results match, then the comparison scan should
// continue.
char u1 = Character.toUpperCase(c1);
char u2 = Character.toUpperCase(c2);
if (u1 == u2) {
continue;
}
// Unfortunately, conversion to uppercase does not work properly
// for the Georgian alphabet, which has strange rules about case
// conversion. So we need to make one last check before
// exiting.
if (Character.toLowerCase(u1) == Character.toLowerCase(u2)) {
continue;
}
}
return false;
}
return true;
}

在这个时候,突然想到phithon曾写过一篇关于:Fuzz中的javascript大小写特性的文章

同样的,有个天马行空的思路就是,有没有可能存在一些字符它的大写等于另一个字符的小写呢?如果存在这种情况,在后面参数绑定过程中ognl.OgnlRuntime#capitalizeBeanPropertyName做参数处理时又通过对其转大写还原成正常的字母

image-20241216204904759

很可惜,跑了很久的代码并没有发现存在这样的情况🤪那么

(Ps: 当然这其中不止失败了一次,期间也想过很多不同的思路,当然都是以失败告终🥱)

Struts2的参数绑定

​ 在上文中提到了,新版的Struts2文件上传拦截器没有参数存储的过程,那么很容易联想到漏洞的利用还是与参数相关,Struts2中对于参数绑定通过Ognl表达式实现,具体实现在com.opensymphony.xwork2.interceptor.ParametersInterceptor拦截器中

image-20241216211033904

简单发一个上传的包Debug做验证

image-20241216211429703

com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters中,有着对参数字符的限制函数,只有isAcceptableParameter条件为true才能做接下来的参数绑定

image-20241216211708349

这部分限制还是满死的,毕竟历史上Struts2被爆出无数RCE漏洞,其中修修补补无数(没学过的自己去补补课),因此想要绕过各种个样限制直接完成RCE是极为困难的。另外在这里,我也不会把所有的参数限制条件列举出来,哪里卡住绕哪里即可,这里就浪费时间讲解一些不重要的过程,当然有兴趣也可以具体看看各个限制条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// com.opensymphony.xwork2.interceptor.ParametersInterceptor#isAcceptableParameter
protected boolean isAcceptableParameter(String name, Object action) {
ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null;
return this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name));
}

// com.opensymphony.xwork2.interceptor.ParametersInterceptor#isAcceptableParameterValue
protected boolean isAcceptableParameterValue(Parameter param, Object action) {
ParameterValueAware parameterValueAware = action instanceof ParameterValueAware ? (ParameterValueAware)action : null;
boolean acceptableParamValue = parameterValueAware == null || parameterValueAware.acceptableParameterValue(param.getValue());
if (this.hasParamValuesToExclude() || this.hasParamValuesToAccept()) {
acceptableParamValue &= this.acceptableValue(param.getName(), param.getValue());
}

return acceptableParamValue;
}

在这里,RCE的宏伟目标就暂不考虑了,我们只需要知道既然Struts2使用了Ognl做参数绑定的实现,那么便可以尝试通过参数绑定的过程去实现对上传文件名的修改,从而绕过系统对于目录穿越的限制

S2-067之多文件上传场景绕过

回到本身,简单整理下漏洞绕过的思路,用一句话来概括就是:

在参数名与文件上传参数不一致的前提下,能通过Ognl参数绑定过程对文件名做修改

在多文件上传情景下,为方便调试,首先简单构造一个上传多文件的数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /uploads.action HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryq0PW93h6lyBzjZNZ
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
Content-Length: 138

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

y4tacker
------WebKitFormBoundaryq0PW93h6lyBzjZNZ
Content-Disposition: form-data; name="Upload";filename="2.txt"
Content-Type: text/plain

1
------WebKitFormBoundaryq0PW93h6lyBzjZNZ--

在这个场景下如何使用简单的Ognl表达式对文件名做赋值呢?

由于在这里我们的uploadFileName是列表的格式

image-20241216215633895

我们很容易想到使用中括号写法uploadFileName[0]的形式对其中的文件名做修改,简单在控制台尝试,在这里成功对我们的文件名做了修改

image-20241216214230468

在这个场景下,很容易验证得到绕过的Poc,在自己尝试时同样别忘了参数保存是在TreeMap中,这是个有序列表,简单解释下尽管在FileUploadInterceptor中参数保存在无序的HashMap中了,但是在com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters完成参数绑定的过程中重新用了有序的Treemap做包装

image-20241217121431646

因此错误的大小写以及参数名会影响其排列顺序,导致文件名无法覆盖(S2-066的时候也讲过)

image-20241216215221263

S2-067之单文件上传场景绕过

同样的Payload放在单文件上传的场景自然而然就失效了,毕竟我们的uploadFileName在这里只是一个String类型的变量

image-20241216215916972

同样的为了完成文件名的修改,我们依旧需要在参数名与文件上传参数不一致的前提下,通过Ognl参数绑定过程对文件名做修改

在讲解之前我们需要知道一个概念,在Ognl中有个重要的概念叫做值栈,值栈主要目的是为了让能方便的访问Action的属性

在Struts2中默认的实现为OgnlValueStack,Struts2在执行一次请求的过程中会把当前的Action对象自动存入值栈中,

因此我们只要能获取到这个对象就能完成对文件名的修改

为了方便调试Ognl语句,我们首先构造一个正常的Http流量包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /upload.action HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryq0PW93h6lyBzjZNZ
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
Content-Length: 138

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

y4tacker
------WebKitFormBoundaryq0PW93h6lyBzjZNZ--

在Struts2中我们可以使用[0]获取整个栈对象,为方便显示转换为String对象,调用其 toString()方法输出对象信息,可以看到栈顶元素即为我们的Action对象

image-20241216222946380

因此我们可以使用top关键词直接获取到栈顶的Action对象,从而获取到FileName参数

image-20241216223426658

因此我们可以尝试使用[0].top.UploadFilename来对文件名做修改,但显然从返回结果来看并没有成功

image-20241216223556486

经过调试发现,这里的isAcceptableParameter返回了false

image-20241216223739866

没通过的条件是com.opensymphony.xwork2.interceptor.ParametersInterceptor#isAccepted

对应的表达式为\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[\u4e00-\u9fa5]-?)+'])|(\('(\w-?|[\u4e00-\u9fa5]-?)+'\)))*

image-20241216224001694

没通过的原因很简单[0]前面不能为空

image-20241216224332972

这个条件Bypass也很简单,在表达式中[0].top等价于top

image-20241216224605814

最终我们成功实现了在单文件上传场景下的绕过

image-20241216224806731

参考文章

https://developer.aliyun.com/article/330800

https://paper.seebug.org/794

https://cwiki.apache.org/confluence/display/WW/S2-067

https://y4tacker.github.io/2023/12/09/year/2023/12/Apache-Struts2-%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E5%88%86%E6%9E%90-S2-066/