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"?> <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:8080Connection : keep-aliveContent-Type : multipart/form-data; boundary=----WebKitFormBoundaryq0PW93h6lyBzjZNZUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36Content-Length : 138Content-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,在自己尝试时同样别忘了参数保存是在TreeMap
中,这是个有序列表,简单解释下尽管在FileUploadInterceptor
中参数保存在无序的HashMap
中了,但是在com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters
完成参数绑定的过程中重新用了有序的Treemap
做包装
因此错误的大小写以及参数名会影响其排列顺序,导致文件名无法覆盖(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:8080Connection : keep-aliveContent-Type : multipart/form-data; boundary=----WebKitFormBoundaryq0PW93h6lyBzjZNZUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36Content-Length : 138Content-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
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/