前言
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"?>
<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>
|
目录结构如下

前置知识
由于是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对比快速定位漏洞原因

原因是官方直接使用了一个新的类,在官方文档中,告诉我们在处理上传时推荐使用新的拦截器org.apache.struts2.interceptor.ActionFileUploadInterceptor
简单分析不难看到,其与之前的org.apache.struts2.interceptor.FileUploadInterceptor
最大的区别在于,这一次并没有参数存储的过程,因此也不存在变量覆盖的问题

失败的尝试
在一开始,没有其他背景知识的情况下,我的第一个思路是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; 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) { char u1 = Character.toUpperCase(c1); char u2 = Character.toUpperCase(c2); if (u1 == u2) { continue; } if (Character.toLowerCase(u1) == Character.toLowerCase(u2)) { continue; } } return false; } return true; }
|
在这个时候,突然想到phithon曾写过一篇关于:Fuzz中的javascript大小写特性的文章
同样的,有个天马行空的思路就是,有没有可能存在一些字符它的大写等于另一个字符的小写呢?如果存在这种情况,在后面参数绑定过程中ognl.OgnlRuntime#capitalizeBeanPropertyName
做参数处理时又通过对其转大写还原成正常的字母

很可惜,跑了很久的代码并没有发现存在这样的情况🤪那么
(Ps: 当然这其中不止失败了一次,期间也想过很多不同的思路,当然都是以失败告终🥱)
Struts2的参数绑定
在上文中提到了,新版的Struts2文件上传拦截器没有参数存储的过程,那么很容易联想到漏洞的利用还是与参数相关,Struts2中对于参数绑定通过Ognl表达式实现,具体实现在com.opensymphony.xwork2.interceptor.ParametersInterceptor
拦截器中

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

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

这部分限制还是满死的,毕竟历史上Struts2被爆出无数RCE漏洞,其中修修补补无数(没学过的自己去补补课),因此想要绕过各种个样限制直接完成RCE是极为困难的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| protected boolean isAcceptableParameter(String name, Object action) { ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null; return this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name)); }
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
Content-Disposition: form-data; name="Upload";filename="1.txt" Content-Type: text/plain
y4tacker
Content-Disposition: form-data; name="Upload";filename="2.txt" Content-Type: text/plain
1
|
在这个场景下如何使用简单的Ognl表达式对文件名做赋值呢?
由于在这里我们的uploadFileName是列表的格式

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

在这个场景下,很容易验证得到绕过的Poc,在自己尝试时同样别忘了参数保存是在Map
中的,错误的大小写会影响其排列顺序,导致文件名无法覆盖(S2-066的时候讲过,这里不再赘述)

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

同样的为了完成文件名的修改,我们依旧需要在参数名与文件上传参数不一致的前提下,通过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
Content-Disposition: form-data; name="Upload";filename="1.txt" Content-Type: text/plain
y4tacker
|
在Struts2中我们可以使用[0]
获取整个栈对象,为方便显示转换为String对象,调用其 toString()方法输出对象信息,可以看到栈顶元素即为我们的Action对象

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

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

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

没通过的条件是com.opensymphony.xwork2.interceptor.ParametersInterceptor#isAccepted
对应的表达式为\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[\u4e00-\u9fa5]-?)+'])|(\('(\w-?|[\u4e00-\u9fa5]-?)+'\)))*

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

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

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

参考文章
https://developer.aliyun.com/article/330800
https://paper.seebug.org/794