0%

CodeQL学习 (1)

最近接触了一些漏洞挖掘比赛,在大神的引导下接触了CodeQL,这个常用的代码审计工具。与其他代码审计工具不同,它自己定义了一种语言,用于获取代码审计结果,这也使得该工具具有一定的学习成本。下面,我们就来学习一下该工具的使用。

实际上,我们可以将CodeQL语言看成Java语言与SQL语言的结合。CodeQL需要获取一个工程的构建命令,并在工程构建之时构建整个工程代码的抽象语法树(AST)。这个AST可以看做一个数据库,其中包含了工程中的所有代码逻辑,而CodeQL语言则可以对数据库进行筛选与查找,以完成对代码特定部分的审计。如我们需要获取工程中使用了哪些加密算法,工程中是否存在某种特定漏洞,都可以使用CodeQL语言定义匹配模式。

下面,我们就通过实例与代码结合的方式对CodeQL的语法进行学习。

A. 环境搭建与基础操作

A.1 CodeQL环境安装

VSCode对CodeQL的支持较好,这里选择以VSCode为基础搭建环境。

系统环境:Linux Mint

我们需要下载两个东西,一个是CodeQL-cli,用于编译CodeQL规则,是闭源的。第二个是第三方仓库,其中定义了很多实用的CodeQL类,后面会用到。

1
2
wget https://github.com/github/codeql-cli-binaries/releases/download/v2.17.6/codeql-linux64.zip
git clone https://github.com/Semmle/ql

这里的第二条命令可能会执行失败,这可能是因为该仓库较大,可以尝试使用下面的命令完成clone:

1
2
3
git clone https://github.com/Semmle/ql --depth 1
cd ql/
git fetch --unshallow

随后,将第一个闭源的文件解压后的目录加入PATH中,使用source命令更新后,即可使用codeql命令。但实际上我们基本不需要在命令行中使用该命令,而是多在VSCode中完成相关配置。

A.2 创建数据库

下面是使用codeql命令创建工程数据库的命令:

1
codeql database create <database_dir> --language="<lang>" --command="<build_command>" --source-root=<source_root>

其中database_dir即为数据库的目录,lang为需要审计的语言,build_command为构建命令,source_root为工程的根目录。这里目录与命令中的目录最好使用绝对路径。

A.3 VSCode插件

在VSCode插件界面中,搜索CodeQL后安装。

随后打开 “首选项 > 设置”,搜索CodeQL,修改 “Code QL > Cli: Executable Path” 为codeql可执行文件的路径。即完成了CodeQL插件的配置。

B. CodeQL 语法

B.1 基本查询

一个CodeQL脚本执行后,最终应该输出一个表格,其中保存有所有匹配该脚本中定义的模式的工程代码元素。

首先,我们以一个demo工程作为示例。该工程中只有1个main.c文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

const char* names[] = {
"Hornos",
"Colin",
"Alex",
"Torvalds",
};

int main() {
int idx;
scanf("%d", &idx);
printf("%s", names[idx % 4]);
}

使用上面的数据库生成命令为该工程生成一个数据库,构建命令为gcc main.c -o test。当最后输出Successfully created database at …时,即完成了数据库构建。

随后,我们需要在指定目录下编写CodeQL脚本,否则CodeQL将无法找到实用类。对于C/C++文件,应该在第三方仓库根目录下 /cpp/ql/examples 进行编写。

Example 01

1
2
3
4
import cpp

from Include include
select include

上面是一个简单的CodeQL脚本,用于获取工程中包含的所有头文件。这里的头文件是递归获取的。

  • import cpp:导入cpp,这是一个qll文件,位于/cpp/ql/examples/lib中,包含了很多实用的cpp模块。
  • from Include include:定义筛选对象,这里的Include是一个类,其中定义了与C/C++头文件有关的属性等,include为对象名。
  • select include:选择所有数据库中的Include对象并输出。

编写完上面的脚本后,直接点击右上角的启动即可开始运行脚本。运行结果如下图所示。

实际上,这里的输出是调用了Include类中的toString方法。

1
2
3
4
5
6
// ql/cpp/ql/lib/semmle/code/cpp/include.qll, line 19

class Include extends PreprocessorDirective, @ppd_include {
override string toString() { result = "#include " + this.getIncludeText() }
...
}

考虑到不同语言的语法结构可能有很大不同,因此对于不同的语言,第三方仓库中定义有不同的工具类,因此需要进行某种语言的审计时,最好先对该语言定义的一些模块与类进行了解。

B.2 条件查询

如果需要在查询语句中添加一些限制条件,可以在from之后,select之前添加限制条件。

Example 02

1
2
3
4
5
import cpp

from Literal literal
where literal.toString().length() >= 4
select literal

在上面的脚本中,Literal为字符串字面量类,如果没有where语句,则将获取所有的字符串字面量。这里where语句限制只选择长度不小于4的字符串字面量。最终输出结果就是我们所定义的4个字符串。

注意:CodeQL的字符串类属于内置数据类型,相关方法与Java基本相同,方法名都一样。CodeQL的内置数据类型有:boolean、float、int、string、date。另外单等于号既可以表示等于又可以表示赋值,放在条件判断中表示等于,其他则表示赋值。

B.3 predicate谓词

当条件查询中的条件较为复杂时,可以通过使用predicate谓词将条件进行包装,这样可以提升脚本文件的模块化水平与可读性。

如上面的Example 02可以改成相同语义的下面这个脚本:

Example 03

1
2
3
4
5
6
7
8
9
import cpp

predicate enough_string_length(Literal literal) {
literal.toString().length() >= 4
}

from Literal literal
where enough_string_length(literal)
select literal

predicate关键字可以看做定义返回布尔类型的函数。在函数体内部,默认以最后一条语句的结果作为返回值。

B.4 函数定义

CodeQL的函数定义与Java类似,不同的是,CodeQL以内部变量result作为返回值,不使用return关键字。

Example 04

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import cpp

predicate enough_string_length(Literal literal) {
literal.toString().length() >= 4
}

string long_string_upper(Literal literal) {
result = literal.toString().toUpperCase()
and literal.toString().length() >= 5
}

from Literal literal
where enough_string_length(literal)
select literal, long_string_upper(literal)

需要注意的是,由于CodeQL不是专用于常规代码逻辑的语言,因此CodeQL基本没有实现代码的流程控制,在关键字中不存在通用编程语言中常见的whilefor等。因此在脚本中,函数实际上也是predicate谓词的一种形式。在Example 03中,定义的谓词没有result作为返回值,因此被称为无返回值谓词。而在Example 04中,定义了一个返回值为string的谓词,称为有返回值谓词。需要注意的是,有返回值谓词不一定只能返回某个值,它还能附加上一些限制条件,如这里的and literal.toString().length() >= 5就对传入的参数进行了限制。

无返回值谓词只能放在where关键字之后,而有返回值谓词可以放在whereselect之后,均在作为返回值类型使用的同时针对内部限制条件进行筛选;如这里的长度限制就会筛选掉一个长度为4的字符串Alex,输出结果如下。

可以看到,输出中最后一列的列名为[1]。如果需要修改这里,可以在long_string_upper(literal)后面添加as ...指定列名。

B.5 类定义与类继承

CodeQL还可以定义类,类可以定义继承关系。

在类中可定义同名谓词,即直接以类名作为谓词使用。

Example 05

1
2
3
4
5
6
7
8
9
10
11
import cpp

class EnoughLength extends Literal {
EnoughLength() {
this.toString().length() >= 4
}
}

from Literal literal
where literal instanceof EnoughLength
select literal

上面的例子是谓词的第三种写法,即包装在类内部。对于CodeQL中的类继承关系,可以理解为:子类是满足某些条件的父类的子集,这里的“某些条件”定义在子类的构造函数中。如这里即定义了Literal的子类,要求字面量长度不小于4。

注意:下面的写法是错误的:

Example 06 (WRONG)

1
2
3
4
5
6
7
8
9
10
11
import cpp

class EnoughLength extends string {
EnoughLength() {
this.length() >= 4
}
}

from Literal literal
where literal.toString() instanceof EnoughLength
select literal

报错发生于EnoughLength构造函数中:The characteristic predicate for ‘test::EnoughLength’ does not bind ‘this’ to a value.

这个报错刚出现时,我非常困惑,询问了多个GPT也没有获得满意的结果。折腾了好一阵子之后,最终还是在CodeQL官方文档中找到了答案(还是要多看文档啊):CodeQL在处理谓词时需要确保处理对象是一个有限集,这样才能够在有限时间内完成处理。对于上面的例子,由于string字符串类型是一个无限集,其长度可以为任意长度,因此CodeQL无法处理。相同的报错也会发生在尝试继承int、double等其他基本类型中,虽然int和double实际上在计算机中表示时本质上是有限集,但在数学上是无限集,因此当做无限集看待。

有一种情况例外:

Example 07

1
2
3
4
5
class EnoughLength extends string {
EnoughLength() {
this in ["a", "b", "c"]
}
}

在这个示例中,this已经被明确为一个指定集合,子类的范围已经明确,不需要确定父类范围。这种写法是正确的。

那么,如果非要针对无限集定义predicate,应该如何处理呢?答案是——注解bindingset[]。这是一种annotation注解,可以将谓词中的参数显式绑定到有限集,只需要添加一行代码,就可以让example 06通过编译:

Example 08

1
2
3
4
5
6
7
8
9
10
11
12
import cpp

bindingset[this]
class EnoughLength extends string {
EnoughLength() {
this.length() >= 4
}
}

from Literal literal
where literal.toString() instanceof EnoughLength
select literal

在上例中,我们通过bindingset注解将类自身绑定到有限集中,这个有限集取决于使用该类的代码。如这里是literal.toString(),即相当于将类EnoughLength首先绑定到由所有literal调用toString()方法获取的有限集中。这样实际处理的就不是无限集了。

需要注意的是,在文档中提到,bindingset可以针对多个参数使用,有两种书写形式:

1
2
bindingset[x] bindingset[y]
bindingset[x, y]

对于第一种,它的含义是:只需要x和y的其中之一被绑定,则认定x与y均被绑定(两个绑定的指定相互独立)。对于一个有两个参数的谓词,如果只指定x绑定,则含义为:当x被绑定时,认为x和y都被绑定。

而对于第二种,它的含义是:x与y必须都被绑定。这种书写形式多用于带返回值的谓词中。

Example 09

1
2
3
4
5
6
7
8
bindingset[x] bindingset[y]
predicate plusOne(int x, int y) {
x + 1 = y
}

from int x, int y
where y = 42 and plusOne(x, y)
select x, y

上例为CodeQL文档中的一个示例。这个示例是正确的。将bindingset[x]删除后依然正确,但删除bindingset[y]则错误。原因是where限制条件中只限制了y到一个有限集{42},却没有限制x。CodeQL在这种情况下能够求出x的值(41)并输出。

B.6 模块

除了类之外,CodeQL还有更加高层次的一个代码结构——模块。根据CodeQL文档,模块分为几种:

  • 文件模块:每个ql与qll后缀的文件都会隐式生成一个模块。
    • 库模块:qll文件的模块。
    • 查询模块:ql文件的模块。
  • 显式模块:使用module关键字显式声明的模块。
    • 参数化模块:除了使用module显式声明外,还使用<>指定谓词参数的模块,相当于带有谓词泛型的模块。

Example 10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import cpp
import mymodule

module mymodule {
class EnoughLenLiteral extends Literal {
EnoughLenLiteral() {
this.toString().length() >= 4
}
}
}

from Literal literal
where literal instanceof EnoughLenLiteral
select literal

如上例所示,在模块中,可以定义类、谓词等,需要使用模块时,需要首先进行导入,即使该模块在当前文件中定义也需要导入。

Example 11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cpp
import MyModule

bindingset[x]
signature predicate mysig(int x);

module MyModuleTemplate<mysig/1 limit>{
predicate enough_string_length(Literal literal) {
limit(literal.toString().length())
}
}

bindingset[x]
predicate morethan3(int x) { x > 3 }

module MyModule = MyModuleTemplate<morethan3/1>;

from Literal literal
where MyModule::enough_string_length(literal)
select literal

上例展示了带有谓词参数的模块定义与实例化。MyModuleTemplate是我们定义的模块泛型,包含1个泛型谓词limit,谓词格式为mysig格式。这是一个谓词signature签名,在CodeQL中,signature可用于指定谓词类型,如这里的signature调用即定义了一个谓词格式,它具有1个int类型的参数、没有返回值、且参数被绑定。在泛型模块定义时,需要指定每个泛型谓词的参数个数,即mysig后面的/1,表示该泛型谓词有1个参数。在泛型模块之内,可以直接调用模块谓词。关于signature关键字的使用在后面将详细说明。

由于泛型模块内存在未确定的谓词,因此需要使用泛型模块前,首先需要指定泛型类型以将其实例化。这里的MyModule即为模块泛型MyModuleTemplate的实例化结果,它使用morethan3这个谓词作为泛型参数传递。

B.7 模块实现

在CodeQL中,可以使用signature关键字定义一个模块签名,可以看做是一个模块接口,其他模块可以通过使用implements接口实现该模块。一旦要实现某个模块,必须在该模块中实现模块签名中除使用default修饰的所有内容,包括数据类型、类、谓词等。

Example 12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cpp
import MyModule

signature module MyModuleTemplate{
predicate enough_string_length(Literal literal);
default string description() { result = "Default description" }
}

module MyModule implements MyModuleTemplate {
predicate enough_string_length(Literal literal) {
literal.toString().length() > 3
}
}

from Literal literal
where MyModule::enough_string_length(literal)
select literal, MyModule::description()

在上例中,我们定义了一个模块签名MyModuleTemplate,其中定义了一个谓词,在模块签名中的谓词不需要使用signature,会默认将其看做谓词签名。下面还定义了一个default修饰的谓词,这个谓词可以选择不实现,这样实例化模块中会直接使用default中的谓词逻辑。