struts2 045曝出已经近6天了,网上的站也修复得差不多了,针对此漏洞,LSA参考了网上相关文章,加上LSA的一些浅陋的分析,总结整理得出此文。
1.漏洞原因:
1.1:由于Strus2对错误消息处理时,出现了纰漏。
1.2:通过content-type这个header头,注入OGNL语言,进而执行命令。
1.3: 基于Jakarta Multipart parser的文件上传模块在处理文件上传(multipart)的请求时候对异常信息做了捕获,并对异常信息做了OGNL表达式处理。但在在判断content-type不正确的时候会抛出异常并且带上Content-Type属性值,可通过精心构造附带OGNL表达式的URL导致远程代码执行。
1.4:官方公告
https://cwiki.apache.org/confluence/display/WW/S2-045
2.影响版本:
Struts 2.3.5 – Struts 2.3.31
Struts 2.5 – Struts 2.5.10
基本通杀!
3.漏洞分析(基于2.3.20):
3.1:jakarta
首先,通过官方说明可知漏洞发生在文件上传过程中。上传下载是一个常用功能,而Struts2本身并不提供上传解析器的,在org.apache.struts2包中,struts2向我们提供了以下三种方式支持文件上传。
default.properties:
### Parser to handle HTTP POST requests, encoded using the MIME-type multipart/form-data # struts.multipart.parser=cos # struts.multipart.parser=pell # struts.multipart.parser=jakarta-stream struts.multipart.parser=jakarta # uses javax.servlet.context.tempdir by default struts.multipart.saveDir= struts.multipart.maxSize=2097152
本次漏洞之所以影响广泛,重要原因之一是因为本次出问题的模块是系统的默认提供的模块—Jakarta。Jakarta依赖于commons-fileupload和commons-io两个包,所以只要存在这两个包,就可以模拟文件上传。而在struts2提供的基本示例struts2_blank中,这两个包也是存在的。
Struts2上传默认使用的是org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest类,对上传数据进行解析。不存在插件这个说法,只不过它最终调用了第三方组件common upload完成上传操作。
struts.multipart.parser:该属性指定处理multipart/form-data的MIME类型(文件上传)请求的框架,该属性支持cos、pell和jakarta等属性值,即分别对应使用cos的文件上传框架、pell上传及common-fileupload文件上传框架。该属性的默认值为jakarta。
3.2: request封装
为了能被action访问到上传的文件,通常会重新封装request,首先进入StrutsPrepareAndExecuteFilter类,这是Struts2默认配置的入口过滤器。在里面可以看到,Struts2首先对输入请求对象request的进行封装:
request = prepare.wrapRequest(request);
跟进这条语句,可以看到封装为StrutsRequestWrapper的过程:
Dispatcher.java:
public HttpServletRequest wrapRequest(HttpServletRequest request) throws IOException { // don't wrap more than once if (request instanceof StrutsRequestWrapper) { return request; } String content_type = request.getContentType(); if (content_type != null && content_type.contains("multipart/form-data")) { MultiPartRequest mpr = getMultiPartRequest(); LocaleProvider provider = getContainer().getInstance(LocaleProvider.class); request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider); } else { request = new StrutsRequestWrapper(request, disableRequestAttributeValueStackLookup); } return request; }
关键点1:content_type.contains(“multipart/form-data”),poc会利用到。
关键点2:getMultiPartRequest(),默认返回JakartaMultiPartRequest类,就是默认开启的jakarta上传机制,所以此洞危害巨大。
3.3:异常信息:
既然在Struts里是可以直接执行异常里的错误信息,那么在common upload file 组件的异常里我们看看哪些是会把客户端传递的值作为错误信息返回。
很幸运,我们在FileUploadBase.java中,发现了一个方法(这个FileUploadBase.java文件LSA在2.3.20和2.3.31版本找了很久也没找到,只找到FileUploadBase.class,反编译不完全,不知道网上的高手们在哪里挖出这个文件的)
1.teratorImpl(RequestContext ctx) 2. throws FileUploadException, IOException { 3. if (ctx == null) { 4. throw new NullPointerException("ctx parameter"); 5. } 6. 7. String contentType = ctx.getContentType(); 8. if ((null == contentType) 9. || (!contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART))) { 10. throw new InvalidContentTypeException( 11. format("the request doesn't contain a %s or %s stream, content type header is %s", 12. MULTIPART_FORM_DATA, MULTIPART_MIXED, contentType)); 13. }
当content type不是以multipart/为头的时候,就会抛出异常,并且直接将客户端输入的信息,作为异常信息返回(抛出异常这里LSA还是不懂,既然不是以multipart/为头就抛出异常,那么poc里包含了multipart/那就是不抛异常了,那怎么触发漏洞呢?)
3.4:浅看异常处理:
jakarta模块在处理文件请求的时候,会对异常信息进行OGNL表达式解析处理,进入buildErrorMessage函数。
JakartaStreamMultiPartRequest.java:
public void parse(HttpServletRequest request, String saveDir) throws IOException { try { setLocale(request); processUpload(request, saveDir); } catch (Exception e) { e.printStackTrace(); String errorMessage = buildErrorMessage(e, new Object[]{}); if (!errors.contains(errorMessage)) errors.add(errorMessage); } }
JakartaMultiPartRequest.java:
public void parse(HttpServletRequest request, String saveDir) throws IOException { try { setLocale(request); processUpload(request, saveDir); } catch (FileUploadBase.SizeLimitExceededException e) { if (LOG.isWarnEnabled()) { LOG.warn("Request exceeded size limit!", e); } String errorMessage = buildErrorMessage(e, new Object[]{e.getPermittedSize(), e.getActualSize()}); if (!errors.contains(errorMessage)) { errors.add(errorMessage); } } catch (Exception e) { if (LOG.isWarnEnabled()) { LOG.warn("Unable to parse request", e); } String errorMessage = buildErrorMessage(e, new Object[]{}); if (!errors.contains(errorMessage)) { errors.add(errorMessage); } } }
3.5:深究异常处理:
当解析上传协议抛出异常的时候,struts 会去尝试去构建错误信息,进入buildErrorMessage函数。
JakartaStreamMultiPartRequest.java:
private String buildErrorMessage(Throwable e, Object[] args) { String errorKey = "struts.message.upload.error." + e.getClass().getSimpleName(); if (LOG.isDebugEnabled()) LOG.debug("Preparing error message for key: [#0]", errorKey); return LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, e.getMessage(), args); }
为了保证错误信息可以支持多语言,在构建上传错误的时候,使用了localizedTextUtil,就是在这个LocalizedTextUtil.findText()里面让ognl得到执行,跟进这个函数看一下。
localizedTextUtil.java:
public static String findText(ResourceBundle bundle, String aTextName, Locale locale, String defaultMessage, Object[] args, ValueStack valueStack) { try { reloadBundles(valueStack.getContext()); String message = TextParseUtil.translateVariables(bundle.getString(aTextName), valueStack); MessageFormat mf = buildMessageFormat(message, locale); return formatWithNullDetection(mf, args); } catch (MissingResourceException ex) { if (devMode) { LOG.warn("Missing key [#0] in bundle [#1]!", aTextName, bundle); } else if (LOG.isDebugEnabled()) { LOG.debug("Missing key [#0] in bundle [#1]!", aTextName, bundle); } } GetDefaultMessageReturnArg result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage); if (LOG.isWarnEnabled() && unableToFindTextForKey(result)) { LOG.warn("Unable to find text for key '" + aTextName + "' in ResourceBundles for locale '" + locale + "'"); } return result != null ? result.message : null; }
struts 尝试从default message里获取内容,又调用了getDefaultMessage方法,继续:
private static GetDefaultMessageReturnArg getDefaultMessage(String key, Locale locale, ValueStack valueStack, Object[] args, String defaultMessage) { GetDefaultMessageReturnArg result = null; boolean found = true; if (key != null) { String message = findDefaultText(key, locale); if (message == null) { message = defaultMessage; found = false; // not found in bundles } // defaultMessage may be null if (message != null) { MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale); String msg = formatWithNullDetection(mf, args); result = new GetDefaultMessageReturnArg(msg, found); } } return result; }
传入的key无法在资源文件中找到的时候,会直接使用默认的message 也就是刚才的异常的信息作为返回的信息,但是在将message格式化的时候,struts定义的message 使用了TextParseUtil.translateVariables 转化message里的参数,熟悉OGNL的人都知道,TextParseUtil.translateVariables 是支持OGNL的
TextParseUtil.translateVariables可以执行在message体中的${ognl}或者%{ognl}OGNL表达式格式
看看translateVariables方法的定义:
TextParseUtil.java:
public static String translateVariables(String expression, ValueStack stack) { return translateVariables(new char[]{'$', '%'}, expression, stack, String.class, null).toString(); }
public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) { ParsedValueEvaluator ognlEval = new ParsedValueEvaluator() { public Object evaluate(String parsedValue) { Object o = stack.findValue(parsedValue, asType); if (evaluator != null && o != null) { o = evaluator.evaluate(o.toString()); } return o; } }; TextParser parser = ((Container)stack.getContext().get(ActionContext.CONTAINER)).getInstance(TextParser.class); return parser.evaluate(openChars, expression, ognlEval, maxLoopCount); }
将错误信息当做ognl表达式执行了,当然,是提取出有效的部分,注意到$以及 %,exp上是%{…..},实际上${….}也可以,不知道会不会绕过某些waf呢。
3.6:最终执行:
最终在OgnlTextParser的evaluate方法执行了命令:
OgnlTextParser.java:
public class OgnlTextParser implements TextParser { public Object evaluate(char[] openChars, String expression, TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) { // deal with the "pure" expressions first! //expression = expression.trim(); Object result = expression = (expression == null) ? "" : expression; int pos = 0; for (char open : openChars) { int loopCount = 1; //this creates an implicit StringBuffer and shouldn't be used in the inner loop final String lookupChars = open + "{"; while (true) { int start = expression.indexOf(lookupChars, pos); if (start == -1) { loopCount++; start = expression.indexOf(lookupChars); } if (loopCount > maxLoopCount) { // translateVariables prevent infinite loop / expression recursive evaluation break; } int length = expression.length(); int x = start + 2; int end; char c; int count = 1; while (start != -1 && x < length && count != 0) { c = expression.charAt(x++); if (c == '{') { count++; } else if (c == '}') { count--; } } end = x - 1; if ((start != -1) && (end != -1) && (count == 0)) { String var = expression.substring(start + 2, end); Object o = evaluator.evaluate(var); String left = expression.substring(0, start); String right = expression.substring(end + 1); String middle = null; if (o != null) { middle = o.toString(); if (StringUtils.isEmpty(left)) { result = o; } else { result = left.concat(middle); } if (StringUtils.isNotEmpty(right)) { result = result.toString().concat(right); } expression = left.concat(middle).concat(right); } else { // the variable doesn't exist, so don't display anything expression = left.concat(right); result = expression; } pos = (left != null && left.length() > 0 ? left.length() - 1: 0) + (middle != null && middle.length() > 0 ? middle.length() - 1: 0) + 1; pos = Math.max(pos, 1); } else { break; } } } return result; } }
4.官方修补:
2.3.32:
https://github.com/apache/struts/commit/352306493971e7d5a756d61780d57a76eb1f519a
只是加了个判断……
禁止了异常的信息可执行OGNL表达式,异常信息只是作为参数传递显示了
其他用LocalizedTextUtil.findText的地方仔细挖掘应该还能挖出漏洞。
5.修复方案:
5.1:
官方:hello:
You can also add a filter before StrutsPrepareAndExecuteFilter in web.xml, Just make a simple judgment in the filter, if there are illegal characters exists on Content-Type, Don’t call StrutsPrepareAndExecuteFilter.
5.2:升级到
Struts 2.3.32
Struts 2.5.10.1
5.3:
在用户不便进行升级的情况下,作为临时的解决方案,用户可以进行以下操作来规避风险:修改WEB-INF/classes目录下的struts.xml中的配置
在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。
5.4:
切换到不同的实现文件上传Multipart解析器。
6:LSA对struts2 045的总结:
个人认为,struts2每次爆漏洞都杀伤力巨大啊,我java太菜,上述分析是看了网上别人的分析加上自己的一点分析整理出来的,梳理了一下大概的流程,虽然感觉这个洞利用起来很简单,但是如果个人能挖出来的话,那真是挖洞技术达到了很高的水平啊(据说是安恒团队挖的)。
参考链接:
http://paper.seebug.org/241/
http://tieba.baidu.com/p/5012372638
http://www.codesec.net/view/542458.html
http://blog.csdn.net/u011721501/article/details/60768657
http://blog.nsfocus.net/apache-struts2-remote-code-execution-vulnerability-analysis-program/?utm_source=tuicool&utm_medium=referral
http://blog.csdn.net/raintungli/article/details/60787630
http://blog.csdn.net/jnu_simba/article/details/60778184