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

本文详细介绍了如何通过Clang静态分析器构建自定义检测插件,利用符号执行和污点分析技术识别OpenSSL中的Heartbleed漏洞,包含具体实现策略、代码示例及技术挑战分析。

利用静态分析与Clang发现Heartbleed漏洞

作者:Andrew Ruef
发布日期:2014年4月27日
标签:编译器, 静态分析

背景

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

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

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

策略

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

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

我们的Clang分析器插件应识别程序中通过ntohl写入变量的位置,对其进行污点标记,然后在这些污点值用作memcpy的大小参数时发出警报。但这并不完全准确,因为使用可能是安全的。我们还会在调用位置检查污点值的约束:如果污点值未被程序逻辑以某种方式约束,并且被用作memcpy的参数,则报告漏洞。这也可能遗漏一些漏洞,但我在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/ntohs的调用
  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’中,因此如果污点的符号值在当前路径上约束不足,我们报告一个漏洞。

一些实现陷阱

事实证明,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


页面内容
最近文章
使用Deptective调查你的依赖项
系好安全带,Buttercup,AIxCC的评分回合正在进行中!
使你的智能合约超越私钥风险
Go解析器中意外的安全陷阱
我们审查首批DKLs23库的收获
来自Silence Laboratories的库
© 2025 Trail of Bits。 使用Hugo和Mainroad主题生成。

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