CodeQL 学习笔记【7】数据流分析

3,302 阅读8分钟

前面一篇文章介绍了 java 中的一些库函数的介绍,当时 java 程序可不止定义类代码就完事了,一个程序最重要的还是数据如何传递,所以我们接下来看看 QL 如何查询 java 中的数据流

CodeQL 为数据流定义了两种类型,一种是 局部数据流 Local data flow 也就是一个函数内部,或者一个代码块内部的数据流。一种是 全局数据流 Global data flow 全局数据流,也就是代表了整个程序中所有的数据是如何流动的。

局部数据流 Local Data Flow

现在我们先从简单的局部数据流开始看起,我们先看一个非常简单的例子,然后再来说明都有哪些知识点

import java
import semmle.code.java.dataflow.DataFlow

from Method m1, MethodCall mc1, Expr e1
where m1.getDeclaringType().hasQualifiedName("java.sql", "Statement")
and   m1.hasName("executeQuery")
and   mc1.getCallee() = m1
and   DataFlow::localFlow(DataFlow::exprNode(e1), DataFlow::exprNode(mc1.getArgument(0)))
select mc1, e1

image.png

QL 代码中,我先定义了一个 Method m1、一个 MethodCall 、一个 Expr,然后我限制了这个 Method 是 java.sql.Statement 中的 executeQuery() 函数 然后定义了 MethodCall 就是调用我们刚才限制的 m1。所以前三句话就是查询了 java 代码中所有调用 java.sql.Statement::executeQuery() 的地方。

然后我用 DataFlow::localFlow(DataFlow::exprNode(e1), DataFlow::exprNode(mc1.getArgument(0))) 查询了所有流向 mc1 第一个参数的表达式。

相对来说也是非常好理解的。

DataFlow::localFlow(Node1, Node2) 的用法就像上面的代码里的用法一样,就是帮忙查询一下所有从 Node1 流向 Node2 的情况。

然后我这里 Node1 和 Node2 都是使用的 exprNode ,也就是表达式节点。这里的 Node1 就是所谓的 sources ,这里的 Node2 就是 sinks。我们可以通过限定 sources 来查询所有的 sinks,或者反过来向上面代码里一样,通过限定 sinks 来反向查询所有的 sources。

这里的知识点参考:CodeQL 学习笔记【4】:juejin.cn/post/736828…

Node 可以有很多的类型,比如这里的 exprNode 就是代表了 java 里的一个表达式节点。 同理还有 parameterNode 等:

import java
import semmle.code.java.dataflow.DataFlow

from Method m1, MethodCall mc1, Parameter p1
where m1.getDeclaringType().hasQualifiedName("java.sql", "Statement")
and   m1.hasName("executeQuery")
and   mc1.getCallee() = m1
and   DataFlow::localFlow(DataFlow::parameterNode(p1), DataFlow::exprNode(mc1.getArgument(0)))
select mc1, p1

image.png

我们简单改了一下代码,这次换成了,数据流从一个 parameterNode 流向 exprNode,继续分析第 8 行代码的含义

节点含义节点类型最后查出来的是什么
Node1输入节点(sources)parameterNode是 Parameter
Node2输出节点(sinks)exprNode是 Argument

其实看看代码就很容易理解了。我们查询了所有从函数入参,然后直接传递到 m1 的第一个参数的所有结果。

再来查一个复杂点的数据流,这次查询字符串格式化函数,然后这个函数的字符格式化表达式是字符串字面量。

import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.StringFormat

from StringFormatMethod sfm1, MethodCall mc1, Expr e1, Expr e2
where mc1.getMethod() = sfm1
and   mc1.getArgument(sfm1.getFormatStringIndex()) = e1
and   DataFlow::localFlow(DataFlow::exprNode(e1), DataFlow::exprNode(e2))
and   e1 instanceof StringLiteral
select e1

image.png

局部污点追踪 Local Taint Track

String x = "危险数据";
String y = x + "其他数据";
eavl(y);

观察这个例子,正常情况下如果我们追踪 x --> eavl(y) 会发现是没有直接的数据流的。所以我们要引入污点追踪,当 y 被 x 污染后,也要将 y 追加到追踪对象目标里去。

举个例子:

import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.TaintTracking

from Method m1, MethodCall mc1, Parameter p1
where m1.getDeclaringType().hasQualifiedName("java.sql", "Statement")
and   m1.hasName("executeQuery")
and   mc1.getCallee() = m1 // 限制 sink 为 executeQuery()
and   TaintTracking::localTaint(DataFlow::parameterNode(p1), DataFlow::exprNode(mc1.getArgument(0)))
select mc1, p1

image.png

这里我们可以看到,传入函数的参数是 accountName ,然后 accountName 传递给了 query ,然后 query 成为了 executeQuery() 的参数。

全局数据流 Global Data Flow

全局数据流的精度是比局部数据流更低的,而且需要更多的内存和运行时间。

全局数据流的查询格式跟局部数据流不太一样,我们还是举一个例子,然后再分析具体的代码。

import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.FlowSources

class Configuration extends DataFlow::Configuration {
  Configuration() {
    this = "Configuration"
  }

  override predicate isSource(DataFlow::Node source) {
    source instanceof RemoteFlowSource
  }

  override predicate isSink(DataFlow::Node sink) {
    exists(Call call |
      sink.asExpr() = call.getArgument(0) and
      call.getCallee().(Constructor).getDeclaringType().hasQualifiedName("java.net", "URL")
    )
  }
}

from DataFlow::Node source, DataFlow::Node sink, Configuration config
where config.hasFlow(source, sink)
select source, sink

image.png

可以看到,44 行从 completed() 参数中获取到的 url 参数,先是传递到了 furBall() 函数,然后再 furBall() 函数内部,又传递到了 new URL(url)。这种跨越多个函数追踪数据的能力是局部数据流追踪所做不到的。

通过上述的 QL 代码可以看到:

首先我们需要自己定义一个控制类,然后继承 DataFlow::Configuration(这个类可能有点过时了,后续应该会换),然后我们覆写 isSource() 和 isSink() 这两个函数。用来判断什么是 source 什么是 sink

然后使用继承自 ConfigurationhasFlow() 来过滤全局的数据流。

代码里出现的 RemoteFlowSource 是一个标准库提供的类型,就是代表哪些用户可以控制的输入。

全局污点追踪 Global Taint Track

参考 局部污点追踪 和 全局数据流,可以很容易理解 全局污点追踪

import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking

class Configuration extends TaintTracking::Configuration {
  Configuration() {
    this = "Configuration"
  }

  override predicate isSource(DataFlow::Node source) {
    source instanceof RemoteFlowSource
  }

  override predicate isSink(DataFlow::Node sink) {
    exists(Call call |
      sink.asExpr() = call.getArgument(0) and
      call.getCallee().(Constructor).getDeclaringType().hasQualifiedName("java.net", "URL")
    )
  }
}

from DataFlow::Node source, DataFlow::Node sink, Configuration config
where config.hasFlow(source, sink)
select source, sink

image.png

但是全局污点追踪是有局限性的,比如下面这个就跟踪不到,应该是需要跟踪的层数太多了,导致 QL 放弃跟踪了。

image.png

净化函数 isSanitizer()

在进行数据跟踪的时候,还有一个重要的现象需要讨论,比如一个用户控制的参数 id ,通过一系列的跟踪后发现被传入到了 executeQuery() 函数中,那么是否就能认为一定存在 SQL 注入漏洞呢?未必,因为如果 id 在传递的中途经过了过滤函数的处理了呢?那就不一定存在 SQL 注入漏洞了。

所以数据流追踪还涉及到净化函数的处理。我们先举一个例子:

import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.FlowSources
import semmle.code.java.dataflow.TaintTracking

class Configuration extends TaintTracking::Configuration {
  Configuration() {
    this = "Configuration"
  }

  override predicate isSource(DataFlow::Node source) {
    exists(source.asParameter())
  }

  override predicate isSink(DataFlow::Node sink) {
    exists(Method method, MethodCall call |
        // 假设 createSecretFileWithRandomContents() 就是危险函数
        method.hasName("createSecretFileWithRandomContents")
        and
        call.getMethod() = method and
        sink.asExpr() = call.getArgument(0)
      )
  }

//   override predicate isSanitizer(DataFlow::Node node) {
//     exists(Method method, MethodCall call |
//         // 假设经过 reset 函数后就不危险了
//         method.hasName("reset") and call.getMethod() = method and node.asExpr() = call.getAnArgument())
//   }
}

from DataFlow::Node source, DataFlow::Node sink, Configuration config
where config.hasFlow(source, sink)
select source, sink

image.png

我们假设 createSecretFileWithRandomContents() 是一个危险函数,然后假设 user 就是危险参数,通过上述代码,我们可以查询到这个 sourcesink

然后我们把代码注释放开,可以发现,经过 isSanitizer() 的过滤,已经查不到这条数据流了。

image.png

注意这里需要理解 node.asExpr() = call.getAnArgument() 只要 node 成为 call 的参数,那么就终止了。

数据流续接函数 isAdditionalTaintStep()

现在思考下面这个问题

x = "危险数据"
y = x.someFuntions("someArgs")
eval(y)

因为我们现在写程序是要引用很多的库的,那么这些库大概率是通过 jar 引入的,所以 CodeQL 建立数据库的时候是扫描不到这些库的源码的。而一些库的一些函数是可能传递污点的。假设上面例子中 someFuntions() 就是一个会传播污点的函数,可以将污点 x 传播到 y。但是 CodeQL 并不知道,因为这个库可能 CodeQL 没见过,或者还没来得及对其中的函数进行建模。那么这个时候,就需要我们自己来建模了。

下面举个例子:

import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.TaintTracking

class MyConfig extends TaintTracking::Configuration {
    MyConfig(){
        this = "Test_isAdditionalTaintStep"
    }

    override predicate isSource(DataFlow::Node source) {
        exists(source.asParameter())
        and source.asParameter().hasName("json")
        and source.getType() instanceof ParameterizedType
    }

    override predicate isSink(DataFlow::Node sink) {
        exists(MethodCall methodcall
            | methodcall.getMethod().hasName("createNewTokens") 
            | methodcall.getAnArgument() = sink.asExpr())
    }
}


from DataFlow::Node source, DataFlow::Node sink, MyConfig conf
where conf.hasFlow(source, sink)
select source, sink

image.png

运行代码可以看到,CodeQL 发现了一条数据流。但是我们自己研究代码可以发现, 下面还有一条数据流是没有被发现的:

当然了,我这里只是举例子说明 CodeQL 的用法,不是说这个代码就一定有漏洞啥的。而且我这里接续的条件有点牵强,但是大概的意思还是很容易理解的。

image.png

因为数据的传递发生在 catch 块里,而且数据还是从 e.getClaims() 里传递过来的,所以 CodeQL 无法发现这条数据流,那么我们来增加一下数据流之间的连接。

import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.TaintTracking

class MyConfig extends TaintTracking::Configuration {
    MyConfig(){
        this = "Test_isAdditionalTaintStep"
    }

    override predicate isSource(DataFlow::Node source) {
        exists(source.asParameter())
        and source.asParameter().hasName("json")
        and source.getType() instanceof ParameterizedType
    }

    override predicate isSink(DataFlow::Node sink) {
        exists(MethodCall methodcall
            | methodcall.getMethod().hasName("createNewTokens") 
            | methodcall.getAnArgument() = sink.asExpr())
    }

    override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) { 
        this.isSource(node1)
        and
        exists(MethodCall methodcall | 
            methodcall.getMethod().hasName("get") and
            methodcall.getParent() = node2.asExpr()
        )
    }
}


from DataFlow::Node source, DataFlow::Node sink, MyConfig conf
where conf.hasFlow(source, sink)
select source, sink

image.png

可以看到成功找到了这条数据流。

这里也需要理解 node1 --> node2 的连接是: 参数 json --直接连接到--> get() 调用表达式的父表达式(这里其实是强制类型转换表达式):

看下面我标记的逻辑:

黄色代表 source、sink,绿色代表 CodeQL 能够自动寻找的数据流,红色就代表我们自己设置的数据流的续接。

image.png

总的来说主要就是三种情况,即外部包的调用中:

  1. 污点从参数传播到函数调用的返回结果中:x = "危险"; y = jar.somFunc(x)
  2. 污点从参数通过Call传播到实例中: x = "危险"; y = new Jar(x)
  3. 污点从实例传播到Call的返回结果中: x = new Jar("危险"); y = x.getFun()

参考:山楂园 (jus4fun.xyz)