CodeQL入门

前言

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,出现如下图就说明引擎设置完成

image-20250409131900882

SDK安装

在这边下载规则库文件https://github.com/github/codeql,用来在后续库中进行查询

将解压后的文件放入~/CodeQL中,之后输入codeql pack ls来查看当前SDK中支持的规则集。

image-20250409154407515

VSCode开发插件安装

在 vscode 中安装 CodeQL 插件

image-20250409154926481

然后在该插件的设置中设置 codeql 的可执行文件路径(路径中最好不要有中文)

image-20250409171548823

然后就设置好了,接下来写个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 添加进去

image-20250409180422875

然后出现这个就表示数据库加载成功了

image-20250409180504379

接着再添加CodeQL SDK:“文件”-“将文件夹添加到工作区”

然后在 sdk/java/ql 目录下创建个 demo.ql 查询文件,然后 Run Query on Selected Database,就会打印 heloooo test

image-20250409182017378

基本语法

以上面的靶场为例

image-20250409182314089

可以看到 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

image-20250409185510005

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

image-20250410215223683

然后添加下过滤条件,筛选出名字为 getStudent 的方法名称

1
2
3
4
5
import java
 
from Method k
where k.hasName("getStudent")
select k.getName(), k.getDeclaringType()

image-20250410215935790

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的链路是通的,才表示当前漏洞是存在的。

image-20250410221945041

设置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等信息会写入到审计报告中。

这里的isSourceisSink 根据自己需要进行重写,而判断中间是否疏通可以使用CodeQL提供的config.hasFlowPath(source, sink)来帮我们处理

image-20250411180745584

可以看到真的很方便,直接把source处和整个从source到sink的链子都显示出来了

这里爆警告说是 DataFlow::PathGraph 在新版本中被弃用了,所以这段代码只能在低版本的规则库里跑

这里开头有 @kind path-problem ,说明结果至少是4列,写了这个结果就会输出完整的污点传播路径,除此之外对输出还有其他要求,比如每列要输出的类型也有要求,但是我这 source.getNode(),source, sink,"source" 就能正常输出,source, sink,source.getNode(),"source" 却是啥也没有

误报分析

我们可以看到有一处的 sql 注入,其输入的参数类型为 List<Long> ,不可能存在注入

image-20250411183001837

这里说明我们给的限制并未严格要求参数类型,就会导致以上的误报产生,我们可以用 isSanitizer 来避免这种情况

image-20250411183843751

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 方法来将断了的节点给它强制连接上

image-20250412162413776

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 方法,实现手法不一样罢了,貌似第一种更方便,不知道为啥我这啥都没修改还是检测出来了

image-20250412171148651

批量化实现

以上 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语法糖

1
a instanceof b

上面语句就是检测 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+()   // 获取作用域

+ 是从上一级开始调用

image-20250412182154821

* 是从自身开始调用

image-20250412182236018

自己封装方法来实现递归调用也是可以的,比如这里我这只想找相差一层的类

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)   // 获取作用域

image-20250412182753822

强制类型转换

在 CodeQL 中可以用 getType() 来对返回结果做强制类型转换

查询下当前数据库中所有的参数及其类型

1
2
3
4
import java
 
from Parameter param
select param, param.getType()

image-20250413132629826

可以看到有5k多条而且有不同类型的参数,强制转换下,只留下是整型的参数

image-20250413132902211

只有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/

0%