翻译自“CVE-2018-11776: How to find 5 RCEs in Apache Struts with Semmle QL”
译者(Ph0rse@d0g3)注:人在北京,已穷到吃土。修洞之余,翻一下大佬的文章,向这种优秀的思路学习~
2018年4月,Man You Mo向Struts安全团队提交了一个新的远程执行代码漏洞编号为CVE-2018-11776(S2-057),当Struts2使用了危险配置是,远程访问即可触发。影响版本为
Struts 2.3 - Struts 2.3.34, Struts 2.5 - Struts 2.5.16,详情请移步官方公告,公告博客文章。
这是Man You Mo对Apache Struts安全特性的一部分研究内容。在这篇文章中,作者将介绍如何发现CVE-2018-11776。作者通过之前的旧洞信息,研究Struts的内部安全防护机制,通过创建Semmle QL运行那些可能有问题的代码。查询结果已分享在Github上,在后续的研究中也会向这个项目中添加更多的内容,以辅助Struts2以及其它项目的安全审计。
很多漏洞都是接收了危险的数据输入,随后数据流向了某些危险的功能函数中,比如执行SQL语句、反序列化以及其它的解析方式。QL可以很轻松地搜索这种漏洞,只需要我们配置好出各种数据源和危险功能函数,然后让DataFlow库来完成遍历操作。对于某个特定的程序,想要挖掘这种危险数据流的最好方法是查看它的历史漏洞,跟踪历史漏洞的过程中,就可以让我们理解这个程序的安全运作机制,从而更容易发现新的漏洞。
审计之前,作者先跟进调试了S2-032/S2-033/S2-037,这些漏洞都将可控输入进行了OGNL表达式解析,从而导致任意代码执行。而最有意思的地方是,同一类漏洞、同样的问题,但确要修复3次,这或许意味着,这个隐患可能仍然存在。
上面的三个RCE漏洞,都是远程通过OgnlUtil::getValue()将数据赋予了methodName变量:
String methodName = proxy.getMethod(); //<--- untrusted source, but where from?
LOG.debug("Executing action method = {}", methodName);
String timerKey = "invokeAction: " + proxy.getActionName();
try {
UtilTimerStack.push(timerKey);
Object methodResult;
try {
methodResult = ognlUtil.getValue(methodName + "()", getStack().getContext(), action); //<--- RCE
proxy实现了ActionProxy接口,在接口的定义里,出了getMethod()还有很多获取数据的方法,比如getActionName()和getNamespace()。由于和getMethod()功能相似,所以我们先假设这些方法传入的数据可控。
现在尝试运用QL挖掘思路,开始对这些数据源建模:
class ActionProxyGetMethod extends Method {
ActionProxyGetMethod() {
getDeclaringType().getASupertype*().hasQualifiedName("com.opensymphony.xwork2", "ActionProxy") and
(
hasName("getMethod") or
hasName("getNamespace") or
hasName("getActionName")
)
}
}
predicate isActionProxySource(DataFlow::Node source) {
source.asExpr().(MethodAccess).getMethod() instanceof ActionProxyGetMethod
}
我们在确定了数据源之后,再用类似的方法,定位Struts2中的OGNL解析代Struts中有许多函数最终会把参数作为OGNL表达式进行解析; 在前三个漏洞中,使用的是OgnlUtil::getValue(),但是在S2-045(CVE-2017-5638)中,使用的是TextParseUtil::translateVariables()。通过对这两个函数的分析,我们了解到,最终,数据流将流向OgnlUtil::compileAndExecute()和OgnlUtl::compileAndExecuteMethod(),所以我们使用一个QL谓词中这两个函数,如下所示:
predicate isOgnlSink(DataFlow::Node sink) {
exists(MethodAccess ma | ma.getMethod().hasName("compileAndExecute") or ma.getMethod().hasName("compileAndExecuteMethod") |
ma.getMethod().getDeclaringType().getName().matches("OgnlUtil") and
sink.asExpr() = ma.getArgument(0)
)
}
现在我们已经在QL中定义了数据源和接收器,然后可以用DataFlow库来定义DataFlow配置:
class OgnlTaintTrackingCfg extends DataFlow::Configuration {
OgnlTaintTrackingCfg() {
this = "mapping"
}
override predicate isSource(DataFlow::Node source) {
isActionProxySource(source)
}
override predicate isSink(DataFlow::Node sink) {
isOgnlSink(sink)
}
override predicate isAdditionalFlowStep(DataFlow::Node node1, DataFlow::Node node2) {
TaintTracking::localTaintStep(node1, node2) or
exists(Field f, RefType t | node1.asExpr() = f.getAnAssignedValue() and node2.asExpr() = f.getAnAccess() and
node1.asExpr().getEnclosingCallable().getDeclaringType() = t and
node2.asExpr().getEnclosingCallable().getDeclaringType() = t
)
}
}
from OgnlTaintTrackingCfg cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink
在这里使用了之前定义的isActionProxySource和isOgnlSink谓词。
请注意,这里还覆盖了一个名为的谓词isAdditionalFlowStep。这个谓词包含一些步骤,这些步骤可以转入有害数据。例如,将特定项目信息合并到流配置中。再者,如果我们有通过某个网络层进行通信的组件,就可以在QL中描述那些各种网络端点的代码是什么样的,能够允许DataFlow库通过这些抽象出的结构,来跟踪被污染的数据。
为了跟踪有害数据,我写了两段代码,来供DataFlow使用。
第一个:
TaintTracking::localTaintStep(node1, node2)
可以跟踪标准Java库调用,字符串操作等的标准QL 库操作。第二个添加也类似,允许我们通过字段访问来跟踪污点数据:
exists(Field f, RefType t | node1.asExpr() = f.getAnAssignedValue() and node2.asExpr() = f.getAnAccess() and
node1.asExpr().getEnclosingCallable().getDeclaringType() = t and
node2.asExpr().getEnclosingCallable().getDeclaringType() = t
)
如果在同一类中,存在两个方法可以传递有害数据,DataFlow会直接认为,这是攻击链的一部分。比如说:
public void foo(String taint) {
this.field = taint;
}
public void bar() {
String x = this.field; //x is tainted because field is assigned to tainted value in `foo`
}
如果foo()之前没有调用过bar(),在bar()中的this.field就没有被污染。我们并不能确定bar()中调用的this.field是可控的,所以,在DataFlow::Configuration中配置的默认情况下,并不会认为这段代码收到污染。然而,对于挖掘漏洞而言,这种数据可控的可能性是需要考虑的,所以我们需要再挖掘漏洞时在DataFlow::Configuration里额外配置。在之后的文章中,作者将分享一些类似于的分析技巧,这些技巧对于半自动化挖掘漏洞很有用。
我们在将上述步骤运用到实践之后,可以得到初步结果,但很奇怪的是,在这些初步结果中,已修复的S2-032,S2-033和S2-037代码片段仍然会被认为存在漏洞。在尝试发现新的攻击路径之前,我们要先搞明白,为啥会误报。
经过验证后发现,第一个漏洞出现之后,官方是通过过滤关键字的方式修补。S2-037爆出后,S2官方决定用OgnlUtil::callMethod()来替换OgnlUtil::getValue()
methodResult = ognlUtil.callMethod(methodName + "()", getStack().getContext(), action);
callMethod()调用了compileAndExecuteMethod():
public Object callMethod(final String name, final Map<String, Object> context, final Object root) throws OgnlException {
return compileAndExecuteMethod(name, context, new OgnlTask<Object>() {
public Object execute(Object tree) throws OgnlException {
return Ognl.getValue(tree, context, root);
}
});
}
并且compileAndExecuteMethod()在解析表达式之前,执行了一些额外的过滤
private <T> Object compileAndExecuteMethod(String expression, Map<String, Object> context, OgnlTask<T> task) throws OgnlException {
Object tree;
if (enableExpressionCache) {
tree = expressions.get(expression);
if (tree == null) {
tree = Ognl.parseExpression(expression);
checkSimpleMethod(tree, context); //<--- Additional check.
}
所以,我们可以把compileAndExecuteMethod()从危险函数中移除。
可以看到,这个误报被基本消除了。但还是存在一点点误差,比如对DefaultActionInvocation.java中,调用的getActionName()进行了告警,但其实,有害数据并不能传入compileAndExecute()。
为了研究为什么会出现这个误报,我们需要了解DataFlow生成结果的详细步骤。QL允许编写一些特定的请求,通过生成可以逐节点探索的可变长度路径,去调试“可疑结果”。
写这篇博客时,LGTM本身没有调试“错误结果”的UI ,因此我们需要使用另一个Semmle应用程序:QL for Eclipse。这个Eclipse插件可以可视化调试“错误结果”。可按照此处说明,安装这个插件。它不仅可以在LGTM.com上对开源项目进行离线分析,还可以为提供更强大的开发环境。可以在git clone后的Semmle/SecurityQueries下找到semmle-security-java。根据README.md来安装插件。
安装好插件
首先,运行查询initial.ql。在QL for Eclipse中,在运行结果中选中DefaultActionInvocation.java,就可以在Path Explorer窗口中看到从源到接收器的详细路径。
由图片可知,经过几个步骤后,getActionName()返回的值流入到了pkg.getActionConfigs().get():
String chainedTo = actionName + nameSeparator + resultCode;
//actionName comes from `getActionName` somewhere
ActionConfig chainedToConfig = pkg.getActionConfigs().get(chainedTo);
//chainedTo contains `actionName` and ended up in the `get` method.
下一步,跟到ValueStackShadowMap::get(),如下所示:
public Object get(Object key) {
Object value = super.get(key); //<--- key gets tainted?
if ((value == null) && key instanceof String) {
value = valueStack.findValue((String) key); //<--- findValue ended up evaluating `key`
}
return value;
}
分析可得,pkg.getActionConfigs()返回的是一个Map,而ValueStackShadowMap实现了Map接口,所以程序认为,pkg.getActionConfigs()返回的值可能是一个ValueStackShadowMap的实例。因此,QL DataFlow库将ValueStackShadowMap这个调用链标注为危险。但实际上,ValueStackShadowMap属于jasperreports插件,它的实例仅在几个地方创建,并不能作为pkg.getActionConfigs()的返回值。经过以上分析,可以确定ValueStackShadowMap::get()不是有效利用点,因此作者添加了在DataFlow::Configuration中添加了一个barrier,解决了这个误报:
override predicate isBarrier(DataFlow::Node node) {
exists(Method m | (m.hasName("get") or m.hasName("containsKey")) and
m.getDeclaringType().hasName("ValueStackShadowMap") and
node.getEnclosingCallable() = m
)
}
这段代码告诉程序,如果有害输入流入了ValueStackShadowMap的get()或者containsKey()方法,就不需要继续跟踪它,扔掉这个调用链。(containsKey()的情况和上述的get()是一样的)
在进一步针对 ActionMapping::toString()中添加barrierfor后,(toString()调用时容易产生误报),重新运行查询,就产生了最终的结果,可以在Eclipse插件中来显示、审计这些调用链。
最后只剩下10个调用链,很容易通过人工跟进检测是否存在漏洞。通过一些路径,把测试用例中的路径剔除之后,产生了以下结果:
比如,ServletActionRedirectResult.java:
首先,getNamespace()将恶意输入带入了变量namespace,而这个变量是ActionMapping构造函数的参数:
public void execute(ActionInvocation invocation) throws Exception {
actionName = conditionalParse(actionName, invocation);
if (namespace == null) {
namespace = invocation.getProxy().getNamespace(); //<--- source
} else {
namespace = conditionalParse(namespace, invocation);
}
if (method == null) {
method = "";
} else {
method = conditionalParse(method, invocation);
}
String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null)); //<--- namespace goes into constructor of ActionMapping
setLocation(tmpLocation);
接下来,getUriFromActionMapping()返回一个使用namespace构造的URL字符串ActionMapping。然后这个URK流入了变量tmpLocation中,tmpLocation为setLocation()的参数。
setLocation()将location的值赋予给了超类StrutsResultSupport中的location变量:
public void setLocation(String location) {
this.location = location;
}
然后在ServletActionResult调用了execute():
String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null));
setLocation(tmpLocation);
super.execute(invocation);
location字段会被带入到conditionalParse()中:
public void execute(ActionInvocation invocation) throws Exception {
lastFinalLocation = conditionalParse(location, invocation);
doExecute(lastFinalLocation, invocation);
}
conditionalParse()中将location传入到了translateVariables()的param参数,随后被带入了OGNL解析器中:
protected String conditionalParse(String param, ActionInvocation invocation) {
if (parse && param != null && invocation != null) {
return TextParseUtil.translateVariables(
param,
invocation.getStack(),
new EncodingParsedValueEvaluator());
} else {
return param;
}
}
所以如果ServletActionRedirectResult中没有设置namespace参数,就会从ActionProxy中获取namespace变量,然后将其作为OGNL表达式进行评估。为了测试,作者将struts中的一个配置文件(比如struts-actionchaining.xml)替换为了以下内容:
<struts>
<package name="actionchaining" extends="struts-default">
<action name="actionChain1" class="org.apache.struts2.showcase.actionchaining.ActionChain1">
<result type="redirectAction">
<param name = "actionName">register2</param>
</result>
</action>
</package>
</struts>
本地运行后,通过访问URL,成功在本地弹出计算器。
命令执行需要花了一些时间去绕过OGNL沙箱。出于安全考虑,作者暂时不公开沙箱绕过的细节。
不仅如此,从ActionChainResult、PostbackResult、ServletUrlRenderer传入也可以触发漏洞。PortletActionRedirectResult可能也存在漏洞,但作者并没有测试它。四个RCE已经足以证明问题的严重性。
在这篇文章中,作者通过老洞,来进行污点建模,把遍历操作交给QL DataFlow库处理。通过研究之前的3个RCE漏洞,发现了新的4个RCE漏洞(可能是五个)!
鉴于S2-032,S2-033和S2-037都是在同一段时间内被发现的,大家很快能通过S2-032寻找到类似的S2-033和S2-037。那么问题来了,(S2-057)也来自类似的污染源,为啥整整两年时间内都没人发现嘞?个人认为,这是因为S2-032,S2-033和S2-037都出现在源代码中的相似位置(都在Rest插件中)。而S2-057和S2-032之间的相似性更多的是在语义层面。
如果你认为这个0day只是侥幸,因为作者假设了从ActionProxy传入的namespace可控,那请关注作者的下一篇文章,下一篇文章会详细介绍从HttpRequestServlet本身开始攻击链挖掘。作者还会分享他的一些工具,以及一些改进算法的Tips。在这个过程中,将看到QL如何发现S2-045!