未定义的行为通常是C程序员的事情(2011)

2020-09-04 06:29:23

在本系列的第1部分中,我们讨论了什么是未定义行为,以及它如何允许C和C++编译器生成比安全语言更高性能的应用程序。这篇帖子讨论了C语言到底是如何不安全的,解释了一些不确定的行为可能导致的一些非常令人惊讶的影响。在第3部分中,我们讨论友好的编译器可以做些什么来减轻一些意外,即使它们不是必需的。我喜欢把这称为为什么未定义的行为对C程序员来说通常是一件可怕和可怕的事情。:-)日语中提供的翻译现代编译器优化器包含许多优化,这些优化以特定的顺序运行,有时会迭代,并随着编译器随时间的发展而变化(例如,新版本的推出)。此外,不同的编译器通常具有本质上不同的优化器。由于优化运行在不同的阶段,因此由于先前的优化更改了代码,可能会出现紧急效果。让我们看一个愚蠢的示例(从Linux内核中发现的一个可利用漏洞简化而来),使其更加具体:void CONTAINS_NULL_CHECK(int*P){int dead=*P;if(P==0)return;*P=4;}。

在本例中,代码清楚地检查空指针。如果编译器恰好在通过冗余空检查消除之前运行失效代码消除,则我们会看到代码在以下两个步骤中演变:void CONTAINS_NULL_CHECK_AFTER_DCE(INT*P){//INT DEAD=*P;//被优化器删除。如果(P==0)返回;*P=4;}。

然后:void CONTAINS_NULL_CHECK_AFTER_DCE_AND_RNCE(INT*P){IF(P==0)//NULL检查不冗余,并保留。返回;*P=4;}。

但是,如果优化器的结构恰好不同,那么它可以在运行DCE之前运行RNCE。这将给我们提供这两个步骤:void CONTAINS_NULL_CHECK_AFTER_RNCE(int*P){int Dead=*P;if(False)//P此时已解除引用,因此它不能为NULL RETURN;*P=4;}。

然后运行死代码消除:void CONTAINS_NULL_CHECK_AFTER_RNCE_AND_DCE(int*P){//int Dead=*P;//if(False)//return;*P=4;}。

对很多人来说(合情合理!)。程序员,从这个函数中删除NULL检查将是非常令人惊讶的(他们可能会对编译器提出错误:)。但是,根据该标准,";contains_null_check_after_DCE_and_RNCE";和";contains_null_check_after_RNCE_and_DCE";都是";CONTAINS_NULL_CHECK";的完全有效的优化形式,并且涉及的这两个优化对于各种应用程序的性能都很重要。虽然这是一个有意设计的简单示例,但这种事情在内联中经常发生:内联函数通常会暴露出大量的二次优化机会。这意味着,如果优化器决定内联一个函数,各种局部优化都会生效,从而改变代码的行为。根据标准,这既是完全有效的,而且在实践中对性能也很重要。C系列编程语言用于编写广泛的安全关键代码,例如内核、setuid守护进程、Web浏览器等等。此代码暴露在恶意输入中,错误可能导致各种可利用的安全问题。C语言的一个被广泛引用的优点是,当您阅读代码时,它相对容易理解正在发生的事情。但是,未定义的行为会使此属性消失。毕竟,大多数程序员会认为";CONTAINS_NULL_CHECK";会在上面执行NULL检查。虽然这种情况不太可怕(如果通过NULL检查,代码可能会在存储中崩溃,这相对容易调试),但有很多看起来非常合理的C片段是完全无效的。这个问题已经影响了许多项目(包括Linux内核、openssl、glibc等),甚至导致CERT发布了针对GCC的漏洞说明(尽管我个人认为所有广泛使用的优化C编译器都容易受到此漏洞的影响,不仅仅是GCC)。让我们看一个例子。考虑一下这段精心编写的C代码:void process_thing(Int Size){//catch整数溢出。IF(SIZE&gT;SIZE+1)ABORT();...//从此代码跳过错误检查。Char*string=malloc(size+1);read(fd,string,size);string[size]=0;do_omething(String);free(String);}。

这段代码正在检查以确保malloc足够大,可以容纳从文件读取的数据(因为需要添加NUL终止符字节),如果发生整数溢出错误就退出。然而,这正是我们在前面给出的示例,在该示例中,编译器被允许(有效地)优化检查。这意味着编译器完全可以将其转换为:void process_omething(int*data,int size){char*string=malloc(size+1);read(fd,string,size);string[size]=0;do_omething(String);

Clang优化为:允许这样做,因为调用空指针是未定义的,这允许它假定必须在调用()之前调用set()。在这种情况下,开发人员忘记调用";set";,没有使用空指针取消引用而崩溃,并且当其他人进行调试构建时,他们的代码崩溃。结果是这是一个可以修复的问题:如果您怀疑像这样发生了什么奇怪的事情,请尝试构建at-O0,在那里编译器进行任何优化的可能性要小得多。我们见过许多这样的情况:当使用较新的LLVM构建应用程序时,或者当应用程序从GCC移动到LLVM时,看起来正在工作的应用程序突然崩溃。虽然LLVM本身偶尔也会有一两个bug:-),但这通常是因为应用程序中现在由编译器公开的潜在bug。这可能以各种不同的方式发生,两个例子是:1.一个未初始化的变量,它在";之前被幸运地初始化为零,现在它共享一些不是零的其他寄存器。这通常通过寄存器分配更改来暴露。2.堆栈上的数组溢出,它开始破坏实际重要的变量,而不是已死的变量。当编译器重新安排它在堆栈上打包东西的方式,或者更加积极地为具有非重叠生存期的值共享堆栈空间时,就会暴露出这一点。需要认识到的重要而可怕的一点是,几乎任何基于未定义行为的优化都可能在未来的任何时间被有错误的代码触发。内联、循环展开、内存提升和其他优化将不断改进,它们存在的一个重要原因是公开像上面这样的二次优化。对我来说,这是非常不满意的,部分原因是编译器不可避免地会受到指责,但也因为这意味着巨大的C代码体是等待爆炸的地雷。更糟的是因为..。使地雷变得更糟糕的是,没有好的方法来确定大规模应用程序是否没有未定义的行为,从而在未来不容易受到破坏。有很多有用的工具可以帮助找到一些错误,但是没有任何工具可以完全保证您的代码在将来不会崩溃。让我们看看这些选项中的一些,以及它们的优缺点:1.Valgrind memcheck工具是查找各种未初始化变量和其他内存错误的绝佳方法。Valgrind是有限的,因为它相当慢,它只能找到仍存在于生成的机器码中的错误(因此它无法找到优化器删除的内容),并且它不知道源语言是C语言(因此它无法发现移位超出范围或带符号的整数溢出错误),因此Valgrind是有限的,因为它速度很慢,只能找到仍存在于生成的机器码中的错误(因此它无法找到优化器删除的内容),并且不知道源语言是C语言(因此它无法发现移位超出范围或带符号的整数溢出错误)。2.Clang有一个实验-fcatch-未定义的行为模式,它插入运行时检查来查找违规行为,如移位量超出范围、一些简单的数组超出范围的错误等。这是有限的,因为它会减慢应用程序的运行时速度,而且它不能帮助您进行随机指针取消引用(如Valgrind可以),但它可以发现其他重要的错误。Clang还完全支持-ftrapv标志(不要与-fwrapv混淆),该标志会导致在运行时捕获带符号的整数溢出错误(GCC也有这个标志,但根据我的经验,它完全不可靠/有缺陷)。以下是-fcatch-unfined-behavior的快速演示:$cat T.C int Foo(Int I){int x[2];x[i]=12;return x[i];}int main(){return foo(2);}$clang T.C$./a.out$clang T.C-fcatch-unfinded-behavior$./a.out非法指令。

3.编译器警告消息有助于查找此类错误的某些类别,如未初始化的变量和简单的整数溢出错误。它有两个主要限制:1)它在执行时没有关于您的代码的动态信息,2)它必须运行得非常快,因为它所做的任何分析都会减慢编译时间。4.Clang Static Analyzer执行更深入的分析以尝试查找错误(包括使用未定义的行为,如空指针取消引用)。您可以将其视为生成增强的编译器警告消息,因为它不受正常警告的编译时间约束。静态分析器的主要缺点是:1)它在运行时没有关于您的程序的动态信息;2)对于许多开发人员来说,它没有集成到正常的工作流中(尽管它集成到Xcode3.2和更高版本中是非常棒的)。5.LLVM";Klee&34;子项目使用符号分析来尝试一段代码中的每条可能路径,以查找代码中的错误,并生成测试用例。这是一个很棒的小项目,主要受到在大型应用程序上运行的不切实际的限制。6.虽然我从未尝试过,但Chucky Ellison的C-Semantics工具