利用静态分析与Clang发现Heartbleed漏洞的技术实践

本文详细介绍了如何通过Clang静态分析器插件检测Heartbleed漏洞,包括技术策略实现、符号执行原理、约束条件检查方法,并展示了在OpenSSL代码库中的实际检测效果。

使用静态分析和Clang发现Heartbleed漏洞

背景

周五晚上,我端着一杯麦卡伦15年威士忌,决定编写一个静态检查器来发现Heartbleed漏洞。我决定将其实现为一个树外Clang分析器插件,并在几个包含Heartbleed漏洞特征的小型函数上进行评估,最后在存在漏洞的OpenSSL代码库上进行测试。

Clang项目随编译器提供了分析基础设施,通过scan-build调用。它会挂钩现有的make系统,将Clang分析器插入构建过程,分析器使用与编译器相同的参数调用。这样,分析器可以"访问"程序中所有在Clang下编译的编译单元。Clang分析器存在一些限制,我将在讨论部分提及。

这个练习让我意识到有些事情只能在喝酒时完成:喝啤酒时我在一阶逻辑方面取得最佳成果,而喝苏格兰威士忌时在Clang分析器上表现最好。

策略

Coverity最近提出了一种静态识别Heartbleed的方法,即将ntohl和ntohs调用的返回值标记为输入数据。对像OpenSSL这样的大型状态机进行静态分析的一个问题是,分析要么需要了解状态机以跟踪整个程序中受攻击者影响的值,要么需要在程序中添加某种注释来告诉分析器哪里使用了输入数据。

我喜欢这个观察,因为它非常具有可操作性。你将ntohl调用标记为产生污染数据,这是一种启发式方法,但相当有效,因为程序员不太可能对自己数据进行htonl操作。

我们的Clang分析器插件应该做的是:识别程序中通过ntohl写入变量的位置,污染这些值,然后在这些污染值被用作memcpy的大小参数时发出警报。但这并不完全准确,因为使用可能是安全的。我们还会在调用位置检查污染值的约束:如果污染值没有受到程序逻辑的某种约束,并且被用作memcpy的参数,就报告bug。这也可能遗漏一些bug,但我在24小时内喝着苏格兰威士忌编写了这个工具,提高精度可以后续进行。

Clang分析器细节

Clang分析器实现了一种符号执行来分析C/C++程序。作为分析器接入这个框架需要你理解Clang分析器对程序状态的视图。这是我消耗最多苏格兰威士忌的地方。

分析器在底层对程序状态进行符号/抽象探索。这种探索是流敏感和路径敏感的,因此与传统编译器数据流分析不同。分析为程序的每条路径维护一个"状态"对象,其中包含关于该路径上程序执行的约束和事实。你的分析器可以查询这个状态对象,并且可以更改状态以包含分析产生的信息。

这是我编写分析器时最大的障碍之一——当我在特定状态下有一个"符号变量"时,如何查询该符号变量的范围?假设有这样一个程序片段:

1
2
3
4
5
6
7
8
int data = ntohl(pkt_data);
if(data >= 0 && data < sizeof(global_arr)) {
 // CASE A
...
} else {
 // CASE B
 ...
}

从分析器的角度看,状态在if语句处"分裂"为两个不同的状态A和B。在状态A中,有一个约束是data在特定边界内,在情况B中有一个约束是data不在特定边界内。如何从检查器中访问这些信息?

如果你的检查器在其给定的"状态"对象上调用"dump"方法,将打印出类似以下的数据:

1
2
3
Ranges of symbol values:
 conj_$2{int} : { [-2147483648, -2], [0, 2147483647] }
 conj_$9{uint32_t} : { [0, 6] }

在这个例子中,conj_$9{uint32_t}是我们上面的’data’值,状态处于A状态。我们对’data’有一个范围约束,将其置于0到6之间。作为检查器,我们如何观察这个范围与无约束范围(比如[-2147483648, 2147483648])之间的差异?

答案是,我们创建一个公式来测试’data’的符号值与我们强加的条件,然后询问状态当这个公式为真和为假时存在哪些程序状态。如果新公式与现有公式矛盾,状态就不可行,不会生成任何状态。所以我们创建一个大致说"data > 500"的公式,来询问data是否可能大于500。当我们向状态询问这个条件为真和为假的新状态时,它只会给我们一个为假的状态。

这是Clang分析器内部用于回答状态约束问题的惯用法。数组边界检查器使用这个技巧来识别数组大小没有被用作数组索引约束的状态。

实现

你的分析器实现为一个C++类。你定义不同的"检查"函数,希望在分析器探索程序状态时得到通知。例如,如果你的分析器想要在函数调用前考虑函数参数,你创建一个具有如下签名的成员方法:

1
void checkPreCall(const CallEvent &Call, CheckerContext &C) const;

你的分析器可以匹配即将(符号)调用的函数。因此我们的实现分为三个阶段:

  1. 识别对ntohl/ntoh的调用
  2. 污染这些调用的返回值
  3. 识别污染数据的无约束使用

我们通过一个checkPostCall访问器完成前两个目标,大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void NetworkTaintChecker::checkPostCall(const CallEvent &Call,
CheckerContext &C) const {
  const IdentifierInfo *ID = Call.getCalleeIdentifier();

  if(ID == NULL) {
    return;
  }

  if(ID->getName() == "ntohl" || ID->getName() == "ntohs") {
    ProgramStateRef State = C.getState();
    SymbolRef       Sym = Call.getReturnValue().getAsSymbol();

    if(Sym) {
      ProgramStateRef newState = State->addTaint(Sym);
      C.addTransition(newState);
    }
  }
}

相当直接,我们只是获取返回值(如果存在),污染它,并通过’addTransition’添加带有污染返回值的状态作为我们访问的输出。

对于第三个目标,我们有一个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
26
27
void NetworkTaintChecker::checkPreCall(const CallEvent &Call,
CheckerContext &C) const {
  ProgramStateRef State = C.getState();
  const IdentifierInfo *ID = Call.getCalleeIdentifier();

  if(ID == NULL) {
    return;
  }
  if(ID->getName() == "memcpy") {
    SVal            SizeArg = Call.getArgSVal(2);
    ProgramStateRef state =C.getState();

    if(state->isTainted(SizeArg)) {
      SValBuilder       &svalBuilder = C.getSValBuilder();
      Optional<NonLoc>  SizeArgNL = SizeArg.getAs<NonLoc>();

      if(this->isArgUnConstrained(SizeArgNL, svalBuilder, state) == true) {
        ExplodedNode  *loc = C.generateSink();
        if(loc) {
          BugReport *bug = new BugReport(*this->BT, "Tainted,
unconstrained value used in memcpy size", loc);
          C.emitReport(bug);
        }
      }
    }
  }
}

也相对直接,我们检查值是否无约束的逻辑隐藏在’isArgUnConstrained’中,因此如果在我们当前路径中,一个污染的符号值没有足够的约束,我们就报告一个bug。

一些实现陷阱

事实证明,OpenSSL不使用ntohs/ntohl,他们有n2s/n2l宏来重新实现字节交换逻辑。如果这是在LLVM IR中,可以编写一个"字节交换识别器",使用一定量的逻辑来证明一段代码何时近似于字节交换的语义。

在Clang为openssl创建AST的过程中,还有一些行为我没有弄清楚,其中对ntohs的调用被替换为__builtin_pre(__x),这没有IdentifierInfo,因此没有名称。为了解决这个问题,我将n2s宏替换为对xyzzy的函数调用,导致链接失败,并调整了我的函数检查以检查名为xyzzy的函数。这足以识别Heartbleed漏洞。

演示程序和OpenSSL的解决方案输出

首先让我们看一些小型演示程序。这里是一个带有输出的玩具示例:

 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
$ cat demo2.c

...

int data_array[] = { 0, 18, 21, 95, 43, 32, 51};

int main(int argc, char *argv[]) {
  int   fd;
  char  buf[512] = {0};

  fd = open("dtin", O_RDONLY);

  if(fd != -1) {
    int size;
    int res;

    res = read(fd, &size, sizeof(int));

    if(res == sizeof(int)) {
      size = ntohl(size);

      if(size < sizeof(data_array)) {
        memcpy(buf, data_array, size);
      }

      memcpy(buf, data_array, size);
    }

    close(fd);
  }

  return 0;
}

$ ../docheck.sh
scan-build: Using '/usr/bin/clang' for static analysis
/usr/bin/ccc-analyzer -o demo2 demo2.c
demo2.c:30:7: warning: Tainted, unconstrained value used in memcpy size
      memcpy(buf, data_array, size);
      ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 warning generated.
scan-build: 1 bugs found.
scan-build: Run 'scan-view /tmp/scan-build-2014-04-26-223755-8651-1' to
examine bug reports.

最后,为了看到它在OpenSSL中存在Heartbleed漏洞的两个位置都捕获到了,请参见以下内容。

讨论

这种方法需要一些改进,我们以一种非常粗粒度的方式推理污染值是否"适当"约束。有时这是你能做的最好的——如果你的分析不知道特定缓冲区有多大,也许向分析员显示"嘿,这个值可能大于5000,并且被用作memcpy的参数,这样可以吗?“就足够了。

我真的不喜欢Clang分析器在AST上操作的局限性。我花了很多时间与Clang AST表示ntohs的方式作斗争,我仍然不明白问题的根源是什么。我有点只想在一个语义非常简单的虚拟机中考虑程序语义,所以LLVM IR对我来说似乎很理想。这可能只是我的编程语言背景在作祟。

我真的很喜欢Clang分析器对路径约束的接口。我认为那个接口非常强大,一旦你理解了如何将你的问题应用于询问状态满足你约束的新状态是否可行,编写新的分析就相当直接了。

编辑:代码发布

我已经将检查器的代码发布到Github,这里。

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

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