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