使用CodeQL高效发现未处理错误的技术实践

本文详细介绍了如何利用CodeQL进行变体分析,通过构建自定义查询包和污点跟踪技术,系统性地发现代码库中未处理的错误返回码,提升代码安全性。

使用CodeQL查找未处理错误 - Trail of Bits博客

构建CodeQL数据库

要能够对代码库运行CodeQL查询,我们首先需要构建CodeQL数据库。通常,这可以通过CodeQL CLI使用以下命令完成:

1
codeql database create -l <language> -c '<build command>' <database name>

在我们的案例中,被审计的代码库是在Windows上使用Visual Studio开发的。由于CodeQL不直接与Visual Studio集成,我们使用MSBuild.exe从Windows命令行构建解决方案:

1
codeql database create -l cpp -c 'MSBuild.exe <solution>.sln' <solution>.codeql

设置自定义查询包

为了能够对数据库运行查询,我们定义了一个自定义查询包(QL包),其中包含查询元数据。查询包还可用于定义自定义查询套件和查询测试套件。在包含自定义查询的同一目录中,我们创建了一个名为qlpack.yml的文件,内容如下:

1
2
3
name: <some snazzy QL pack name>
version: 0.0.1
libraryPathDependencies: [codeql-cpp]

此文件定义了自定义查询包及其依赖关系。qlpack.yml文件的最后一行简单地表示查询包依赖于内置的C和C++ CodeQL库。

查找未处理错误

假设代码库使用名为CustomErrorType的自定义错误类型来传播错误。为了定位所有返回CustomErrorType的函数调用,我们首先创建一个名为CustomError的新CodeQL类型:

1
2
3
4
5
class CustomError extends FunctionCall {
    CustomError() {
        this.getUnderlyingType().getName() = "CustomErrorType"
    }
}

由于返回值在CodeQL中表示为函数调用,因此扩展FunctionCall类型是有意义的。使用this.getUnderlyingType()确保底层类型的名称为CustomErrorType。这意味着我们捕获所有返回类型为CustomErrorType或解析为CustomErrorType的任何typedef的函数调用。

为了测试CustomError类是否符合预期,我们只需在代码库上运行查询并选择所有CustomErrorType返回值。我们在类定义下方添加了以下select子句:

1
2
3
4
5
6
from 
    CustomError ce
select
    ce.getLocation(), 
    "Unhandled error code in ", ce.getEnclosingFunction().getName(), 
    "Error code returned by ", ce.getTarget().getName()

这里,ce.getEnclosingFunction()返回包含CustomErrorType实例的函数(即调用函数),ce.getTarget()返回底层FunctionCall的目标函数(即被调用函数)。

我们将文件保存为一个描述性名称(例如UnhandledCustomError.ql)。要运行查询,我们编写了以下命令:

1
codeql query run -d <database name here> UnhandledCustomError.ql

此查询返回代码库中所有返回CustomErrorType类型值的函数的调用站点,以及调用函数和被调用函数的名称。

什么是未处理错误?

为了将结果限制为未处理的错误,我们需要使用CodeQL定义处理错误的含义。直观地说,处理错误意味着对返回值进行操作并以某种方式影响控制流。这个想法可以通过检查返回值是否污染分支语句(如if语句、while语句或switch语句)的条件来使用CodeQL捕获。

由于CodeQL支持本地和全局污点跟踪,我们可以选择如何建模这个问题。在我们的案例中,我们最初有点担心CodeQL的全局污点跟踪引擎在较大的代码库上的表现,因此我们决定尝试使用本地污点跟踪来建模问题。

作为第一个近似,我们考虑了返回的错误代码以某种方式直接影响调用函数的控制流的情况。为了捕获这些情况,我们在CustomError CodeQL类型中添加了以下谓词:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
predicate isChecked() {
    exists (IfStmt is |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(is.getCondition().getAChild*())
        )
    ) or
    exists (WhileStmt ws |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(ws.getCondition().getAChild*())
        )
    ) or
    exists (SwitchStmt ss |
        TaintTracking::localTaint(
            DataFlow::exprNode(this), 
            DataFlow::exprNode(ss.getExpr().getAChild*())
        )
    )
}

由于TaintTracking::localTaint只建模本地数据流,我们不需要要求条件语句(接收器)与返回的错误(源)位于同一函数中。使用本地污点跟踪,我们可以免费获得这一点。

为了限制CodeQL select语句的输出,我们在查询中添加了一个where子句:

1
2
3
4
5
6
7
8
from 
    CustomError ce
where
    not ce.isChecked()
select
    ce.getLocation(), 
    "Unhandled error code in ", ce.getEnclosingFunction().getName(), 
    "Error code returned by ", ce.getTarget().getName()

优化查询

再次运行查询后,我们注意到它在代码库中发现了许多返回错误未正确处理的位置。然而,它也发现了许多误报,其中返回值确实以某种方式全局影响控制流。手动审查一些结果后,我们注意到三大类误报:

  1. 返回的错误只是从封闭函数返回并沿调用链传递
  2. 返回的错误作为参数传递给函数(希望以某种有意义的方式处理错误)
  3. 返回的错误分配给类成员变量(然后可以在代码库的其他地方检查)

所有这些情况下的本地行为都可以使用本地污点跟踪来建模。

首先,为了排除返回的错误用于更新调用函数返回值的所有情况,我们在CustomError类中添加了以下谓词:

1
2
3
4
5
6
7
8
predicate isReturnValue() {
    exists (ReturnStmt rs |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(rs.getExpr())
        )
    )
}

其次,为了过滤掉返回值作为参数传递给其他函数的情况,我们添加了以下谓词:

1
2
3
4
5
6
7
8
predicate isPassedToFunction() {
    exists (FunctionCall fc |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(fc.getAnArgument())
        )
    )
}

最后,为了建模返回的错误用于更新类成员变量值的情况,我们需要表达调用函数是类方法,并且返回值用于更新同一类上成员变量的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
predicate isAssignedToMemberVar() {
    exists (MemberVariable mv, MemberFunction mf |
        mf = this.getEnclosingFunction() and
        mf.canAccessMember(mv, mf.getDeclaringType()) and
        TaintTracking::localTaint(
            DataFlow::exprNode(this), 
            DataFlow::exprNode(mv.getAnAccess())
        )
    )
}

运行更新版本的查询后,我们注意到一些结果来自单元测试。由于单元测试中的问题通常不太受关注,我们希望将它们从最终结果中排除。这可以通过在codeql的输出上使用grep -v轻松完成,也可以通过使用CodeQL本身限制调用站点的位置来完成。

为了按文件路径过滤,我们定义了一个名为IgnoredFile的新类,它捕获了我们想要从结果中排除的文件类型。在这种情况下,我们排除了绝对路径包含"test"一词的任何文件:

1
2
3
4
5
class IgnoredFile extends File {
    IgnoredFile() {
        this.getAbsolutePath().matches("%test%")
    }
}

然后我们在最终查询的where子句中添加了以下行,排除了我们不太感兴趣的所有位置:

1
not ce.getFile() instanceof IgnoredFile

最终查询产生了略多于100个代码位置,我们可以手动审查和验证。

全局数据流怎么样?

CodeQL通过DataFlow::Configuration和TaintTracking::Configuration类支持全局数据流和污点跟踪。如前所述,我们可以使用DataFlow模块来跟踪值保留操作,使用TaintTracking模块来跟踪值可能沿流路径更新的更一般流。

虽然我们最初担心被审查的代码库太大,CodeQL的全局污点跟踪引擎无法处理,但我们也很好奇全局分析是否能比本地分析提供更准确的结果。事实证明,使用全局数据流的查询更容易表达,并且在与使用本地数据流的查询相同的运行时间内获得了更准确的结果!

由于我们不想将自己限制在值保留操作上,我们需要扩展TaintTracking::Configuration类。为此,我们通过重写isSource和isSink谓词来定义什么是源和接收器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class GuardConfiguration extends TaintTracking::Configuration {
    GuardConfiguration() { this = "GuardConfiguration" }
  
    override predicate isSource(DataFlow::Node source) {
        source.asExpr().(FunctionCall).getUnderlyingType().getName() =
            "CustomErrorType"
    }
  
    override predicate isSink(DataFlow::Node sink) {
        exists (IfStmt is | sink.asExpr() = is.getCondition().getAChild*()) or
        exists (WhileStmt ws | sink.asExpr() = ws.getCondition().getAChild*()) or
        exists (SwitchStmt ss | sink.asExpr() = ss.getExpr().getAChild*())
    }
}

然后我们根据全局污点跟踪重新定义了CustomError::isChecked谓词:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class CustomError extends FunctionCall {
    CustomError() {
        this.getUnderlyingType().getName() = "CustomErrorType"
           }
  
    predicate isCheckedAt(Expr guard) {
        exists (GuardConfiguration config |
            config.hasFlow(
                DataFlow::exprNode(this), 
                DataFlow::exprNode(guard)
            )
        )
    }
  
    predicate isChecked() {
        exists (Expr guard | this.isCheckedAt(guard))
    }
}

也就是说,如果返回的错误污染了代码库中任何地方的if、while或switch语句的条件,则认为该错误已被处理。这实际上使整个查询简单得多。

有趣的是,全局分析的运行时间与本地污点跟踪的运行时间大致相同(在2020年Intel i5 MacBook Pro上编译查询约20秒,运行查询约10秒)。

使用全局污点跟踪运行查询给了我们200多个结果。通过手动审查这些结果,我们注意到错误代码通常最终传递给为代码库定义的API用户生成响应的函数。由于这是预期行为,我们将所有此类情况从最终结果中排除。为此,我们只需在GuardCondition::isSink的定义中添加一行:

1
2
3
4
5
6
7
override predicate isSink(DataFlow::Node sink) {
    exists (ReturnStmt rs | sink.asExpr() = rs.getExpr()) or
    exists (IfStmt is | sink.asExpr() = is.getCondition().getAChild*()) or
    exists (WhileStmt ws | sink.asExpr() = ws.getCondition().getAChild*()) or
    exists (SwitchStmt ss | sink.asExpr() = ss.getExpr().getAChild*()) or
    exists (IgnoredFunctionCall fc | sink.asExpr() = fc.getExtParam())
}

这里,IgnoredFunctionCall是一个自定义类型,捕获对生成用户响应的函数的调用。运行查询后,我们最终得到了大约150个可以手动检查的位置。最终,使用CodeQL识别的大多数位置代表了需要客户解决的真实问题。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计