Gwok HiujinGwok Hiujin

The Bird of Hermes is my name, eating my wings to make me tame.

Feb 18, 2024CSA8960 words in 45 min


『CSA』CSA Checker 编写指北

主要参考资料:

前置知识:

  • 关于静态分析和符号执行的基本概念
  • C++ 面向对象程序设计经验

动机与背景知识:CSA

CSA 概览

Clang Static Analyzer(CSA)是一个用于 Bug 查找的 静态分析 工具,它可以基于符号执行,对程序进行路径敏感的探索,并依靠一系列叫做 Checker 的 cpp 文件来实现 Bug 查找的逻辑,进而检测和构建针对具体 Bug 的错误报告 —— 可以理解为 Checker 是一种类似编译优化的执行文件,每次使用 CSA 查找 Bug 的过程,就是运行一系列 checkers 检查输出编译警告信息的过程。Checker 内部的 Bug 查找逻辑是可以根据开发者的具体需求自行编写、定制的,只要遵循一定的编写规则即可。

CSA 库(llvm-project/clang/lib/StaticAnalyzer)的结构包括以下几层:

  1. 静态分析核心引擎(/Core)
  2. 一系列静态检查器(/Checkers 内部的 xChecker.cpp)
  3. 零碎的前端接口(/Frontend)

pFJcorR.png

CSA 中的符号执行

CSA 分析引擎的优势在于使用了符号执行,我们的 Checker 也都是基于符号执行逻辑进行编写的。

当然,你也完全可以:

  • 书写一个跟 Bug 分析完全没关系的 Checker ,比如一遇到某种类型的变量就输出一些报告文字 —— 但即使是这么简单的功能,也要遵循一定的书写规范,而 Checker 的书写规范与 CSA 引擎的符号执行逻辑有关
  • 基于 AST 结构而不是 CFG 结构注册 Checker ,进而编写与词法 / 语法分析相关的 Checker(不涉及控制流、可以不使用路径敏感分析完成),当然这需要使用 AST 相关的接口,本文不对这个做进一步讨论

在 CSA 中,符号执行的场景是这样的:

  • Clang 为程序生成了它的控制流图 CFG ,分析引擎需要对这张 CFG 进行路径敏感的探索
    • 探索程序中每一条可能的执行路径
    • 使用符号值
  • 收集每条路径上的符号值的约束条件
  • 使用收集到的约束条件,结合具体的算法和 Bug 判定逻辑,决定路径的可行性(feasibility)『NJU程序分析』CFL-Reachability & IFDS - Gwok Hiujin’s Blog

以下图中的代码为例,可以设计一个这样的符号执行逻辑并编写对应的 Checker ,以判定一个文件是否在每条路径上都被合理关闭了:为打开文件的操作赋予一个符号值 $F ,并在 CFG 的每一个节点上都记录文件的打开 / 关闭情况为 open 或者 closed ,如果在某条路径上, open 符号值可以到达函数的 exit node ,我们就发现了一条“feasible path”,认为发现了一个 Bug 并报告它,认为它可能引发资源泄漏问题。

p9NjfAJ.png

程序对应的符号执行 Graph (红点表示在该节点处文件处于打开状态):

p9NjR74.png

这个例子给了我们一个提示:编写 Checker 的一个重要思路是关注 CFG 中 节点 的信息。

实际上,Checker 的工作逻辑就是根据特定的 Bug 判定逻辑为节点添加状态信息(类似图上的 true 和 false),然后分析器的符号执行引擎在探索爆炸图的时候,就会基于这些状态信息生成不同的 状态转移分支,进而探索到程序中所有可能执行的路径。

关键接口与数据结构:ExplodedGraph、ProgramPoint 与 ProgramState

CSA 分析引擎这种对每条可能路径进行探索的策略被称为 exploded(节点爆炸),由被探索到的路径(控制流图的边)组成的控制流信息图被称为 explodedGraph(爆炸图),图中的每一个节点被称为 explodedNode(爆炸节点),每一个节点都由 ProgramPointProgramState 两个信息域组成。

ProgramPoint 表示程序(或者 CFG)的 ”执行位点“(location),还可以记录 ProgramState 是何时 / 如何添加的。例如,一个称为 PostPurgeDeadSymbolsKind 的 ProgramPoint 就表示当前程序状态是清除 DeadSymbols 后的结果。

ProgramState 则表示程序的抽象状态。

它们各自的组成部分如下所示:

ProgramPoint ProgramState
Execution location: 指示当前节点在程序中的执行位点,如语句前、语句后、进入调用等,具体类型见后文 Environment Map: source code Expr -> symbolic values
// 从 clang 表达式到静态分析值的信息对
抽象的栈帧: 允许用户对程序间分析的信息进行推理 Store Map: memory location -> symbolic values
// 标记栈和堆内存的值信息
一个叫做 GenericDataMap 的数据结构(GDM): 符号值的约束信息允许 Checker 开发者根据自定义的 Checker 逻辑,在 ProgramState 中存储不透明的数据
// 我们需要重点关注的部分

右侧的表格其实写得不太准确:我们可以认为 ProgramState 这个抽象概念就是用一个叫 GDM 的数据结构维护的,其中存储着很多张 Map ,包括 Environment Map 和 Store Map 。用户可以向其中添加自定义的 Map ,记录节点的状态信息。为了精确表达被分析程序从程序入口点开始的行为,ProgramState 和 ExplodedNode 在生成后是 不可变 的。

综上,我们可以利用 GDM 结构在 ProgramState 中做自己喜欢的存储 / 标记,并通过一些接口获取当前爆炸图节点的状态值信息。需要注意的是,Checker 本身是无状态的,Checker 中定义的状态信息都记录在 ProgramState 中。

ProgramPoint 的具体类型和在 explodedNode 中显示的对应整型数值如下所示:

BlockEdgeKind 0
BlockEntranceKind 1
BlockExitKind 2
PreStmtKind 3
PreStmtPurgeDeadSymbolsKind 4
PostStmtPurgeDeadSymbolsKind 5
PostStmtKind 6
PreLoadKind 7
PostLoadKind 8
PreStoreKind 9
PostStoreKind 10
PostConditionKind 11
PostLValueKind 12
PostAllocatorCallKind 13
MinPostStmtKind = PostStmtKind 14
MaxPostStmtKind = PostAllocatorCallKind 15
PostInitializerKind 16
CallEnterKind 17
CallExitBeginKind 18
CallExitEndKind 19
FunctionExitKind 20
PreImplicitCallKind 21
PostImplicitCallKind 22
MinImplicitCallKind = PreImplicitCallKind 23
MaxImplicitCallKind = PostImplicitCallKind 24
LoopExitKind 25
EpsilonKind 26

结合上一节的符号执行知识可以看到:从概念上说,CSA 的工作流就是使用 ExplodedGraph 这个数据结构做 可达性分析。静态检查器 Checker 会提供一个针对爆炸图节点的表达式分析规则,CSA 引擎运行时,从最初的根节点开始,然后通过分析 Checker 定义的独立表达式来模拟程序状态(ProgramState)的转换,这个转换过程会在爆炸图中产生包含更新后的 ProgramState 和 ProgramPoint 的爆炸图节点 —— 也就是所谓的 explod 的本质:“炸” 出新的状态转移分支。

为了避免这个过程造成指数爆炸,CSA 采用了 缓存节点 的方案:如果一个即将被生成的新节点与已经存在的节点有相同的程序点和状态,那么这条路径会被 “缓存” 起来,以及直接重用已经存在的节点(并不会生成新节点)。所以 ExplodedGraph 仅仅在理论上是一个有向无环图,实际上它可能包含回到缓存路径的环。

ExplodedGraph 的构建

CoreEngine::ExecuteWorkList 函数是 Clang Static Analyzer 中的一个核心函数,它的主要任务是驱动静态分析的主循环。 具体来说,ExecuteWorkList 函数会从一个待处理的工作列表(WorkList)中取出一个节点(也就是程序的一个状态),然后使用 ExprEngine 来模拟执行从这个状态开始的代码。

在这个过程中, ExprEngine 可能会生成新的状态,并将它们添加到工作列表中。例如,如果当前的状态是一个条件语句,那么 ExprEngine 就会生成两个新的状态,分别对应条件为真和条件为假的情况,并将它们添加到 worklist 中。然后,ExecuteWorkList 函数会继续从工作列表中取出下一个节点,重复这个过程,直到工作列表为空,或者达到了预设的限制(例如,最大节点数或最大路径数)。

通过这种方式,ExecuteWorkList 函数可以遍历程序的所有可能的执行路径,并生成一个完整的 ExplodedGraph 。然后,Clang Static Analyzer 就可以通过分析这个图来检测潜在的编程错误。

Checkers are Visitors

我认为, visitor 模式是对自定义行为的支撑。通过注册不同的 visitor ,就可以决定在控制流图中的什么位置上进行分析,以及如何报告分析的结果。

Checker 的执行流程有点像编译的前端解析,尤其像沿着抽象语法树 visit 语法元素的过程。事实上 Checker 的运作遵循的就是一种基于控制流图结构的 Visitor 模式。如下图所示,Checker 在执行过程中需要对进入分析的程序执行位点做一次 visitor 注册,有可能还要再针对自定义的 Bug 报告行为,做一次 BugReporter 的 visitor 注册。假如检出了当前 Bug 之后,用户不希望分析继续执行,则可以通过生成一种叫做 sink node 的结构停止分析(溺死,,,,)。

p9Nj2BF.png

首先 Checker 需要根据自己感兴趣的程序执行位点(例如语句执行前 / 后,或者返回语句之前),在文件中注册(register)相应的 visitor 模式,这样静态分析器在搜索控制流图的时候就会在控制流图中对应的位置运行 Checker ,文档中把这种行为称为 Checker callbacks 。例如,下面这段 Checker 程序就注册了 4 个执行位点的 visitor:

p9NjgnU.png

通过查阅 官方文档 ,目前 CSA 支持以下这些位点的 callbacks ,具体含义和注册语句示例可以自行在文档中查找:

  • PreStmt / PostStmt
  • PreCall / PostCall
  • Location
  • Bind(Checker 中对存储行为的说法)
  • EndAnalysis
  • EndFunction
  • BranchCondition
  • LiveSymbols(关注变量生命周期)
  • DeadSymbols(关注垃圾回收)
  • RegionChanges(关于 Region 的定义请复习 CSA 内存模型)
  • PointerEscape
  • eval::Assume
  • eval::Call

在 Checker 中,我们还可能需要对 BugReporter 注册对应的 visitor —— 这个 visitor 不是沿着 CFG 执行的,而是沿着 Bug report path 执行的。

通常情况下,在分析结束的时候 CSA 默认的 BugReporter 会通过打印当前符号执行路径上的所有事件来向用户解释到底是如何发现这个路径敏感的错误的,这个被打印出来的 explodedNodeList 被称作 Bug report path ,一般来说,这个就够用了。

但有时,用户可能希望它能标记并显示额外的事件。例如,当报告一个内存 double free 的错误时,用户可能还希望知道第一次 free 是什么时候发生的。在这种情况下,Checker 作者就需要注册一个 BugReporter 的 visitor ,沿着 Bug report path 做 visit ,找到第一次 free 的节点后就记录或打印 Bug 信息。

BugReporter visitor 的编写语法如下所示:

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
class MyVisitor : public BugReporterVisitorImpl<MyVisitor> {
void Profile(llvm::FoldingSetNodeID &ID) const {
/*
Give your Bug reporter a profile so that you can identify your
targeted Bug information stored in the LLVM folding set of
path diagnostic callbacks!
*/
}
PathDiagnosticPiece *VisiNode (const ExplodedNode *N,
const ExplodedNode *PrevN,
BugReporterContext &BRC,
BugReport &BR) {
/*
Identify the node of interest, construct a path diagnostic
for it and return it, or return a null pointer if the node
should be skipped.
*/
if (const Stmt *S = /* A statement for diagnostic */) {
PathDiagnosticLocation Pos(S, BRC.getSourceManager(),
N->getLocationContext());
return new PathDiagnosticEventPiece(Pos, "Bug Message");
}
/*
It is not uncommon to identify nodes by statements
in their program points. In this case, the static helper
method getStmt(...) of PathDiagnosticLocation class should be
useful:

/// const Stmt *S = PathDiagnosticLocation::getStmt(N); ///

But remember: there are most likely multiple nodes
corresponding to the same statement.
*/
return NULL;
}
}

符号执行中的“符号值”

在符号执行过程中, SVal 对象用于表示表达式的语义求值,表示具体的 整数、符号值或内存位置 (表现为 CSA 中所说的内存区域)—— 这些值跟通常所说的表达式求值结果不同,指的是可以用于符号执行的 Symbolic 的值。如果一个 Value 不是 Symbolic 的,就意味着它没有可以跟踪的符号信息,在符号执行引擎中,会把它们计算成 UnknownVal / lazy computation 或者其他的一些字段,如果一个 Checker 对节点状态的检查与跟踪是基于符号值的(一般都是),且关注的数据类型并非 Symbolic ,那么跟踪到此处就失效了。例如:

  • 浮点数型 SVal 会被计算成 UnknownVal

这种情况需要 Checker 设计者自己想办法处理 (比如正在肝项目的笔者)

通过 .dump() 方法,我们可以在符号执行过程中查看 SVal 的值 —— 这个值包装的一般都是持久对象,比如符号或者内存区域,我们一般在符号执行过程中就关注这两个。

  • 符号

SymExpr (符号)用于表示抽象的、但有名称的符号值。符号表示的一个实际的(不可变的)值。我们可能不知道它的具体值是什么,但我们可以在分析路径时将约束条件与该值联系起来。现在说可能有点抽象,结合后面的代码示例就一目了然了。

对应程序中计算得到的具体值。

一般都用形如 $0 的字段标记在 ExplodedNode 中。

  • 内存区域

MemRegion 类似于符号,具体含义和分层模型请复习 CSA 内存模型。SymbolicRegion 的用途是用唯一的符号关联 MemRegion ,相当于用符号命名了这个 Region 。

对应程序中“左值”的概念。


用这样一段代码说明:

1
2
3
4
5
int foo(int x) {
int y = x * 2;
int z = x;
// ......
}
  • Stmt 1 :

    • 当 x 被求值时,首先构造一个表示 x 的左值的 SVal ,此时它引用了 x 的 MemRegion 符号值。然后,当我们进行从左值到右值的转换时,我们得到一个新的 SVal ,它引用了当前绑定到 x 的值 —— 它是 x 在函数开始时的值,可以称为 $0
    • 类似地,我们计算表达式 2 的值,并得到一个引用具体数字 2 的SVal。计算 x * 2 时,我们取子表达式的两个 SVal ,并创建一个新的 SVal 来表示它们的结果——这个 SVal 是一个新的符号表达式,记录为 $1
    • 当我们给 y 赋值时,我们再次计算它的左值(一个 MemRegion ),然后将RHS(引用符号值 $1)的 SVal 绑定到符号存储中的 MemRegion
  • Stmt 2 :

    • 当我们再次计算 x 时,同样创建一个引用符号值 $0 的 SVal ,然后绑定到 z 的左值对应的 SVal 上(这个 SVal 引用了 z 的 MemRegion 符号值)

总而言之,MemRegion 可以看作是 CSA 中 Region 的唯一名称(以符号表示),Symbol 则是抽象符号值的唯一名称。而 SVal 这个对象只是对值的引用,它可以引用 MemRegion 、符号或具体值。

Checker 的基本交互过程

每当分析器的核心引擎沿着源代码的 AST 探索到一条新语句,它就会在对应的位点通知所有在此处注册过的 Checker 对语句进行监听,Checker 此时创建一个 sink node 暂停核心引擎的探索过程,并根据自己的检查逻辑选择此时是要报告一个 Bug 还是通过改写 GDM 来修改 ProgramState 信息。

如前面的流程图所展示的那样,注册于当前位点的多个 checkers 是按照预定的顺序(观看编译输出信息,应该是 profile 的字典序?)一个接一个地被调用的。

“… thus, calling all the checkers adds a chain to the ExplodedGraph. ”

这一句话没太理解,不过不是很重要(应该吧(心虚))

由于 Checker 的执行流程是基于 ExplodedGraph 和 Clang AST 结构的,所以在编写自己的 Checker 之前,最好通过阅读官方文档,了解我们能够操作的 class 等信息。不过,笔者支持的学习模式是“做中学”—— 先仿照下面的 Toy Checker 试手,阅读一两个 core Checker 的源码,然后直接开始写正式的 Checker ,遇到不了解的再去查文档。

Checker 编写流程:以 Unix Stream API Checker 为例

感觉写 Checker 的过程有点像软件工程。)

需求分析

这个 Checker 想要检测的是文件流状态转换的问题。我们知道,文件处理的状态变化是由形如 fopen , fclose 这样的 API 调用驱动的,这里简化问题为只由这两个 API 改变文件处理状态。基于这样的背景,我们定义两种需要由 Checker 检出并报告的 Bug :

  • Double Close: 已经 fclose 了的文件再次被 fclose access
  • Leaked: 已经 fopen 了的文件没有被 fclose

定义 Checker Recipe

根据需求分析的结果,我们需要定义 Checker Recipe 来指导 Checker 内部的执行逻辑:

Step 1:定义状态

我们需要定义并记录当前节点对应的文件操作状态。

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
struct StreamState {
enum Kind {
Opened,
Closed
} K;

StreamState(Kind InK) : K(InK) { }

bool operator==(const StreamState &SS) const {
return K == SS.K;
}
void Profile(llvm::FoldingSetNodeID &ID) const {
ID.AddInteger(K);
}

bool isOpened() const {
return K == Opened;
}
bool isClosed() const {
return K == Closed;
}

static StreamState getOpened() {
return StreamState(Opened);
}
static StreamState getClosed() {
return StreamState(Closed);
}
}

谨记:Checker 中定义的“状态”是 ProgramState 的一部分!所以定义好了状态之后,别忘了我们还要把这个信息注册到爆炸图节点的 ProgramState 中去。具体而言,是建立从真实的文件处理状态到 StreamState 的映射关系,储存到 GMD 中去。

1
2
3
// Register a map from tracked stream symbols to their state
// The map will be recorded in the GenericDataMap of ProgramState
REGISTER_MAP_WITH_PROGRAMSTATE(StreamMap, SymbolRef, StreamState);

当我们通过后面的状态转移修改状态时,我们需要更新这个映射关系:

1
2
3
// "State" is the current ProgramState, which contain many maps
// "FileDesc" is the returnValue of function fopen or fclose
State = State->set<StreamMap>(FileDesc, StreamState::getOpened());

在分析过程中,从这个 Map 里可以检索当前文件描述符对应的状态。

1
const StreamState *SS = State->get<StreamMap>(FileDesc);

Step 2:添加状态转移函数

  • 添加与 fopen 相关的状态转移逻辑:
  1. 识别源代码中的 fopen 操作
  2. 状态未定义的节点经过 fopen 操作之后会转变为 Opened 状态

正式的 Check 逻辑从这一步开始。

前面提到,操作之前需要先使用对应的模板函数注册 visitor callback 的位置。为了识别 fopen 函数,显然我们要选择的位置与调用操作有关,此时我们选择 PostCall 作为访问位置,它的含义是当前 Checker 会在函数调用操作处理完之后被调用。

1
2
3
4
5
6
// Register the Checker callback
class SimpleStreamChecker : public Checker<check::PostCall> {
public:
// Process fopen function
void checkPostCall(const CallEvent &Call, CheckerContext &C) const;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Process fopen
void SimpleStreamChecker::checkPostCall(const CallEvent &Call,
CheckerContext &C) const {
if (!Call.isGlobalFunction("fopen")) {
return;
}
// Get the symbolic value corresponding to the file handle
SymbolRef FileDesc = Call.getReturnValue().getAsSymbol();
if (!FileDesc) {
return;
}
// Generate the transition
// It will generate an edge in the explodedGraph
ProgramStateRef State = C.getState();
State = State->set<StreamMap>(FileDesc, StreamState::getOpened());
C.addTransition(State);
// The transition part will be handled automatically
// by static analyzer engine. We can see that Checkers add
// new nodes to the explodedGraph with 'addTransition'
}
  • 添加与 fclose 相关的状态转移逻辑:
  1. 识别源代码中的 fclose 操作
  2. 状态未定义的节点和状态为 Opened 的节点经过 fclose 操作后会转变为 Closed 状态

为了防止程序运行出现错误,我们要在 fclose 真正执行之前就终止可能出现的错误操作并报告 Bug(这也是静态分析的意义)。同时,我们还要完成正确的 fclose 结束之后的状态转移操作。因此,我们要在 PreCall 节点注册 Checker 。

1
2
3
4
5
6
7
8
9
// Register the Checker callback
class SimpleStreamChecker : public Checker<check::PostCall,
check::PreCall> {
public:
// Process fopen function
void checkPostCall(const CallEvent &Call, CheckerContext &C) const;
// Process fclose function
void checkPreCall(const CallEvent &Call, CheckerContext &C) const;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void SimpleStreamChecker::checkPreCall(const CallEvent &Call, 
CheckerContext &C) const {
if (!Call.isGlobalFunction("fclose") || Call.getNumArgs() != 1) {
return;
}
// Get the symbolic value corresponding to the file handle
SymbolRef FileDesc = Call.getArgSVal(0).getAsSymbol();
if (!FileDesc) {
return;
}
// Generate the transition
// It will generate an edge in the explodedGraph
ProgramStateRef State = C.getState();
State = State->set<StreamMap>(FileDesc, StreamState::getClosed());
C.addTransition(State);
// The transition part will be handled automatically
// by static analyzer engine. We can see that Checkers add
// new nodes to the explodedGraph with 'addTransition'
}

Step 3:检测并报告 Bug

详解如何报告 Bug

报告 Bug 除了实现具体的检测逻辑,还要关注 BugReport 类。一般情况下,形成 BugReport 需要三个参数:

  • Bug 的类型,指定为 BugType 类的一个实例,如:

    1
    2
    std::unique_ptr<BugType> DoubleCloseBugType;
    std::unique_ptr<BugType> LeakBugType;
  • 一个简短的描述性字符串,用于打印在 scan-build 逐行生成的详细输出中想要展示的 Bug 信息

  • Bug 发生的上下文信息,这既包括程序中发生 Bug 的位置,也包括到达该位置时程序的状态——这些信息都封装在 ExplodedNode 中,因此我们只要在检出 Bug 的时候,把相关的节点作为参数传入就可以了

第三个参数尤为关键——我们需要决定传入什么节点,总是把当前节点传入不是在每一种分析中都凑效的。为了传入正确的 ExplodedNode ,我们必须判断分析:在发现了当前 Bug 之后,其他 Checker 的分析是否还可以沿着当前路径继续进行?像前面那张流程图展示的那样,一个 Checker 只是分析链中的一环。

这个决定基于检测到的 Bug 是否会阻止被分析的程序继续运行。例如,发现了资源泄漏问题后不应该停止分析,因为程序可以在泄漏后继续运行;而对于解引用空指针问题,则应该立刻停止分析,因为在发生这样的错误后,程序没有办法有意义地继续运行下去。

如果分析可以继续,那么 Checker generate 的最新的 ExplodedNode 可以直接被传递给 BugReport 的构造函数,而不必做任何修改——这个 ExplodedNode 是最近一次调用 CheckerContext::addTransition() 返回的节点。如果在 Bug 报告之前还没有状态转移在当前 Checker callback 中被执行,Checker 应该主动调用 CheckerContext::addTransition() 并使用返回值用于 Bug 报告。

如果分析无法继续,则将当前状态转换到所谓的 sink 节点中去,在该节点上不再执行进一步的分析。这一步是通过调用 CheckerContext::generateSink() 函数来完成的,它的执行逻辑其实与 addTransition 函数一样,只是会将节点状态标记为 sink 节点。和 addTransition 一样,它会返回一个更新了状态的 ExplodedNode ,我们可以将其传递给 BugReport 构造函数。

创建完 BugReport 之后,通过调用 CheckerContext::emitReport() 函数将它 pass 给分析器核心,就结束了报告 Bug 的过程。

具体实现
  • 报告 Double Close 错误

对 Bug 的识别是通过检查节点状态完成的,而且,前面已经提到过,需要在 Bug 发生之前就做出提醒并中止程序,所以我们将 Bug 报告的部分注册到 PreCall 位置。具体的操作是,在 PreCall 访问函数中,在做状态转移之前,对取出的符号值 FileDesc 做 Bug 分析:

1
2
3
4
5
6
7
8
9
10
11
void SimpleStreamChecker::checkPreCall(const CallEvent &Call, 
CheckerContext &C) const {
// ......
// Check double close error
const StreamState *SS = C.getState()->get<StreamMap>(FileDesc);
if (SS && SS->isClosed()) {
reportDoubleClose(FileDesc, Call, C);
return;
}
// ......
}

然后再定义一个 BugReport 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void SimpleStreamChecker::reportDoubleClose(SymbolRef FilDescSym, 
const CallEvent &Call,
CheckerContext &C) const {
// When we reach a Bug, we should stop exploring the path here
// by generating a sink node
ExplodedNode *ErrNode = C.generateSink();

// If we've already reached this node on another path, return
if (!ErrNode) {
return;
}

// Generate the report
BugReport *R = new BugReport(*DoubleCloseBugType,
"Closing a previously closed file stream",
ErrNode);
R->addRange(Call.getSourceRange());
R->markInteresting(FileDescSym);
C.emitReport(R);
}
  • 报告 Leaked 错误

针对这个错误,我们需要把 Checker callback 注册到一个叫做 DeadSymbols 的地方去 —— 这个类型的 callback 在符号值进入垃圾回收环节、被当作垃圾收集时被调用。如果这个阶段中文件仍处于 Opened 状态,就需要报告 Leaked 错误。

1
2
3
4
5
6
7
8
9
10
11
12
// Register the Checker callback
class SimpleStreamChecker : public Checker<check::PostCall,
check::PreCall,
check::DeadSymbols> {
public:
// Process fopen function
void checkPostCall(const CallEvent &Call, CheckerContext &C) const;
// Process fclose function
void checkPreCall(const CallEvent &Call, CheckerContext &C) const;
// Process Leaked bugReport
void checkDeadSymbols(SymbolReaper &SymReaper, CheckerContext &C) const;
};

具体执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void SimpleStreamChecker::checkDeadSymbols(SymbolReaper &SymReaper, 
CheckerContext &C) const {
ProgramStateRef State = C.getState();
SymbolVector LeakedStreams;
StreamMapTy TrackedStreams = State->get<StreamMap>();
for (StreamMapTy::iterator I = TrackedStreams.begin(),
E = TrackedStreams.end(); I != E; I++) {
// I is a pair stored in the map: <SymbolicValue, StreamState>
SymbolRef Sym = I->first;
bool IsSymDead = SymReaper.isDead(Sym);
// core
if (isLeaked(Sym, I->second, IsSymDead)) {
LeakedStreams.push_back(Sym);
}
// Remember to clean out the State 'cause we will
// never refer to the deadSymbols again
if (IsSymDead) {
State = State->remove<StreamMap>(Sym);
}
}
ExplodedNode *N = C.addTransition(State);
reportLeaks(LeakedStreams, C, N);
}

其中核心的判定函数 isLeaked 如下所示(这里有一个隐含逻辑,就是当 fopen 函数执行失败并返回 NULL 值时不报告 Leaked 错误,本质是为了消除分析中的 False Positive ):

1
2
3
4
5
6
7
8
9
10
11
static bool isLeaked(SymbolRef Sym, const StreamState &SS, bool IsSymDead) {
if (IsSymDead && SS.isOpened()) {
// If a symbol is NULL, assume that fopen failed on this path.
// A symbol should only be considered leaked if it is non-null.
ConstraintManager &CMgr = State->getConstraintManager();
ConditionTruthVal OpenFailed = CMgr.isNull(State, Sym);
return !OpenFailed.isConstrainedTrue();
} else {
return false;
}
}

最后补充一下 BugReport :

1
2
3
4
5
6
7
8
9
10
11
12
13
void SimpleStreamChecker::reportLeaks(ArrayRef<SymbolRef> LeakedStreams,
CheckerContext &C,
ExplodedNode *ErrNode) const {
// Attach Bug reports to the leak node.
// TODO: Identify the leaked file descriptor.
for (SymbolRef LeakedStream : LeakedStreams) {
auto R = std::make_unique<PathSensitiveBugReport>(
*LeakBugType, "Opened file is never closed; potential resource leak",
ErrNode);
R->markInteresting(LeakedStream);
C.emitReport(std::move(R));
}
}

做完了这些工作之后,还需要在 Checker 的末尾加上这两段程序注册它,使其生效:

1
2
3
4
5
6
7
8
void ento::registerSimpleStreamChecker(CheckerManager &mgr) {
mgr.registerChecker<SimpleStreamChecker>();
}

// This Checker should be enabled regardless of how language options are set.
bool ento::shouldRegisterSimpleStreamChecker(const CheckerManager &mgr) {
return true;
}

这段代码的用处是在分析开始时实际创建 Checker 实例。
根据开发手册的描述,“ 在这一部分还可以禁用整个翻译单元的某些 Checkers(例如,在普通C文件中仅针对C++缺陷的检查器),在 Checkers 之间引入依赖关系或设置 Checkers 选项 ”,但笔者还没有探索这方面的用途。


SimpleStreamChecker 的完整源码:

clang 16 的 Checkers 文件夹里有,但是没有在 CSA 中注册,想体验的话要记得把它注册并重新编译安装 llvm 项目,步骤在下一节中介绍。

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#include "clang/StaticAnalyzer/Checkers/BuiltinCheckerRegistration.h"
#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h"
#include "clang/StaticAnalyzer/Core/Checker.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallDescription.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h"
#include <utility>

using namespace clang;
using namespace ento;

namespace {
typedef SmallVector<SymbolRef, 2> SymbolVector;

struct StreamState {
private:
enum Kind { Opened, Closed } K;
StreamState(Kind InK) : K(InK) { }

public:
bool isOpened() const { return K == Opened; }
bool isClosed() const { return K == Closed; }

static StreamState getOpened() { return StreamState(Opened); }
static StreamState getClosed() { return StreamState(Closed); }

bool operator==(const StreamState &X) const {
return K == X.K;
}
void Profile(llvm::FoldingSetNodeID &ID) const {
ID.AddInteger(K);
}
};

class SimpleStreamChecker : public Checker<check::PostCall,
check::PreCall,
check::DeadSymbols,
check::PointerEscape> {
CallDescription OpenFn, CloseFn;

std::unique_ptr<BugType> DoubleCloseBugType;
std::unique_ptr<BugType> LeakBugType;

void reportDoubleClose(SymbolRef FileDescSym,
const CallEvent &Call,
CheckerContext &C) const;

void reportLeaks(ArrayRef<SymbolRef> LeakedStreams, CheckerContext &C,
ExplodedNode *ErrNode) const;

bool guaranteedNotToCloseFile(const CallEvent &Call) const;

public:
SimpleStreamChecker();

/// Process fopen.
void checkPostCall(const CallEvent &Call, CheckerContext &C) const;
/// Process fclose.
void checkPreCall(const CallEvent &Call, CheckerContext &C) const;

void checkDeadSymbols(SymbolReaper &SymReaper, CheckerContext &C) const;

/// Stop tracking addresses which escape.
ProgramStateRef checkPointerEscape(ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const;
};

} // end anonymous namespace

/// The state of the Checker is a map from tracked stream symbols to their
/// state. Let's store it in the ProgramState.
REGISTER_MAP_WITH_PROGRAMSTATE(StreamMap, SymbolRef, StreamState)

SimpleStreamChecker::SimpleStreamChecker()
: OpenFn({"fopen"}), CloseFn({"fclose"}, 1) {
// Initialize the Bug types.
DoubleCloseBugType.reset(
new BugType(this, "Double fclose", "Unix Stream API Error"));

// Sinks are higher importance bugs as well as calls to assert() or exit(0).
LeakBugType.reset(
new BugType(this, "Resource Leak", "Unix Stream API Error",
/*SuppressOnSink=*/true));
}

void SimpleStreamChecker::checkPostCall(const CallEvent &Call,
CheckerContext &C) const {
if (!Call.isGlobalCFunction())
return;

if (!OpenFn.matches(Call))
return;

// Get the symbolic value corresponding to the file handle.
SymbolRef FileDesc = Call.getReturnValue().getAsSymbol();
if (!FileDesc)
return;

// Generate the next transition (an edge in the exploded graph).
ProgramStateRef State = C.getState();
State = State->set<StreamMap>(FileDesc, StreamState::getOpened());
C.addTransition(State);
}

void SimpleStreamChecker::checkPreCall(const CallEvent &Call,
CheckerContext &C) const {
if (!Call.isGlobalCFunction())
return;

if (!CloseFn.matches(Call))
return;

// Get the symbolic value corresponding to the file handle.
SymbolRef FileDesc = Call.getArgSVal(0).getAsSymbol();
if (!FileDesc)
return;

// Check if the stream has already been closed.
ProgramStateRef State = C.getState();
const StreamState *SS = State->get<StreamMap>(FileDesc);
if (SS && SS->isClosed()) {
reportDoubleClose(FileDesc, Call, C);
return;
}

// Generate the next transition, in which the stream is closed.
State = State->set<StreamMap>(FileDesc, StreamState::getClosed());
C.addTransition(State);
}

static bool isLeaked(SymbolRef Sym, const StreamState &SS,
bool IsSymDead, ProgramStateRef State) {
if (IsSymDead && SS.isOpened()) {
// If a symbol is NULL, assume that fopen failed on this path.
// A symbol should only be considered leaked if it is non-null.
ConstraintManager &CMgr = State->getConstraintManager();
ConditionTruthVal OpenFailed = CMgr.isNull(State, Sym);
return !OpenFailed.isConstrainedTrue();
}
return false;
}

void SimpleStreamChecker::checkDeadSymbols(SymbolReaper &SymReaper,
CheckerContext &C) const {
ProgramStateRef State = C.getState();
SymbolVector LeakedStreams;
StreamMapTy TrackedStreams = State->get<StreamMap>();
for (StreamMapTy::iterator I = TrackedStreams.begin(),
E = TrackedStreams.end(); I != E; ++I) {
SymbolRef Sym = I->first;
bool IsSymDead = SymReaper.isDead(Sym);

// Collect leaked symbols.
if (isLeaked(Sym, I->second, IsSymDead, State))
LeakedStreams.push_back(Sym);

// Remove the dead symbol from the streams map.
if (IsSymDead)
State = State->remove<StreamMap>(Sym);
}

ExplodedNode *N = C.generateNonFatalErrorNode(State);
if (!N)
return;
reportLeaks(LeakedStreams, C, N);
}

void SimpleStreamChecker::reportDoubleClose(SymbolRef FileDescSym,
const CallEvent &Call,
CheckerContext &C) const {
// We reached a Bug, stop exploring the path here by generating a sink.
ExplodedNode *ErrNode = C.generateErrorNode();
// If we've already reached this node on another path, return.
if (!ErrNode)
return;

// Generate the report.
auto R = std::make_unique<PathSensitiveBugReport>(
*DoubleCloseBugType, "Closing a previously closed file stream", ErrNode);
R->addRange(Call.getSourceRange());
R->markInteresting(FileDescSym);
C.emitReport(std::move(R));
}

void SimpleStreamChecker::reportLeaks(ArrayRef<SymbolRef> LeakedStreams,
CheckerContext &C,
ExplodedNode *ErrNode) const {
// Attach Bug reports to the leak node.
// TODO: Identify the leaked file descriptor.
for (SymbolRef LeakedStream : LeakedStreams) {
auto R = std::make_unique<PathSensitiveBugReport>(
*LeakBugType, "Opened file is never closed; potential resource leak",
ErrNode);
R->markInteresting(LeakedStream);
C.emitReport(std::move(R));
}
}

bool SimpleStreamChecker::guaranteedNotToCloseFile(const CallEvent &Call) const{
// If it's not in a system header, assume it might close a file.
if (!Call.isInSystemHeader())
return false;

// Handle cases where we know a buffer's /address/ can escape.
if (Call.argumentsMayEscape())
return false;

// Note, even though fclose closes the file, we do not list it here
// since the Checker is modeling the call.

return true;
}

// If the pointer we are tracking escaped, do not track the symbol as
// we cannot reason about it anymore.
ProgramStateRef
SimpleStreamChecker::checkPointerEscape(ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const {
// If we know that the call cannot close a file, there is nothing to do.
if (Kind == PSK_DirectEscapeOnCall && guaranteedNotToCloseFile(*Call)) {
return State;
}

for (InvalidatedSymbols::const_iterator I = Escaped.begin(),
E = Escaped.end();
I != E; ++I) {
SymbolRef Sym = *I;

// The symbol escaped. Optimistically, assume that the corresponding file
// handle will be closed somewhere else.
State = State->remove<StreamMap>(Sym);
}
return State;
}

void ento::registerSimpleStreamChecker(CheckerManager &mgr) {
mgr.registerChecker<SimpleStreamChecker>();
}

// This Checker should be enabled regardless of how language options are set.
bool ento::shouldRegisterSimpleStreamChecker(const CheckerManager &mgr) {
return true;
}

练习资料

如果对编写 Checker 感兴趣或者需要相应的练手作业,这个网站上提供了许多的 potential Checker 项目以及可供测试的示例代码:

List of potential checkers

Checker 的注册与使用

  • 文件的具体路径可能根据构建 llvm or clang 的具体方法而有所不同,但形式都大同小异,稍微找一下就能找到
  • 具体的执行参数可能根据 Checker 的具体实现和用户本身对编译等的要求而有所不同

注册 Checker

编写并测试好了 Checker 之后,就要把它部署安装到 CSA 引擎中。一般步骤是(假设 Checker 的源代码文件名为 MyChecker.cpp ):

  • 将 MyChecker.cpp 文件复制到目录 llvm-project/clang/lib/StaticAnalyzer/Checkers

  • 配置Checkers.td,将 “MyChecker” 在 Checkers 表中定义,文件位于 llvm- project/clang/include/clang/StaticAnalyzer/Checkers/Checkers.td ,具体定义方法是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    let ParentPackage = Core in {
    ...
    def MyChecker : Checker<"MyChecker">,
    HelpText<"My Checker">,
    CheckerOptions<[
    CmdLineOption<..., // Checker 的命令行参数,根据需求自定义,下面是例子
    Released // 看本地构建的 llvm 项目是 Released 还是 Debug

    ,
    CmdLineOption<Boolean,
    "myParam",
    "It is the HelpText of myParam",
    "true", // Default value
    Released
    ,
    ......
    ]>,
    Documentation<NotDocumented>;
    ...
    } //end "core"

使用的时候从命令行设置具体参数:

1
-analyzer-config core.myChecker:myParam=false
  • 配置CMakeLists,将 MyChecker.cpp 添加到 llvm-project/clang/lib/StaticAnalyzer/Checkers/CMakeLists.txt

  • 重新编译安装 llvm ,安装结束后 Checker 生效

    • 首次注册 Checker 需要执行以上所有步骤,而之后如果对 Checker 做了修改,需要对其进行调试等操作,就只需要执行最后一步(在 llvm-project/build 文件夹中执行 make 命令)
    • 第一次编译安装花费的时间会比较长,之后就快了(在笔者的 VMWare Workstation + Ubuntu20.04 + x64 ,HP OMEN 16 条件下,编译安装一次大约用时 20s ),每一次对 Checker 进行修改,都需要再 make 整个项目让新 Checker 生效,之后就可以通过在 clang 参数中指定该 Checker ,用自己编写的测试程序测试其正确性

回归测试与使用 Checker

进入 llvm-project/clang 目录后:

  • Checker 相关源码的存放位置

    • include/clang/StaticAnalyzer

    • lib/StaticAnalyzer

    • test/Analysis

      • 可以选择把 Checker 测试文件放到这个目录下

      • 根据这个目录下的文件可以进行分析器的回归测试,具体命令是:

        1
        2
        3
            cd ../../../;
        cd build/tools/clang;
        TESTDIRS=Analysis make test
  • 罗列可用的 Checker

    1
    -analyzer-Checker-help
  • 如何指定特定的 Checker 分析文件(以 SimpleStream 为例)

    1
    -analyzer-Checker=core.SimpleStream

相关调试指令

  • 查找 CSA 相关的可用命令行指令选项:
1
clang -cc1 -help | grep analyze
  • 只分析特定的函数:
1
-analyzer-function <value>
  • 将被分析的函数打印到终端:
1
-analyzer-display-progress
  • 生成一个 ExplodedGraph 的 GraphViz 点图:
1
-analyzer-viz-egraph-graphviz

这个图很可能非常长,一般都用指令 -trim-egraph 将其简化为只显示出错的路径。

不使用 GraphViz 处理的话,导出的 ExplodedNode 是 JSON 格式的(实际上 CSA 中的 dump 函数都是以 JSON 形式输出的),表现为一个 .dot 文件。用的话可视化程度高一些,方便调试,如下所示。

pFJcHVx.png

具体的图处理文件是 /llvm-project/clang/utils/analyzer/exploded-graph-rewriter.py。

  • 查找 CFG 相关的可用命令行指令选项:
1
clang -cc1 -help | grep cfg
  • 对所有分析,添加 C++ 的隐式析构函数到 CFG:
1
-cfg-add-implicit-dtors
  • 对所有分析,添加 C++ 的构造函数到 CFG:
1
-cff-add-initializers
  • 在终端打印文本格式的 CFG:
1
-cfg-dump
  • 使用 GraphViz 查看 CFG:
1
-cfg-view
  • 对所有分析生成未优化的 CFG:
1
-unoptimized-cfg

Buy me a cup of coffee ☕.