实际上在C中未定义的行为可能会发生什么
我读了很多关于未定义行为(UB)的文章,但所有人都谈论理论。 我想知道在实践中会发生什么,因为包含UB的程序实际上可能会运行。
我的问题涉及类似unix的系统,而不是嵌入式系统。
我知道不应该编写依赖于未定义行为的代码。 请不要发送这样的答案:
- 一切都会发生
- 守护进程可以飞出你的鼻子
- 电脑可以跳起来并着火
特别是对于第一个,它不是真的。 通过执行有符号整数溢出显然无法获得root权限。 我这只是为了教育目的而要求这个。
问题A)
资源
实现定义的行为:未指定的行为,其中每个实现记录了如何进行选择
编译implementation
了吗?
问题B)
*"abc" = '\0';
除了发生段错之外的其他事情,我是否需要破坏我的系统? 即使不可预测,究竟会发生什么? 第一个字节可以设置为零吗? 还有什么,怎么样?
问题C)
int i = 0; foo(i++, i++, i++);
这是UB,因为未定义参数的计算顺序。 对。 但是,当程序运行时,谁决定参数的评估顺序:是编译器,操作系统还是别的什么?
问题D)
资源
$ cat test.c int main (void) { printf ("%d\n", (INT_MAX+1) < 0); return 0; } $ cc test.c -o test $ ./test Formatting root partition, chomp chomp
根据其他SO用户,这是可能的。 怎么会发生这种情况? 我需要一个破碎的编译器吗?
问题E)
使用与上面相同的代码。 实际上会发生什么,除了表达式(INT_MAX+1)
产生一个随机值?
问题F)
GCC -fwrapv
选项是否定义了有符号整数溢出的行为,或者它是否仅使GCC假定它将环绕但它实际上可能不会在运行时环绕?
问题G)
这个涉及嵌入式系统。 当然,如果PC跳到意外的位置,可以将两个输出连接在一起并产生短路(例如)。
但是,在执行类似于此的代码时:
*"abc" = '\0';
PC不会被引向一般exception处理程序吗? 或者我错过了什么?
实际上,大多数编译器都使用以下任一方式使用未定义的行为:
- 在编译时打印警告,通知用户他可能犯了错误
- 推断变量值的属性并使用它们来简化代码
- 执行不安全的优化,只要它们只打破未定义行为的预期语义
编译器通常不是恶意的。 利用未定义行为的主要原因通常是从中获得一些性能优势。 但有时这可能涉及消除完全死代码。
A)是的。 编译器应记录他选择的行为。 但通常很难预测或解释UB的后果。
B)如果字符串实际上在内存中实例化并且在可写页面中(默认情况下它将在只读页面中),那么它的第一个字符可能变成空字符。 最有可能的是,整个表达式将作为死代码抛出,因为它是一个临时值,从表达式中消失。
C)通常,评估顺序由编译器决定。 在这里它可能决定将其转换为i += 3
(或者如果它是愚蠢的,则i = undef
)。 CPU可以在运行时重新排序指令,但如果它破坏了其指令集的语义,则保留编译器选择的顺序(编译器通常不能进一步向下转发C语义)。 寄存器的增量不能与该相同寄存器的其他增量并行或并行执行。
D)当你检测到未定义的行为时,你需要一个打印“格式化根分区,chomp chomp”的傻编译器。 最有可能的是,它会在编译时打印一个警告,用他选择的常量替换表达式,并生成一个二进制文件,只需用该常量执行打印。
E)它是一个语法正确的程序,因此编译器肯定会生成一个“工作”二进制文件。 从理论上说,这个二进制文件可以和你在互联网上下载并运行的任何二进制文件具有相同的行为。 最有可能的是,你得到一个直接退出的二进制文件,或打印上述消息并立即退出。
F)它告诉GCC假设使用2的补语语义在C语义中包含有符号整数。 因此,它必须生成一个在运行时环绕的二进制文件。 这很容易,因为无论如何大多数架构都有这种语义。 C具有UB的原因是编译器可以假定a + 1 > a
,这对于certificate循环终止和/或预测分支是至关重要的。 这就是为什么使用有符号整数作为循环归纳变量可以导致更快的代码,即使它被映射到硬件中完全相同的指令。
G)未定义的行为是未定义的行为。 生成的二进制文件确实可以运行任何指令,包括跳转到未指定的位置……或者干净地触发中断。 最有可能的是,您的编译器将摆脱不必要的操作。
在我看来,面对未定义的行为可能发生的最糟糕的事情是明天会有所不同 。
我喜欢编程,但我也喜欢完成一个程序,然后继续处理其他事情。 我不喜欢不断修补我已编写的程序,让他们面对因硬件,编译器或其他情况不断变化而自发发展的错误。
所以当我编写程序时,它还不够工作。 它必须以正确的理由工作。 我必须知道它有效,它将在下周,下个月和明年继续工作。 它似乎无法正常工作,对于迄今为止我运行它的必要有限的测试用例集给出了明显正确的答案。
这就是为什么未定义的行为是如此有害的原因:今天它可能做得非常好,然后明天做一些完全不同的事情,当时我不在捍卫它。 行为可能会更改,因为有人在稍微不同的计算机上运行它,或者使用更多或更少的内存,或者在非常不同的输入集上运行它,或者在使用不同的编译器重新编译它之后。
另见这个答案的第三部分(部分以“现在,还有一件事,如果你还在我身边”)开头。
通过执行有符号整数溢出显然无法获得root权限。
为什么不?
如果你假设有符号整数溢出只能产生一些特定值,那么你就不可能以这种方式获得root。 但是关于未定义行为的事情是优化编译器可以假设它不会发生,并基于该假设生成代码。
操作系统有bug。 除其他外,利用这些错误可以调用权限提升 。
假设您使用有符号整数算法来计算数组的索引。 如果计算溢出,您可能会意外地破坏预期数组之外的一些任意内存块。 这可能会导致您的程序做任意不好的事情。
如果一个bug可以被故意利用(并且恶意软件的存在清楚地表明这是可能的),那么它至少可能被意外地利用。
另外,请考虑这个简单的人为计划:
#include #include int main(void) { int x = INT_MAX; if (x < x + 1) { puts("Code that gets root"); } else { puts("Code that doesn't get root"); } }
在我的系统上,它打印出来
Code that doesn't get root
当用gcc -O0
或gcc -O1
编译时,和
Code that gets root
用gcc -O2
或gcc -O3
。
我没有签名整数溢出触发安全漏洞的具体例子(如果我有一个,我不会发布这样的例子),但显然是可能的。
未定义的行为原则上可以使您的程序意外地执行以相同权限启动的程序可能故意执行的任何操作。 除非您使用的是无错操作系统,否则可能包括权限提升,删除硬盘驱动器或向您的老板发送令人讨厌的电子邮件。
过去,你可以指望编译器做一些“合理”的事情。 但是,越来越多的编译器在编写未定义的代码时真正利用他们的许可来做奇怪的事情。 以效率为名,这些编译器引入了非常奇怪的优化,这些优化并没有做任何接近你可能想要的事情。
阅读这些post:
- Linus Torvalds描述了一个内核错误,它比 gcc利用未定义的行为更糟糕
- 关于未定义行为的LLVM博客文章 (三部分中的第一部分,也是两部分,三部分)
- John Regehr撰写的另一篇精彩博文 (也是三部分中的第一部分: 二 , 三部分 )