翻译自“CVE-2018-11776: How to find 5 RCEs in Apache Struts with Semmle QL
译者(Ph0rse@d0g3)注:人在北京,已穷到吃土。修洞之余,翻一下大佬的文章,向这种优秀的思路学习~

正文:

2018年4月,Man You Mo向Struts安全团队提交了一个新的远程执行代码漏洞编号为CVE-2018-11776S2-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
}

定位OGNL解析函数

我们在确定了数据源之后,再用类似的方法,定位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插件中来显示、审计这些调用链。

0day

最后只剩下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!

源链接

Hacking more

...