codeql入门
codeql入门
前言
最初接触codeql,是暑假公司师傅们提到了一下,当时在公司宿舍聊天,聊到各位师傅当时的面试题,,有一个师傅被问了AST语法树分析,,,
然后一脸懵逼的我听着师傅的讲解,谈到了2021 强网杯 那道几十万行的pop链,瞬间知道他是干啥的,,,
主要用于污点追踪分析
Codeql基本概念
是什么
CodeQL是一个代码分析平台,在它的帮助下,安全研究人员可以利用已知的安全漏洞来挖掘类似的漏洞,可以实现变种分析的自动化。这里所谓的变种分析,就是以已知的安全漏洞作为参照物,在我们的目标代码中寻找类似的安全问题的过程。
漏洞挖掘范式
我们知道,挖掘漏洞的时候,对于大佬来说,其实是可以硬审的,但是,对安全人员要求比较高,换句话说,对新手不太友好,
那么,我们就可以照猫画虎去模仿的写一个类似的,去分析上面题到的变种类型。
QL
QL是一种通用的、面向对象的语言,可以用来查询任何类型的数据。在CodeQL平台上面,为了帮助安全研究人员完成各种代码分析工作,提供了许多现成的查询和代码库——这些都是使用QL语言编写的,
并且,它们都是开源的,源码可以从这里(https://github.com/semmle/ql)下载。
我们可以使用mysql
去对比codeql
- MySQL是一个数据库管理系统,可以用来存储、管理和分析数据;而CodeQL则可以看作是一个代码库管理系统,用于存储、管理和分析代码。
- 为了分析数据,我们需要SQL语言来查询数据库;而为了分析代码,这里则需要利用QL语言来查询代码库。
==CodeQL数据库中存放的是使用CodeQL创建和分析的关系数据。 我们可以将其看作是QL的快照,但是针对CodeQL工具进行了相应的优化处理。==
codeql的安装
CodeQL主要分为引擎和库两部分,都可以在github上下载,核心的解析引擎部分
是不开源的,用于解析数据库执行等操作,库是开源的,针对语言提供了很多函数和类型以方便我们写自己的规则。
由于
CodeQL
的处理对象并不是源码本身,而是中间生成的AST结构数据库,所以我们先需要把我们的项目源码转换成CodeQL
能够识别的CodeDatabase
。这里先下载不开源的解析引擎所以我们需要先下载codeql的客户端
1
https://github.com/github/codeql-cli-binaries/releases
需要下载一下QL库,但这里还有一种更为简单的方法,可以使用
vscode-codeql-starter
项目进行启动1
https://github.com/github/vscode-codeql-starter
由于其需要加载
ql库
,所以我们需要使用递归的方式下载1
git clone --recursive https://github.com/github/vscode-codeql-starter
主要是加载
submoudle
简单来说就是一个模块引入了另一个子模块,所以才需要循环下载
这里遇到一些问题,就是版本的问题:ql的规则库和cli版本不对应,导致导入之后ql
报错,和下图类似:
参考 issue :https://github.com/github/codeql/issues/6768
更换了 codeql-cli
版本,我是更换到了2.8.0
,此时最新版本是2.8.3
,使用最新版本会报错。具体内容参考issue
其实官方还提供了一个CodeQL的在线版本:https://lgtm.com/search , 可以使用
一个练习codeql语法的github项目
https://lab.github.com/githubtraining/codeql-u-boot-challenge-(cc++)
codeql数据库操作
需要先创建数据库
codeql database create <database> --language=<language-identifier> -source-root=<path> --command="mvn clean install --file pom.xml"
注意:如果省略
--command
参数,则 codeQL 会自动检测并使用自己的工具来构建。但还是强烈推荐使用自己自定义的参数,尤其是大项目时。
更新数据库
codeql database upgrade <path>
对于建好的数据库,他的目录目录应该是如下的:
1 | - log/ # 输出的日志信息 |
注意:
- 对于非编译性的语言来说,直接扫描,像php,python等脚本语言可以不用编译
- 对于java这种语言来说,需要先经过编译。对于go来说,可编译也可不编译
可以使用codeql resolve languages
来看codeql
支持哪些语言的版本
第一个codeql代码
这里在Vscode
上安装了对应的扩展,直接搜索codeql
即可
测试代码样例:https://github.com/l4yn3/micro_service_seclab/
其他项目:在 Github Learning Lab 中,有一个用于学习 CodeQL 的入门课程 - CodeQL U-Boot Challenge (C/C++)]
https://lab.github.com/GitHubtraining/codeql-u-boot-challenge-%28cc++%29
上面我们提到,我们需要使用codeql引擎,将需要分析的项目,转换成,可以被codeql识别的database
,这个过程中,codeql引擎把我们的java代码
转换成了可识别的AST数据库
。
AST分析出来是这样子的(需要对单独的类进行分析)
先导入qlpack.yml文件
这一步其实可以直接再starter
里面写,但是也可以将starter和 新的文件夹 加入工作区
,后者才需要导入qlpack.yml
1 | name: getting-started/codeql-extra-queries-java |
1 | <queries language="java"/> |
之后进行一下测试,看看能不能进行查询
1 | select "hello world"; |
总的来说,QL的查询语句和SQL
很像,类似如下结构:
1 | import java |
- 第一行表示,我们要引入
Codeql
的类库,因为我们要分析的项目是java的,所以在ql语句里,必不可少 from int i
:表示我们要定义一个变量 i ,他的类型是int,表示我们获取所有int类型的数据where i = 1 表示当i等于1的时候,符合条件
select i
表示输出 i
一句话总结就是:在所有的整形数字i
中,当i==1
的时候,惊奇输出
这样一来,我们就得到了QL查询的语法结构
1 | from [datatype] var |
语法
谓词
和sql一样,where部分的查询条件如果过长,会显得很乱。Codeql提供一种机制,可以把你很长的查询语句,封装成函数。
这个函数,就是谓词
predicate 表示当前方法没有返回值。类似于 JAVA中的
void
谓词方式定义如下:
1 | predicate name(type arg) |
定义谓词有三个要素:
- 关键词 predicate(如果没有返回值),或者结果的类型(如果当前谓词内存在返回值)
- 谓词的名称
- 谓词的参数列表
- 谓词主体
1. 无返回值的谓词
- 无返回值的谓词以
predicate
关键词开头。若传入的值满足谓词主体中的逻辑,则该谓词将保留该值。 - 无返回值谓词的使用范围较小,但仍然在某些情况下扮演了很重要的一个角色
- 举一个简单的例子
1 | predicate isSmall(int i) { |
若传入的 i
是小于 10 的正整数,则 isSmall(i)
将会使得传入的集合 i
只保留符合条件的值,其他值将会被舍弃。
2. 有返回值的谓词
当需要将某些结果从谓词中返回时,与编程语言的 return 语句不同的是,谓词使用的是一个特殊变量 result
。谓词主体的语法只是为了表述逻辑之间的关系,因此务必不要用一般编程语言的语法来理解。
1 | int getSuccessor(int i) { |
在谓词主体中,result
变量可以像一般变量一样正常使用,唯一不同的是这个变量内的数据将会被返回。
1 | import java |
这里会返回两个结果:”Belgium” 与 “Germany”
谓词不允许描述的数据集合个数不限于有限数量大小的。
1 | // 该谓词将使得编译报错 |
但如果我们仍然需要定义这类函数,则必须限制集合数据大小,同时添加一个 bindingset
标注。该标注将会声明谓词 plusOne
所包含的数据集合是有限的,前提是 i
绑定到有限数量的数据集合。
1 | import java |
类
在 CodeQL 中的类,并不意味着建立一个新的对象,而只是表示特定一类的数据集合,定义一个类,需要三个步骤:
- 使用关键字
class
- 起一个类名,其中类名必须是首字母大写的。
- 确定是从哪个类中派生出来的
其中,基本类型 boolean
、float
、int
、string
以及 date
也算在内。
如官方的案例:
1 | import java |
可以直接从输出的结果中查询到数据:输出1和2
其中,特征谓词类似于类的构造函数,它将会进一步限制当前类所表示数据的集合。它将数据集合从原先的 Int
集,进一步限制至 1-3 这个范围。this
变量表示的是当前类中所包含的数据集合。与 result
变量类似,this
同样是用于表示数据集合直接的关系。
此外,在特征谓词中,比较常用的一个关键字是 exists
。该关键字的语法如下:
1 | exists(<variable declarations> | <formula>) |
这个关键字的使用引入了一些新的变量。如果变量中至少有一组值可以使 formula 成立,那么该值将被保留。
1 | import java |
类库
上面提到的method
变量,具体和java反射
中的变量相似,结合生成的AST
结构的代码来看
比方说,我们想获取类中所有的方法,在AST里面的Method代表的就是类当中的方法,
我们想过的所有方法的调用,MethodAccess获取的就是所有方法调用
名称 | 解释 |
---|---|
Method | 方法类,表示获取当前项目中所有的方法 |
MethodAccess | 方法调用类,MethodAccess call表示获取当前项目中所有方法调用 |
Parameter | 参数类,Parameter表示当前项目中所有存在的参数 |
结合ql的语法,我们尝试获取micro-service-seclab项目中定义的所有方法
1 | import java |
我们在通过Method类内置的一些方法,把结果过滤一下,比如我们获取的名字是getStudent
的方法名称
1 | import java |
method.getName() 获取的是当前方法的名称
method.getDeclaringType() 获取的是当前方法所属class的名称。
java 有 五大类库
Program Elements
,程序元素,例如类和方法AST nodes
,抽象树节点,例如语句和表达式Metadata
,元数据,例如注解和注释metrics
,计算指标,例如循环复杂度Call Gragh
,调用图
这些类包括:包(Package)、编译单元(CompilationUnit)、类型(Type)、方法(Method)、构造函数(Constructor)和变量(Variable)。
它们共同的超类是 Element,它提供了常用的成员谓词,用于确定程序元素的名称和检查两个元素是否相互嵌套。
因此可以方便的引用一个方法或构造函数的元素。此外,Callable
类是 Method
和 Constructor
的共同超类,可以用于此目的。
具体如图所示
Type
类 Type 有许多子类,用于表示不同种类的类型。
PrimitiveType
表示原始类型,即boolean
,byte
,char
,double
,float
,int
,long
,short
;QL 也将void
和nulltype
归为原始类型。- RefType是非原始类型,它又有几个子类。
Class
interface
enum
Array
例如, 如果我们要查询程序中所有的int
类型的变量
1 | import java |
引用类型也是根据他们的声明范围来划分的
TopLevelType
代表在编译单元(一个.java
文件)的顶层声明的类。NestedType
是一个在另一个类型内声明的类型。LoadClass
:在成员方法或构造方法中声明的类AnonymousClass
:匿名类
如下,可以找到所有名称与编译单元不一致的顶层类型
1 | import java |
最后,该库还有一些单例子类,如:TypeObject
、TypeCloneable
、TypeRuntime
、TypeSerializable
、TypeString、TypeSystem
和 TypeClass
。每个 CodeQL 类都代表其名称所暗示的标准 Java 类。
一个找到所有直接继承 Object
的嵌套类的查询
NestedClass —-> 嵌套类
1 | import java |
Generics
GenericType
是 GenericInterface
或 GenericClass
。它代表了一个泛型型声明,如 Java 标准库中的接口 java.util.Map
:
1 | package java.util.; |
类型参数,如本例中的 K 和 V,由 TypeVariable
类表示。
一个泛型的参数化实例提供了一个具体实现该类型的参数,如 Map<String, File>
。这样的类型由 ParameterizedType
表示,它与 GenericType
不同。要从 ParameterizedType
到其相应的 GenericType
,可以使用谓词 getSourceDeclaration
。
我们可以如下查到java.util.Map
的所有参数化实例
1 | import java |
一般来说,泛型需要限制类型参数可以与哪些类型绑定。例如,一个从字符串到数字的映射类型可以被声明如下:
1 | class StringToNumMap<N extends Number> implements Map<String, N> { |
这意味着 StringToNumberMap
的参数化实例只能使用 Number
或它的一个子类型来实例化类型参数 N,而不能用其它类,如说 File
。我们说 N
是一个有界的类型参数,Number
是它的上界。在 QL 中,一个类型变量可以用谓词 getATypeBound
来查询它的类型边界。类型边界本身由 TypeBound
类表示,它有一个成员谓词 getType
来检索变量被约束的类型。
如下的查询找到所有以Number
类型为边界的变量
1 | import java |
为了处理那些在泛型出现之前的遗留代码,每个泛型都有一个没有任何类型参数的 「原始」版本。在 CodeQL 库中,原始类型用 RawType
类表示,它有预期的子类 RawClass
和 RawInterface
。同样,有一个谓词 getSourceDeclaration
用于获得相应的通用类型。如下的查询可以找到(原始)类型 Map 的变量。实际上,现在仍然有许多项目在使用原始类型的 Map。
1 | import java |
Variable
类 Variable
表示 Java 中的变量,它可以是一个类的成员字段(无论是否静态),也可以是一个局部变量,或者是函数的参数。因此,有三个子类来满足这些特殊情况的需要。
Field
:字段LocalVariableDecl
:本地变量.Parameter
:方法或构造函数的参数。
AST抽象语法树
该类中包含了抽象语法树的节点,也就是语句(QL 中的类 Stmt
)和表达式(QL 中的类 Expr
)。关于标准 QL 库中可用的表达式和语句类型的完整列表,可以参考 https://codeql.github.com/docs/codeql-language-guides/abstract-syntax-tree-classes-for-working-with-java-programs/
Expr
和 Stmt
都提供了成员谓词来获取程序的抽象语法树:
Expr.getAChildExpr
返回一个给定表达式的子表达式。Stmt.getAChild
返回直接嵌套在给定语句中的语句或表达式。Expr.getParent
和Stmt.getParent
返回 AST 节点的父节点
下面的查询可以找到所有父类为返回语句的表达式。
1 | import java |
因此,程序中如果包含:return x + y
子表达式,QL 的查询结果将会返回:x + y
。
下面的查询可以找到某个表达式的父级为 if 语句:
1 | import java |
一个查询的例子,可以找到方法体。
1 | import java |
如上的这些例子可知,表达式的父节点并不总是表达式:它也可能是一个语句,例如 IfStmt
。同样,语句的父节点也不总是一个语句:它也可能是一个方法或构造函数。为了抓住这一点,QL Java 库提供了两个抽象类 ExprParent
和 StmtParent
,前者代表可能是表达式的父节点的任何节点,后者代表可能是语句的父节点的任何节点。
Metadata(元数据)
除了 Java 程序代码本身之外,Java 程序还有几种元数据。其中包括有注解(Annotations) 和 Javadoc 注释。由于这些元数据对于加强代码分析或者是作为分析目标本身都很有用处,因此,QL 库定义了用于访问这些元数据的类。
对于注解(Annotations),类 Annotatable
是所有可以被注解的程序元素的超类。这包括包、引用类型、字段、方法、构造函数和声明的局部变量。对于每个这样的元素,类中的谓词 getAnAnnotation
可以检索该元素可能有的任何注释。例如,下面的查询可以找到构造函数上的所有注解。
1 | import java |
设置 source和sink
在自动化代码安全审计论中有一个核心的三元组概念:source,sink,sanitizer
source是指漏洞污染链条的输入点。比如获取http请求的参数部分,就是非常明显的Source。
sink是指漏洞污染链条的执行点,比如SQL注入漏洞,最终执行SQL语句的函数就是sink(这个函数可能叫query或者exeSql,或者其它)。
sanitizer又叫净化函数,是指在整个的漏洞链条当中,如果存在一个方法阻断了整个传递链,那么这个方法就叫sanitizer。
只有source和sink同时存在,并且从source到sink的链路是通的,才表示当前的漏洞是存在的。
新定义的 Config
类继承于 TaintTracking::Configuration
。类中重载的 isSource
谓语定义为污点的源头,而 isSink
定义为污点汇聚点。
在codeql中,我们通过
1 | override predicate isSource(DataFlow::Node src) {} |
来设置source
那么,我们在此靶场中的source是什么?
可以看到,我们所使用的是Springboot的框架,那么source就是http参数入口的代码参数,在controller
中可以找到
这里的source就是username
同理,这里的source就是Student
对象
但是,在这里,我们将source设置为
1 | override predicate isSource(DataFlow::Node src) { src instanceof RemoteFlowSource } |
这是SDK自带的规则,里面包含了大多数常用的Source入口,其中也包括Springboot,我们可以直接使用
instance则是codeql提供的语法
在codeql中,我们通过
1 | override predicate isSink(DataFlow::Node sink){ |
来设置sink
在这里,我们将query方法(Method)的调用(MethodAccess),所以我们设置sink为
1 | override predicate isSink(DataFlow::Node sink) { |
Flow数据流
设置好了source和sink,我们就相当于搞定了首和尾,但是,只有连通才能决定是否存在漏洞
一个受污染的变量,能够毫无阻拦的流传到危险函数,就证明存在漏洞。
这个连通工作就是使用codeql引擎本身来完成的。我们通过使用config.hasFlowPath(source,sink)来判断是否连通,
比如
1 | from VulConfig config, DataFlow::PathNode source, DataFlow::PathNode sink |
我们传递给了config.hasFlowPath(Source,sink)我们定义好的source和sink,系统会自动帮我们判断是否存在漏洞
初步codeql代码
1 | /** |
注意:上面的注释和其他语言是不一样的,不能删除,也是程序的一部分。因为我们在生成测试报告的时候,上面的name,description等信息会写入审计报告中。
这样,我们就拿到了最终的漏洞
错误修改
我们发现,上面自动审计出来的漏洞中,发现了一个误报
这个方法的参数是 List<long>,不可能存在注入漏洞
这说明,我们的规则里,对于List<long>型,甚至List<Integer>类型都会产生误报。source误把这种类型的参数涵盖了
我们需要采取手段消除这种误报,这个手段就是isSanitizer
isSanitizer是codeql的类TaintTracking::Configuration提供的净化方法。他的函数是:
override predicate isSanitizer(DataFlow::Node node){}
在Codeql自带的默认规则里,对当前的节点做了相应的判断
1 | override predicate isSantizer(DataFlow::Node node){ |
表示如果当前节点是上面提到的基础类型,那么此污染链将被净化阻断,漏洞将不存在
由于Codeql检测SQL注入里的isSanitizer方法,只对基础类型做了判断,并没有对这种复合类型做判断,才引起了这次误报的问题
那么我们只要将这种符合类型的方法加入到isSanitizer,即可消除这种误报
1 | override predicate isSanitizer(DataFlow::Node node) { |
以上代码的意思是:如果当前node节点的类型为基础类型,数字类型和泛型数字类型(比如List)时,就切断数据流,认为数据流断掉了,不会继续往下检测。
重新执行query,发现误报已经消除。
修改2
我们发现,如下的sql没有被codeql捕捉到
1 | public List<Student> getStudentWithOptional(Optional<String> username) { |
漏报理论上讲是不能接受的。如果出现误报我们还可以通过人工筛选来解决,但是漏报会导致很多漏洞流经下一个环节到线上,从而产生损失。
那我们如果通过CodeQL来解决漏报问题呢?答案就是通过isAdditionalTaintStep
方法。
实现原理就是:==断了就给他接上==
isAddtionalTaintStep方法是Codeql的类TainTracking::Configuration提供的方法,他的原型是:
1 | override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) {} |
他的作用是将一个可控节点A强制传递给另外一个节点B,那么节点B也就成了可控节点
多次测试以后,发现是username.get()
断掉了,大概是因为Optional
这种类型的使用没有在Codeql语法库里
那么,我们让username强制流转到username.get()
,这样username.get()就变得可控了,这样就能识别出这个注入漏洞了。
修改codeql语句
1 | /** |
上述,我们实现了一个isTaintedString
谓词,并使用exists子查询方式实现了强制把Optional<String> username
关联 Optional<String> username.get()
最后,注入就可以被跑出来了
我们就简单粗暴的把数据流连通了。
Lombok问题
lombok是非常有名的java类,通过注解省略了很多不必要的臃肿代码
1 | package com.l4yn3.microserviceseclab.data; |
但是,这样的话,由注解生成的代码,导致codeql无法获取到lombok自动生成的代码,所以就导致使用了lombok的代码即使存在漏洞,也无法被识别的问题
再codeql里的issue里面,有人给出了这个问题的解决办法,如下
1 | # get a copy of lombok.jar |
上面实现的功能是,去掉代码里lombok注解,并且还原getter
和setter
方法的java代码,从而使得codeql的flow流能够顺利流下去。从而检索到相应的漏洞
(根据对应的操作系统自动转化相应的方法)
最终优化
1 | * @id java/examples/vuldemo |
codeql进阶
我们再上面的案例中看到了instanceof
,如果我们去看codeql自带的规则库,会发现大量的instanceof
语句
我们已经知道,可以使用exists(|)这种方式来定义source 和sink,但是如果,source/sink特别复杂(比如,我们为了规则通用,可能要适配Springboot,Thrift RPC,Servlet等source),如果我们把这些都在一个子查询内完成,比如,condition 1 or condition 2 or condition3 ,这样就比较难维护,比较冗杂
instanceof给我们提供了一种机制,我们只需要定义一个abstract class,比如这个案例当中的:
1 | /** A data flow source of remote user input. */ |
然后再isSource方法里进行instanceof,判断src是RemoteFlowSource就可以了
1 | override predicate isSource(DataFlow::Node src) { |
这里的话,java和codeql会有一些不一样。
我们继承了一个abstract抽象类,但是没有实现方法,怎么获得source?
codeql的特性:只要继承了RemoteFlowSource类,那么所有的子类就会被调用,他所代表的source也会被加载。我们在RemoteFlowSource下可以看到非常多的子类,他们的结果都会被用and串联加载
递归问题
递归调用可以帮我们解决一类问题:我们不确定需要调用多少次方法才能得到我们想要的结果的时候,我们就可以选择递归调用
CodeQL里面的递归调用语法是:在谓词方法的后面跟*或者+,来表示调用0次以上和1次以上(和正则类似),0次会打印自己。
我们来举一个例子:
在java语言里,我们可以使用class嵌套class,多个内嵌class的时候,我们需要知道最外层的class是什么怎么办?
比如
1 | public class StudentService { |
我们需要根据innnerTwo类定位到最外层的StudentService类,怎么办?
按照非递归的写法,我们可以这样做
1 | import java |
我们通过连续2次调用getEnclosingType方法是能够拿到最外层的StudentService的。
但正如我们开始所说,实际情况是我们并不清楚一开始有多少层外嵌,而且多个文件可能每个嵌套数量都不一样,我们没办法通过调用的次数来解决此问题,我们就需要用递归的方式去解决。
1 | from Class classes |
也可以自己封装方法来调用
1 | import java |
强制类型转换问题
在CodeQL的规则集里,我们会看到很多类型转换的代码,比如:
打印所有方法的参数名称和类型
1 | import java |
换成如下语句
1 | import java |
这样就强制转换成了RefType,意思就是从前面的结果中过滤出RefType
的类型参数
RefType是一种引用类型,就是去掉int等基本类型之后的数据
IntegralType 与上面相反的,必要类型