漏洞描述 由于文件上传逻辑存在缺陷,攻击者可以操纵文件上传参数来实现路径穿越,在某些情况下,通过上传的恶意文件可实现远程代码执行。
影响版本 2.5.0 <= Apache Struts <= 2.5.32 6.0.0 <= Apache Struts <= 6.3.0
环境搭建 使用idea创建一个maven-web项目,修改pom.xml文件添加依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <dependency > <groupId > junit</groupId > <artifactId > junit</artifactId > <version > 3.8.1</version > <scope > test</scope > </dependency > <dependency > <groupId > org.apache.struts</groupId > <artifactId > struts2-core</artifactId > <version > 2.5.30</version > </dependency > <dependency > <groupId > javax.servlet</groupId > <artifactId > javax.servlet-api</artifactId > <version > 4.0.1</version > <scope > provided</scope > </dependency >
编辑web.xml
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" "http://java.sun.com/dtd/web-app_2_3.dtd" > <web-app > <display-name > Archetype Created Web Application</display-name > <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 > /*</url-pattern > </filter-mapping > <welcome-file-list > <welcome-file > index.jsp</welcome-file > </welcome-file-list > </web-app >
在webapp目录下创建upload.jsp
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <%-- Created by IntelliJ IDEA. User: hejixiong Date: 2025 /4 /25 Time: 10 :49 To change this template use File | Settings | File Templates. --%> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>Title</title> </head> <body> <form action="/upload" method="post" enctype="multipart/form-data" > <input type="file" name="upload" > <input type="text" name="uploadFileName" value="../../test.jsp" > <input type="submit" value="上传" > </form> </body> </html>
在resources
目录下创建struts.xml
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.5//EN" "http://struts.apache.org/dtds/struts-2.5.dtd" > <struts > <package name ="default" extends ="struts-default" > <action name ="upload" class ="org.example.demo.UploadAction" > <interceptor-ref name ="fileUpload" > <param name ="maximumSize" > 52428800</param > </interceptor-ref > <interceptor-ref name ="defaultStack" /> <result name ="success" > /success.jsp</result > <result name ="error" > /error.jsp</result > </action > </package > </struts >
在java
目录下创建UploadAction.java
类
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 package org.example.demo;import com.opensymphony.xwork2.ActionSupport;import org.apache.struts2.dispatcher.multipart.UploadedFile;import java.io.File;import java.io.FileInputStream;import java.io.FileOutputStream;import java.nio.file.Files;public class UploadAction extends ActionSupport { private UploadedFile upload; private String uploadFileName; private String uploadContentType; public UploadedFile getUpload () { return upload; } public void setUpload (UploadedFile upload) { this .upload = upload; } public String getUploadFileName () { return uploadFileName; } public void setUploadFileName (String uploadFileName) { this .uploadFileName = uploadFileName; } public String getUploadContentType () { return uploadContentType; } public void setUploadContentType (String uploadContentType) { this .uploadContentType = uploadContentType; } @Override public String execute () throws Exception { System.out.println("uploadFileName:" + uploadFileName); File file = new File (uploadFileName); FileOutputStream fileOutputStream = new FileOutputStream (file); File content = (File) upload.getContent(); FileInputStream fileInputStream = new FileInputStream (content); byte [] fileContent = Files.readAllBytes(content.toPath()); fileOutputStream.write(fileContent); fileOutputStream.flush(); fileOutputStream.close(); return super .execute(); } }
配置调试环境 在idea高级版中由内置的tomcat调试器,选择添加后按照下图配置 配置应用上下文为/
漏洞成因 该漏洞因org.apache.struts2.interceptor.FileUploadInterceptor
而起,我们也从这个类开始分析
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 public String intercept (ActionInvocation invocation) throws Exception { ActionContext ac = invocation.getInvocationContext(); HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST); if (!(request instanceof MultiPartRequestWrapper)) { if (LOG.isDebugEnabled()) { ActionProxy proxy = invocation.getProxy(); LOG.debug(getTextMessage("struts.messages.bypass.request" , new String []{proxy.getNamespace(), proxy.getActionName()})); } return invocation.invoke(); } ValidationAware validation = null ; Object action = invocation.getAction(); if (action instanceof ValidationAware) { validation = (ValidationAware) action; } MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request; if (multiWrapper.hasErrors() && validation != null ) { TextProvider textProvider = getTextProvider(action); for (LocalizedMessage error : multiWrapper.getErrors()) { String errorMessage; if (textProvider.hasKey(error.getTextKey())) { errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs())); } else { errorMessage = textProvider.getText("struts.messages.error.uploading" , error.getDefaultMessage()); } validation.addActionError(errorMessage); } } Enumeration fileParameterNames = multiWrapper.getFileParameterNames(); while (fileParameterNames != null && fileParameterNames.hasMoreElements()) { String inputName = (String) fileParameterNames.nextElement(); String[] contentType = multiWrapper.getContentTypes(inputName); if (isNonEmpty(contentType)) { String[] fileName = multiWrapper.getFileNames(inputName); if (isNonEmpty(fileName)) { UploadedFile[] files = multiWrapper.getFiles(inputName); if (files != null && files.length > 0 ) { List<UploadedFile> acceptedFiles = new ArrayList <>(files.length); List<String> acceptedContentTypes = new ArrayList <>(files.length); List<String> acceptedFileNames = new ArrayList <>(files.length); String contentTypeName = inputName + "ContentType" ; String fileNameName = inputName + "FileName" ; for (int index = 0 ; index < files.length; index++) { if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) { acceptedFiles.add(files[index]); acceptedContentTypes.add(contentType[index]); acceptedFileNames.add(fileName[index]); } } if (!acceptedFiles.isEmpty()) { Map<String, Parameter> newParams = new HashMap <>(); newParams.put(inputName, new Parameter .File(inputName, acceptedFiles.toArray(new UploadedFile [acceptedFiles.size()]))); newParams.put(contentTypeName, new Parameter .File(contentTypeName, acceptedContentTypes.toArray(new String [acceptedContentTypes.size()]))); newParams.put(fileNameName, new Parameter .File(fileNameName, acceptedFileNames.toArray(new String [acceptedFileNames.size()]))); ac.getParameters().appendAll(newParams); } } } else { if (LOG.isWarnEnabled()) { LOG.warn(getTextMessage(action, "struts.messages.invalid.file" , new String []{inputName})); } } } else { if (LOG.isWarnEnabled()) { LOG.warn(getTextMessage(action, "struts.messages.invalid.content.type" , new String []{inputName})); } } } return invocation.invoke(); }
我们调试到appendAll
方法里面查看各个变量的值
1 2 3 4 public HttpParameters appendAll (Map<String, Parameter> newParams) { parameters.putAll(newParams); return this ; }
通过观察可知,ActionContext的parameters变量在追加前就已经又一个元素了,这个元素来自我们文件上传时添加的一个input标签其name为uploadFileName
,value为../../test.jsp
这个元素与UploadFileName
只有首字母大小不一样。 当追加完成后parameters
拥有了4个元素,且key为uploadFileName的元素位于第一个
关于FileUploadInterceptor
拦截器的代码分析这里就告一段落了,当程序继续运行会运行到另一个拦截器ParametersInterceptor
该拦截器为当前请求对应的Action对象设置属性。 设置属性的方式是通过ActionContext
的parameters
属性的值将首字母大写后与set
拼接作为方法名进行发射调用。 如:属性uploadFileName
对应的方法为setUploadFileName
,UploadFileName
对应的方法为setUploadFileName
,这两个属性对应的方法相同, 那么调用的先后顺序是什么,调用是否会出现短路,选择哪一个进行调用都有讲究。
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 public String doIntercept (ActionInvocation invocation) throws Exception { Object action = invocation.getAction(); if (!(action instanceof NoParameters)) { ActionContext ac = invocation.getInvocationContext(); HttpParameters parameters = retrieveParameters(ac); if (LOG.isDebugEnabled()) { LOG.debug("Setting params {}" , getParameterLogMap(parameters)); } if (parameters != null ) { Map<String, Object> contextMap = ac.getContextMap(); try { ReflectionContextState.setCreatingNullObjects(contextMap, true ); ReflectionContextState.setDenyMethodExecution(contextMap, true ); ReflectionContextState.setReportingConversionErrors(contextMap, true ); ValueStack stack = ac.getValueStack(); setParameters(action, stack, parameters); } finally { ReflectionContextState.setCreatingNullObjects(contextMap, false ); ReflectionContextState.setDenyMethodExecution(contextMap, false ); ReflectionContextState.setReportingConversionErrors(contextMap, false ); } } } return invocation.invoke(); }
在setParameters
方法中parameters
被依次遍历检查参数名是否符合约定,并将结果存储到一个新的treeMap
中,此时4个参数的先后数据发生了变化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 protected void setParameters (final Object action, ValueStack stack, HttpParameters parameters) { HttpParameters params; Map<String, Parameter> acceptableParameters; if (ordered) { params = HttpParameters.create().withComparator(getOrderedComparator()).withParent(parameters).build(); acceptableParameters = new TreeMap <>(getOrderedComparator()); } else { params = HttpParameters.create().withParent(parameters).build(); acceptableParameters = new TreeMap <>(); } for (Map.Entry<String, Parameter> entry : params.entrySet()) { String parameterName = entry.getKey(); if (isAcceptableParameter(parameterName, action)) { acceptableParameters.put(parameterName, entry.getValue()); } } ... }
这时我们发现uploadFileName
排在了UploadFileName
的后面,这是不是意味着uploadFileName
对应的setUploadFileName
方法将UploadFilename
设置的值覆盖掉呢?setParameters
方法中有一段代码是这样的,它将acceptableParameters
的每个元素依次遍历出来然后调用newStack
的setParameter
方法。 这里在进行treeMap
遍历的时候是有固定的先后顺序,UploadFileName
会被uploadFileName
先遍历。
1 2 3 4 5 6 7 8 9 10 11 for (Map.Entry<String, Parameter> entry : acceptableParameters.entrySet()) { String name = entry.getKey(); Parameter value = entry.getValue(); try { newStack.setParameter(name, value.getObject()); } catch (RuntimeException e) { if (devMode) { notifyDeveloperParameterException(action, name, e.getMessage()); } } }
而这个 newStack.setParameter
最终会导致Action
的属性setter的调用。 那么参数到方法名的转化是什么样的呢,我们直接到关键的地方,下面给出从setParameters
方法到getDeclaredMethods
之间的调用堆栈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 getDeclaredMethods:2680, OgnlRuntime (ognl) _getSetMethod:2912, OgnlRuntime (ognl) getSetMethod:2881, OgnlRuntime (ognl) hasSetMethod:2952, OgnlRuntime (ognl) hasSetProperty:2970, OgnlRuntime (ognl) setProperty:83, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor) setProperty:3356, OgnlRuntime (ognl) setValueBody:134, ASTProperty (ognl) evaluateSetValueBody:220, SimpleNode (ognl) setValue:308, SimpleNode (ognl) setValue:780, Ognl (ognl) execute:436, OgnlUtil$1 (com.opensymphony.xwork2.ognl) execute:428, OgnlUtil$1 (com.opensymphony.xwork2.ognl) compileAndExecute:523, OgnlUtil (com.opensymphony.xwork2.ognl) setValue:428, OgnlUtil (com.opensymphony.xwork2.ognl) trySetValue:186, OgnlValueStack (com.opensymphony.xwork2.ognl) setValue:173, OgnlValueStack (com.opensymphony.xwork2.ognl) setParameter:157, OgnlValueStack (com.opensymphony.xwork2.ognl) setParameters:214, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
OgnlRuntime#getDeclaredMethods
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static List getDeclaredMethods (Class targetClass, String propertyName, boolean findSets) { List result = null ; ClassCache cache = _declaredMethods[findSets ? 0 : 1 ]; Map propertyCache = (Map)cache.get(targetClass); if (propertyCache == null || (result = (List)propertyCache.get(propertyName)) == null ) { synchronized (cache) { Map propertyCache = (Map)cache.get(targetClass); if (propertyCache == null || (result = (List)((Map)propertyCache).get(propertyName)) == null ) { String baseName = capitalizeBeanPropertyName(propertyName); List result = new ArrayList (); collectAccessors(targetClass, baseName, result, findSets); if (propertyCache == null ) { cache.put(targetClass, propertyCache = new HashMap (101 )); } ((Map)propertyCache).put(propertyName, result.isEmpty() ? NotFoundList : result); return result.isEmpty() ? null : result; } } } return result == NotFoundList ? null : result; }
capitalizeBeanPropertyName 方法会对属性名进行处理,如uploadFileName
会被处理为UploadFileName
然后调用 collectAccessors
查找对应的方法添加到result
中。 最终在ognl.OgnlRuntime.invokeMethodInsideSandbox
方法中完成Action
属性的设置。 这样便完成了UploadAction
的uploadFileName
属性的覆盖。 当用户程序未对uploadFileName
的值进行检查的时候可能会导致可上传任意文件到任意目录,通过上传.jsp
文件的方式便有可能造成代码执行漏洞,或者上传ssh
密钥等。
修复方案 在HTTPParameters
中添加remove
方法以删除通过表单上传的参数。
参考链接