LLVM 之 Clang 静态分析器篇(5):程序缺陷诊断——fopen 和 fclose API 误用

Author: stormQ

Created: Thursday, 03. June 2021 09:11PM

Last Modified: Wednesday, 09. June 2021 10:07PM



摘要

本文基于release/12.x版本的 LLVM 源码,研究了 Clang 静态分析器中alpha.unix.SimpleStream检查器的工作原理。从而,有助于按照自身需求修改或扩展 Clang 静态分析器。

alpha.unix.SimpleStream检查器用于检测以下两种程序缺陷:

为了便于研究,本文以插件方式实现了plugin.alpha.unix.SimpleStream检查器。相比于alpha.unix.SimpleStream检查器,其源码实现略有不同。


研究过程


alpha.unix.SimpleStream检查器的源码实现目录如下:

plugin.alpha.unix.SimpleStream检查器的源码实现目录如下:


原检查器订阅的程序点

alpha.unix.SimpleStream检查器中,检查器类SimpleStreamChecker用于实现fopen 和 fclose API 误用相关程序缺陷的诊断功能,其源码实现如下(定义在 clang/lib/StaticAnalyzer/Checkers/SimpleStreamChecker.cpp 文件中):

27 namespace {
// 省略 ...
50 class SimpleStreamChecker : public Checker<check::PostCall,
51                                            check::PreCall,
52                                            check::DeadSymbols,
53                                            check::PointerEscape> {
// 省略 ...
83 };
84 
85 } // end anonymous namespace

从上面的代码可以看出,该检查器类订阅了以下 4 个程序点:

要实现alpha.unix.SimpleStream检查器,需要获取fopen函数的返回值(即新打开的文件描述符),从而用于跟踪文件描述符的打开情况。而该信息可以在回调函数checkPostCall()中获取,但不能在回调函数checkPreCall()中获取。因此,需要订阅clang::ento::check::PostCall程序点。

另外,也需要获取fclose函数的参数(即要关闭的文件描述符),从而用于跟踪文件描述符的关闭情况。该信息既可以在回调函数checkPostCall()中获取,也可以在回调函数checkPreCall()中获取。Clang 静态分析器在实现alpha.unix.SimpleStream检查器时选择了后者。这样做,虽然逻辑更清晰,但不是必须的。因此,订阅了clang::ento::check::PreCall程序点。

需要注意的是, 虽然程序点clang::ento::check::PreStmt<CallExpr>clang::ento::check::PreCall的被调用时机是相同的,程序点clang::ento::check::PostStmt<CallExpr>clang::ento::check::PostCall的被调用时机也是相同的,但实际上 Clang 静态分析器在实现alpha.unix.SimpleStream检查器时订阅的是后者而不是前者。这样做,是因为通过后者回调函数中的CallEvent参数可以更容易地获取被调用函数的相关信息,比如:函数返回值、函数参数列表等。

订阅clang::ento::check::DeadSymbols程序点出于以下两个目的:

订阅clang::ento::check::PointerEscape程序点是为了能够在分析器引擎无法有效地跟踪文件描述符时做一些处理,从而避免在某些情况下误报资源泄露的程序缺陷。这些情况比如:将文件描述符赋值给全局变量、分析器引擎无法对其建模的函数等。

返回上一级


自己动手实现重复释放的检测

step 1: 定义检查器类

1) 需要订阅哪些程序点

要实现检测重复释放(即对同一个资源重复调用fclose函数)的功能,我们至少需要订阅clang::ento::check::PostCall程序点。除此之外,最好也订阅clang::ento::check::DeadSymbols程序点(但不是必须的)。

2) 定义检查器类

using namespace clang;
using namespace ento;

namespace {

class SimpleStreamChecker : public Checker<check::PostCall,
                                           check::DeadSymbols> {
public:
  void checkPostCall(const CallEvent &Call, CheckerContext &C) const;
  void checkDeadSymbols(SymbolReaper &SR, CheckerContext &C) const;
};

// anonymous namespace

注:

3) 依赖的头文件

#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h"
#include "clang/StaticAnalyzer/Core/Checker.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h"
#include "clang/StaticAnalyzer/Frontend/CheckerRegistry.h"

注:

step 2: 自定义程序状态

Clang 静态分析器允许检查器自定义程序状态,从而跟踪检查器自身特定的信息。Clang 静态分析器通过提供以下宏定义来做到这一点(定义在 clang/include/clang/StaticAnalyzer/Core/PathSensitive/ProgramStateTrait.h 文件中):

上述宏定义将这些检查器自身特定的信息保存到程序状态的GDM(Generic Data Map)中而不是检查器类的内部。这样做,是因为如果保存到检查器类中,那么这些信息对于所有的执行路径来说都是相同的,而这一点显然是不合理的。

需要注意的是, 在调用上述宏定义时,不能将其置于任何命名空间内,并且末尾不可以带分号。否则,会发生编译错误。

要实现检测重复释放的功能,我们需要跟踪同一个文件描述符的打开和关闭情况。宏定义REGISTER_MAP_WITH_PROGRAMSTATE(Name, Key, Value)适合于该场景。

1) 定义状态枚举

namespace {

enum Kind {
  Opened,
  Closed,
};

// anonymous namespace

其中,枚举值Opened表示文件描述符处于已打开的状态,枚举值Closed表示文件描述符处于已关闭的状态。

2) 调用注册宏

REGISTER_MAP_WITH_PROGRAMSTATE(SimpleStreamMap, SymbolRef, int)

在调用上述代码后,宏内部会定义一个数据类型为SimpleStreamMap的类,用于表示自定义程序状态,以及定义一个实际数据类型为llvm::ImmutableMap<SymbolRef, int>using声明——SimpleStreamMapTy,并提供一组操作该自定义程序状态的函数等。

参数SimpleStreamMap,表示自定义程序状态的类型(实质上是一个 C++ 类名,在该宏定义的内部进行定义)。

参数SymbolRef,为llvm::ImmutableMap<Key, Value>Key的数据类型,表示文件描述符所对应的符号表达式。

参数int,为llvm::ImmutableMap<Key, Value>Value的数据类型,表示文件描述符的状态(是已打开还是已关闭)。

3) 如何设置自定义程序状态(示例)

SymbolRef FileDesc = /*...*/;
ProgramStateRef State = /*...*/;
State = State->set<SimpleStreamMap>(FileDesc, Opened);

上述代码的逻辑为:返回一个新的程序状态。这个新的程序状态是基于程序状态State(位于等号右侧)创建的,并添加一个Key值为FileDescValue值为Opened的元素到自定义程序状态SimpleStreamMap中。

需要注意的是, 由于ProgramState的不可修改性,等号两端的State指向两个不同的ProgramState对象。

4) 如何获取自定义程序状态(示例)

SymbolRef FileDesc = /*...*/;
ProgramStateRef State = /*...*/;
const auto K = State->get<SimpleStreamMap>(FileDesc);

上述代码的逻辑为:从程序状态StateGDM中查询自定义程序状态SimpleStreamMap中的元素,要查询元素的Key值为FileDesc。如果成功,返回指向该元素的Value值的指针;否则,返回NULL

step 3: 实现检查器类

1) 实现checkPostCall()成员函数

a) 判断是否为 C 语言风格的全局函数

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

  // 省略 ...
}

上述代码的逻辑为:如果被调用的函数不是 C 语言风格的全局函数,那么直接返回。从而,避免不必要的后续处理。

b) 实现fopen函数被调用的处理逻辑

void SimpleStreamChecker::checkPostCall(const CallEvent &Call,
                                        CheckerContext &C) const {
  // 省略 ...

  const IdentifierInfo *II = Call.getCalleeIdentifier();
  if (!II) {
    return;
  }

  if (II->isStr("fopen")) {
    SymbolRef FileDesc = Call.getReturnValue().getAsSymbol();
    if (!FileDesc) {
      return;
    }

    ProgramStateRef State = C.getState();
    State = State->set<SimpleStreamMap>(FileDesc, Opened);
    C.addTransition(State);
    return;
  }

  // 省略 ...
}

上述代码的逻辑为:如果被调用的函数是fopen,那么在自定义程序状态SimpleStreamMap中添加一个元素。其Key值为文件描述符所对应的符号表达式(这里是FileDesc),Value值为表示文件描述符已打开的枚举值。

需要注意的是, 这里用于判断被调用函数是否为fopen的方法是低效的。因为clang::IdentifierInfo::isStr()函数的实现是通过比较字符串。

c) 实现fclose函数被调用的处理逻辑

void SimpleStreamChecker::checkPostCall(const CallEvent &Call,
                                        CheckerContext &C) const {
  // 省略 ...

  if (II->isStr("fclose")) {
    SymbolRef FileDesc = Call.getArgSVal(0).getAsSymbol();
    if (!FileDesc) {
      return;
    }

    ProgramStateRef State = C.getState();
    if (const auto K = State->get<SimpleStreamMap>(FileDesc)) {
      if (*K == Opened) {
        State = State->set<SimpleStreamMap>(FileDesc, Closed);
        C.addTransition(State);
      }
      else if (*K == Closed) {
        reportDoubleClose(C);
      }
    }
    return;
  }
}

上述代码的逻辑为:如果被调用的函数是fclose,那么从自定义程序状态SimpleStreamMap中查询元素。要查询元素的Key值为刚关闭的文件描述符所对应的符号表达式。如果查询成功并且其文件描述符状态为已打开,那么将其设置为已关闭,并扩展可达程序图,从而让后继节点获知这一信息。如果文件描述符的状态为已关闭,那么表明发生了重复释放,并报告该程序缺陷。

2) 报告程序缺陷

a) 定义相关数据成员和成员函数

namespace {

class SimpleStreamChecker : public Checker<check::PostCall> {
  mutable std::unique_ptr<BugType> DoubleCloseBT;

  void reportDoubleClose(CheckerContext &C) const;

  // 省略 ...
};

// anonymous namespace

我们需要定义一个数据类型为std::unique_ptr<BugType>的数据成员(这里是DoubleCloseBT),用于表示一个新的程序缺陷。为了在对象析构时能够自动释放内存,因此使用了智能指针。

另外,我们将报告该程序缺陷的处理逻辑封装到成员函数reportDoubleClose()中。

b) 实现程序缺陷报告逻辑

void SimpleStreamChecker::reportDoubleClose(CheckerContext &C) const {
  if (!DoubleCloseBT) {
    DoubleCloseBT.reset(
      new BugType(this"Double fclose""Unix Stream API Error"));
  }

  if (ExplodedNode *N = C.generateErrorNode()) {
    auto R = std::make_unique<PathSensitiveBugReport>(
      *DoubleCloseBT, DoubleCloseBT->getDescription(), N);
    C.emitReport(std::move(R));
  }
}

上述代码的逻辑为:报告重复释放的程序缺陷,并终止该执行路径的探索。

3) 实现checkDeadSymbols()成员函数

void SimpleStreamChecker::checkDeadSymbols(SymbolReaper &SR,
                                           CheckerContext &C) const {
  ProgramStateRef State = C.getState();
  for (const auto &TrackedStream : State->get<SimpleStreamMap>()) {
    SymbolRef Sym = TrackedStream.first;
    if (SR.isDead(Sym)) {
      State = State->remove<SimpleStreamMap>(Sym);
    }
  }

  C.addTransition(State);
}

上述代码的逻辑为:首先,遍历当前程序状态State中自定义程序状态SimpleStreamMap的所有元素。如果一个元素中文件描述符所对应的符号表达式要被回收了,那么返回一个新的程序状态(变量名称也是State),并且该新程序状态的GDM中已经移除了该元素。最后,如果程序状态发生了变化,那么扩展程序状态图,从而让后继节点获知这一信息,并且可以继续探索该执行路径。

step 4: 构建测试

1) 构建

注:具体过程可参考笔者的另一篇文章:《LLVM 之 Clang 静态分析器篇(2):如何扩展 Clang 静态分析器》。

2) 运行

执行如下命令运行plugin.alpha.unix.SimpleStream检查器(示例):

$ clang -cc1 -w -load ~/git-projects/llvm-project/build_ninja/lib/SimpleStreamCheckerPlugin.so -analyze -analyzer-checker=plugin.alpha.unix.SimpleStream simple-stream-checks.c -I ~/git-projects/llvm-project/clang/test/Analysis/

输出结果如下:

simple-stream-checks.c:13:5warning: Double fclose [plugin.alpha.unix.SimpleStream]
    fclose(F); // expected-warning {{Closing a previously closed file stream}}
    ^~~~~~~~~
1 warning generated.

simple-stream-checks.c是 LLVM 项目中自带的测试文件,其中重复释放的程序缺陷仅有一处,即 simple-stream-checks.c:13 行。也就是说,我们上述实现的检查器能够检测出该程序缺陷。

返回上一级


自己动手实现资源泄露的检测

step 1: 需要订阅哪些程序点

要实现检测资源泄露(即对同一个资源在调用了fopen函数之后未再调用fclose函数)的功能,我们需要订阅clang::ento::check::DeadSymbolsclang::ento::check::PointerEscape程序点。

因此,修改后的检查器类定义如下:

namespace {

// 省略 ...

class SimpleStreamChecker : public Checker<check::PostCall,
                                           check::DeadSymbols,
                                           check::PointerEscape> {
// 省略 ...
};

// anonymous namespace

同样地,我们需要实现一个函数原型如下的成员函数:

ProgramStateRef checkPointerEscape(ProgramStateRef State,
                                   const InvalidatedSymbols &Escaped,
                                   const CallEvent *Call,
                                   PointerEscapeKind Kind)
 const
;

step 2: 实现检查器类

1) 修改checkDeadSymbols()成员函数

如果一个文件描述符发生了资源泄露,那么一定同时满足以下 3 个条件:

需要注意的是, 上述条件是必要不充分条件。也就是说,如果同时满足了以上 3 个条件,那么不一定发生了资源泄露

修改后的checkDeadSymbols()源码实现如下:

void SimpleStreamChecker::checkDeadSymbols(SymbolReaper &SR,
                                           CheckerContext &C) const {
  auto isNotNull = [&](ProgramStateRef State, SymbolRef Sym) {
    // Return true if a symbol is not NULL.
    return !State->getConstraintManager().isNull(State, Sym).isConstrainedTrue();
  };

  SymbolVector LeakedStreams;
  ProgramStateRef State = C.getState();
  for (const auto &TrackedStream : State->get<SimpleStreamMap>()) {
    SymbolRef Sym = TrackedStream.first;
    if (SR.isLive(Sym)) {
      continue;
    }

    if (TrackedStream.second == Opened && isNotNull(State, Sym)) {
      LeakedStreams.push_back(Sym);
    }

    State = State->remove<SimpleStreamMap>(Sym);
  }

  if (LeakedStreams.empty()) {
    C.addTransition(State);
  }
  else {
    reportLeaks(LeakedStreams, C, State);
  }
}

上述代码的逻辑为:首先,遍历当前程序状态State中自定义程序状态SimpleStreamMap的所有元素。如果一个元素中文件描述符所对应的符号表达式要被回收了,那么依次执行如下两个操作:

在上述遍历结束后,判断是否发生了资源泄露。如果有,则报告这些程序缺陷;否则,如果程序状态发生了变化,那么扩展程序状态图,从而让后继节点获知这一信息,并且可以继续探索该执行路径。

需要注意的是, 由于即使程序发生了资源泄露仍可以继续执行。因此,用于报告资源泄露程序缺陷的reportLeaks()函数必须以最新的程序状态State而不是当前程序状态(C.getState())来创建节点,从而让后继节点获知最新的程序状态。如果不这样做,会产生误报。

2) 报告程序缺陷

a) 定义相关数据成员和成员函数

namespace {

class SimpleStreamChecker : public Checker<check::PostCall> {
  // 省略 ...

  mutable std::unique_ptr<BugType> LeakBT;

  // 省略 ...

  void reportLeaks(ArrayRef<SymbolRef> LeakedStreams, CheckerContext &C,
                   ProgramStateRef State)
 const
;

  // 省略 ...
};

// anonymous namespace

同样地,我们需要定义一个数据类型为std::unique_ptr<BugType>的数据成员(这里是LeakBT),用于表示一个新的程序缺陷。

另外,我们将报告该程序缺陷的处理逻辑封装到成员函数reportLeaks()中。

b) 实现程序缺陷报告逻辑

void SimpleStreamChecker::reportLeaks(ArrayRef<SymbolRef> LeakedStreams,
                                     CheckerContext &C,
                                     ProgramStateRef State) const {
  ExplodedNode *N = C.generateNonFatalErrorNode(State);
  if (!N) {
    return;
  }

  if (!LeakBT) {
    LeakBT.reset(new BugType(this"Resource Leak""Unix Stream API Error",
      /*SuppressOnSink=*/true));
  }

  for (const auto &LeakedStream : LeakedStreams) {
    (void)LeakedStream;
    auto R = std::make_unique<PathSensitiveBugReport>(
      *LeakBT, LeakBT->getDescription(), N);
    C.emitReport(std::move(R));
  }
}

上述代码的逻辑为:为了在检测到资源泄露后分析器引擎仍可以继续探索该执行路径。因此,使用clang::ento::CheckerContext::generateNonFatalErrorNode()函数创建节点。如果创建成功,则报告所有的资源泄露程序缺陷。

需要注意的是, 创建数据成员LeakBT时,其构造函数的最后一个参数bool SuppressOnSink的值设置为true。这样做,是因为调用assert()exit()函数会导致程序退出,在这种情况下,如果尚未关闭文件描述符,那么不认为发生了资源泄露。如果不这样做,会产生误报。

3) 实现checkPointerEscape()成员函数

ProgramStateRef
SimpleStreamChecker::checkPointerEscape(ProgramStateRef State,
                                        const InvalidatedSymbols &Escaped,
                                        const CallEvent *Call,
                                        PointerEscapeKind Kind) const {
  if (Kind == PSK_DirectEscapeOnCall && Call->isInSystemHeader()) {
    return State;
  }

  for (SymbolRef Sym : Escaped) {
    State = State->remove<SimpleStreamMap>(Sym);
  }
  return State;
}

上述代码的逻辑为:如果文件描述符作为实参传递给了某个函数并且该函数来自于系统文件(即调用fclose()的情况),那么直接返回;否则,返回一个新的程序状态,并且该程序状态已经清除了所有无法跟踪的文件描述符。

step 3: 诊断结果对比

对于同一个测试文件simple-stream-checks.c,分别运行自己实现的检查器plugin.alpha.unix.SimpleStream和 Clang 静态分析器实现的检查器alpha.unix.SimpleStream

1) 运行检查器plugin.alpha.unix.SimpleStream

执行命令:

$ clang -cc1 -w -load ~/git-projects/llvm-project/build_ninja/lib/SimpleStreamCheckerPlugin.so -analyze -analyzer-checker=plugin.alpha.unix.SimpleStream simple-stream-checks.c -I ~/git-projects/llvm-project/clang/test/Analysis/

输出结果如下:

simple-stream-checks.c:13:5warning: Double fclose [plugin.alpha.unix.SimpleStream]
    fclose(F); // expected-warning {{Closing a previously closed file stream}}
    ^~~~~~~~~
simple-stream-checks.c:23:7warning: Resource Leak [plugin.alpha.unix.SimpleStream]
  if (Data) // expected-warning {{Opened file is never closed; potential resource leak}}
      ^~~~
simple-stream-checks.c:48:1warning: Resource Leak [plugin.alpha.unix.SimpleStream]
} // expected-warning {{Opened file is never closed; potential resource leak}}
^
simple-stream-checks.c:52:3warning: Resource Leak [plugin.alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:77:3warning: Resource Leak [plugin.alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:84:3warning: Resource Leak [plugin.alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:96:1warning: Resource Leak [plugin.alpha.unix.SimpleStream]
} // expected-warning {{Opened file is never closed; potential resource leak}}
^
7 warnings generated.

2) 运行检查器alpha.unix.SimpleStream

执行命令:

$ clang -cc1 -w -analyze -analyzer-checker=alpha.unix.SimpleStream simple-stream-checks.c -I ~/git-projects/llvm-project/clang/test/Analysis/

输出结果如下:

simple-stream-checks.c:13:5: warning: Closing a previously closed file stream [alpha.unix.SimpleStream]
    fclose(F); // expected-warning {{Closing a previously closed file stream}}
    ^~~~~~~~~
simple-stream-checks.c:23:7: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
  if (Data) // expected-warning {{Opened file is never closed; potential resource leak}}
      ^~~~
simple-stream-checks.c:48:1: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
// expected-warning {{Opened file is never closed; potential resource leak}}
^
simple-stream-checks.c:52:3: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:77:3: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:84:3: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
  return; // expected-warning {{Opened file is never closed; potential resource leak}}
  ^~~~~~
simple-stream-checks.c:96:1: warning: Opened file is never closed; potential resource leak [alpha.unix.SimpleStream]
// expected-warning {{Opened file is never closed; potential resource leak}}
^
7 warnings generated.

从上面的输出结果可以看出,对于同一个测试文件simple-stream-checks.c而言,检查器plugin.alpha.unix.SimpleStream的诊断效果与检查器alpha.unix.SimpleStream是相同的。

返回上一级


研究结论

要实现检测重复释放(即对同一个资源重复调用fclose函数)和资源泄露(即对同一个资源在调用了fopen函数之后未再调用fclose函数)的功能,我们需要跟踪文件描述符的打开和关闭情况。通过自定义程序状态可以做到这一点。

检测重复释放的工作原理:当程序调用fopen函数后,将返回的文件描述符和该文件描述符已打开的信息保存到自定义程序状态中。当程序调用fclose函数时,如果要关闭的文件描述符处于已打开的状态,那么将其更新为已关闭状态。如果要关闭的文件描述符处于已关闭状态,那么表明发生了重复释放

检测资源泄露的工作原理:如果在分析器引擎要回收文件描述符所对应的符号时,文件描述符的状态处于已打开状态并且其值不为NULL,那么表明发生了资源泄露

为了避免误报资源泄露,需要在分析器引擎无法跟踪文件描述符时清除相应的自定义程序状态。除此之外,如果程序在调用assert()exit()函数时尚未关闭文件描述符,那么不认为发生了资源泄露


References


下一篇:LLVM 之 Clang 静态分析器篇(6):程序缺陷诊断——内存重复释放

上一篇:LLVM 之 Clang 静态分析器篇(4):程序缺陷诊断——除零错误

首页