使用CodeQL高效挖掘未处理错误代码

本文详细介绍如何利用CodeQL进行变体分析,通过构建自定义查询包和污点跟踪技术,自动化检测代码库中未处理的错误返回码,显著提升安全审计效率。

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

您的一位开发人员在代码库中发现了一个bug——一个未处理的错误代码——并想知道是否还有更多。他仔细检查代码,发现一个接一个的未处理错误。一个孤独的开发人员在玩打地鼠游戏。这还不够。而您那支未经训练的斯坦福一年级毕业生团队从未学过软件工程。您注定要失败。

优秀的开发人员知道,未处理的错误可能被利用,并在代码库中造成严重问题。以CVE-2018-1002105为例,这是Kubernetes中的一个关键漏洞,允许攻击者利用错误处理的错误通过Kubernetes API建立后端连接。

在Trail of Bits,我们经常发现这样的问题,并且我们知道有比手动逐个搜索更好的方法来找到其余的问题。最近发现的一个问题促使我们写了这篇文章。与其像我们可怜的开发人员那样手动筛选代码库玩打地鼠游戏,我们使用CodeQL进行变体分析(获取现有漏洞并搜索类似模式)。在本文中,我们将带您了解我们如何使用CodeQL一次性打击所有地鼠。

构建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类型是有意义的(实际上,它是用于建模任意表达式的更通用Expr类型的子类型)。使用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定义处理错误的含义。直观上,处理错误意味着对返回值采取行动并以某种方式影响控制流。这个想法可以使用CodeQL通过检查返回值是否污染分支语句(如if语句、while语句或switch语句)的条件来捕获。由于CodeQL支持本地和全局污点跟踪,我们可以选择如何建模这一点。

在我们的案例中,我们最初有点担心CodeQL的全局污点跟踪引擎在较大的代码库上如何处理,因此我们决定尝试使用本地污点跟踪来建模问题。作为第一个近似,我们考虑了返回的错误代码以某种方式直接影响调用函数的控制流的情况。为了捕获这些情况,我们向CustomError CodeQL类型添加了以下谓词(因此,下面的this指的是CustomError的实例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// True if the return value is checked locally.
predicate isChecked() {
    // The return value flows into the condition of an if-statement.    	    
    exists (IfStmt is |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(is.getCondition().getAChild*())
        )
    ) or
    // The return value flows into the condition of a while-statement.
    exists (WhileStmt ws |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(ws.getCondition().getAChild*())
        )
    ) or
    // The return value flows into the condition of a switch-statement.
    exists (SwitchStmt ss |
        TaintTracking::localTaint(
            DataFlow::exprNode(this), 
            DataFlow::exprNode(ss.getExpr().getAChild*())
        )
    )
}

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

我们的直觉告诉我们,我们想要建模污点流入分支语句的条件,但看看这是如何建模的,我们实际上要求污点流入条件的子表达式。例如,is.getCondition().getAChild*()返回if语句条件的子表达式。(*表示操作应用0次或多次。您可以使用+表示1次或多次。)

可能不是立即 obvious 为什么我们需要在这里使用getAChild()。如果this污染了if语句条件C的子表达式,那么自然假设this也会污染整个条件。然而,查看CodeQL污点跟踪文档,很明显,污点仅从源传播到接收器,如果“源的实质性部分信息在接收器处保留”。特别是,布尔表达式(仅携带单个比特信息)不会自动被认为被其各个子表达式污染。因此,我们需要使用getAChild()来捕获this污染条件的子表达式。

值得一提的是,本可以使用DataFlow模块来建模本地数据流:DataFlow::localFlow和TaintTracking::localTaint都可用于捕获本地数据流。然而,由于DataFlow::localFlow仅用于跟踪保留值的操作,在我们的案例中,使用更通用的TaintTracking::localTaint谓词更有意义。这使我们能够捕获如下表达式,其中返回的错误在检查之前被突变:

1
2
3
4
if ( ((CustomErrorType)(response.GetStatus(msg) & 0xFF)) == NO_ERROR )
{
    []
}

为了限制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
9
// The return value is returned from the enclosing function.
predicate isReturnValue() {
    exists (ReturnStmt rs |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(rs.getExpr())
        )
    )
}

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

1
2
3
4
5
6
7
8
9
// The return value is passed as an argument to another function.
predicate isPassedToFunction() {
    exists (FunctionCall fc |
        TaintTracking::localTaint(
            DataFlow::exprNode(this),
            DataFlow::exprNode(fc.getAnArgument())
        )
    )
}

再次,由于TaintTracking::localTaint仅建模本地数据流,我们不需要要求FunctionCall节点fc的封闭函数与this的封闭函数相同。

最后,为了建模返回错误用于更新类成员变量值的情况,我们需要表达调用函数是类方法,并且返回值用于更新同一类上成员变量的值。我们通过将封闭函数转换为MemberFunction,然后要求同一对象上存在被this污染的成员变量来建模这种情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Test if the return value is assigned to a member variable.
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())
        )
    )
}

请注意,仅要求数据从this流到成员变量的访问是不够的。如果您不进一步限制mv,mv可能是代码库中定义的任何类的成员。显然,我们还需要要求调用函数是某个类的方法,并且成员变量是同一类的成员。我们使用谓词canAccessMember捕获此要求,当封闭方法mf可以在mf.getDeclaringType()类的上下文中访问成员变量mv时,该谓词为真。

运行更新版本的查询,我们然后注意到一些结果来自单元测试。由于单元测试中的问题通常不太感兴趣,我们希望将它们从最终结果中排除。当然,这可以使用grep -v在codeql的输出结果上轻松完成,但也可以通过使用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识别的大多数位置代表了需要客户解决的真正问题。更新后的文件UnhandledCustomError.ql可以在这里找到。

在Trail of Bits,我们经常说我们不想在客户的代码库中两次看到相同的错误,为了确保这种情况不发生,我们经常在审计报告中提供安全工具,如模糊测试工具和静态分析工具。在这方面,像CodeQL这样的工具很棒,因为它们让我们能够将关于错误类的知识编码为任何人都可以运行并从中受益的查询——实际上,确保我们永远不会再看到那个特定的错误。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容 构建CodeQL数据库 设置自定义查询包 查找未处理错误 那么什么是未处理错误? 优化查询 但全局数据流呢? 最近帖子 构建安全消息传递很难:对Bitchat安全辩论的细致看法 使用Deptective调查您的依赖项 系好安全带,Buttercup,AIxCC的评分回合正在进行中! 使您的智能合约超越私钥风险 Go解析器中意想不到的安全陷阱 © 2025 Trail of Bits. 使用Hugo和Mainroad主题生成。

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