利用静态分析与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的参数,则警报提示错误。这也可能遗漏一些错误,但我在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’中,因此如果受污染的符号值在当前路径上没有足够的约束,我们报告一个错误。

一些实现陷阱

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

页面内容 最近的帖子 Trail of Bits的Buttercup在AIxCC挑战赛中获得第二名 Buttercup现已开源! AIxCC决赛:记录表 攻击者的提示注入工程:利用GitHub Copilot 作为新员工发现NVIDIA Triton中的内存损坏 © 2025 Trail of Bits。 使用Hugo和Mainroad主题生成。

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