Goshawk源码阅读

我不造啊,我什么都不造(T_^);发生了什么?怎么就过去了一个月?

Goshawk: Hunting Memory Corruptions via Stucture-Aware and Object-Centric Memory Operation Synopsis

摘要

现有用于自动检测内存破坏漏洞的工具在实践中效果并不理想。这些工具通常仅识别标准的内存管理(MM)API(例如mallocfree),并假设一种简单的成对使用模型——即一个分配器后面紧跟一个特定的释放器。然而,我们观察到,程序员常常设计自己的内存管理函数,并且这些自定义函数通常表现出两个主要特征:(1)自定义分配器函数执行多对象或嵌套分配,因此需要具备结构感知能力的释放函数;(2)自定义分配器与释放器遵循非成对使用模型。因此,更有效的漏洞检测适应这些特征,并捕获与非标准内存管理行为相关的内存漏洞。

在本文中,我们剔除了一种感知内存管理函数的内存漏洞检测技术,通过引入结构感知和面向对象的内存操作摘要(Memory Operation Synopsis,MOS)概念来实现。MOS抽象地描述了给定内存管理函数中的内存对象,它们如何被该函数管理,以及它们之间的结构关系。通过使用MOS,漏洞检测器可以探索更少的代码,同时仍然能够处理多对象或嵌套分配,而不依赖于成对使用模型。

此外,为了广泛识别内存管理函数并自动为其生成MOS,我们提出了一种结合自然语言处理(NLP)与数据流分析的新识别方法,使得即使在极大的代码库中,也能高效而全面地识别出内存管理函数。我们实现了一个基于MOS的内存漏洞检测系统——GOSHAWK,用于发现由复杂和自定义内存管理行为引起的内存漏洞。我们将GOSHAWK应用于多个经过充分测试并广泛使用的开源项目,包括操作系统内核、服务器应用程序和物联网开发套件。实验结果显示,GOSHAKW在分析速度和准确识别的内存管理函数数量方面,比最先进的基于数据流分析的漏洞检测工具快了一个数量级。此外,GOSHAWK使用基于MOS的方式生成对开发者友好的漏洞描述,并成功检测出92个新的double-free和use-after-free漏洞。

GOSHAWK 源码阅读

Goshawk是通过Clang的插件机制写了四个插件分别负责完成ExtractAllocationFree以及Analyze

编写基于RecursiveASTVisitor的ASTFrontendActions

在编写基于Clang的插件时,通用的入口点是FrontendActionFrontendAction是一个抽象类(接口),你通过继承它,重写里面的方法(比如CreateASTConsumer)来告诉Clang: 我想在处理每个源文件的时候做哪些自定义动作, 这些动作可以是打印函数名、分析变量、收集调用图等等。

Clang提供了一个方便的接口叫ASTFrontendAction(它继承自FrontendAction),用于那些只需要访问AST(抽象语法树)的用户。只需要实现CreateASTConsumer()方法,返回一个ASTConsumer实例,它会在每个编译单元/源文件中执行。

在这个ASTConsumer中,通常会创建一个RecursiveASTVisitor实例来遍历AST。

创建ASTConsumer接口

ASTConsumer是一个接口类,用来定义我们想要在AST上进行的处理动作。不管这个AST是怎么生成的(比如来自编译器前端,或手动构造的)都可以用ASTConsumer来访问它、分析它、处理它。

ASTConsumer是提供给用户写自己的AST分析逻辑的抽象父类

虽然ASTConsumer提供了很多可以”介入”的入口函数(比如HandleTopLevelDeclHandleTagDeclDefinition等等),但对于绝大多数用户(尤其是用RecursiveASTVisitor的场景)来说,只需要实现一个函数:

1
void HandleTranslationUnit(ASTContext &Context);

这个函数在Clang完成了当前translation unit的AST构建后呗调用,也就是说AST已经构建好了,可以完整地遍历和分析它了。关于ASTConsumer的创建示例如下:

1
2
3
4
5
6
7
8
9
10
11
class FindNamedClassConsumer : public clang::ASTConsumer {
public:
virtual void HandleTranslationUnit(clang::ASTContext &Context) {
// Traversing the translation unit decl via a RecursiveASTVisitor
// will visit all nodes in the AST.
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
private:
// A RecursiveASTVisitor implementation.
FindNamedClassVisitor Visitor;
};

这里定义了一个ASTConsumer子类,叫FindNamedClassConsumer,用来实现自定义的处理逻辑。随后通过HandleTranslationUnit()来处理,当Clang构建完当前源文件的AST后,它就会调用这个函数。

注意到,里面有一个Context.getTranslationUnitDecl(),这是获取整个Translation unit的AST根节点(TranslationUnitDecl)。AST是一个树结构,这个根节点使得我们可以访问整个源代码结构。

最后的Visitor.TraverseDecl(...)就是把AST根节点交给我们定义好的Visitor去递归访问了。当然,我们也可以实现各种各样的VisitXXXDecl()函数,然后使用Visitor去访问。

使用RecursiveASTVisitor

RecursiveASTVisitor是一个用于遍历AST的visitor类。它是一个模板类,要求用户将自己的子类名作为模板参数传入。要使用它,需要从它继承,并实现Visit*方法,用于处理”interesting”的AST节点类型。用法示例如下:

1
2
3
4
5
6
7
8
9
class FindNamedClassVisitor
: public RecursiveASTVisitor<FindNamedClassVisitor> {
public:
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
Declaration->dump();
return true;
}
};

示例代码定义了一个FindNamedClassVisitor的类,它继承自RecursiveASTVisitor,并把自己的类名作为模板参数传入。关于VisitCXXRecordDecl()方法,在每次遇到一个CXXRecordDecl(即类/结构体定义)时被调用。dump()是一个调试函数,它会把这个AST节点的结构打印出来,通常是打印到标准输出stdout。返回true表示继续遍历后续节点,返回false会提前中断。

RecursiveASTVisitor的方法中,可以充分利用Clang AST的功能,深入到”interesting”的那部分。例如,要将输出限制为特定类的声明(比如只打印类 n::m::C),可以通过如下方式对名字进行过滤:

1
2
3
4
5
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
if (Declaration->getQualifiedNameAsString() == "n::m::C")
Declaration->dump();
return true;
}

getQualifiedNameAsString()返回完整限定名(例如std::vector<int>会返回std::vector),如果我们只关心某个特定类,可以通过字符串比较来过滤掉其他类。这是一种非常常见的Visitor模式写法:只处理我们关心的节点。

访问SourceManagerASTContext

一些关于AST的信息,例如源码位置(source locations)和全局标识符信息,并不直接存储在AST节点(如FunctionDeclCXXRecordDecl)中,而是保存在ASTContext及其关联的SourceManager中。要获取这些信息,需要将ASTContext传递给我们的RecursiveASTVisitor实例。

AST节点本身包含语法和语义信息(如名称、类型、结构),但不会直接提供源码的行号等位置信息。Clang将这些“与源文件”绑定的信息集中保存在ASTContextSourceManager中,以避免重复数据。

例如,FunctionDecl->getBeginLoc()返回的是一个SourceLocation,但它只是一个偏移或token标识。需要用ASTContext中的SourceManager去解析它,从而拿到具体的文件名,行号等等。

我们可以在CreateASTConsumer调用时,通过CompilerInstance获取到ASTContext,然后把它传入我们刚刚创建的FindNameClassConsumer中。例如:

1
2
3
4
virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(
clang::CompilerInstance &Compiler, llvm::StringRef InFile) {
return std::make_unique<FindNamedClassConsumer>(&Compiler.getASTContext());
}

这是在ASTFrontendAction中的典型做法,CompilerInstance是Clang编译器内部的主要“环境对象”,里面封装了所有上下文信息。通过Compiler.getASTContext()获取语义上下文。在构造ASTConsumer的时候,将ASTContext*传给它,间接传给Visitor

既然我们已经可以在RecursiveASTVisitor中访问ASTContext,那么我们现在就能做一些更有趣的事情,比如获取AST节点在源码中的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
if (Declaration->getQualifiedNameAsString() == "n::m::C") {
// getFullLoc uses the ASTContext's SourceManager to resolve the source
// location and break it up into its line and column parts.
FullSourceLoc FullLocation = Context->getFullLoc(Declaration->getBeginLoc());
if (FullLocation.isValid())
llvm::outs() << "Found declaration at "
<< FullLocation.getSpellingLineNumber() << ":"
<< FullLocation.getSpellingColumnNumber() << "\n";
}
return true;
}

其中,getBeginLoc()是获取当前类声明节点的起始位置(类型是SourceLocation)。getFullLoc(loc)将传入的SourceLocation对象转换为FullSourceLoc对象,它包含完整的文件信息。然后再通过这个FuzzSourceLoc对象,利用两个对应的API获取该位置(原SourceLocation对象)的行号和列号。

summary

现在可以将上述所有部分整合为一个小型的示例:

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
#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendAction.h"
#include "clang/Tooling/Tooling.h"

using namespace clang;

// 定义Visitor
class FindNamedClassVisitor
: public RecursiveASTVisitor<FindNamedClassVisitor> {
public:
explicit FindNamedClassVisitor(ASTContext *Context)
: Context(Context) {}
// 定义遇到所有类声明的时候,做的行为
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
if (Declaration->getQualifiedNameAsString() == "n::m::C") {
FullSourceLoc FullLocation = Context->getFullLoc(Declaration->getBeginLoc());
if (FullLocation.isValid())
llvm::outs() << "Found declaration at "
<< FullLocation.getSpellingLineNumber() << ":"
<< FullLocation.getSpellingColumnNumber() << "\n";
}
return true;
}

private:
ASTContext *Context;
};
// 定义Consumer
class FindNamedClassConsumer : public clang::ASTConsumer {
public:
explicit FindNamedClassConsumer(ASTContext *Context)
: Visitor(Context) {}

virtual void HandleTranslationUnit(clang::ASTContext &Context) {
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
private:
FindNamedClassVisitor Visitor;
};

class FindNamedClassAction : public clang::ASTFrontendAction {
public:
virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(
clang::CompilerInstance &Compiler, llvm::StringRef InFile) {
return std::make_unique<FindNamedClassConsumer>(&Compiler.getASTContext());
}
};

int main(int argc, char **argv) {
if (argc > 1) {
clang::tooling::runToolOnCode(std::make_unique<FindNamedClassAction>(), argv[1]);
}
}

整体执行流就串一下,首先runToolOnCode是一个工具函数,会创建一个临时内存虚拟文件,将argv[1](传入的参数)的代码字符串保存为input.cc,然后创建一个ClangTool对象,并调用ClangTool::run()运行分析工具。

ClangTool::run()会创建一个编译器实例CompilerInstance,然后会执行CompilerInstance::ExecuteeAction(),其会调用我们传递FindNamedClassAction::XXX返回FindNamedClassConsumer

此后,便串起来了,由Consumer执行HandleTranslationUnit进行遍历节点,然后在每个节点都会进行Visitor中定义的类型匹配。这里就是一个类声明的匹配,做了相应的操作。然后继续遍历节点。

前文提到过,ASTConsumer会在每个编译单元/源文件中执行。这里定义了一个遍历行为,那么就会对AST进行遍历,对每个节点的类声明进行做一些行为:输出行列号(用户自定义的)。

Clang插件机制

Clang插件可以在编译期间运行额外的用户定义操作。Clang插件通过代码运行FrontendAction

写一个PluginASTAction

与编写普通的FrontendAction最大的区别在于,插件可以处理命令行传入的参数。为此,PluginASTAction提供了一个虚函数ParseArgs(),我们需要在插件中实现它。

1
2
3
4
5
6
7
8
9
10
bool ParseArgs(const CompilerInstance &CI,
const std::vector<std::string>& args) {
for (unsigned i = 0, e = args.size(); i != e; ++i) {
if (args[i] == "-some-arg") {
// Handle the command line argument.
}
}
return true;
}

这个插件会在被加载并初始化时被调用,args是我们在命令行传给插件的参数列表,例如:

1
clang -cc1 -load ./myplugin.so -plugin myplugin -plugin-arg-myplugin -some-arg

这里的-some-arg就会传递到args中。

注册一个插件

Clang插件是在编译器运行时从动态库(即.so文件)中加载的。要在插件动态库中注册一个插件,使用下面的注册宏:

1
static FrontendPluginRegistry::Add<MyPlugin> X("my-plugin-name", "插件描述");

这其实是在使用Clang内部的插件注册系统FrontendPluginRegistry,这是一个静态对象X,在.so动态库加载时自动构造并注册这里的插件类MyPlugin到Clang的插件注册表中。

Clang加载.so插件时的过程如下:

  1. 运行clang -cc1 -load ./myplugin.so -plugin my-plugin-name ...
  2. clang -cc1会通过dlopen()加载.so文件
  3. 加载.so文件后,所有static变量会执行初始化——我们写的X(...)就在这一步完成插件的注册。
  4. Clang查找注册表中是否有名字为my-plugin-name的插件,找到就执行其中的PluginASTAction

定义#pragma指令

插件也可以定义自定义的#pragma指令。只需要声明一个类继承自ProgramHandler,然后通过PragmaHandlerRegistry::Add<>注册这个handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Define a pragma handler for #pragma example_pragma
class ExamplePragmaHandler : public PragmaHandler {
public:
ExamplePragmaHandler() : PragmaHandler("example_pragma") { }
void HandlePragma(Preprocessor &PP, PragmaIntroducer Introducer,
Token &PragmaTok) {
// Handle the pragma
}
};

static PragmaHandlerRegistry::Add<ExamplePragmaHandler>
Y("example_pragma", "example pragma description");

其中,定义了一个继承自ProgramHandler的类——它负责处理特定的#pragma指令。并且通过ExamplePragmaHandler():PragmaHandler("example_pragma")构造函数指定了要处理的#pragma名字,这里是:

1
#pragma example_pragma

定义属性

插件可以通过声明一个ParsedAttrInfo类并使用ParsedAttrInfoRegistry::Add<>注册来自定义属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ExampleAttrInfo : public ParsedAttrInfo {
public:
ExampleAttrInfo() {
Spellings.push_back({ParsedAttr::AS_GNU,"example"});
}
AttrHandling handleDeclAttribute(Sema &S, Decl *D,
const ParsedAttr &Attr) const override {
// Handle the attribute
return AttributeApplied;
}
};

static ParsedAttrInfoRegistry::Add<ExampleAttrInfo> Z("example_attr","example attribute description");

Spellings中定义了这个属性的语法支持类型和拼写方式:

  • ParsedAttr:AS_GNU表示使用GNU分割,即__attribute__((example))
  • 可以支持多个拼写(如C++11fengge [[example]]

handleDecAttribute()是插件逻辑的核心:

  • 当该属性用于声明(如函数,变量)时触发
  • 可以在里面检查属性参数是否合法、是否允许用于该声明
  • 返回AttributeApplied表示接受,AttributeNotApplied表示拒绝

自定义属性必须实现的接口成员Spellings,每个Spellings都包含属性的语法以及属性名称在该语法下的拼写。例如:

1
2
3
Spellings.push_back({ParsedAttr::AS_GNU, "example"});
Spellings.push_back({ParsedAttr::AS_CXX11, "example"});
Spellings.push_back({ParsedAttr::AS_CXX11, "ns::example"}); // 有命名空间

自定义属性可选实现的接口成员如下:

成员 说明
NumArgs / OptArgs 指定属性所需参数数量和可选参数数量
diagAppertainsToDecl 检查属性是否用于合法声明(例如不能用在 typedef 上)
handleDeclAttribute 实现属性对声明的作用,通常是添加 Attr 子类到 AST
diagAppertainsToStmt 检查属性是否用于合法语句(如 for/if 等)
handleStmtAttribute 实现属性对语句的作用,逻辑同上
diagLangOpts 检查语言选项是否允许此属性(如仅 C++ 才允许)
existsInTarget 检查目标平台是否支持该属性

插件.so被加载后,ParsedAttrInfoRegistry::Add<>会注册这个属性处理器,编译器在预处理和语义分析阶段扫描属性,如果属性名匹配,则调用对应的ParsedAttrInfo子类来进行语法、语义验证,并生成AST属性节点。

summary

也组装成一个小示例:PrintFunctionNames.cpp

主体结构如下所示:

1
2
3
4
5
6
7
8
9
10
namespace {

class PrintFunctionsConsumer : public ASTConsumer { ... };

class PrintFunctionNamesAction : public PluginASTAction { ... };

}

static FrontendPluginRegistry::Add<PrintFunctionNamesAction>
X("print-fns", "print function names");

定义了两个核心类:

  • PrintFunctionsConsumer
  • PrintFunctionNamesAction

然后通过注册名称"print-fns"被调用:

1
clang -cc1 -load ./PrintFunctionNames.so -plugin print-fns ...

PrintFunctionsConsumer

1
2
3
4
5
6
7
8
9
10
class PrintFunctionsConsumer : public ASTConsumer {
CompilerInstance &Instance;
std::set<std::string> ParsedTemplates;
public:
PrintFunctionsConsumer(CompilerInstance &Instance,
std::set<std::string> ParsedTemplates)
: Instance(Instance), ParsedTemplates(ParsedTemplates) {}
bool HandleTopLevelDecl(DeclGroupRef DG) override{...}
void HandleTranslationUnit(ASTContext& context) override{...}
};

首先通过构造函数PrintFunctionsConsumer()接收CompilerInstance,用于获取上下文。ParseTemplates用于记录用户传入的函数模板名。

然后Consumer中定义的第一个方法HandleTopLevelDecl()如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool HandleTopLevelDecl(DeclGroupRef DG) override {
// 遍历当前`DeclGroupRef`中的每一个声明
for (DeclGroupRef::iterator i = DG.begin(), e = DG.end(); i != e; ++i) {
// 取出每个声明指针,准备分析
const Decl *D = *i;
// 尝试将这个声明向下转型为`NamedDecl`
if (const NamedDecl *ND = dyn_cast<NamedDecl>(D))
// 如果转型成功,获取其名字并print
llvm::errs() << "top-level-decl: \"" << ND->getNameAsString() << "\"\n";
}

return true;
}

它是ASTConsumer的虚函数之一,Clang会在每处理完一组顶层声明 之后调用它。如果是NamedDecl(带名字的声明,如函数/变量等),就打印其名字。

第二个方法是HandleTranslationUnit()

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
void HandleTranslationUnit(ASTContext& context) override {
if (!Instance.getLangOpts().DelayedTemplateParsing)
return;
// 创建visitor 遍历AST找出延迟模板函数
struct Visitor : public RecursiveASTVisitor<Visitor> {
const std::set<std::string> &ParsedTemplates;
Visitor(const std::set<std::string> &ParsedTemplates)
: ParsedTemplates(ParsedTemplates) {}
bool VisitFunctionDecl(FunctionDecl *FD) {
if (FD->isLateTemplateParsed() &&
ParsedTemplates.count(FD->getNameAsString()))
LateParsedDecls.insert(FD);
return true;
}

std::set<FunctionDecl*> LateParsedDecls;
} v(ParsedTemplates);
v.TraverseDecl(context.getTranslationUnitDecl());
clang::Sema &sema = Instance.getSema();
for (const FunctionDecl *FD : v.LateParsedDecls) {
clang::LateParsedTemplate &LPT =
*sema.LateParsedTemplateMap.find(FD)->second;
sema.LateTemplateParser(sema.OpaqueParser, LPT);
llvm::errs() << "late-parsed-decl: \"" << FD->getNameAsString() << "\"\n";
}
}

这个方法前文提及过,在Clang完成整个语法树构建后调用。 这个函数做的事:

  1. 如果未启用-fdelayed-template-parsing,则什么都不做。
  2. 否则:
    • 自定义Visitor遍历AST,找出模板函数
    • 匹配用户指定的函数名(通过ParsedTemplates
    • 调用Sema::LateTemplateParser(...)强制解析这些延迟模板
    • 打印信息

PrintFunctionNamesAction

这是插件的主类,继承自PluginASTAction,定义插件的执行逻辑和参数解析。主体结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PrintFunctionNamesAction : public PluginASTAction {
std::set<std::string> ParsedTemplates;
protected:
std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI,
llvm::StringRef) override {
return std::make_unique<PrintFunctionsConsumer>(CI, ParsedTemplates);
}

bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &args) override {...}
void PrintHelp(llvm::raw_ostream& ros) {
ros << "Help for PrintFunctionNames plugin goes here\n";
}

};

通过CreateASTConsumer()创建自定义的Consumer,这里就是PrintFunctionsConsumer

ParseArgs用来解析插件的参数。最后的PrintHelp是打印插件的帮助信息。

注册插件

最后就是注册插件:

1
2
static FrontendPluginRegistry::Add<PrintFunctionNamesAction>
X("print-fns", "print function names");

这是插件在Clang加载.so时被注册的关键代码。通过以下:

1
clang -cc1 -load ./PrintFunctionNames.so -plugin print-fns ...

来执行这个插件。

运行插件

可以使用Clang的标准命令行接口运行插件:

  • -fplugin=<path/to/xxx.so>,加载插件xxx.so动态库
  • -fplugin-arg-<plugin-name>-<plugin-arguments>,向插件传递参数

插件名不能包含-,否则命令行识别会失败。

1
2
3
4
5
export BD=/path/to/build/directory
make -C $BD CallSuperAttr
clang++ -fplugin=$BD/lib/CallSuperAttr.so \
-fplugin-arg-call_super_plugin-help \
test.cpp

这段命令会编译生成插件库CallSuperAttr.so,并且使用clang++加载插件,传递参数-help给名为Call_super_plugin的插件。

也可以通过clang -cc1命令来运行插件。但是需要手动指定:

  • -load <path/to/plugin> 加载插件
  • -plugin <plugin name> 运行某个插件
  • -plugin-arg-<plugin name> <arguments>向插件传递参数

更好的做法是使用-Xclang传给cc1:

1
2
3
4
5
clang++ test.cpp \
-Xclang -load -Xclang ./PrintFunctionNames.so \
-Xclang -plugin -Xclang print-fns \
-Xclang -plugin-arg-print-fns -Xclang help

插件类可以实现getActionType()方法,从而使得Clang能够自动运行插件。关于这部分内容,请详见官方文档Clang Plugins — Clang 21.0.0git documentation

至此,大致对于CSA的插件有一定了解。紧接着看看Clang的检查器。

CSA检查器

Clang静态分析器是通过一组检查器对源码进行缺陷诊断。

订阅程序点

什么是程序点?

程序点表示程序流中在语句之前或之后的特定节点。比如:在函数调用之前、在函数调用之后、在条件分支语句之前等都是程序点。

所有可以被检查器订阅的程序点见clang/lib/StaticAnalyzer/Checkers/CheckerDocumentation.cpp

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
class CheckerDocumentation
: public Checker<
// clang-format off
check::ASTCodeBody,
check::ASTDecl<FunctionDecl>,
check::BeginFunction,
check::Bind,
check::BranchCondition,
check::ConstPointerEscape,
check::DeadSymbols,
check::EndAnalysis,
check::EndFunction,
check::EndOfTranslationUnit,
check::Event<ImplicitNullDerefEvent>,
check::LiveSymbols,
check::Location,
check::NewAllocator,
check::ObjCMessageNil,
check::PointerEscape,
check::PostCall,
check::PostObjCMessage,
check::PostStmt<DeclStmt>,
check::PreCall,
check::PreObjCMessage,
check::PreStmt<ReturnStmt>,
check::RegionChanges,
eval::Assume,
eval::Call
// clang-format on
> {

其中一个程序点为PreCall,表示在函数调用之前。其源码实现如下(定义在clang/include/clang/StaticAnalyzer/Core/Checker.h文件中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PreCall {
template <typename CHECKER>
static void _checkCall(void *checker, const CallEvent &msg,
CheckerContext &C) {
((const CHECKER *)checker)->checkPreCall(msg, C);
}

public:
template <typename CHECKER>
static void _register(CHECKER *checker, CheckerManager &mgr) {
mgr._registerForPreCall(
CheckerManager::CheckCallFunc(checker, _checkCall<CHECKER>));
}
};

从实现角度讲,一个程序点就是一个C++类,并且不同程序点之间相互独立。意味着,它们之间不存在类继承等关系。

每个程序点都会调用一个由其规定的回调函数(这里是checkPreCall())。可以理解为,程序点负责为检查器提供信息(即回调函数的参数),而检查器利用这些信息实现错误诊断代码逻辑。

订阅程序点

所谓的检查器订阅程序点,本质上就是检查器实现由所订阅程序点规定的回调函数。

每个具象检查器类都应该继承自类模板Checker<...>。其源码实现如下(定义在clang/include/clang/StaticAnalyzer/Core/Checker.h文件中):

1
2
3
4
5
6
7
8
9
template <typename CHECK1, typename... CHECKs>
class Checker : public CHECK1, public CHECKs..., public CheckerBase {
public:
template <typename CHECKER>
static void _register(CHECKER *checker, CheckerManager &mgr) {
CHECK1::_register(checker, mgr);
Checker<CHECKs...>::_register(checker, mgr);
}
};

其关键之处为:通过可变参数模板typename... CHECKs,从而达到检查器可以同时订阅多个不同程序点的目的。

为了应对具象检查器类只订阅一个程序点的情况,该类模板实现了一个模板特例化。其源码如下:

1
2
3
4
5
6
7
8
template <typename CHECK1>
class Checker<CHECK1> : public CHECK1, public CheckerBase {
public:
template <typename CHECKER>
static void _register(CHECKER *checker, CheckerManager &mgr) {
CHECK1::_register(checker, mgr);
}
};

具象检查器类想要订阅程序点,只需要继承类模板Checker<...>,并将...替换为要订阅的程序点。然后,实现由所订阅程序点规定的回调函数。

推荐做法:将具象类检查器类的定义置于匿名空间中。从而减少命名冲突。

订阅一个程序点时,具象检查类的示例如下:

1
2
3
4
5
6
7
8
9
namespace {

class MainCallChecker : public Checker<check::PreCall>
{
public:
void checkPreCall(const CallEvent &Call, CheckerContext &Ctx) const;
};

} // anonymous namespace

这里只订阅了一个程序点check::PreCall。其规定的回调函数为void checkPreCall(...);订阅多个程序点时,具象检查器类的示例如下:

1
2
3
4
5
6
7
8
9
10
namespace {

class MainCallChecker : public Checker<check::PreCall, check::PostCall>
{
public:
void checkPreCall(const CallEvent &Call, CheckerContext &Ctx) const;
void checkPostCall(const CallEvent &Call, CheckerContext &Ctx) const;
};

} // anonymous namespace

注:这里订阅了两个程序点check::PreCallcheck::PostCall

实现错误诊断代码逻辑

我们应该在由所订阅程序点规定的回调函数中实现错误诊断代码逻辑。虽然不同的检查器要实现的错误诊断代码逻辑的细节不同。但是,它们的实现通常包含如下几部分:读取回调函数的参数、判断是否满足检查器给定的不变量以及报告所检测到的错误。

检查器注册函数的实现

在实现检查器类后,还需要实现检查器注册函数。

首先声明检查器注册函数,只需要在实现检查器的源文件中包含如下语句:

1
#include "clang/StaticAnalyzer/Checkers/BuiltinCheckerRegistration.h"

然后需要为每个检查器实现如下两个注册函数:

  • void registerXXX(...);
  • bool shouldRegisterXXX(...)

需要注意的是,检查器注册函数名称中的XXX必须与Checkers.td文件中用于描述检查器的定义名称保持一致。这是因为Checkers.td文件的内容决定了Checkers.inc文件中的CLASS名称,而CLASS名称正是检查器注册函数名称的后半部分。

一个示例:自定义检查器类MainCallChecker的注册函数实现如下:

1
2
3
4
5
6
7
void ento::registerMainCallChecker(CheckerManager &Mgr) {
Mgr.registerChecker<MainCallChecker>();
}

bool ento::shouldRegisterMainCallChecker(const CheckerManager &mgr) {
return true;
}

对于检查器注册函数registerMainCallChecker()shouldRegisterMainCallChecker(),其函数名称中的后半部分(这里MainCallChecker)必须与Checkers.td文件中用于描述检查器的定义名称保持一致。

语句Mgr.registerChecker<MainCallChecker>();中的MainCallChecker()是检查器类名。

检查器注册函数shouldRegisterMainCallChecker()的返回值为false表示不注册名称为MainCallChecker的检查器,意味着无论如何都无法启用该检查器;返回值为true表示注册名称为MainCallChecker的检查器,意味着可以通过命令行参数决定是否启用该检查器。

summary

所有可以注册的检查器就是Checkers.td文件中所描述的那些检查器。而要注册的检查器就是通过-analyzer-checker标志指定的检查器。

一个检查器要注册成功需要同时满足以下两个条件:

  • 启用了该检查器。启用的含义:通过-analyzer-checker标志显式地启用了检查器并且检查器注册函数shouldRegisterXXX()的返回值为true
  • 该检查器所有直接和间接强依赖的检查器都未被禁用。禁用的含义:通过-analyzer-disable-checker标志显式地禁用了检查器或者检查器注册函数shouldRegisterXXX()的返回值为false

run.py

直接能够看到的是main

1
2
3
4
5
6
if __name__ == "__main__":
Step_0_Cleanup()
Step_1_Extract()
Step_2_Allocation()
Step_3_Free()
Step_4_Analyze()

接下来依次分析它们分别做了什么,以及为什么,怎么做。

Step_0_Cleanup()

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
def Step_0_Cleanup():
print("Step0: Cleanup start!")
# 删除目录./temp,并重新创建。
remake_new_dir("temp")
# 创建目录 ./output
if not os.path.exists("output"):
os.mkdir("output")
# 重置目录 output/alloc
remake_new_dir("output/alloc")
# 重置目录 output/free
remake_new_dir("output/free")
# 重置目录 output/CSA
remake_new_dir("temp/CSA")
# 删除目录 output/report_html
delete_exist_dir("output/report_html")
# 检查compilation.json文件是否存在
compile_database_file = project_dir + os.sep + "compilation.json"
if not os.path.exists(compile_database_file):
print("\ncompile database not exist! Please make sure that there is a compilation.json under the project "
"directory!\n")
exit(-1)
# 移除编译失败的参数
rm_err_flag(compile_database_file)
print("Step0: Cleanup finished!")
print("-----------------------------------------------\n------------------------------------\n")

关于最后一个rm_err_flag()函数主要把config.comp_err_flags里定义的字符串比如-D_GENKSYMS__全部删除。非常粗糙的做法,直接使用replace进行替换:

1
2
3
4
5
6
7
8
def rm_err_flag(compile_database_file):
with open(compile_database_file, "r") as f:
data = f.read()
for flag in config.comp_err_flags:
# 直接将所有的flag删除
data = data.replace(flag, "")
with open(compile_database_file, "w") as f:
f.write(data)

随后,结束清理工作(一些目录,文件的初始化工作)。

Step_1_Extract()

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def Step_1_Extract():
print("\n\n-----------------------------------------------\n")
print("Step1: Extract Call Graph from source code Start!")
print("-----------------------------------------------\n\n\n")
# 获取开始extract的时间
extract_start = time.time()

flag = "extract-funcs"
# 通过plugin_run() + flag的形式来统一调度/利用写好的插件
plugin_run(project_dir, flag)
# call_graph_path为: path/to/call_graph.json
remove_dup_caller(config.call_graph_path, config.call_graph_path)
# 结束的时间
extract_end = time.time()
with open("temp/call_graph_extract.time", "w") as f:
f.write(str(extract_end - extract_start) + "\n")
print("-----------------------------------------------\n\n\n")
print("Step1: Extract Call Graph from source code Finished!")
print("-----------------------------------------------\n\n\n")

接下来查看plugin_run中的extract-funcs相关函数和remove_dup_caller()

plugin_run()

这里能够直接定位到auto_extract_func.py > walking_compile_database()

1
2
if flag == "extract-funcs":
new_cmd = format_clang_command(plugin_dir, temp_dir, "{0}/ExtractFunctionPrototypes.so", "extract-funcs", "{1}/call_graph.json", "{1}/indirect_call.json")

发现它会用上ExtractFunctionProtypes.so以及两个json文件,并且直接返回一个clang相关的cmd命令。

1
2
3
4
5
6
7
8
def format_clang_command(plugin_dir, temp_dir, plugin, plugin_name, *plugin_args):
arg_list = ["clang -fsyntax-only -Xclang -load -Xclang", plugin, "-Xclang -plugin -Xclang", plugin_name]
for arg in plugin_args:
arg_list.append("-Xclang -plugin-arg-" + plugin_name)
arg_list.append("-Xclang")
arg_list.append(arg)
cmd = " ".join(arg_list).format(plugin_dir, temp_dir) + " "
return cmd

在这里就很简单了:

1
2
3
4
5
6
7
8
9
clang -fsyntax-only \
-Xclang -load \
-Xclang ./plugins/ExtractFunctionPrototypes.so \
-Xclang -plugin \
-Xclang extract-funcs \
-Xclang -plugin-arg-extract-funcs \
-Xclang call_graph.json \
-Xclang -plugin-arg-extract-funcs \
-Xclang indirect_call.json

那么实际上这里就是调用的已经写好的ExtractFunctionPrototypes.so插件,依然分析一下主体结构:

1
2
3
4
5
6
7
8
9
10
namespace {
class PrintFunctionVisitor : public clang::RecursiveASTVisitor<PrintFunctionVisitor> { ... }

class PrintFunctionConsumer : public ASTConsumer { ... }

class PrintFunctionNamesAction : public PluginASTAction { ... }

}
static FrontendPluginRegistry::Add<PrintFunctionNamesAction>
X("extract-funcs", "Extract function prototypes from c/c++ files.")

与标准文档中定义的有些许不同,因为clang版本不同。goshawk用的是clang-15

这一次,从执行顺序的先后来看插件ExtractFunctionPrototypes.so

PrintFunctionNamesAction

依然贴出主体结构方便理解:

1
2
3
4
5
6
7
8
9
class PrintFunctionNamesAction : public PluginASTAction {
std::set<std::string> ParsedTemplates;
protected:
std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI,
llvm::StringRef) override { ... }

bool ParseArgs(const CompilerInstance &CI,
const std::vector<std::string> &args) override { ... }
};

首先查看CreateASTConsumer()

1
2
3
4
std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI,
llvm::StringRef) override {
return std::make_unique<PrintFunctionConsumer>(CI, ParsedTemplates);
}

当Clang分析每个translation unit时,会调用CreateASTConsumer方法,通过此方法返回PrintFunctionConsumer类,也就是一个自定义的consumer类,负责处理AST。然后是ParseArgs方法,解析参数。

1
2
3
4
5
6
7
8
9
10
bool ParseArgs(const CompilerInstance &CI,
const std::vector<std::string> &args) override {
if(args.size() < 2) {
llvm::errs()<< "Lack log path!\n";
return false;
}
log_path = args[0];
indirect_call_path = args[1];
return true;
}

它会接收来自命令行的插件参数,其中第一个参数作为log_path,第二个参数作为indirect_call_path。保存着两个路径信息。

PrintFunctionConsumer

这个就很简单了,直接贴代码:

1
2
3
4
5
6
7
8
9
10
11
class PrintFunctionConsumer  : public ASTConsumer {
PrintFunctionVisitor Visitor;
public:
explicit PrintFunctionConsumer(CompilerInstance &Instance, std::set<std::string> ParsedTemplates)
:Visitor(Instance,ParsedTemplates) {}

virtual void HandleTranslationUnit(clang::ASTContext &Context) {
//llvm::errs()<<"HandleTranslationUnit!\n";
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
};

首先声明了一个Visitor成员,然后通过构造函数初始化Visitor为自定义的Visitor。后续在HandleTranslationUnit()中通过自定义的Visitor对象执行TraverseDecl()从而开始AST遍历。

PrintFunctionVisitor

代码太长了,按序解释一下,首先是核心函数VisitFunctionDecl(FunctionDecl* FD),这是用于访问每个函数定义的回调方法。

也就是遍历AST时,每遇到一个函数就会调用一次VisitFunctionDecl方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
bool VisitFunctionDecl(FunctionDecl* FD)  {
// 获取当前处理的源代码管理器SourceManager
clang::SourceManager &SourceManager = Instance.getSourceManager();

if (FD) {
// 排除纯声明函数(无函数体的函数)
FD = FD->getDefinition() == nullptr ? FD : FD->getDefinition();
if (!FD->isThisDeclarationADefinition()) return true;
// ...
}
// ...
}

Clang会为每个函数出现的位置都创建一个FunctionDeclgetDefinition()可以获得真正的定义体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool VisitFunctionDecl(FunctionDecl* FD)  {
if(FD){
//...
// 提取函数信息
std::ostringstream ostring;
SourceLocation begin1 = FD->getSourceRange().getBegin();
SourceRange sr = FD->getSourceRange();
PresumedLoc begin = SourceManager.getPresumedLoc(sr.getBegin());
PresumedLoc end = SourceManager.getPresumedLoc(sr.getEnd());
ostring << "{\"return_type\": \"" << FD->getReturnType() ...
<< ", \"funcname\": \"" << FD->getQualifiedNameAsString() ...
<< ", \"params\": \"";
// 取函数体
auto funcBody = FD->getBody();
if(!funcBody)return true;
StmtWorkList worklist;
worklist.push(funcBody);
//...
}
//...
}

这段代码拿到函数中的起止位置,然后使用getPresumedLoc把这些位置转换为文件名,行号,列号。紧接着输出函数原型信息,最终输出如下JSON结构的信息:

1
2
3
4
5
6
7
8
{
"return_type": "int",
"funcname": "foo::bar::baz",
"params": "int@x,char*@y,"
"file": "foo.cpp",
"begin": [12, 5],
"end": [18, 1]
}

紧接着对函数体进行遍历。取得函数体(Stmt*),用StmtWorkList进行深度优先遍历语法树。遍历语句查找函数调用的表达式:

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
bool VisitFunctionDecl(FunctionDecl* FD)  {
//...
while (!worklist.empty()) {
auto currentStmt = worklist.pop();
if (auto callExpr = dyn_cast<CallExpr>(currentStmt)) {
// 尝试解析被调函数的声明
auto CD = callExpr->getCalleeDecl();
// 解析失败则跳过
if (!CD) continue;
// 是否是直接调用
if (auto calleeFD = dyn_cast<FunctionDecl>(CD))
{
std::ostringstream temp_stream = PrintCalleeDecl(calleeFD);
ostring << temp_stream.str();
}
// 是否是间接调用
else if(auto calleeFD = dyn_cast<VarDecl>(CD )){
std::ostringstream temp_stream = PrintIndirectDecl<VarDecl>(calleeFD);
ostring << temp_stream.str();
log_indirect_call(temp_stream.str());
}
// 是否是字段调用
else if(auto calleeFD = dyn_cast<ValueDecl>(CD))
{
std::ostringstream temp_stream = PrintIndirectDecl<ValueDecl>(calleeFD);
ostring << temp_stream.str();
log_indirect_call(temp_stream.str());
}
}
for (auto stmt : currentStmt->children())
if(stmt)worklist.push(stmt);
}
std::string result = ostring.str();
std::ofstream file;
file.open(log_path,std::ios::app);
file<<result;
file.close();
//...
}

找到函数体中的每个CallExpr(函数调用),然后通过getCalleeDecl()尝试解析出“被调用”的Decl(声明),可能是函数、变量、字段等等。如果无法解析出调用目标(比如复杂的lambda等),就continue

随后进行分类判断:

  • 如果是直接函数调用(FunctionDecl),说明是正常调用函数,例如foo()。调用PrintCalleeDecl()获取函数签名的JSON信息,并追加到总输出中。
  • 如果是间接调用(VarDecl),说明调用的是一个变量名(如函数指针fp())。用PrintIndirectDecl<VarDecl>()解析类型并输出。并调用log_indirect_call()专门把间接调用记录到另一个日志文件。
  • 如果是字段调用(ValueDecl),处理逻辑同间接调用。

最后的for循环将当前语句的子语句加入worklist中,递归处理整个函数体。这样可以遍历所有嵌套语句(if,for,while等)。

结束遍历函数体后,将处理结果写入日志文件。

如何解析间接调用/字段调用类型可看函数PrintIndirectDecl<XXXDecl>()

至此,完成第一步,提取并生成了各个函数调用的JSON文件。

Step_2_Allocation()

源码如下:

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
def Step_2_Allocation():
print("\n\n-----------------------------------------------\n")
print("Step2: Identify Allocation Functions from source code Start!")
print("-----------------------------------------------\n\n\n")

"""
Here, we use Siamese network to generate similarity socres (allocation) to each function prototype in project.
Then, we adopt a threshold (config.inference_threshold) to classify these function prototypes with ["allocation", "non-allocation"].
Those function annotated with "allocation" are considered as a candidate MM allocation function.
"""
annotation_start = time.time()
run_alloc(config.call_graph_path, step=1)
annotation_end = time.time()
with open(config.time_record_file, "w") as f:
f.write("alloc_annotation:" + str(annotation_end - annotation_start) + "\n")

"""
Call the data flow tracking plugins to track the data flows inside the MM candidates.
Merge the data flows and generate MOS.
"""
generation_start = time.time()
flag = "point-memory-alloc"
plugin_run(project_dir, flag)
run_alloc(config.call_graph_path, step=2)
generation_end = time.time()
with open(config.time_record_file, "w") as f:
f.write("alloc_generation:" + str(generation_end - generation_start) + "\n")
print("\n\n-----------------------------------------------\n")
print("Step2: Identify Allocation Functions from source code Finished!")
print("-----------------------------------------------\n\n\n")

根据注释,这一步利用Siamese网络(孪生神经网络)计算目标项目中的函数原型和“已知分配函数”的相似度。并设定阈值(如config.inference_threshould),将函数划分为allocationnon-allocation。重要的函数主要有两个run_alloc()plugin_run。先看run_alloc()

run_alloc()

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
def run_alloc(in_file=config.call_graph_path, step=1):
"""
1. 为所有的函数原型计算sim_alloc相似性分数
2. 过滤掉sim_alloc < infer_similarity的函数
3. 得到剩余函数的函数调用链
4. 调用插件获得目标函数的数据流
5. 合并过程间的数据流,并生成MOS
:param in_file:
:param memory_flow_file:
:param step:
:return:
"""
import time
if step == 1:
start = time.time()
model = tf.keras.models.load_model(os.path.join(config.model_dir, "alloc", "maxauc_model"))
_ = get_all_funcs(in_file, "temp/extract_all_func")
func_similarity = working_on_json_function_prototype(model, "temp/extract_all_func", "alloc")
end = time.time()
print("alloc similarity score generated time:%s"% (end - start))
else:
func_similarity = load_func_name_similarity()
call_graph = read_caller_and_callee(in_file)
candidate_set = get_candidate_alloc_function_set(func_similarity, call_graph)

# step1 : Find the initial strong belief allocation functions
belief_functions = initial_special_alloc_function(func_similarity, call_graph)
seed_allocators = generate_seed_allocators(belief_functions)
belief_bitmaps = get_belief_func_bitmap(func_similarity, seed_allocators)
allocation_set = call_chain_check_1(func_similarity, candidate_set, belief_bitmaps)
write_allocation_set(allocation_set)
if step == 1:
return

if not os.path.exists(config.mos_alloc_outpath):
write_final_alloc_result(belief_bitmaps)
return
# step2: Check whether the allocation functions are strong belief functions.
load_memory_flow(config.mos_alloc_outpath)
belief_bitmaps = call_chain_check_2(func_similarity, call_graph, allocation_set, belief_bitmaps)
write_final_alloc_result(belief_bitmaps)

这里首先会通过load_model()加载一个预训练好的模型。然后通过get_all_funcs()从第一步得到的call_graph.json中进行一个去重(根据函数名去重),然后保存为一个新的文件temp/extract_all_func

去重之后,在函数working_on_json_function_prototype()依次通过一些其他函数做了以下事情:

  • 通过normalize_on_file()将函数原型做归一化处理,也就是将JSON文件中的函数原型结构转换为一种统一格式的token序列。并将这些序列保存在temp/func_seg中。

    • 每个函数原型归一化为形如:<cls> <ret_type> func name (<arg_type> <arg_name> <dot> ...)的形式。4

      举个例子,有一个函数原型如下:

      1
      {"return_type": "_Bool", "funcname": "__kasan_check_read", "params": "const volatile void *@p,unsigned int@size,", "file" :"./include/linux/kasan-checks.h", "begin": [22, 1], "end": [25, 1]}

      它会变成:

      1
      <cls> <noptr> kas an check read ( <ptr> p <dot> <noptr> size )

      至于为什么是kas an而不是kasan呢?因为用了一个字典subword_dataset/vocab,很容易就找到了kasan,却无法找到kasan。关于这个vocab形如:

      1
      2
      3
      4
      5
      6
      7
      indicators	12149
      proces 12147
      pute 12147
      collapsible 12138
      selen 12137
      mdl 12131
      kas 12129

      会产生什么重大影响么?接着往后看。

  • 紧接着通过extract_embedding()将函数原型字符串(经过分词处理后的),转化成模型可以处理的向量,并最终得到每个函数的嵌入向量表示,这里会有一个补”0“到指定长度的操作。关于字符串转换成数字的部分,是通过上面描述的vocab并经过一个create_id_word_map()来创建词表映射字典。

    最终得到文件temp/embedding。这个文件记录着每个函数原型字符串通过模型得到的一个128维的的浮点向量,这些浮点数表示函数在不同语义维度上的特征值。

    整个工作流程如下所示:

    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
                    ┌──────────────────────────────┐
    in_file │ <cls> <ptr> alloc page ( ... )│ ← 已归一化的函数原型字符串
    └──────────────────────────────┘


    ┌────────────────────────────────┐
    │ funcname_to_vector(funcname) │ ← 每行调用一次
    │ - 分词 用vocab │
    │ - 查词表 word_id_map → id │
    │ - pad 到 config.max_seq_length │
    └────────────────────────────────┘


    NumPy 向量,如:[1, 2, 10, 30, 20, 0, 0, ...]


    ┌──────────────────────────────────────────────┐
    │ extract_embedding_per_file(model, in_file, out_file) │
    └──────────────────────────────────────────────┘

    分 batch(如每 128 行) → 堆叠成 (batch_size, max_seq_len)


    ┌─────────────────────┐
    │ model(inputs) │ ← 推理(inference)
    └─────────────────────┘

    返回 (batch_size, embedding_dim)


    拼接:原函数原型 | 向量 → 写入 out_file
    示例:
    ```
    <cls> <ptr> alloc page ( ... ) | 0.31 0.09 -0.24 ... 0.67

  • 再通过create_link_func_string()建立模型输入与函数原型字符串的映射关系。也就是建立文件temp/call_graph.json和文件temp/func_seg之间的映射。分词前后形成一个关联。

  • 最后是重中之重calculate_similarity()计算相似度分数,首先通过load_embedding_to_list从文件中加载函数的embedding向量,然后通过get_reference_embedding()加载一个缓存好的参考向量model/alloc/embedding/mean_embedding.npy。如果没有缓存,则会从model/alloc/embedding/target.train.embedding文件读取向量然后计算平均向量。

    对每个函数向量做余弦相似度比较,按相似度进行排序写入两个输出文件中:

    • 原型 + 相似度:temp/func_similarity
    • 函数名 + 相似度: temp/func_name_similarity

最后返回了一个函数名+相似度的dict。到此,就获得了对于每个函数名的一个相似度分数,用来判断是否为分配函数。

紧接着回到run_alloc()中。会调用read_caller_and_callee(),将call_graph.json文件(每一行是一个函数原型,带缩进表示层级关系)解析成结构化的函数调用信息列表。例如:

1
2
3
4
{
"caller": {...},
"callees": [{...}, {...}, ...]
}

随后,通过get_candidate_alloc_function_set(func_similarity, call_graph),这里的call_graph是上面刚得到的调用信息列表。它会遍历每一个caller,然后过滤不在func_similarity文件中的函数,且过滤相似度分数小于config.inference_threshold的函数。inference_thresholdconfig.py文件中定义,预设值为0.5。最后会检查一下函数的返回值和参数,如果参数和返回值都没有涉及指针类型的变量,那么就不太可能是个内存分配器,即过滤掉。

随后,将符合要求的func列表。最后会把候选函数名(仅函数名,不带结构,不带函数信息)写入temp/candidate_alloc.txt文件中。

候选过滤了一遍,然后又继续筛选(有些重复了)。首先,返回值必须为<ptr>,其次参数中的<ptr>类型必须为kmem_cache*,出现<dptr>的也被排除(例如**foo)。callees必须存在以及相似度分数必须大于config.strong_belief_threshold,这个值预设为0.95。其次,函数体内必须调用过至少一个allocation函数(这里的allocation函数指的是在候选allocation函数列表中),如果所有的callees都不是候选alloc函数,那么过滤。如果返回类型是结构体指针(如struct *foo),那么它最多只能调用一个alloc函数。

最后将上述符合条件的函数加上预收集的官方allocation函数 以及 总的函数 共同构成一个置信位图,其中1表示该函数是经过筛选认定的alloc函数,0表示是未认定的函数。

最最后,调用call_chain_check_1。对最初的candidate分配函数利用得到的位图做判断,如果这些函数的callees中不是置信位图中记录的可信alloc函数,且自己也不是可信alloc函数,那么就被排除。此时在candidate分配函数结合中符合条件的分配函数构成allocation_set再将这个集合写入temp/allocation_set中,作为静态分析的分配函数。

此时,本阶段的run_alloc()就结束了,进入到插件的处理逻辑当中。

plugin_run()

运行插件MemoryDataFlow.so,进入MemoryDataFlow.cpp中查看主体结构。

1
2
3
4
5
6
7
8
namespace {
//... some extra func
class FindFunctionVisitor : public clang::RecursiveASTVisitor<PrintFunctionVisitor> { ... }
class MemoryDataFlowConsumer : public ASTConsumer { ... }
class MemoryDataFlowAction : public PluginASTAction { ... }
}
static FrontendPluginRegistry::Add<MemoryDataFlowAction>
X("point-memory", "Point the dataflow of memory functions.")

MemoryDataFlowAction

入口点函数,老样子成员函数CreateASTConsumer传入CompilerInstance对象,随后创建并返回自定义的MemoryDataFlowConsumer类对象。然后是解析参数的成员函数ParseArgs,解析用户通过插件参数传入的字符串参数。

解析输入文件通过read_call_graph函数,这里传递给插件的input文件为allocation_set,也就是run_alloc()最终生成的可信分配函数集合。

MemoryDataFlowConsumer

源码如下:

1
2
3
4
5
6
7
8
9
class MemoryDataFlowConsumer : public ASTConsumer {
FindFunctionVisitor Visitor;
public:
explicit MemoryDataFlowConsumer(ASTContext* Context) {}

virtual void HandleTranslationUnit(clang::ASTContext &Context) {
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
};

没啥好说的,这里正式调用用户自定义的Visitor。也就是Visitor.TraverseDecl(),这个VisitorFindFunctionVisitor类对象。核心处理逻辑在FindFunctionVisitor()

FindFunctionVisitor

分步解析一下VisitFunctionDecl函数,作为整个插件的核心处理函数。首先:

1
2
3
4
if (FD) {
FD = FD->getDefinition() == nullptr ? FD : FD->getDefinition();
if (!FD->isThisDeclarationADefinition()) return true;
if (!FD->doesThisDeclarationHaveABody()) return true;

还记得一点哈:返回true说明继续处理下一个节点。且VisitFunctionDecl会在处理AST时每遇到一个函数声明节点便调用。

那么这里依然使用getDefinition获取函数定义,然后跳过无函数体和只有函数原型(声明)的函数。紧接着:

1
2
3
current_funcname = FD->getQualifiedNameAsString();
std::set<std::string>::iterator iter0 = visited.find(current_funcname);
if (iter0 != visited.end()) return true;

这里会获取函数名,且是会带命名空间/类前缀的函数名。如果该函数已经在visited(在开头预定义了:std::set<std::string> visited;)集合中,说明已经处理过,避免重复处理。

然后判断是否在call_graph中:

1
2
std::map<std::string,std::set<std::string>>::iterator iter = call_graph.find(current_funcname);
if(iter == call_graph.end()) return true;

这里的call_graph是在Action中对其进行初始化的,存储着allocation_set中的可信allocation函数。如果不是可信allocation函数,那么就会跳过。这里只对可信allocation函数”感兴趣“。

然后遍历感兴趣的callee

1
2
3
4
5
6
7
8
9
10
11
12
13
auto funcBody = FD->getBody();
if (!funcBody) return true;

if (debug) {
llvm::errs() << "Current funcname:\t" << current_funcname << "\n";
}

std::set<std::string> callees = iter->second;

for (callee_iter = callees.begin(); callee_iter != callees.end(); callee_iter++) {
ProcessACallee(funcBody, current_funcname, *callee_iter);
}

获取函数体funcBody,然后遍历该函数的所有callee,调用ProcessACallee()进一步处理。这就是整个Visitor的逻辑。

ProcessACallee()

在函数中,首先初始化变量:

1
2
3
std::set<std::string> var_name_set;
std::set<std::string> ret_value_set;
int direct_return_flag = 0;

用于收集callee相关的变量和返回值信息,然后使用StmtWorkStack进行手动遍历函数体的每条语句。

1
2
3
4
StmtWorkStack workstack;
workstack.push(funcBody);
while (!workstack.empty()) {
auto current_stmt = workstack.pop();

每次从栈中取出一个语句,进行类型判断和处理。检查变量声明是否调用callee。

1
2
3
4
5
6
7
if (auto declStmt = dyn_cast<DeclStmt>(current_stmt)) {
...
if (CheckVarDecl(varDecl, callee_name)) {
std::string varName = varDecl->getNameAsString();
var_name_set.insert(varName);
}
}

通过CheckVarDecl()检查初始化表达式中是否有调用callee_name的,例如char *buf = malloc(20);如果有那么将其加入var_name_set中。这里处理的是形如Type var = callee_name(...)的语句。

1
2
3
4
if (auto binaryOperator = dyn_cast<BinaryOperator>(current_stmt)) {
CheckBinaryOperator(binaryOperator, var_name_set, callee_name);
}

通过CheckBinaryOperator()检查二元操作中是否有涉及到callee的语句。这里处理的是形如x = callee_name(...)或者x = y + callee_name(...)的语句。

1
2
3
4
5
6
7
if (auto returnStmt = dyn_cast<ReturnStmt>(current_stmt)) {
int flag = CheckRetrenStmt(returnStmt, callee_name, ret_value_set);
if (flag == 2) {
direct_return_flag = 1;
}
}

这里有错别词retren啊哈哈,作者实际想用的应该是CheckReturnStmt

通过CheckReturnStmt检查return是否直接返回callee结果。这里处理的是形如return callee_name(...),或者return表达式中涉及到callee_name的变量或结果。

1
2
3
4
5
6
7
for (auto stmt : current_stmt->children()) {
if (stmt) temp_workstack.push(stmt);
}
while (!temp_workstack.empty()) {
auto temp_stmt = temp_workstack.pop();
workstack.push(temp_stmt);
}

使用stmt来模拟AST深度优先遍历,处理所有子句。最后保存结果与arg[1]中,也就是temp/memory_flow_alloc.json中。

最后还有一次run_alloc()不过是阶段二,只做了检查,确保通过第二步得到的allocation函数都是可信函数。

这里挺迷的,插件中已经是只对可信allocation函数做处理了,这里的检查貌似…有点没必要?

也就是说经过Step 2,筛选出alloc相关的函数了,并保存在temp/memory_flow_alloc.json中。

Step_3_free()

来到找free函数了,它会先执行run_free(),那么看看run_free做了什么。

run_free()

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
def run_free(call_graph_file, step=1):
"""
Step 0: Only debug use, do not need to perform similarity inference from the beginning,
directly load the similarity from file.
Step 1: Perform Similarity inference for each function,
to infer the similarity scores with allocation/deallocation functions.
Step 2:
"""
if step == 1:
model = tf.keras.models.load_model(os.path.join(config.model_dir, "free", "maxauc_model"))
_ = get_all_funcs(call_graph_file, "temp/extract_all_func")
func_similarity = working_on_json_function_prototype(model, "temp/extract_all_func", "free")
else:
func_similarity = load_func_name_similarity()
call_graph = read_caller_and_callee(call_graph_file)

if step == 2:
call_graph = convert_call_graph_to_dict(call_graph)
sp_funcs = special_deallocator_identification(call_graph, func_similarity, config.free_check_file, config.min_reset)
generate_seed_free(sp_funcs)
call_chains = MMD_call_chains(call_graph)
return call_graph, call_chains
# step1 : Find the initial strong belief deallocation functions
candidate_functions = initial_candidate_free_function(func_similarity, call_graph)
count_free_call_site(candidate_functions, call_graph)

这里类似于alloc,通过model对提取到的所有extract_all_func进行相似度推理。总结一下,在step == 1的情况下,run_free做了以下事情:

  • 通过get_all_funcs()call_graph.json进行去重,得到temp/extract_all_func
  • 通过working_on_json_function_prototype()extract_all_func进行规范化以及分段,然后为这些func生成embedding。再根据它计算每个函数的相似度,并将它们以不同形式保存于temp/func_similarity(函数原型)和temp/func_name_similarity(函数名)中。
  • 通过read_caller_and_callee()call_graph.json文件解析成结构化的函数调用信息列表。
  • 通过initial_candidate_free_function()选出candidate释放函数。(像alloc那样通过一些筛选规则)
    • 但这里没有alloc那样分多个函数多个规则过滤,这里直接在该函数中进行各种规则过滤:
      • 相似度分数大于一个阈值,返回类型为void或非指针类型,函数包含参数且为指针,函数实现中至少调用了一个高于相似性阈值的函数。
    • 最后写入temp/candidate_free.txt
  • 通过count_free_call_site()对选出的candidate 释放函数在整个call_graph.json中的出现次数,并写入temp/Dealloc_Number.txt

然后进入到plugin_run()里头了。

plugin_run()

首先是FreeNullCheck.so插件,参数分别为candidate_free.txtfree_check.txtvisited.txt。看看插件的主体结构:

1
2
3
4
5
6
7
8
9
namespace{
// ... some extra functions ...
class FindFunctionVisitor : public clang::RecursiveASTVisitor<FindFunctionVisitor>{ ... }
class NullCheckConsumer : public ASTConsumer { ... }
class FreeNullCheckAction : public PluginASTAction { ... }

}
static FrontendPluginRegistry::Add<FreeNullCheckAction>
X("free-check", "Point the dataflow of memory functions.");

一些额外的函数都是FindFunctionVisitor中所调用的。

FreeNullCheckAction

源码如下:

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
class FreeNullCheckAction : public PluginASTAction {
protected:
std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI,
llvm::StringRef) override {
return std::make_unique<NullCheckConsumer>(&CI.getASTContext());
}

bool ParseArgs(const CompilerInstance &CI,
const std::vector<std::string> &args) override {

if(args.size() <3)
{
llvm::errs()<< "Lack input, output and visited path!\n";
return false;
}
input_path = args[0];
output_path = args[1];
visited_path = args[2];
bool ret = ReadFreeFuncs();
if(!ret) return false;
ret = read_visited();
if(!ret) return false;
return true;
}
};

除了创建Consumer外,初始化处理参数时,调用ReadFreeFuncs()读取candidate_free.txt中的函数名称。调用read_visited()读取visited.txt中的函数名称。

NullCheckConsumer

1
2
3
4
5
6
7
8
9
class NullCheckConsumer : public ASTConsumer {
FindFunctionVisitor Visitor;
public:
explicit NullCheckConsumer(ASTContext* Context) {}

virtual void HandleTranslationUnit(clang::ASTContext &Context) {
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
};

进入FindFunctionVisitor的处理逻辑中。

FindFunctionVisitor

1
2
3
4
FD = FD->getDefinition() == nullptr ? FD : FD->getDefinition();
if (!FD->isThisDeclarationADefinition()) return true;
if (!FD->doesThisDeclarationHaveABody()) return true;
current_funcname = FD->getQualifiedNameAsString();

提取函数定义,忽略声明而非定义的函数,并获取当前函数名。

1
if (visited.find(current_funcname) != visited.end()) return true;

做了一个去重操作,如果已经在visited.txt中出现过,那么就跳过。

1
2
auto funcBody = FD->getBody();
if(!funcBody)return true;

获取函数体,如果为空则跳过。

1
2
3
Result.clear();
StmtWorkStack workstack;
workstack.push(funcBody);

和之前类似,对AST进行遍历,使用StmtWorkStack

1
2
3
4
5
6
7
8
9
10
11
12
while(!workstack.empty()){
auto current_stmt = workstack.pop();
if (auto callExpr = dyn_cast<CallExpr>(current_stmt)) {
CheckCallExpr(callExpr, var_name_set, param_func_map);
} else if (auto binaryOperator = dyn_cast<BinaryOperator>(current_stmt)) {
CheckBinaryOperator(binaryOperator, var_name_set, param_func_map);
}

for (auto stmt : current_stmt->children()) {
if(stmt) workstack.push(stmt);
}
}

判断当前AST语句的stmt的类型,如果是callExpr或者binaryOperator的话,那么就进入对应的check。

  • CheckCallExpr()识别当前语句是否调用了candidate释放函数。
  • binaryOperator()检测candidate释放函数中传入的指针参数是否在之后被设置为NULL。如果调用候选释放函数后置空了,那么将其加入Result中。

最后都会执行一个save_result()函数将当前Result队列中的内容保存下来。最终得到的free_check.txt(部分)格式如下:

1
2
3
4
5
6
7
8
9
10
wb_put	0
kfree_skb 0
kfree_skb 0
kfree_skb 0
ksmbd_release_crypto_ctx 0
ksmbd_share_config_del 0
tcp_destroy_socket 0
ksmbd_user_session_put 0
ksmbd_put_durable_fd 0
smb_direct_free_sendmsg 1

其中分别记录着执行这些函数会对参数执行置空的操作,后面跟随的数字为参数的索引。


插件结束,回到Step_3_free()中。会执行cleanup_free_null_check(),这个函数对释放函数中参数置空行为的统计结果做整理,举例说明:

1
2
3
4
5
6
kfree_skb	0
kfree_skb 0
kfree_skb 1
ib_destroy_qp 0
ib_destroy_qp 0
ib_destroy_qp 0

该函数会统计每个函数名各个参数索引出现的次数,并找出出现次数最多的那个参数索引,这个参数就作为该函数的”主释放参数“,然后按频次从高到底排序写回原文件。上述例子经过该函数后的输出为:

1
2
ib_destroy_qp	0	3
kfree_skb 0 2

然后又会执行一次run_free()进入阶段二,会执行以下代码:

1
2
3
4
5
6
if step == 2:
call_graph = convert_call_graph_to_dict(call_graph)
sp_funcs = special_deallocator_identification(call_graph, func_similarity, config.free_check_file, config.min_reset)
generate_seed_free(sp_funcs)
call_chains = MMD_call_chains(call_graph)
return call_graph, call_chains

阶段二所做的事如下:

  • 通过convert_call_graph_to_dict()将函数调用关系信息表转化成dict的形式。

  • 通过special_deallocator_identification()从初次比较宽松的free识别出的func_similarity进行一次严格的筛选。并将结果写入temp/special_deallocator.txt。(值得注意的是,在对ksmbd进行分析的时候这个文件为空)

    • 语义相似度分数:大于0.95
    • 行为统计数据:来自free_check.txt(被置NULL的次数)和Dealloc_Number.txt(调用次数统计)。
  • 通过generate_seed_free()将上一步严格筛选的结果,以及official_deallocator中预选的函数一起写入seed_free.txt中。

    • 由于严格筛选出的结果为空,因此此时seed_free.txt中的内容就为temp/official_deallocator.txt中的函数。
  • 然后通过MMD_call_chains()从每个候选函数触发,递归向下查找它调用的函数,如果最终某条路径调用到了seed_free.txt中的函数,那么就构造一棵“调用链”树,整个过程通过递归构建Node(func),其.next指向其callees,形成树状结构。

    • 也会有大量的.next为空。

紧接着回到Step_3_free()中,执行以下代码:

1
2
3
4
5
6
7
8
	"""
According the MM deallocation candidates, track the data flows inside their implementations.
"""
generation_start = time.time()
flag = "point-memory-free-1"
next_step_TU = retrive_next_step_TU(project_dir, call_graph, call_chains, 0)
plugin_run(project_dir, flag, next_step_TU)
deduplicate_dataflow(config.mos_free_outpath)

首先是retrive_next_step_TU(),筛选调用链中恰好node.depth1的候选函数,作为本轮迭代的目标。然后读取这些目标的caller所在的文件。并遍历compilation.json,获取其中涉及这些caller所在文件的条目(编译指令)。紧接着通过plugin_run运行插件进行处理。

MemoryDataFlowFree.so

参数依次为:

1, candidate_free.txt, seed_free.txt, last_step_mos.json, memory_flow_free.json, visited.txt

解析参数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
step = args[0];
candidate_free_path = args[1];
seed_free_path = args[2];
mos_seed_path = args[3];
mos_free_outpath = args[4];
visited_file_path = args[5];

if (!read_visited())
return false;

if(!read_free_function()) // step1: According the seed functions, to generate the first mos functions.
return false;

if (step == "2") // According seed functions and already generated mos fucntions, to generate more MOS.
if(!read_mos_free())
return false;

再看看插件的回调核心处理函数VisitFunctionDecl()

1
2
3
4
5
6
bool VisitFunctionDecl(FunctionDecl* FD){
if (FD) {
return GetMemoryFlow(FD);
}
return true;
}

实际调用GetMemoryFlow,以及一系列衍生函数。

GetMemoryFlow()

核心是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Iterate with all AST nodes.
StmtWorkStack workstack;
workstack.push(funcBody);
while(!workstack.empty()){
auto current_stmt = workstack.pop();
if (auto callExpr = dyn_cast<CallExpr>(current_stmt))
{
CheckCallExpr(callExpr,var_name_set,member_name_set, current_funcname);
}
if (auto binaryOperator = dyn_cast<BinaryOperator>(current_stmt))
{
CheckBinaryOperator(binaryOperator,var_name_set,member_name_set,name_map);
}
if (auto declStmt = dyn_cast<DeclStmt>(current_stmt))
{
if (!declStmt->isSingleDecl())continue;
auto varDecl = dyn_cast<VarDecl>(declStmt->getSingleDecl());
if (!varDecl) continue;
CheckVarDecl(varDecl,var_name_set,member_name_set,name_map);
}

for (auto stmt : current_stmt->children())
if(stmt)workstack.push(stmt);
}

遍历函数体内的所有语句(深度优先),调用检查函数:

  • CheckCallExpr():识别是否调用seed_free.txt中的函数,找到传给释放函数的变量,例如khd->kcq。加入到对应集合中:var_name_setmember_name_set
  • CheckBinaryOperator():识别形如a = b的赋值语句,并记录name_map[a] = b;如果a已经在释放变量集合中,就反推b也应该记录。
  • CheckVarDecl():识别变量定义时的初始化表达式struct foo *x = something;如果x是释放目标,就将something添加到追踪集合中。

后续通过CheckConsistent检查var_name_setmember_name_set中的变量,是否与函数参数匹配。处理结构体成员的别名传播关系(通过name_map替换base name)。最后返回一个整数数组,结果写入memory_flow_free.json中。例如,本次提取的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{"funcname": "ksmbd_destroy_file_table", "param_names": [], "member_name": [0, "ft->idr"]}
{"funcname": "free_reserved_page", "param_names": [0, "page"], "member_name": []}
{"funcname": "ptr_ring_cleanup", "param_names": [], "member_name": [0, "r->queue"]}
{"funcname": "sbitmap_free", "param_names": [], "member_name": [0, "sb->map"]}
{"funcname": "sbitmap_queue_free", "param_names": [], "member_name": [0, "sbq->ws"]}
{"funcname": "__reqsk_free", "param_names": [0, "req"], "member_name": [0, "req->saved_syn"]}
{"funcname": "tcp_saved_syn_free", "param_names": [], "member_name": [0, "tp->saved_syn"]}
{"funcname": "inet_hashinfo2_free_mod", "param_names": [], "member_name": [0, "h->lhash2"]}
{"funcname": "inet_ehash_locks_free", "param_names": [], "member_name": [0, "hashinfo->ehash_locks"]}
{"funcname": "ip_fib_metrics_put", "param_names": [0, "fib_metrics"], "member_name": []}
{"funcname": "ip_dst_metrics_put", "param_names": [], "member_name": [0, "dst->_metrics"]}
{"funcname": "ksmbd_inode_free", "param_names": [0, "ci"], "member_name": []}
{"funcname": "__ksmbd_close_fd", "param_names": [1, "fp"], "member_name": [1, "fp->stream.name"]}
{"funcname": "free_lease", "param_names": [], "member_name": [0, "opinfo->o_lease"]}
{"funcname": "free_opinfo", "param_names": [0, "opinfo"], "member_name": [0, "opinfo->conn"]}
{"funcname": "ahash_request_free", "param_names": [0, "req"], "member_name": []}
{"funcname": "aead_request_free", "param_names": [0, "req"], "member_name": []}
{"funcname": "smb_direct_free_transport", "param_names": [0, "kt"], "member_name": []}
{"funcname": "smb_direct_free_rdma_rw_msg", "param_names": [1, "msg"], "member_name": []}

然后会通过一个While循环,循环调用这个插件,以不同(递增)node.depth的候选函数为目标,进行分析。最终得到一个更完善的memory_flow_free.json文件。再利用classify_free_data()对得到的释放函数进行分类,分为两类:标准形式的释放函数(只有一个参数,且是第0个)、更复杂释放逻辑的函数(涉及结构体成员、多个参数等)

两者分别存放于output/free/FreeNormalFile.txtoutput/FreeCustomizedFile.txt

然后调用add_primitive_functions()seed_free.txt中的free函数也进行分类,分别写于上面两者中。判断依据即是参数的位置(因为seed_free.txt中存储了调用参数的位置)。

最后通过get_CSA_format()清洗FreeCustomizedFile.txt中的JSON数据结构,使得符合CSA的输入格式要求。

Step_4_Analyze()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def Step_4_Analyze():
out_alloc_dir = "output/alloc/"
shutil.copy(out_alloc_dir + "AllocNormalFile.txt", config.temp_dir + os.sep + "CSA/AllocNormalFile.txt")
shutil.copy(out_alloc_dir + "AllocCustomizedFile.txt", config.temp_dir + os.sep + "CSA/AllocCustomizedFile.txt")
out_free_dir = "output/free/"
shutil.copy(out_free_dir + "FreeNormalFile.txt", config.temp_dir + os.sep + "CSA/FreeNormalFile.txt")
shutil.copy(out_free_dir + "FreeCustomizedFile.txt", config.temp_dir + os.sep + "CSA/FreeCustomizedFile.txt")

cmd, parser_cmd = format_analyzer_command()
print("\nThe bug detection phase start!\n")
os.system("clear")
print(cmd)
subprocess.call(cmd, shell=True)

print("\nParsing the detection result to html report!\n")
os.system("clear")
print(parser_cmd)
subprocess.call(parser_cmd, shell=True)

html_path = "output/report_html/index.html"
cleaner = csa_report_cleaner(html_path)
cleaner.clean()

这里除了cp了一下allocfree的函数文件以外,就是调用format_analyzer_command了。逐步分析format_analyzer_command()

1
2
3
4
retCode = isCodeCheckerExist()
if retCode == 0:
# some print
exit(-1)

会先检查系统中是否安装了CodeChecker。并且如果没安装会直接退出。

1
2
with open("subword_dataset/static_analyzer.cfg", "r") as f:
cfg_cmd = f.read().strip()

这个subword_dataset/static_analyzer.cfg保存着一些参数信息:

1
-Xclang -analyzer-inline-max-stack-depth -Xclang 5 -Xclang -analyzer-max-loop -Xclang 1 -Xclang -analyzer-config -Xclang c++-shared_ptr-inlining=true,c++-container-inlining=true,c++-container-inlining=true,max-inlinable-size=1000,ctu-import-threshold=10000,max-nodes=2250000

然后配置插件路径和一些input的路径。

1
2
3
4
analyzer_plugin = config.plugin_dir + os.sep + "GoshawkAnalyzer.so"
MemFuncDir = config.temp_dir + os.sep + "CSA"
PathNumberFile = MemFuncDir + os.sep + "path_number.txt"
ExterFile = MemFuncDir + os.sep + "extern_count.txt"

然后构造最终的命令:

1
cfg_cmd += " -Xclang -load -Xclang {0} -Xclang -analyzer-checker=security.GoshawkChecker ..."  

然后将其保存在temp/static_analyzer.cfg中。

1
2
3
analyzer_cfg = config.temp_dir + os.sep + "static_analyzer.cfg"
with open(analyzer_cfg, "w") as f:
f.write(cfg_cmd)

然后构造CodeChecker的命令,生成的命令如下所示:

1
2
3
4
5
CodeChecker analyze --analyzers clangsa -j<N> compilation.json \
--saargs <上面写入的 analyzer_cfg> \
-d <一些不使用的默认检查器> \
--ctu \
--output <输出目录>

使用clangsa后端;启用CTU(跨翻译单元分析);多线程分析;忽略很多默认规则,只关注指定的插件GoshawkChecker

然后构造parse命令:

1
parser_cmd = analyzer_cmd + " parse {0} -e html -o {1}".format(ctu_cache, report_path)

会变成以下命令:

1
CodeChecker parse analyze_cache -e html -o output/report_html

紧接着通过子进程对这两个指令分别执行。先执行插件。

1
2
3
os.system("clear")
print(cmd)
subprocess.call(cmd, shell=True)

GoshawkAnalyzer.so

这个插件是一个checker,首先是注册

1
2
3
4
5
6
7
8
9
// Register plugin!
extern "C" void clang_registerCheckers(CheckerRegistry &registry) {
registry.addChecker(registerMemMisuseProChecker,shouldRegisterMemMisuseProChecker,
"security.GoshawkChecker", "Detect DoubleFree, UAF by MOS.","",false);
registry.addCheckerOption("string", "security.GoshawkChecker", "MemFuncsDir","/tmp/CSA","The directory that store the MOS.","alpha");
registry.addCheckerOption("string", "security.GoshawkChecker", "PathNumberFile","/tmp/CSA/path_number","The path of file to save the number of paths that analyzed.","alpha");
registry.addCheckerOption("string", "security.GoshawkChecker", "ExternFile","/tmp/CSA/extern_number","The number of MOS funcs that be modeled.","alpha");

}

通过addChecker()注册插件,然后通过addCheckerOption()注册插件参数。类名为MemMisuseChecker,订阅的程序点有:

  • checkPreCall(),函数调用前会触发。
  • evalCall(),函数调用时评估
  • checkDeadSymbols(),SSA symbol死亡时
  • checkLocation,变量访问时
  • checkEndFunction,函数结束时

checkPreCall()

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
void MemMisuseChecker::checkPreCall(const CallEvent &Call, CheckerContext &C) const{
if (const AnyFunctionCall *FC = dyn_cast<AnyFunctionCall>(&Call)) {
const FunctionDecl *FD = FC->getDecl();
if (!FD)
return;
std::string func_name = FD->getNameAsString();

if(MemFunc->isFreeFunction(func_name)) // free function will be eval at evalcall.
{
return;
}
}


for (unsigned I = 0, E = Call.getNumArgs(); I != E; ++I) {
SVal ArgSVal = Call.getArgSVal(I);
if (ArgSVal.getAs<Loc>()) {
SymbolRef Sym = ArgSVal.getAsSymbol();
if (!Sym)
continue;
if (checkUseAfterFree(Sym, C, Call.getArgExpr(I)))
return;
}
}
}

如果当前要调用的是一个函数(AnyFunctionCall),则尝试获取其函数名,利用MemFunc->isFreeFunction()判断该函数是否是一个free类型的释放函数。如果是,则跳过。

注意,这里的判断依据是前面Step_3_free()所得到的FreeCustomizedFile.txtFreeNormalFile.txt

如果不是,则检查函数参数中是否有Use-After-Free风险,首先遍历函数的所有参数,如果参数是个地址(Loc类型),提取它的symbol;并使用checkUseAfterFree()判断这个symbol是否早已被释放。

通过getArgSVal()拿到一个SVal类型的值,SvalSymbolic Value的缩写,它是Clang Static Analyzer用来描述程序中变量、常量、地址、运行时值等的一个抽象封装类型。

Sval有两个大类:

  • Loc表示一个地址,比如某个变量的地址、指针指向的地址、结构体字段的地址等。
  • NonLoc表示非地址的值,比如整数、浮点数、符号表达式、函数返回值等。

而通过getAsSymbol()获得SymbolRef类型对象SymSymbolRef是指向一个符号对象的引用,用来唯一表示程序中的某块内存或某个值。在CSA中,堆内存通常会绑定一个Symbol。提取出来的Sym对象是“符号表示的内存”,没有symbol表示它不是符号化的值,通常是常量、NULL、或不可识别的区域。

evalCall()

该函数会模拟函数执行,会在函数call后调用该程序点。goshawk的相关源码如下:

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
bool MemMisuseChecker::evalCall(const CallEvent &Call, CheckerContext &C) const{  
// ...
ProgramStateRef State = C.getState();
if (struct MallocEntry* entry = MemFunc->isMallocFunction(func_name))
{
if(entry->kind == Normal)
State = ModelMallocNormal(C, Call, State);
else if(entry->kind == Customized)
State = ModelMallocCustomized(C, Call, *entry, State);
}
else if(struct FreeEntry* entry = MemFunc->isFreeFunction(func_name))
{
if (entry->kind == Normal)
State = ModelFreeNormal(C, Call, State);
else if(entry->kind == Customized)
State = ModelFreeCustomized(C, Call, *entry, State);
else
return false;
}
else if(MemFunc->isReallocFunction(func_name))
{
State = ModelReallocMem(C, Call, State);
}
else
{
return false;
}
if (State)
C.addTransition(State);
// ...
}

该函数首先有三个检查(代码并未贴出):函数声明检查,确保能够获取到有效的函数声明;函数类型检查,只处理真正的函数;全局C函数检查,只处理全局作用域的C函数调用。随后检查函数是否有函数定义,并获取当前程序状态。再对函数的“具体”类型进行判断,例如是否是分配/释放函数。然后分别调用不同的处理逻辑函数。且CustomizedNormal是不同的处理逻辑,判断依据便是传入该插件的.txt文件,这些文件分别存储着两种类型的分配和释放函数名。

ModelFreeCustomized()

kfree等官方API的封装,被认为是Customized。由于放在evalcall中进行,所以首先判断它的call合法性:

1
2
3
4
5
6
7
8
9
10
11
12
StoreManager &StoreMgr = C.getStoreManager();
const Expr* expr = Call.getOriginExpr();
if(!expr)
return nullptr;
const CallExpr *CE = dyn_cast<CallExpr>(expr);
if(!CE)
return nullptr;
if(CE->getNumArgs()<1)
return nullptr;
const FunctionDecl* FD = CE->getDirectCallee();
if(!FD)
return nullptr;

然后会遍历参数(在FreeCustomized.txt中记录的参数,并不是遍历所有参数)。然后对这些参数进行以下判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
std::string param_name = FD->getParamDecl(index)->getNameAsString();
SVal ArgVal = Call.getArgSVal(index);
if (!ArgVal.getAs<DefinedOrUnknownSVal>())
return nullptr;
const MemRegion *R = ArgVal.getAsRegion();
if (!R)
break;
R = R->StripCasts();
const SymbolicRegion *SrBase = dyn_cast<SymbolicRegion>(R);
if (!SrBase)
return nullptr;
SymbolRef SymBase = SrBase->getSymbol();
const MemSymState *RsBase = State->get<RegionState>(SymBase);
if(!RsBase)
break;
if(RsBase->isReleased())
{
ReportDoubleFree(C,CE->getSourceRange(),SymBase);
return nullptr;
}
State = State->set<RegionState>(SymBase,MemSymState::getReleased(Call.getArgExpr(index)));

其中SVal是CSA的符号值类型,用于获取函数调用实参(第index个参数)的抽象值。SVal可表示:常量、内存地址、符号未知值等。

然后通过ArgVal.getAs<DefinedOrUnknownSVal>()转换成已定义或未知的符号值,如果无法转换即认定为未初始化的值,那么直接返回。

通过ArgVal.getAsRegion()将参数的值解释为内存区域(MemRegion),CSA将指针遍历、全局变量等映射为不同类型的MemRegion。再通过StripCasts()去除指针类型上的cast,得到原始内存区域。再通过dyn_cast<SymbolicRegion>(R)根据这块内存区域得到符号内存块(SymbolicRegion),如果内存区域不是一个SymbolicRegion,则不继续。也就是如果内存区域不是被malloc等内存分配所分配出来的指针值,那么就break。

这里还会通过State->get<RegionState>(SymBase)获得前面所获取的符号内存块的内存状态,内存状态:释放/分配;根据如果已经释放过了的,就视为double free

这里关键的一点,对于某个SymbolRef(比如malloc返回的符号内存),CSA默认不会追踪“是否释放”的状态。CSA内部的ProgramState维护着:表达式–>SVal,记录每个表达式对应的符号值;内存区域(MemRegion)–>SVal,变量对应的内存值;GDM,用户扩展数据。

只会记录变量对应的内存区域,该内存的状态并不会追踪。这部分Goshawk通过:

1
REGISTER_MAP_WITH_PROGRAMSTATE(RegionState, SymbolRef, MemSymState)

RegionStateSymbolRef --> MemSymState 映射存储于GDM中。

综合来说,double freeuse after free都依赖于它自己的状态维护。最后,如果该内存区域是正常的,那么将这个符号对应的状态变为Released。其次,遍历所有参数的成员变量(如果有的话),对它们(参数的FiledRegion)也做同样的处理。

ModelFreeNormal()

依然先判断call的合法性:

1
2
3
4
5
6
7
8
9
10
11
12
// Check this Call is valid.
const Expr* expr = Call.getOriginExpr();
if(!expr)
return nullptr;
const CallExpr *CE = dyn_cast<CallExpr>(expr);
if(!CE)
return nullptr;
if(CE->getNumArgs()<1)
return nullptr;
const Expr* freed_arg = CE->getArg(0);
if (!freed_arg)
return nullptr;

确保调用是一个标准的函数调用,并拥有至少一个参数。取出第一个参数freed_arg。随后的代码中,对参数是&的情况,例如put_device(&device->dev)实际释放的是device整体,而不仅仅是dev成员。处理这类情况需要复杂建模,因此这里跳过这种情况:

1
2
3
4
5
6
if (auto unaryOperator = dyn_cast<UnaryOperator>(freed_arg->IgnoreCasts())) {
std::string name = unaryOperator->getOpcodeStr(unaryOperator->getOpcode()).str();
if (name == "&") {
return nullptr;
}
}

再获取参数的符号值,内存区域,获取内存区域对应的符号,并查询符号对应的内存状态。查询之前是否已经有记录(RegionState),最后会:

1
State->set<RegionState>(SymBase, MemSymState::getReleased(freed_arg));

设置状态为Released

通过对FreeNormal()FreeCustomized()两者对于参数的释放策略分析,得出以下结论:

  • 只要通过call的合法性判断,ModelFreeNormal就会将参数设置为Released状态;

  • 只有当参数在MOS State中已经存在的情况下,才会将参数设置为Released状态;

    • 永远不会出现CustomizedFree首次就将参数释放的情况

Some problems

由于一开始选取的目标为linux内核的ksmbd模块,而这有一个单独的库。在库中的Makefile只支持GCC的形式,所以前期通过一个替换脚本将其解决,也就是将GCC的一些参数给替换成Clang支持的参数。这种情况下的编译会出现不太“美丽”的情况,笔者在找到漏洞ksmbd-PR时,第一个Case是Goshawk检测出来了,第二个漏洞却没有分析出来。在笔者通过不断地“日志”调试中,发现CSA并不是没有到达这条路径,仅是因为“编译”的问题。所以,补充一下单独编译某个内核模块的方法。

1 Clang 编译内核模块

首先通过make menuconfig将你所需的“模块”进行勾选,记得这是必要的,否则单独对模块进行编译时会什么都没有。

其次,通过Clang对整个内核进行编译:

1
make CC=clang HOSTCC=clang -j$(nproc)

然后,就可以通过Clang对模块进行单独编译:

1
make M=fs/smb/server CC=clang HOSTCC=clang modules

会编译生成对应的*.ko文件。对于使用CodeChecker的情况,需要通过其捕获编译命令:

1
CodeChecker log -b "make M=fs/btrfs CC=clang HOSTCC=clang modules" -o compilation.json

这种情况下就能收集到对应的正确编译命令,而不需要对特定模块做Clang的兼容

2 解决带“&”的参数无法识别的问题

先看看Goshawk原本对参数的检测代码:

1
2
3
4
5
6
7
8
9
if (ArgSVal.getAs<Loc>()) {         // 确认参数是一个地址类的值(指针/引用)
llvm::outs() << "ArgSVal.getAs<Loc>(): " << ArgSVal.getAs<Loc>() << "\n";
SymbolRef Sym = ArgSVal.getAsSymbol(); // 从 SVal 中获取符号(符号化内存对象)
if (!Sym)
continue; // 没有符号(可能是常量地址),跳过
llvm::outs() << "ArgSVal Symbol: " << Sym << "\n";
if (checkUseAfterFree(Sym, C, Call.getArgExpr(I))) // 检查是否是已释放对象
return;
}

首先对参数的符号值(SVal)是否为指针/地址类型,然后直接通过符号值提取对应的符号化内存对象(SymbolRef,这类符号一般是conj_$xxx临时值或者SymRegion{...}内存区域。也就是说,目前只支持这两种情况的符号提取。但ArgSVal其他的可能值为:常量地址&global_var,内联偏移地址&(p->field)等。

进一步看关于参数符号值的问题:

1
2
3
conj_$19{struct oplock_info *, LC318, S3778832, #1} : Released

ArgSVal.getAs<Loc>(): &SymRegion{conj_$19{struct oplock_info *, LC318, S3778832, #1}}.oplock_q

发现提取的符号化内存对象为空,也就是说**ArgSVal.getAsSymbol()为空。当参数的值为&SymRegion{...}时,直接提取符号化内存对象是不可取的,可以通过先尝试获取Region,再提取其基底符号。**如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (ArgSVal.getAs<Loc>()) {
const MemRegion *MR = ArgSVal.getAsRegion();
if(!MR)
continue;
llvm::outs() << "MemRegion: " << MR << "\n";
MR = MR->StripCasts();
llvm::outs() << "After StripCasts: " << MR << "\n";
const MemRegion *Base = MR->getBaseRegion();
if (const SymbolicRegion *SR = dyn_cast<SymbolicRegion>(Base)) {
llvm::outs() << "Base is a SymbolicRegion: " << SR << "\n";
SymbolRef Sym = SR->getSymbol();
llvm::outs() << "SymbolRef: " << Sym << "\n";
if (!Sym)
continue;
if (checkUseAfterFree(Sym, C, Call.getArgExpr(I)))
return;
}
}

这种情况下所获得的符号如下:

1
2
3
4
5
6
7
					== MOS State==
conj_$19{struct oplock_info *, LC320, S3778832, #1} : Released
== Argument State==
MemRegion: SymRegion{conj_$19{struct oplock_info *, LC320, S3778832, #1}}.oplock_q
After StripCasts: SymRegion{conj_$19{struct oplock_info *, LC320, S3778832, #1}}.oplock_q
Base is a SymbolicRegion: SymRegion{conj_$19{struct oplock_info *, LC320, S3778832, #1}}
SymbolRef: conj_$19{struct oplock_info *, LC320, S3778832, #1}

所获得的Base 符号化内存对象和MOS State中的符号化内存对象一致,因此可以识别出此次”use”。

3 一个可有可无的“优化”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void MemMisuseChecker::checkDeadSymbols(SymbolReaper &SymReaper, CheckerContext &C) const{
/*Future: Support Memleak check in the future.*/
ProgramStateRef state = C.getState();
RegionStateTy OldRS = state->get<RegionState>();
RegionStateTy::Factory &F = state->get_context<RegionState>();

RegionStateTy RS = OldRS;
SmallVector<SymbolRef, 2> Errors;
for (RegionStateTy::iterator I = RS.begin(), E = RS.end(); I != E; ++I) {
if (SymReaper.isDead(I->first)) {
// Remove the dead symbol from the map.
RS = F.remove(RS, I->first);
}
}
// If the RegionStateTy has changed, we need to update the state.
if (RS != state->get<RegionState>()) {
state = state->set<RegionState>(RS);
C.addTransition(state);
}

}

增加了一个状态回写,原Goshawk已经完成了死符号状态清理,但是没有做状态机的转移。

CSA的ProgramState是不可变对象,RS=F.remove(...)修改的是副本。需要调用:

1
2
state = state->set<RegionState>(RS);
C.addTransition(state);

否则修改不会生效,路径的状态仍然是旧的。当前阶段的笔者(”菜逼“)认为,由于Goshawk并不检查内存泄露,所以这个符号不更新进入State中也不影响结果。

2025.8.8更:

整个Analyzer很多内容是Goshawk借鉴于MallocChecker的。因此这个优化是必要的,原MallocChecker是加上了C.addTransition()进行状态回写。

其次,关于参数以地址形式传递时无法识别的问题,也并不是Goshawk的问题,这一部分代码也是借鉴于MallocChecker的,因此这实际上是CSA中unix.Malloc这个检查器的问题。关于这一问题笔者提了一个 Issue #152446 · llvm/llvm-project,以及相应的Pull Request #152462 · llvm/llvm-project


Goshawk源码阅读
https://loboq1ng.github.io/2025/08/04/Goshawk源码阅读/
作者
Lobo Q1ng
发布于
2025年8月4日
许可协议