前言
CodeQL 不多说,帮助我们进行代码审计的一个挺好的工具
CodeQL 安装
CodeQL本身包含两部分解析引擎+SDK
。
解析引擎用来解析我们编写的规则,虽然不开源,但是我们可以直接在官网下载二进制文件直接使用。
SDK
完全开源,里面包含大部分现成的漏洞规则,我们也可以利用其编写自定义规则。
引擎安装
去地址:https://github.com/github/codeql-cli-binaries/releases 下载已经编译好的codeql执行程序,解压之后把codeql文件夹放入~/CodeQL,我这里是 windows ,下的是 codeql-win64.zip
为了方便测试我们需要把ql可执行程序加入到环境变量当中:
1
setx PATH "E:\CodeQL\codeql;%PATH%"
然后重开个cmd输入 codeql,出现如下图就说明引擎设置完成
SDK安装
在这边下载规则库文件https://github.com/github/codeql,用来在后续库中进行查询
将解压后的文件放入~/CodeQL中,之后输入codeql pack ls来查看当前SDK中支持的规则集。
VSCode开发插件安装
在 vscode 中安装 CodeQL 插件
然后在该插件的设置中设置 codeql 的可执行文件路径(路径中最好不要有中文)
然后就设置好了,接下来写个demo测试一下
demo
由于CodeQL
的处理对象并不是源码本身,而是中间生成的AST结构数据库,所以我们先需要把我们的项目源码转换成CodeQL
能够识别的CodeDatabase
。
切换到源代码所在的目录然后再执行创建数据库的命令
1
codeql database create <数据库名> --language=<语言标识符> --source-root=<源码路径>
如果源代码是一个Maven项目,可能需要使用Maven命令来构建项目,并在创建数据库时指定该命令
1
--command="mvn clean install"
不同的 language 所对应的语言标识符
Language
Identity
C/C++
cpp
C#
csharp
Go
go
Java
java
javascript/Typescript
javascript
Python
python
这里我主要是用 codeql 来审计 java 代码,所以用 micro_service_seclab 这个靶场来做测试
1
codeql database create micro_service_seclab_database --language="java" --command="mvn clean install -Dmaven.test.skip=true" --source-root=E:\CodeQL\test\micro_service_seclab
稍微解释下,在当前目录下创建个 micro_service_seclab_database
当作存放数据库的目录,指定语言为java,然后写出构建和清楚命令,最后指定源代码跟目录
然后在这里把生成的 micro_service_seclab_database
添加进去
然后出现这个就表示数据库加载成功了
接着再添加CodeQL SDK:“文件”-“将文件夹添加到工作区”
然后在 sdk/java/ql 目录下创建个 demo.ql 查询文件,然后 Run Query on Selected Database
,就会打印 heloooo test
基本语法
以上面的靶场为例
可以看到 CodeQL 引擎的作用就是帮我们把源码转换为 CodeQL 能识别的数据库,所以我们能做的就是编写 QL 规则,再通过其引擎来运行我们的规则,这样就可以达到一个自动审计的功能
QL语法
1
2
3
4
5
import java
from int i
where i = 1
select i
第一行表示我们要引入CodeQL的类库,这里我们以 java 为例
from int i
,表示我们定义一个变量i,它的类型是int,表示我们获取所有的int类型的数据
where i = 1
, 表示当i等于1的时候,符合条件
select i
,表示输出i
如果QL文件在 sdk\java\ql\
目录中时就不需要 import java
了,因为这是 CodeQL 标准库目录,其会自动隐式加载该目录下的依赖,但如果写在其他目录下就需要手动 import java
,包括其子目录
这里我是写在 sdk\java\ql\examples
下的,上面的代码很好理解,所有int类型的数据中筛选出为1的情况,输出就是 1
QL查询的语法结构为:
1
2
3
from [ datatype ] var
where condition ( var = something )
select var
从上面的例子中我们发现完整的QL语法无非就分三部分,先是限定一个查询的区域,然后写出过滤规则,最后输出
类库
上面我们说了CodeQL引擎会将代码转换为数据库,这个数据库其实就是可识别的AST数据库
在AST里面Method代表的就是类当中的方法;比如说我们想过的所有的方法调用,MethodAccess获取的就是所有的方法调用。
我们经常会用到的ql类库大体如下:
名称
解释
Method
方法类,Method method表示获取当前项目中所有的方法
MethodAccess
方法调用类,MethodAccess call表示获取当前项目当中的所有方法调用
Parameter
参数类,Parameter表示获取当前项目当中所有的参数
结合ql的语法,我们尝试获取micro-service-seclab项目当中定义的所有方法:
1
2
3
4
import java
from Method kkk
select kkk
然后添加下过滤条件,筛选出名字为 getStudent 的方法名称
1
2
3
4
5
import java
from Method k
where k.hasName("getStudent")
select k.getName(), k.getDeclaringType()
1
2
3
k.hashName() 判断名字是否匹配
k.getName() 获取的是当前方法的名称
k.getDeclaringType() 获取的是当前方法所属class的名称
谓词
如果限制条件比较多,where 语句就会很冗长。CodeQL提供一种机制可以帮助我们把很长的查询语句封装成函数,而这个函数,就叫谓词。
比如上面的案例,我们可以写成如下,获得的结果跟上面是一样的:
1
2
3
4
5
6
7
8
9
import java
predicate isStudent(Method k) {
k.getName()="getStudent"
}
from Method k
where isStudent(k)
select k.getName(), k.getDeclaringType()
predicate 表示当前方法没有返回值。
设置Source和Sink
什么是source和sink
在代码自动化安全审计的理论当中,有一个最核心的三元组概念,就是(source,sink和sanitizer)
source是指漏洞污染链条的输入点。比如获取http请求的参数部分,就是非常明显的Source
sink是指漏洞污染链条的执行点,比如SQL注入漏洞,最终执行SQL语句的函数就是sink(这个函数可能叫query或者exeSql,或者其它)
sanitizer又叫净化函数,是指在整个的漏洞链条当中,如果存在一个方法阻断了整个传递链,那么这个方法就叫sanitizer,也就是waf
只有当source和sink同时存在,并且从source到sink的链路是通的,才表示当前漏洞是存在的。
设置Source
在CodeQL中我们通过以下方法来设置Source
1
override predicate isSource(DataFlow::Node src) {}
在这个靶场中,我们使用的是Spring Boot
框架,那么source就是http参数入口的代码参数,比如在下面的代码中,source就是username:
1
2
3
4
@RequestMapping(value = "/one")
public List<Student> one(@RequestParam(value = "username") String username) {
return indexLogic.getStudent(username);
}
本例中我们设置Source的代码为:
1
override predicate isSource(DataFlow::Node src) { src instanceof RemoteFlowSource }
RemoteFlowSource
是 CodeQL 标准库中预定义的 “远程数据源” 类,比如HTTP 请求参数,用户输入以及其他外部输入等都是
这是SDK
自带的规则,里面包含了大多常用的Source入口。我们使用的SpringBoot也包含在其中,可以直接使用。
注: instanceof 语法是CodeQL提供的语法,后面在CodeQL进阶部分会提到,这里就是检查获得的 src 是否为 RemoteFlowSource
设置Sink
在CodeQL中我们通过以下方法来设置Sink
1
override predicate isSink(DataFlow::Node sink) {}
在实际中,我们最后都是触发到某个恶意方法,如 getter,setter,所以 sink 应该是个方法,假设我们这里的sink 点是个query
方法(Method)的调用(MethodAccess),所以我们设置Sink为:
1
2
3
4
5
6
7
8
override predicate isSink(DataFlow::Node sink) {
exists(Method method, MethodAccess call |
method.hasName("query")
and
call.getMethod() = method and
sink.asExpr() = call.getArgument(0)
)
}
这里我们使用了exists子查询,这个是CodeQL谓词语法里非常常见的语法结构,它根据内部的子查询返回true or false,来决定筛选出哪些数据。
sink.asExpr() = call.getArgument(0)
:将 sink 节点转换为表达式,并检查它是否等于 call
的第一个参数
故上面sink语句的作用是查找一个query()方法的调用点,并把它的第一个参数设置为sink
在靶场系统(micro-service-seclab
)中,sink为
1
jdbcTemplate.query(sql, ROW_MAPPER);
当刚才设置的source变量流入这个方法时,说明注入点和触发点是通的,就能产生注入漏洞
Flow数据流
设置好Source和Sink,就相当于搞定了首尾,接下来就是疏通中间的利用链。一个受污染的变量,能够毫无阻拦的流转到危险函数,就表示存在漏洞
这个连通工作就是CodeQL引擎本身来完成的。我们通过使用config.hasFlowPath(source, sink)
方法来判断是否连通。
比如如下代码:
1
2
3
from VulConfig config, DataFlow::PathNode source, DataFlow::PathNode sink
where config.hasFlowPath(source, sink)
select source.getNode(), source, sink, "source"
我们传递给config.hasFlowPath(source, sink)
我们定义好的source和sink,系统就会自动帮我们判断是否存在漏洞了。
source.getNode()
:获取源节点的底层语法树节点(AST Node),显示漏洞源头在代码中的具体位置
代码测试
初步检测
综上,可以写个 ql 查询代码来检测 sql 注入漏洞
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @ id java / examples / vuldemo
* @ name Sql - Injection
* @ description Sql - Injection
* @ kind path - problem
* @ problem . severity warning
*/
import java
import semmle . code . java . dataflow . FlowSources
import semmle . code . java . security . QueryInjection
import DataFlow :: PathGraph
class VulConfig extends TaintTracking :: Configuration {
VulConfig () { this = "SqlInjectionConfig" }
override predicate isSource ( DataFlow :: Node src ) {
src instanceof RemoteFlowSource
}
override predicate isSink ( DataFlow :: Node sink ) {
exists ( Method method , MethodAccess call |
method . hasName ( "query" )
and
call . getMethod () = method and
sink . asExpr () = call . getArgument ( 0 )
)
}
}
from VulConfig config , DataFlow :: PathNode source , DataFlow :: PathNode sink
where config . hasFlowPath ( source , sink )
select source . getNode (), source , sink , "source"
CodeQL 在定义类上的语法和 Java 类似,其中 extends 的父类 TaintTracking::Configuration
是官方提供用来做数据流分析的通用类,提供很多数据流分析相关的方法,比如isSource(定义source),isSink(定义sink)
src instanceof RemoteFlowSource
表示src 必须是 RemoteFlowSource 类型。在RemoteFlowSource里,官方提供很非常全的source定义,我们本次用到的Springboot的Source就已经涵盖了。
注:上面的注释和其它语言是不一样的,不能够删除,它是程序的一部分,因为在我们生成测试报告的时候,上面注释当中的name,description等信息会写入到审计报告中。
这里的isSource
和isSink
根据自己需要进行重写,而判断中间是否疏通可以使用CodeQL提供的config.hasFlowPath(source, sink)
来帮我们处理
可以看到真的很方便,直接把source处和整个从source到sink的链子都显示出来了
这里爆警告说是 DataFlow::PathGraph
在新版本中被弃用了,所以这段代码只能在低版本的规则库里跑
这里开头有 @kind path-problem
,说明结果至少是4列,写了这个结果就会输出完整的污点传播路径,除此之外对输出还有其他要求,比如每列要输出的类型也有要求,但是我这 source.getNode(),source, sink,"source"
就能正常输出,source, sink,source.getNode(),"source"
却是啥也没有
误报分析
我们可以看到有一处的 sql 注入,其输入的参数类型为 List<Long>
,不可能存在注入
这里说明我们给的限制并未严格要求参数类型,就会导致以上的误报产生,我们可以用 isSanitizer 来避免这种情况
isSanitizer是CodeQL的类TaintTracking::Configuration提供的净化方法。它的函数原型是:
1
override predicate isSanitizer(DataFlow::Node node) {}
在CodeQL自带的默认规则里,对当前节点是否为基础类型做了判断
1
2
3
4
5
override predicate isSanitizer(DataFlow::Node node) {
node.getType() instanceof PrimitiveType or
node.getType() instanceof BoxedType or
node.getType() instanceof NumberType
}
表示如果当前节点是上面提到的基础类型,那么此污染链将被净化阻断,漏洞将不存在,可以看到这里默认规则只是一些基础类型,没有类似 List<long>
等的复合类型
我们将 TaintTracking::Configuration 中的 isSanitizer 重写下就好了
1
2
3
4
5
6
override predicate isSanitizer(DataFlow::Node node) {
node.getType() instanceof PrimitiveType or
node.getType() instanceof BoxedType or
node.getType() instanceof NumberType or
exists(ParameterizedType pt| node.getType() = pt and pt.getTypeArgument(0) instanceof NumberType ) // 这里的 ParameterizedType 代表所有泛型,判断泛型当中的传参是否为 Number 型
}
这样在检测到 List<long>
时就会将其净化掉,这样链子就不通了,也就不会出现误报的情况
漏报补充
我们发现如下 sql 注入没有被捕捉到(参考文章是这样写的,但我这是捕捉到了的)
1
2
3
4
5
public List<Student> getStudentWithOptional(Optional<String> username) {
String sqlWithOptional = "select * from students where username like '%" + username.get() + "%'";
//String sql = "select * from students where username like ?";
return jdbcTemplate.query(sqlWithOptional, ROW_MAPPER);
}
宁可错杀一百不可放过一个,在代码审计中是如此,误报可能需要花费时间去筛选,但是漏报的损失则无法挽回
在 CodeQL 中,我们可以通过 isAdditionalTaintStep 方法来将断了的节点给它强制连接上
isAdditionalTaintStep 方法是CodeQL的类TaintTracking::Configuration
提供的的方法,它的原型是:
1
override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) {}
它的作用是将一个可控节点,比如A强制传递给另外一个节点B,那么节点B也就成了可控节点。
假设这里是 username.get() 这一步断掉了,我们可以强制让 username 流转到 username.get() ,代码为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* @ id java / examples / vuldemo
* @ name Sql - Injection
* @ description Sql - Injection
* @ kind path - problem
* @ problem . severity warning
*/
import java
import semmle . code . java . dataflow . FlowSources
import semmle . code . java . security . QueryInjection
import DataFlow :: PathGraph
predicate isTaintedString ( Expr expSrc , Expr expDest ) {
exists ( Method method , MethodAccess call , MethodAccess call1 | expSrc = call1 . getArgument ( 0 ) and expDest = call and call . getMethod () = method and method . hasName ( "get" ) and method . getDeclaringType () . toString () = "Optional<String>" and call1 . getArgument ( 0 ) . getType () . toString () = "Optional<String>" )
}
class VulConfig extends TaintTracking :: Configuration {
VulConfig () { this = "SqlInjectionConfig" }
override predicate isSource ( DataFlow :: Node src ) { src instanceof RemoteFlowSource }
override predicate isSanitizer ( DataFlow :: Node node ) {
node . getType () instanceof PrimitiveType or
node . getType () instanceof BoxedType or
node . getType () instanceof NumberType or
exists ( ParameterizedType pt | node . getType () = pt and pt . getTypeArgument ( 0 ) instanceof NumberType )
}
override predicate isSink ( DataFlow :: Node sink ) {
exists ( Method method , MethodAccess call |
method . hasName ( "query" )
and
call . getMethod () = method and
sink . asExpr () = call . getArgument ( 0 )
)
}
override predicate isAdditionalTaintStep ( DataFlow :: Node node1 , DataFlow :: Node node2 ) {
isTaintedString ( node1 . asExpr (), node2 . asExpr ())
}
}
from VulConfig config , DataFlow :: PathNode source , DataFlow :: PathNode sink
where config . hasFlowPath ( source , sink )
select source . getNode (), source , sink , "source"
expSrc = call1.getArgument(0)
:规定了污染源为第一个参数
expDest = call and call.getMethod() = method and method.hasName("get")
:规定了污染目标为 xxx.get()
method.getDeclaringType().toString() = "Optional<String>" and call1.getArgument(0).getType().toString() = "Optional<String>"
:规定了污染源和污染目标的类型为Optional<String>
Lombok问题解决
Lombok是一个非常有名的Java类库,在开发中应该经常遇到,它通过简单的注解来帮助我们简化消除一些必须有但显得很臃肿的Java代码的工具,比如我们可以不用编写 getter,setter
demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Student {
private int id;
private String username;
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
当我们使用 Lombok 的注解,就不需要手动编写 getter 和 setter,以下代码等同于上面的 demo
1
2
3
4
5
6
7
8
9
import lombok.Data;
@Data
public class Student {
private int id;
private String username;
private int sex;
private int age;
}
可以看到用了 lombok 注解后就没有了 getter 和 setter,这就会导致 CodeQL 不能正常检测,source 到 sink 的链条断裂,从而造成漏报
有两种解决办法,第一种是在 pom.xml 中加入依赖,再重新编译即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<build>
<sourceDirectory>target/generated-sources/delombok</sourceDirectory>
<testSourceDirectory>target/generated-test-sources/delombok</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>1.18.20.0</version>
<executions>
<execution>
<id>delombok</id>
<phase>generate-sources</phase>
<goals>
<goal>delombok</goal>
</goals>
<configuration>
<addOutputDirectory>false</addOutputDirectory>
<sourceDirectory>src/main/java</sourceDirectory>
</configuration>
</execution>
<execution>
<id>test-delombok</id>
<phase>generate-test-sources</phase>
<goals>
<goal>testDelombok</goal>
</goals>
<configuration>
<addOutputDirectory>false</addOutputDirectory>
<sourceDirectory>src/test/java</sourceDirectory>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
第二种是有人在 issue 中提出的:https://github.com/github/codeql/issues/4984
1
2
3
4
5
6
7
8
9
10
11
12
# get a copy of lombok.jar
wget https : // projectlombok . org / downloads / lombok . jar - O "lombok.jar"
# run "delombok" on the source files and write the generated files to a folder named "delombok"
java - jar "lombok.jar" delombok - n -- onlyChanged . - d "delombok"
# remove "generated by" comments
find "delombok" - name '*.java' - exec sed '/Generated by delombok/d' - i '{}' ';'
# remove any left-over import statements
find "delombok" - name '*.java' - exec sed '/import lombok/d' - i '{}' ';'
# copy delombok'd files over the original ones
cp - r "delombok/." "./"
# remove the "delombok" folder
rm - rf "delombok"
两种方法都是去掉 lombok 注解,并还原 setter 和 getter 方法,实现手法不一样罢了,貌似第一种更方便,不知道为啥我这啥都没修改还是检测出来了
批量化实现
以上 ql 规则可以成功跑出靶场的 sql 注入漏洞,我们也可以将其运用到其他项目上,先生成相应的数据库,然后再用写好的 ql 文件去分析就好,codeql 命令为
1
codeql database analyze /CodeQL/databases/micro-service-seclab /CodeQL/ql/java/ql/examples/demo --format=csv --output=/CodeQL/Result/micro-service-seclab.csv --rerun
CodeQL进阶
instanceof语法糖
上面语句就是检测 a 对象是否是 b 类型
在遇到复杂的类型时,可能要用多个 exist 子查询语句,但是只用一个 instanceof 语句就好,而且只要匹配一个抽象类,就能匹配到这个抽象类下的所有子类,比如
1
class MyCustomSource extends RemoteFlowSource
如果 x 为 MyCustomSource 类型,则 x instanceof RemoteFlowSource
会返回true
递归
CodeQL里面的递归调用语法是:在谓词方法的后面跟*或者+,来表示调用0次以上和1次以上(和正则类似),0次会打印自己。
demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org . example ;
public class hello {
public class StudentService {
class innerOne {
public innerOne (){}
class innerTwo {
public innerTwo (){}
public String Nihao () {
return "Nihao" ;
}
}
public String Hi (){
return "hello" ;
}
}
}
}
此时如果想要根据innerTwo类定位到最外层的 hello 类
我们可以用以下 ql 查询语句来获得,调用两次 getEnclosingType 方法即可
1
2
3
4
5
import java
from Class classes
where classes.getName().toString() = "innerTwo"
select classes.getEnclosingType().getEnclosingType().getEnclosingType() // getEnclosingtype获取作用域
但是实际情况我们不知道要调用几次,而且这样写也比较麻烦
这时候就可以用到递归了,我们在调用方法后面加*(从自身开始调用)或者+(从上一级开始调用),来解决此问题。
1
2
3
from Class classes
where classes.getName().toString() = "innerTwo"
select classes.getEnclosingType+() // 获取作用域
+
是从上一级开始调用
*
是从自身开始调用
自己封装方法来实现递归调用也是可以的,比如这里我这只想找相差一层的类
1
2
3
4
5
6
7
8
9
import java
RefType demo(Class classes) {
result = classes.getEnclosingType().getEnclosingType()
}
from Class classes
where classes.getName().toString() = "innerTwo"
select demo*(classes) // 获取作用域
强制类型转换
在 CodeQL 中可以用 getType() 来对返回结果做强制类型转换
查询下当前数据库中所有的参数及其类型
1
2
3
4
import java
from Parameter param
select param, param.getType()
可以看到有5k多条而且有不同类型的参数,强制转换下,只留下是整型的参数
只有1k多条了,而且只含有整型的
总结
综上可以知道CodeQL的强大了,稍微入了下门,知道怎么用了,难点就在于规则的编写,最主要的就是source,sink和中间Sanitizer的编写
接下来就是找案例来练手了
参考
https://www.freebuf.com/articles/web/283795.html
https://www.ascotbe.com/2024/12/27/CodeQL/
https://drun1baby.top/2023/09/03/CodeQL-%E5%85%A5%E9%97%A8/