前言
CodeQL 是 GitHub(实际上是Github收购的)推出的一种代码分析引擎和查询语言,用于查找软件中的漏洞和安全问题。它的核心理念是将代码表示成一种可以查询的数据库结构,开发者或研究人员可以像查询数据库一样查询代码的结构和行为,简单来说就是一种可以通过编写 QL
制定规则的静态分析工具(SAST)。
我自己也勉强算是搞SAST的,所以对于现在市面上这些成熟的SAST工具的原理还是蛮好奇的,因为我之前开发TaintScaner的时候其实完全没有了解过其他的SAST的原理,纯粹是看完南大软件分析课后,凭借一种惊人的直觉开发的,因此很多东西都是自己想当然的实现的,完全不知道如今成熟的方案是什么,所以最近准备系统学习一下这些成熟的SAST工具的原理,目前的计划是Codeql、soot和tai-e,后面考虑看一下tabby等等。
原理浅析
当然,CodeQL作为一个不开源的污点分析工具,也没法从源码角度分析它的污点传播原理,我这里主要是看的其他大佬的博客。
codeql 整个漏洞扫描可以分为两部分:
- AST 数据库创建,通过命令行工具即可
- 规则编写,类似 SQL 的语法来编写漏洞查询规则
编译出来的数据库中,在db-java目录里装的是代码解析出来的AST,所以说Codeql其实是在AST层上做的污点传播,这一点我还是蛮吃惊的,因为我之前感觉AST层是搞不了污点分析的,因为太复杂了,我还以为大伙都会利用IR来抽象代码的。
环境搭建
CodeQL
分引擎和SDK
两部分,引擎部分不开源,主要负责解析规则。SDK
是开源的,包含很多漏洞规则,也可以自己写漏洞规则进行使用。
引擎地址:https://github.com/github/codeql-cli-binaries/releases,接着配一下环境变量:
接着去安装sdk:https://github.com/github/codeql,把它改名成ql,放在之前引擎的同目录下:
接着我们在vscode安装插件:
添加一下可执行文件的位置:
如果我们想要执行查询,首先需要去待审计源代码的根目录建立分析数据库,我这里随便找了个java靶场,使用下面的命令建立分析数据库:
codeql database create codeqltest --language=java
执行成功你应该可以在这个目录里看到一个codeqltest目录,接着我们在vscode引入这个目录:
接着我们在 CodeQL\ql\java\ql\examples
目录下创建 demo.ql
,内容为select "Hello World
“,然后利用codeql进行执行,这样就说明我们环境配置好了(我现在的工作区是引擎那个目录,也就是有codeql和ql的那个目录):
规则编写
QL语法
QL语法类似于SQL,大致如下:
from [datatype] var
where condition(var = something)
select var
比如下面的例子,作用就是在 Java 项目中查找所有值为 1 的 int
字面量并输出:
import java // 引入CodeQL类库
from int i // 表示所有int类型的数据
where i = 1 // 表示条件:当i等于1时
select i // 输出i
类库
codeql里有几个类库:
Method
:方法类:Method method表示获取当前项目中所有的方法MethodCall
:方法调用类:MethodCall call表示获取当前项目当中的所有方法调用(在某个版本之后,MethodAccess
被重名了成MethodCall
了)Parameter
:参数类:Parameter表示获取当前项目当中所有的参数
比如下面的例子,作用就是查找方法getStudent的名称和类:
import java
from Method method // 表示所有方法
where method.hasName("getStudent") // 表示条件:方法名为getStudent
select method.getName(), method.getDeclaringType() // 输出方法的名称,和方法所属的类名
可以看到出现了两个输出结果,这就说明在类IndexLogic以及IndexDb里都有一个方法getStudent
谓词
当 where
部分过长时,可以用谓词这个语法,把很长的查询语句封装成函数,其中 predicate
关键词用于声明谓词,exists
作为子查询,根据内部的子查询返回true or false,来决定筛选出哪些数据,比如在下面的例子里我们就把判断方法名称是否为getStudent
的where部分封装成了一个函数,这个函数就被称作谓词:
import java
predicate isStudent(Method method){
exists(|method.hasName("getStudent"))
} // |操作符返回查询结果的数量,大于0则true
from Method method
where isStudent(method)
select method.getName(), method.getDeclaringType().getName()
Source/Sink
source、sink以及sanitizer想必也不需要介绍了,学静态分析的不可能不知道这三个概念,简单来说,source是可控数据产生点,sink是危险函数,sanitizer是过滤函数。
在我们这个java靶场里,username
参数就是一个很明显的source,毕竟这个接口的作用就是利用getStudent方法查询username
参数对应的值
在Codeql里,我们可以用sdk来检测这些源点,语法如下:
predicate isSource(DataFlow::Node src) { src instanceof RemoteFlowSource }
上面这个谓词定义了所有 RemoteFlowSource
类型的节点为“污点源点”,也就是从远程请求传入的用户输入都被认为是不可信的起点,这里的 DataFlow::Node
表示一个数据流节点,下面是一个查询系统里所有源点的demo:
import java
import semmle.code.java.dataflow.FlowSources
predicate isSource(DataFlow::Node src) { src instanceof RemoteFlowSource }
from DataFlow::Node src
where isSource(src)
select src, "是一个Source点"
那么如何判断汇点呢?至少在这个靶场里,判断sql注入的一个简单的方法是找query方法,也就是寻找sql查询函数,比如下面的谓词里,我们就是定义了所有传给名为 query
方法第一个参数的表达式为 Sink 点:
predicate isSink(DataFlow::Node sink) {
exists(Method method, MethodCall call |
method.hasName("query")
and
call.getMethod() = method and
sink.asExpr() = call.getArgument(0)
)
}
下面是完整的代码:
import java
import semmle.code.java.dataflow.FlowSources
predicate isSink(DataFlow::Node sink) {
exists(Method method, MethodCall call |
method.hasName("query") and
call.getMethod() = method and
sink.asExpr() = call.getArgument(0)
)
}
from DataFlow::Node sink
where isSink(sink)
select sink, "传入 query() 方法的第一个参数是敏感点"
最后我们来写一个完整的demo,判断从Source到Sink是否存在路径。
实操
在CodeQL中存在两种数据流:
- 本地数据流:本地数据流是指单个方法或可调用函数内的数据流。本地数据流通常比全局数据流更简单、更快速、更精确,并且足以应对许多查询。
- 全局数据流:全局数据流跟踪整个程序的数据流,因此比局部数据流更强大。然而,全局数据流的精确度低于局部数据流,并且分析通常需要更多的时间和内存。
这里我们当然先学习全局数据流,本地数据流相对而言效果不是太好,我们需要通过实现签名DataFlow::ConfigSig
和应用模块来使用全局数据流库DataFlow::Global<ConfigSig>
:
import java
import semmle.code.java.dataflow.DataFlow
module MyFlowConfiguration implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
...
}
predicate isSink(DataFlow::Node sink) {
...
}
}
module MyFlow = DataFlow::Global<MyFlowConfiguration>;
isSource
– 定义数据流出的位置。isSink
– 定义数据流向何处。isBarrier
– 可选,定义数据流被阻止的位置。isAdditionalFlowStep
– 可选,添加额外的流程步骤。
如果想使用污点追踪,需要使用TaintTracking:
import java
import semmle.code.java.dataflow.TaintTracking
module MyFlowConfiguration implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
...
}
predicate isSink(DataFlow::Node sink) {
...
}
}
module MyFlow = TaintTracking::Global<MyFlowConfiguration>;
最后使用使用谓词执行数据流分析:flow(DataFlow::Node source, DataFlow::Node sink)
from DataFlow::Node source, DataFlow::Node sink
where MyFlow::flow(source, sink)
select source, "Data flow to $@.", sink, sink.toString()
因此对于之前的例子,完整的代码如下:
import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.FlowSources
module SourceToSinkConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource }
predicate isSink(DataFlow::Node sink) {
exists(Method method, MethodCall call |
method.hasName("query") and
call.getMethod() = method and
sink.asExpr() = call.getArgument(0)
)
}
}
module SourceToSink = TaintTracking::Global<SourceToSinkConfig>;
from DataFlow::Node source, DataFlow::Node sink
where SourceToSink::flow(source, sink)
select source, "flow to", sink
比如我们随便点一个,比如第一个Source点username,可以看到是定位到了One这个方法的参数部分:
接着点击第一个Sink点sql,是定位到了getStudent这个方法体里的query中的第一个参数,效果还行。
当然,我们继续向下看就可以发现一些误报,比如这里其实限制了传入类型是Long,所以当然不是可控点了:
这里我们可以写一个sanitizer来排除这些情况,主要是判断到传入类型是基础类型、数字类型、泛型数字类型时,就切断数据流:
predicate isBarrier(DataFlow::Node sanitizer) {
sanitizer.getType() instanceof PrimitiveType
or
sanitizer.getType() instanceof BoxedType
or
sanitizer.getType() instanceof NumberType
or
exists(ParameterizedType pt |
sanitizer.getType() = pt and pt.getTypeArgument(0) instanceof NumberType
)
}
现在完整代码如下:
import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.FlowSources
module SourceToSinkConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) { source instanceof RemoteFlowSource }
predicate isSink(DataFlow::Node sink) {
exists(Method method, MethodCall call |
method.hasName("query") and
call.getMethod() = method and
sink.asExpr() = call.getArgument(0)
)
}
predicate isBarrier(DataFlow::Node sanitizer) {
sanitizer.getType() instanceof PrimitiveType
or
sanitizer.getType() instanceof BoxedType
or
sanitizer.getType() instanceof NumberType
or
exists(ParameterizedType pt |
sanitizer.getType() = pt and pt.getTypeArgument(0) instanceof NumberType
)
}
}
module SourceToSink = TaintTracking::Global<SourceToSinkConfig>;
from DataFlow::Node source, DataFlow::Node sink
where SourceToSink::flow(source, sink)
select source, "flow to", sink
现在的查询结果中就会排除这些点: