在我们系列文章的第一篇中,我们讨论了主动解决内存安全问题的必要性。工具和指南显然无法预防这类漏洞;过去十多年来,被分配CVE编号的漏洞中,内存安全问题所占比例几乎保持不变。我们认为,使用内存安全语言可以从根本上缓解这一问题,而这是工具和培训所无法做到的。
在本文中,我们将探讨一些在微软产品中发现的真实漏洞案例(经过测试和静态分析后),这些漏洞可以通过使用内存安全语言来预防。
内存安全
内存安全是编程语言的一种属性,其中所有内存访问都有明确定义。当今使用的大多数编程语言都是内存安全的,因为它们使用了某种形式的垃圾回收。然而,系统级语言(即用于构建其他软件依赖的底层系统的语言,如操作系统内核、网络堆栈等)通常无法承担垃圾回收器等重型运行时开销,因此通常不具备内存安全性。
正如我们在前一篇文章中指出的,微软修复并分配CVE(通用漏洞与暴露)编号的安全漏洞中,约70%的根本原因是内存安全问题。尽管我们采取了包括严格的代码审查、培训、静态分析等在内的多种缓解措施。
~70%的微软每年分配的CVE漏洞仍然是内存安全问题
尽管许多经验丰富的程序员可以编写正确的系统级代码,但很明显,无论采取多少缓解措施,使用传统的系统级编程语言在大规模开发中几乎不可能编写出内存安全的代码。让我们看一些由不具备内存安全保证的语言导致的实际安全漏洞案例。
空间内存安全
空间内存安全指的是确保所有内存访问都在被访问类型的边界内。实现这一点需要代码跟踪这些大小,并正确检查所有内存操作是否符合这些大小。
检查可能因控制流的边缘情况而缺失,或者由于未考虑整数符号、整数提升或整数溢出的复杂性而实现错误。让我们看看微软Edge中的一个例子,由Alexandru Pitis发现(CVE-2018-8301):
[0]处的检查是正确的。然而,[1]处可能会修改字符串的大小,从而使检索的偏移量无效。[2]处的函数调用了一个复制函数,最终导致与预期偏移量不同,触发了可被利用的越界写入。
修复这个漏洞很简单:将“偏移检查”移到使用时间更近的位置。问题在于,这在复杂的代码库中非常容易出错,简单的代码重构可能会再次打开这个漏洞。现代C++提供了span
来强制边界检查的数组访问。不幸的是,这不是默认行为,因此开发者需要主动选择使用它。在实践中,强制使用这种构造非常困难。
如果语言能自动为我们跟踪和验证大小,我们作为程序员就不再需要担心如何正确实现这些检查,并且可以确定我们的代码中不存在这些问题。
时间内存安全
时间内存安全指的是确保指针在解引用时仍然指向有效的内存。
一种常见的释放后使用模式是从某个内存中获取一个本地指针引用,执行一系列可能导致内存释放或移动的复杂操作,使本地指针变得陈旧,然后在不再有效时解引用它。考虑Edge中的这个源代码示例,由Steven Hunter发现(CVE-2017-8596):
这个漏洞之所以存在,是因为许多复杂API之间的交互以及程序员无法在整个代码库中强制执行内存所有权。在[0]处,程序获取了一个由JavaScript对象拥有的缓冲区的指针。然后在[1]处,由于语言的复杂性,为了获取另一个变量,可能会执行更多的JavaScript代码。在[2]处,它将使用缓冲区和宽度创建一个新的JavaScript对象,内容来自该指针。
问题在于:
- 程序结合使用了垃圾回收和手动内存管理。垃圾回收器跟踪JavaScript对象,但它不知道是否存在指向对象内部部分的指针。
- 由于
VarToInt
重新进入JavaScript,JS程序可以修改状态并清除它别名指针的所有者。
这个漏洞类似于迭代器失效错误,如果状态被修改,指向JavaScript内部状态的所有指针可能会变成悬垂指针。这个问题可以通过多种方式解决。然而,在像浏览器这样复杂的程序中,静态证明这种错误不会再次发生几乎是不可能的。问题的根源在于指向可变状态的别名指针。C和C++没有工具来强制防止此类错误。不过,建议始终使用“智能指针”来跟踪内存所有权。
数据竞争
当单个进程中的两个或多个线程并发访问同一内存位置,至少有一个访问是写操作,并且线程没有使用任何独占锁来控制对该内存的访问时,就会发生数据竞争。当考虑跨多线程执行的共享数据时,保持空间和时间内存安全变得更加困难和容易出错。即使是未同步内存共享的短暂时间窗口,也可能允许另一个线程修改用于引用内存的数据。这会导致,除其他外,检查时间与使用时间漏洞,从而触发空间和时间内存安全漏洞。
Jordan Rabet在Blackhat 2018上展示的VMSwitch漏洞显示了数据竞争的一种潜在影响。这段代码在虚拟机向主机发送特定消息时被调用。这意味着它可以与其他控制消息和数据包的处理并行调用。这是有问题的,因为这些控制消息的处理程序使用了在没有锁定情况下被修改的信息[0]。
查看下一个片段,它用于多个控制消息完成处理程序,我们可以看到更新的信息是如何被使用的:
由于这种未同步的访问,新的缓冲区基址可能与旧的opHostData->AllocatedRanges
[1]值一起使用,导致越界写入[3]。
防止这类漏洞需要对不同线程访问的数据结构进行锁定,以完成其处理所需的时间。然而,在C++中,没有简单的方法可以静态强制执行这些类型漏洞的检查。
我们能做什么
解决上述问题需要采取多种措施。C++中的“现代”构造如span<T>
可以防止至少某些类别的内存安全问题,其他现代C++功能如智能指针应尽可能使用。然而,现代C++仍然不完全具备内存安全性和数据竞争自由。更重要的是,这些功能的使用依赖于程序员始终“做正确的事情”,这在大型和模糊的代码库中可能无法强制执行。C++还缺乏将不安全代码包装在安全抽象中的好工具,这意味着虽然在局部级别可以强制执行正确的编码实践,但在C或C++中构建安全组合的软件组件可能极其困难。
除此之外,只要有可能,软件最终应迁移到完全内存安全的语言,如C#或F#,它们通过运行时检查和垃圾回收确保内存安全。毕竟,只有在必要时才应承担思考内存管理的复杂性。
如果有正当理由需要像C++这样的语言的速度、控制和可预测性,看看是否可以迁移到内存安全的系统级编程语言。在我们的下一篇文章中,我们将探讨为什么我们认为Rust编程语言是目前行业在可能情况下采用的最佳选择,因为它能够以内存安全的方式编写系统级程序。
Ryan Levick,首席云开发倡导者
Sebastian Fernandez,安全软件工程师