程序验证技术可以防止Heartbleed类型的错误发生吗?


9

关于Heartbleed错误,Bruce Schneier在4月15日的Crypto-Gram中写道:““灾难性”是正确的词。在1到10的范围内,这是11。几年前,我读到某个操作系统的内核已经通过现代程序验证系统进行了严格验证。因此,现在是否可以通过应用程序验证技术来防止出现Heartbleed风格的错误,还是这是不现实的,甚至基本上是不可能的?


2
是J. Regehr对这个问题的有趣分析。
Martin Berger 2014年

Answers:


6

要以最简洁的方式回答您的问题-是的,此错误可能已被正式的验证工具捕获。实际上,在大多数规范语言(例如LTL)中,属性“从不发送大于发送的听音的大小的块”相当简单。

问题(这是对形式方法的普遍批评)是您使用的规范是人类编写的。确实,形式化方法只会将寻找错误的挑战从发现错误转变为定义错误是什么。这是一项艰巨的任务。

此外,由于状态爆炸问题,正式验证软件也非常困难。在这种情况下,这尤其重要,因为为了避免状态爆炸,我们多次抽象出界限。例如,当我们想说“每个请求都在100000步之内被授予之后”时,我们需要一个很长的公式,因此我们将其抽象为“每个请求最终都被授予之后”的公式。

因此,在令人沮丧的情况下,即使试图正式化需求,也可以将所讨论的界限抽象化,从而导致相同的行为。

综上所述,可以通过使用形式化方法来避免此错误,但是必须事先指定此属性。


5

诸如Klocwork或Coverity之类的商业程序检查器可能已经找到Heartbleed,因为它是一个相对简单的“忘记做边界检查错误”,这是他们设计要检查的主要问题之一。但是有一种更简单的方法:使用经过良好测试的不透明抽象数据类型,以防止缓冲区溢出。

有许多可用于C编程的“安全字符串”抽象数据类型。我最熟悉的是Vstr。作者James Antill 讨论了为什么您需要一个具有自身构造函数/工厂方法的字符串抽象数据类型以及C的其他字符串抽象数据类型列表


2
Coverity找不到Heartbleed,请参阅John Regehr的分析
Martin Berger 2014年

不错的链接!它证明了故事的真实寓意:程序验证无法弥补设计欠佳(或不存在)的抽象。
Wandering Logic

2
这取决于程序验证的含义。如果您指的是静态分析,那么是的,这始终是一个近似值,这是赖斯定理的直接结果。如果您在交互式定理过程中验证了完整的行为,那么您将得到铸铁保证,该程序符合其规范,但这非常费力。而且您仍然面临规格可能不正确的问题(例如,参见Ariane 5爆炸)。
Martin Berger

1
@MartinBerger:Coverity 现在找到了。
恢复莫妮卡-M.Schröder2014年

4

如果您将运行时边界检查和模糊测试结合起来视为一种“  程序验证技术  ”,那么肯定会捕获到该特定错误

适当的模糊处理将导致现在臭名昭著memcpy(bp, pl, payload);的读取超出存储块pl所属的限制。运行时绑定检查原则上可以捕获任何此类访问,并且在实际情况下,在这种特殊情况下,即使是调试版本的malloc该版本也关心绑定参数的调试memcpy都可以完成此工作(无需在此处与MMU混淆) 。问题是,对每种网络数据包执行模糊测试会很费力。


1
虽然IIRC通常是正确的,但在OpenSSL的情况下,作者实现了自己的内部内存管理,因此memcpy,达到原先由系统请求的(大)区域的真实边界的可能性大大降低malloc
威廉·普赖斯

是的,就在发生错误时memcpy(bp, pl, payload)使用OpenSSL而言,必须检查OpenSSL malloc替代产品使用的范围,而不是系统malloc。这就排除了二进制级别的自动边界检查(至少在不具备malloc替代知识的情况下)。必须使用源代码级向导进行重新编译,例如使用C宏替换令牌malloc或使用任何替代的OpenSSL;似乎memcpy除了MMU技巧非常巧妙之外,我们也需要同样的技巧。
fgrieu 2014年

4

使用更严格的语言不仅可以将目标发布从正确实施实现到正确制定规范。很难做出非常错误却在逻辑上是一致的东西。这就是为什么编译器会捕获许多错误的原因。

正常情况下制定的指针算术是不正确的,因为类型系统实际上并不意味着其应具有的含义。您可以通过使用垃圾回收语言(正常的方法使您也为抽象付出代价)来完全避免此问题。或者,您可以更详细地说明您使用的是哪种类型的指针,以便编译器可以拒绝不一致的内容或无法证明其正确编写的任何内容。这是Rust等某些语言的方法。

构造类型等同于证明,因此,如果您编写了一个忘记了这种类型的类型系统,那么所有事情都会出错。假设有一阵子,当我们声明一个类型时,实际上意味着我们在断言关于变量中内容的真相。

  • int * x; //错误的断言。x存在且不指向int
  • int * y = z; //只有在证明z指向int的情况下才为true
  • *(x + 3)= 5; //仅当(x + 3)指向与x相同的数组中的int时才为true
  • int c = a / b; //仅当b为非零值时才为true,例如:“ nonzero int b = ...;”
  • 可为空的int * z = NULL; // nullable int *与int *不同
  • int d = * z; //错误的断言,因为z可为空
  • if(z!= NULL){int * e = z; } //好,因为z不为null
  • 免费(y); int w = * y; //错误的断言,因为y在w处不再存在

在这个世界上,指针不能为null。NullPointer取消引用不存在,并且不必在任何地方检查指针是否为空。相反,“ nullable int *”是另一种类型,可以将其值提取为null或指针。这意味着在非空假设开始的那一刻,您要么记录您的异常,要么就走一个空分支。

在这个世界上,也不存在数组越界错误。如果编译器无法证明它在范围之内,请尝试重写以使编译器可以证明它。如果不能,那么您将不得不在该位置手动放置一个假设。以后编译器可能会发现与此矛盾。

同样,如果您没有未初始化的指针,那么您将没有指向未初始化内存的指针。如果您有一个指向释放内存的指针,则编译器应拒绝该指针。在Rust中,可以使用不同的指针类型来使这些证明合理。有专门拥有的指针(即没有别名),指向深度不变的结构的指针。默认存储类型是不可变的,等等。

还有一个问题,就是在协议(包括接口成员)上强制实施定义明确的语法,以将输入表面积限制在预期的范围之内。关于“正确性”的事情是:1)摆脱所有未定义的状态2)确保逻辑一致性。从正确性的角度来看,到达那里的困难与使用非常差的工具有很大关系。

这就是为什么两个最差的做法是全局变量和陷阱。这些东西可以防止在任何事物周围放置前置/后置/不变条件。这也是为什么类型如此有效的原因。随着类型变得更强大(最终使用从属类型来考虑实际值),它们自身就成为了建设性的正确性证明。使不一致的程序无法编译。

请记住,这不仅是愚蠢的错误。这也与保护代码库免受聪明的渗透者有关。在某些情况下,您不得不拒绝提交而没有令人信服的机器生成的重要属性证明,例如“遵循正式指定的协议”。



1

自动/正式软件验证很有用,在某些情况下可能会有所帮助,但正如其他人指出的那样,这不是万灵丹。一个人可能会指出,OpenSSL易受攻击,因为它是开源的,但尚未在发布之前进行商业和行业范围的广泛使用,并且没有经过同行的严格审查(一个人想知道该项目上是否有付费开发人员)。缺陷基本上是通过发布后的代码审查发现的,并且代码显然是在发布前进行审查的(请注意,尽管可能无法跟踪谁进行了内部代码审查)。尤其是在发布高度敏感的代码(可能会更好地进行跟踪)之前,理想的情况是,最好是通过代码复习(最好是在其他情况下)进行“令人心动的教学时机”(尤其是在其他方面)。也许OpenSSL现在将受到更多审查。

媒体详细介绍了其起源:

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.