月初和southwind0师傅做代码审计时,发现了一个比较奇葩的问题。系统设置了全局的XSS过滤器,在其他功能点上生效了,但在一个公告发布功能没有过滤到。southwind0师傅通过对比数据包发现公告发布数据包是Multipart数据包(也就是我们常见的文件上传数据包)。后来我经过编写测试代码发现上传数据包确实无法过滤。
这让我不禁思考 “上传包可绕过Java过滤器?” ,如果是真的,那么问题很严重呀,以后过滤器岂不是都可以这样绕过,那这样全局XSS,SQL注入防御过滤器岂不是形同虚设?查了下网上大多数提供XSS过滤器代码基本都存在这个问题,我意识到问题的严重性,打算深入Tomcat和Spring MVC的底层代码一探究竟。
由于审计的代码属于敏感信息,我编写了一个和审计场景几乎一样的测试Demo用于本文的研究。测试Demo有get,post和upload页面用于测试Java过滤器对三种类型请求数据包的过滤情况。
package me.gv7.controller; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; @Controller @RequestMapping("/test") public class TestController{ @RequestMapping(value = "get",method = RequestMethod.GET) public String doGet(Model model, String str,String bbb){ System.out.println("[+] " + str); model.addAttribute("res",str); return "get"; } @RequestMapping(value = "post",method = RequestMethod.GET) public String post(){ return "post"; } @RequestMapping(value = "post",method = RequestMethod.POST) public String doPost(Model model,String str){ System.out.println("[+] " + str); model.addAttribute("res",str); return "post"; } @RequestMapping(value = "upload",method = RequestMethod.GET) public String upload(){ return "upload"; } @RequestMapping(value = "upload",method = RequestMethod.POST) public String doUpload(Model model,String str){/*@RequestParam("str") */ System.out.println("[+] " + str); model.addAttribute("res",str); return "upload"; } }
package me.gv7.filter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; public class XssHttpServletRequestWrapperextends HttpServletRequestWrapper{ public XssHttpServletRequestWrapper(HttpServletRequest request){ super(request); } public String[] getParameterValues(String parameter) { String[] values = super.getParameterValues(parameter); if (values==null) { return null; } int count = values.length; String[] encodedValues = new String[count]; for (int i = 0; i < count; i++) { encodedValues[i] = cleanXSS(values[i]); } return encodedValues; } public String getParameter(String parameter){ String value = super.getParameter(parameter); if (value == null) { return null; } return cleanXSS(value); } public String getHeader(String name){ String value = super.getHeader(name); if (value == null) return null; return cleanXSS(value); } private String cleanXSS(String value){ value = value.replaceAll("<", "<").replaceAll(">", ">"); // value = value.replaceAll("//(", " ").replaceAll("//)", " "); value = value.replaceAll("eval//((.*)//)", ""); value = value.replaceAll("alert//((.*?)//)", ""); value = value.replaceAll("confirm//((.*?)//)", ""); value = value.replaceAll("[///"///'][//s]*javascript:(.*)[///"///']", "/"/""); value = value.replaceAll("(?i)script", ""); return value; } }
<filter> <filter-name>XssFilter</filter-name> <filter-class>me.gv7.filter.XssFilter</filter-class> </filter> <filter-mapping> <filter-name>XssFilter</filter-name> <url-pattern>/*</url-pattern> <dispatcher>REQUEST</dispatcher> </filter-mapping>
想获取完整代码,请到公众号后台回复”上传包绕Java过滤器测试代码”
为了方便描述,我这里将请求分文三种,GET型请求,普通POST型请求和上传POST型请求。本文的普通型POST请求指的是处理上传POST型请求,而上传POST型请求就是我们上传包对应的请求。
为了更透彻的理解出现该问题的原因,我们需要搞清楚Spring MVC框架是如何获取到前端传来的HTTP请求的参数值。
前端提交的请求会先到达Tomcat服务器,其解析请求参数主要在 Request.parseParameters()
中进行。
//org.apache.catalina.connector.Request protected void parseParameters(){ this.parametersParsed = true; Parameters parameters = this.coyoteRequest.getParameters(); boolean success = false; try { parameters.setLimit(this.getConnector().getMaxParameterCount()); ... parameters.handleQueryParameters(); if (this.usingInputStream || this.usingReader) { success = true; } else if (!this.getConnector().isParseBodyMethod(this.getMethod())) { success = true; } else { // 获取请求包ContentType头 String contentType = this.getContentType(); ... // 如果请求ContentType为multipart/form-data,也就是上传POST if ("multipart/form-data".equals(contentType)) { //对上传包进行解析 this.parseParts(); success = true; return; } else if (!"application/x-www-form-urlencoded".equals(contentType)) { success = true; return; } else { int len = this.getContentLength(); ... parameters.processParameters(formData, 0, len); ... success = true; } } } }
Tomcat会根据 ContentType
是否为 multipart/form-data
判断是否问上传POST型请求,若是则会调用
parseParts()
来解析,我们继续跟进。由于 allowCasualMultipartParsing
配置项默认为 false
, parseParts()
直接就返回了,也就是说Tomcat默认不会解析上传POST请求。
private void parseParts(){ if (this.parts == null && this.partsParseException == null) { MultipartConfigElement mce = this.getWrapper().getMultipartConfigElement(); if (mce == null) { /* Tomcat7.0+ 已经内置了multipart支持,但是必须显示激活,默认关闭。在全局tomcat配置文件context.xml,或者为war的本地context.xml添加<Context allowCasualMultipartParsing="true">开启。 */ if (!this.getContext().getAllowCasualMultipartParsing()) { this.parts = Collections.emptyList(); return; } ... } ... } }
对针对GET行请求和普通POST,Tomcat会调用 parameters.processParameters()
方法来解析。我们简单看下它的代码。
private void processParameters(byte[] bytes,int start, int len, Charset charset){ ... int decodeFailCount = 0; int pos = start; int end = start + len; label172: while(pos < end) { int nameStart = pos; int nameEnd = -1; int valueStart = -1; int valueEnd = -1; boolean parsingName = true; boolean decodeName = false; boolean decodeValue = false; boolean parameterComplete = false; do { switch(bytes[pos]) { /*如果遇到%(37)和+(43),会对值进行进行URL解码*/ case 37: case 43: if (parsingName) { decodeName = true; } else { decodeValue = true; } ++pos; break; /*如果遇到的&(38),标记该处为参数名和参数值结尾*/ case 38: if (parsingName) { nameEnd = pos; } else { valueEnd = pos; } parameterComplete = true; ++pos; break; /*如果遇到=(61),标记该处为参数名的结尾,参数值的开始处*/ case 61: if (parsingName) { nameEnd = pos; parsingName = false; ++pos; valueStart = pos; } else { ++pos; } break; default: ++pos; } } while(!parameterComplete && pos < end); if (pos == end) { if (nameEnd == -1) { nameEnd = pos; } else if (valueStart > -1 && valueEnd == -1) { valueEnd = pos; } } ... }
至此,Tomcat层面对前端请求解析工作结束。接下来Spring MVC会收到Tomcat传来的 HttpServletRequest
,此时若请求为上传POST型,Spring MVC会继续调用 commons-fileuplad.jar
对Tomcat传来的原生Servlet请求类 HttpServletRequest
的实例进行解析处理。
Spring MVC将原生的 HttpServletRequest
对象传入 CommonsMultipartResolver
类的 parseRequest()
方法进行解析处理。
CommonsMultipartResolver.parseRequest()
方法主要分两步对上传请求进行解析。
commons-fileupload.jar
中的 ServletFileUpload
的 parseRequest
来解析出保存有上传表单各个元素的 FileItem
列表。 CommonsFileUploadSupport.parseFileItem()
方法解析 FileItem
列表为保存有表单字段名,字段值等信息 MultipartParsingResult
类型的 Map
。 下面我们来看下这两步的执行细节。首先第一步最终的处理方法为 FileUploadBase.parseRequest()
FileUploadBase.parseRequest()
解析完会返回一个 FileItem
实例列表。 FileItem
就是存储着上传表单的各种元素(字段名,ContentType,是否是简单表单字段,文件名。)本例中我们提交的上传表单的 FileItem
内容如下:
接着来到第二步,调用 CommonsFileUploadSupport.parseFileItem()
对 commons-fileupload.jar
处理的结果—FileItem列表,进行处理。
最后将上传表单解析的所有元素(multipartFiles,multipartParameters,multipartParameterContentTypes)封装为一个 MultipartParsingResult
并返回。至此上传POST型请求的解析工作完成。
最后Spring MVC,会使用 HandlerMethodInvoker.resolveRequestParam()
方法,将解析好的请求参数的值,绑定到不同的对象上,方便Controller层获取。具体我们在下面说。
上面我们用较大边幅说明了Spring MVC是如何获取到前端发来的请求的参数值。下面我们就很好理解,问题的所在了。
经过跟踪发现,Spring MVC对各类型请求参数的解析并实现自动绑定,主要在 HandlerMethodInvoker.resolveRequestParam()
方法。
继续跟进到获取参数值的那一步。
通过调式发现,这里如果是GET型和普通POST型请求的话, getRequest()
获取到的对象是我们编写的过滤类 XssHttpServletRequestWrapper
的实例,故调用该对象 getParameterValues()
来获取值,自然是被过滤了!
若是上传POST行请求的话, getRequest()
获取到的是 CommonsMultipartResolver
类的对象。但实际上调用该对象的 getParamterValues()
方法,会执行到 DefaultMultipartHttpServletRequest
类的 getParamterValues()
类获取值。这是调式发现的,我暂时也没有搞清楚为何,不过不影响我们解决本次研究的问题。
到这里我们基本明白了,上传包中的参数值没有被过滤,是因为Spring MVC在解析上传包获取其参数值时,没有使用我们编写的过滤类 XssHttpServletRequestWrapper
中的 getParamterValues()
方法,而是使用了 DefaultMultipartHttpServletRequest
类 getParamterValuses()
。
借助以下继承实现关系图,我们继续看看为何获取不到。
结合我们上面对Spring MVC和Tomcat如何解析到请求包的参数值的过程,知道GET型和普通POST型请求包是可以通过 HttpServletRequest.getParameterValues()
直接获取到对应参数的值,而通过图中可知 XssHttpServletRequestWrapper
实现了 HttpServletRequest
,自然也是可以通过 XssHttpServletRequestWrapper.getParameterValues()
获取到的。
但上传包Tomcat默认没有解析,根据继承关系 XssHttpServletRequestWrapper
对象中保存的解析结果为Tomcat解析请求的结果,故通过该对象的 getParameterValues()
方法获取到的参数值为 null
。也是因此Spring MVC针对Tomcat解析的结果—原生 HttpServletRequest
,使用 common-fileupload.jar
来继续解析,得到 MultipartHttpServletRequest
的实现对象。 DefaultMultipartHttpServletRequest
类实现了 MultipartHttpServletRequest
,故通过该类的 getParameterValues()
方法即可获取到上传POST请求的参数值!
在文章发布区,评论区,公告区….等功能点上常常需要上传图片或附件,这时表单往往会以上传包的形式提交数据。而这些功能点也是hack们最关注的XSS漏洞测试点,若不注意上传可”绕过”过滤器的问题,会造成很严重的后果!
我从新翻开了之前审计的项目代码,发现很多Spring MVC项目都是使用过滤器对XSS和SQL注入进行全局防御。而过滤器的代码与本文例子的中过滤器代码相似,很明显都是从网上Copy过来的。这样编写代码是存在问题的,针对这种情况,我们该如何正确防御,我们下周文章详述!