下载本文的 PDF 版本 PDF

未初始化读取

理解 C 语言提议的修订


Robert C. Seacord,NCC 集团


大多数开发者都明白,在 C 语言中读取未初始化的变量是一种缺陷,但有些人仍然会这样做——例如,为了创建熵。在当前版本的 C 标准 (C11)3 中,读取未初始化对象时会发生什么情况尚未确定。为了解决计划中的 C2X 标准修订中的这些问题,已经提出了各种建议。因此,现在是了解现有行为以及提议的标准修订,从而影响 C 语言发展的良好时机。鉴于未初始化读取的行为在 C11 中尚未确定,谨慎的做法是消除代码中的未初始化读取。

本文描述了对象初始化不确定值陷阱表示,然后检查了示例程序,这些程序说明了这些概念对程序行为的影响。

初始化

理解对象如何以及何时初始化对于理解读取未初始化对象的行为是必要的。

标识符声明为无链接的对象(默认情况下,文件作用域对象具有内部链接)且没有存储类说明符 static 的对象具有自动存储周期。对象的初始值是不确定的。如果为对象指定了初始化,则每次在块的执行中到达声明或复合字面量时都会执行初始化;否则,每次到达声明时,该值都会变为不确定的。

C11 标准4 的 6.7.9 段第 10 小段描述了具有静态或线程存储周期的对象是如何初始化的

如果具有自动存储周期的对象未显式初始化,则其值是不确定的。如果具有静态或线程存储周期的对象未显式初始化,则
— 如果它具有指针类型,则初始化为 null 指针;
— 如果它具有算术类型,则初始化为(正或无符号)零;
— 如果它是一个聚合,则每个成员都根据这些规则(递归地)初始化,并且任何填充位都初始化为零位;
— 如果它是一个联合,则第一个命名的成员根据这些规则(递归地)初始化,并且任何填充位都初始化为零位。

许多动态分配函数不初始化内存。例如,malloc 函数为其大小由其参数指定的对象分配空间,并且其值是不确定的。对于 realloc 函数,新对象中超出旧对象大小的任何字节都具有不确定的值。

不确定值

在所有情况下,未初始化的对象都具有不确定值。C 标准规定不确定值可以是未指定值或陷阱表示。未指定值是相关类型的有效值,C 标准对在任何实例中选择哪个值没有要求。“在任何实例中”这个短语并不清楚。英文单词 instance 的定义是“任何事物的案例或发生”,但从上下文中不清楚正在发生什么。明显的解释是发生是读取。9 陷阱表示是不一定表示对象类型值的对象表示。请注意,未指定值不能是陷阱表示。

如果对象的存储值具有陷阱表示,并且被不具有字符类型的左值表达式读取,则行为是未定义的。因此,自动变量可以被赋予陷阱表示而不会导致未定义的行为,但是直到将适当的值存储在其中之后才能读取该变量的值。

附件 J.2 “未定义行为”不完整地总结了以下情况下的未定义行为

• 陷阱表示被不具有字符类型的左值表达式读取。

• 具有自动存储周期的对象的值在其不确定时被使用。

第二个未定义行为更通用(至少对于具有自动存储周期的对象而言),因为不确定值包括所有未指定值和陷阱表示。这(不正确地)意味着,除非陷阱表示被不具有字符类型的左值表达式读取,否则从具有已分配、静态或线程存储周期的对象读取不确定值是良好定义的行为。

根据现任 WG14 召集人 David Keaton 的说法,在 C 语言中,读取任何存储周期的不确定值都是隐式的未定义行为,并且附件 J.2 中的描述(非规范性)是不完整的。这种未定义行为的修订定义可以表述为“对象的值在其不确定时被读取。”

不幸的是,委员会或更广泛的社区对于未初始化读取没有共识。Memarian 和 Sewell 对 323 位 C 语言专家进行了一项调查,以了解他们对系统软件在实践中依赖的属性以及当前实现提供的属性的看法。5 该调查收集了以下对问题的回答:读取未初始化的变量或 struct 成员(使用当前主流编译器)是

• 未定义行为?139 人(43%)

• 会使涉及该值的任何表达式的结果不可预测吗?42 人(13%)

• 会给出任意且不稳定的值(如果再次读取,可能会得到不同的值)?21 人(6%)

• 会给出任意但稳定的值(如果再次读取,会得到相同的值)?112 人(35%)

陷阱表示

即使对于专家级 C 程序员和编译器编写者而言,陷阱表示也并非总是被很好地理解。6 陷阱表示是不一定表示对象类型值的对象表示。获取陷阱表示可能会执行陷阱,但不是必需的。在 C 语言中执行陷阱会中断程序的执行,以至于不再执行任何操作。

陷阱表示被引入 C 语言是为了帮助调试。未初始化对象可以被赋予陷阱表示,以便未初始化读取会触发陷阱,从而在开发期间被程序员检测到。一些编译器编写者更希望完全消除陷阱表示,而只是将任何未初始化读取都定义为未定义行为——理论是,为什么要因为明显损坏的代码而阻止编译器优化?反驳的论点是,为什么要优化明显损坏的代码,而不只是发出致命诊断?

无符号整数类型

C 标准规定,对于除 unsigned char 之外的无符号整数类型,对象表示分为值位填充位(其中填充位是可选的)。无符号整数类型使用称为值表示的纯二进制表示,但是任何填充位的值都是未指定的。根据 C 标准,填充位的某些组合可能会生成陷阱表示——例如,如果一个填充位是奇偶校验位

奇偶校验位充当对一组二进制值的检查,以这样一种方式计算,即集合中 1 的数量加上奇偶校验位应始终为偶数(或偶尔应始终为奇数)。早期的计算机有时需要使用奇偶校验 RAM,并且奇偶校验检查无法禁用。从历史上看,故障内存相对常见,并且明显的奇偶校验错误并不少见。从那时起,随着简单的奇偶校验 RAM 逐渐退出使用,错误变得不太明显。错误现在是不可见的,因为它们未被检测到,或者它们通过 ECC(纠错码)RAM 无形地得到纠正。ECC 内存可以检测和纠正最常见的内部数据损坏类型。现代 RAM 被认为(有充分的理由)是可靠的,并且错误检测 RAM 已在很大程度上退出非关键应用的使用。奇偶校验位和 ECC 位由内存处理单元看到,但程序员看不到。

对已知值进行的任何算术运算都不会生成陷阱表示,除非作为溢出等异常条件的一部分,而这不会发生在无符号类型中。填充位的所有其他组合都是值位指定的值的替代对象表示。读取陷阱表示具有未定义的行为。但是,没有已知的当前架构为存储在内存中的任何类型的无符号整数(_Bool 除外)实现陷阱表示。因此,对于大多数无符号整数类型,陷阱表示是 C 标准的过时功能。

_Bool 类型

_Bool 类型是无符号类型的一种特殊情况,在许多架构上都具有实际的内存可表示陷阱表示。_Bool 类型的值通常占用一个字节。该字节中除 0 或 1 以外的值都是陷阱表示。因此,实现可以假定对 _Bool 对象的字节读取产生值 0 或 1,并基于该假设进行优化。GCC(GNU 编译器集合)是以这种方式行为的实现示例。

由于将任何非零值转换为 _Bool 类型都会导致值 1,因此需要类型双关来创建 _Bool 类型的对象,该对象包含不表示 _Bool 类型的任何确定性位模式(因此在当前标准中是陷阱表示)。

未定义行为可能源于推论,优化可能随之而来。例如,考虑以下代码

_Bool a, b, c, d, e;
switch (a | (b << 1) | (c << 2) | (d << 3) | (e << 4))

值范围传播可能会推断出 switch 参数的范围在 0 到 31 之间,并在生成表跳转时使用该推断,以便如果其中一个值超出范围,并且因此 switch 参数超出该范围,则跳转到任意地址。没有现有实现被证明会完全省略表跳转的范围测试。GCC 将优化掉 default 情况,并为超出范围的参数跳转到其他情况之一。但是,C 标准允许省略范围测试,并且可能允许定义了 __STDC_ANALYZABLE__ 的实现这样做。

考虑以下代码

unsigned char f(unsigned char y) {
  _Bool a; /* 故意未初始化 */
  unsigned char x[2] = { 0, 0};
  x[a] = 1;
}

在此示例中,对于未定义 __STDC_ANALYZABLE__ 的实现,写入 x[a] 可能会导致越界存储。

有符号整数类型

对于有符号整数类型,对象表示的位分为三组:值位、填充位和符号位。填充位不是必需的;特别是 signed char 不能有填充位。如果符号位为零,则不会影响结果值。

C 标准支持有符号整数值的三种表示形式:符号和幅度、ones' complement 和 two's complement。实现可以自由选择使用哪种表示形式,尽管 two's complement 是最常见的。C 标准还规定,对于符号和幅度以及 two's complement,符号位为 1 且所有值位为零的值可以是陷阱表示或正常值。对于 ones' complement,符号位为 1 且所有值位为 1 的值可以是陷阱表示或正常值。在符号和幅度以及 ones' complement 的情况下,如果此表示形式是正常值,则称为负零。对于 two's complement 变量,这是该类型的最小值(最负值)。

大多数 two's complement 实现将所有表示形式都视为正常值。同样,大多数符号幅度实现和 ones' complement 实现将负零视为正常值。C 标准委员会无法找到任何将这些表示形式视为陷阱值的当前实现,因此这是 C 标准中可能未使用且已过时的功能。

指针类型

整数可以转换为任何指针类型。结果是实现定义的,可能未正确对齐,可能未指向引用类型的实体,并且可能是陷阱表示。用于将指针转换为整数和将整数转换为指针的映射函数旨在与执行环境的寻址结构保持一致。

浮点类型

IEC 605592 要求两种 NaN(非数字):quiet 和 signaling。C 标准委员会仅采用了 quiet NaN。它没有采用 signaling NaN,因为人们认为它们的实用性对于支持它们所需的工作而言太有限了。7

IEC 60559 浮点标准规定了 quiet 和 signaling NaN,但这些术语也可以应用于某些非 IEC 60559 实现。例如,VAX 保留操作数和 Cray 不定值是 signaling NaN。在 IEC 60559 标准算术中,触发 signaling NaN 参数的操作通常返回 quiet NaN 结果,前提是没有发生陷阱。对 signaling NaN 的完全支持意味着可重启的陷阱,例如 IEC 60559 浮点标准中指定的可选陷阱。C 标准支持 quiet NaN 的主要用途“处理其他棘手的情况,例如为 0.0/0.0 提供默认值”,正如 IEC 60559 中所述。

NaN 的其他应用可能被证明是有用的。NaN 的可用部分已用于编码辅助信息——例如,关于 NaN 的来源。Signaling NaN 可能是填充未初始化存储的候选者,它们的可用部分可以区分未初始化的浮点对象。IEC 60559 signaling NaN 和陷阱处理程序可能为维护诊断信息或实现特殊算术提供钩子。

但是,C 语言对 signaling NaN 或可以编码在 NaN 中的辅助信息的支持是有问题的。陷阱处理在不同实现之间差异很大。实现机制可能会以神秘的方式触发或未能触发 signaling NaN。IEC 60559 浮点标准建议 NaN 传播,但这不是必需的,并且并非所有实现都这样做。此外,浮点标准未能通过格式转换指定 NaN 的内容。使 signaling NaN 可预测会施加超出预期收益的优化限制。由于这些原因,C 标准既未定义 signaling NaN 的行为,也未指定 NaN 尾数的解释。

x86 扩展精度格式是一种 80 位格式,最初在 Intel 8087 数学协处理器中实现,并受所有基于 x86 设计的包含浮点单元的处理器支持。伪无穷大、伪零、伪 NaN、非正规数和伪非正规数都是陷阱表示。

Itanium CPU 的 NaT(非事物)标志

Itanium CPU 为每个整数寄存器都有一个 NaT(非事物)标志。NaT 标志用于控制推测执行,并且可能停留在使用前未正确初始化的寄存器中。8 位值可能有多达 257 个不同的值:0-255 和 NaT 值。但是,C99 明确禁止 unsigned char 的 NaT 值。NaT 标志在 C 语言中不是陷阱表示,因为陷阱表示是对象表示,而对象是执行环境中的数据存储区域,而不是寄存器标志。8

为了解释 NaT 标志的可能性,而不是将 Itanium NaT 标志归类为陷阱表示,以下语言已添加到 C11 6.3.2.1 段第 2 小段

如果左值指定一个具有自动存储周期且可以声明为寄存器存储类(从未获取其地址)的对象,并且该对象未初始化(未用初始化器声明,并且在使用前未对其执行任何赋值),则行为是未定义的。

将此句子添加到 C11 中是为了支持 Itanium NaT 标志,以便为编译器开发人员提供自由度,以将所有实现上的适用未初始化读取视为未定义行为。即使对于直接读取 unsigned char 类型的对象,此未定义行为也适用。unsigned char 类型通常在标准中具有特殊状态,因为存储在非位字段对象中的值可以复制到 unsigned char [n] 类型的对象中[例如,通过 memcpy],其中 n 是该类型对象的大小。

示例程序

前面关于陷阱表示的回顾清楚地表明,unsigned char 类型是最有趣的案例。考虑以下代码

unsigned char f(unsigned char y) {
  unsigned char x[1]; /* 故意未初始化 */
  if (x[0] > 10)
   return y/x[0];
 else
   return 10;
}

unsigned char 数组 x 具有自动存储周期,因此未初始化。由于它被声明为数组,因此获取了 x 的地址,这意味着读取是定义的行为。虽然编译器可以避免获取地址,但它不能将代码的语义从未指定值更改为未定义行为。因此,不允许编译器将此代码转换为可能执行陷阱的指令。unsigned char 类型的对象保证没有陷阱值。此示例中的读取是定义的,因为它来自 unsigned char 类型的对象,并且已知由内存支持。但是,不清楚读取哪个值以及该值是否稳定。从这个角度来看,可以认为此行为是隐式未定义的。至少,标准是不清楚的,并且可能是矛盾的。

缺陷报告 #45111 处理了未初始化自动变量的不稳定性。针对此缺陷报告的拟议委员会回复指出,对不确定值执行的任何操作都将产生不确定值作为结果。库函数在不确定值上使用时将表现出未定义的行为。但是,不清楚 y/x[0] 是否会导致陷阱。根据针对缺陷报告 #451 的拟议委员会回复,对于所有没有陷阱表示的类型,未初始化的值可能会改变其值,从而允许符合规范的实现打印两个不同的值。

考虑以下代码

void f(void) {
  unsigned char x[1]; /* 故意未初始化 */
  x[0] ^= x[0];
  printf("%d\n", x[0]);
  printf("%d\n", x[0]);
  return;
}

在此示例中,unsigned char 数组 x 是故意未初始化的,但不能包含陷阱表示,因为它具有字符类型。因此,该值既是不确定的,又是未指定的值。按位异或运算(在初始化值上会产生零)将产生不确定的结果,该结果可能为零,也可能不为零。优化编译器有权删除此代码,因为它具有未定义的行为。两个 printf 调用都表现出未定义的行为,因此可能会执行任何操作,包括为 x[0] 打印两个不同的值。

未初始化的内存已被用作熵源,以在 OpenSSL、DragonFly BSD、OpenBSD 和其他地方为随机数生成器播种。10 但是,如果访问不确定值是未定义的行为,则编译器可能会优化掉这些表达式,从而导致可预测的值。1

结论

与未初始化读取相关的行为是一个未解决的问题,C 标准委员会需要在标准的下一个修订版 (C2X) 中解决。一个简单的解决方案是完全消除陷阱表示,并简单地声明读取不确定值是未定义的行为。这将大大简化标准(本身就很有价值),并为编译器开发人员提供他们想要优化代码的所有自由度。完全相反的解决方案是为未初始化读取定义完全具体的语义,其中保证此类读取会给出内存的实际内容。

最有可能的是,将确定某种中间地带,该中间地带允许编译器优化,但不会消除对程序员的所有保证。一种可能性是引入摆动值,这将允许未初始化对象更改值,而无需将其视为未定义的行为。

陷阱表示是一种奇怪的事物,因为它们的引入是为了帮助诊断未初始化读取,但现在却受到安全和安全社区的怀疑,这些社区担心与读取陷阱值相关的未定义行为正在传递给读取不确定值。

参考文献

1. Debian 安全公告。2008. DSA-1571-1 openssl—可预测的随机数生成器; http://www.debian.org/security/2008/dsa-1571

2. IEC。1989. 微处理器系统的二进制浮点运算 (60559:1989)。

3. ISO/IEC。2011. 编程语言—C,第 3 版。(ISO/IEC 9899:2011)。瑞士,日内瓦。

4. Krebbers, R., Wiedijk, F. N1793: C11 中不确定值的稳定性; http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1793.pdf

5. Memarian, K., Sewell, P. 2016. 澄清 C 内存对象模型(WG14 N2012 的修订版)。剑桥大学; http://www.cl.cam.ac.uk/~pes20/cerberus/notes64-wg14.html#clarifying-the-c-memory-object-model-uninitialised-values

6. Memarian, K., Sewell, P. 2015(2016 年更新)。C 语言在实践中是什么?(Cerberus 调查 v2):响应分析 (n2014) - 带有评论; https://www.cl.cam.ac.uk/~pes20/cerberus/notes50-survey-discussion.html

7. 开放标准。2003. signaling NaN 的可选支持; http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1011.htm

8. Peterson, R. 2007. 缺陷报告 #338。C99 似乎将不确定值排除在未初始化的寄存器之外。开放标准; http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_338.htm

9. Seacord, R. C. 2016. 未指定值的澄清。开放标准; http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2042.pdf

10. Wang, X. 2012. 更多随机性还是更少; http://kqueue.org/blog/2012/06/25/more-randomness-or-less/

11. Wiedijk, F., Krebbers, R. 2013. 缺陷报告 #451。未初始化自动变量的不稳定性。开放标准; http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_451.htm

Robert C. Seacord 是 NCC 集团的首席安全顾问,他在那里与软件开发人员和软件开发组织合作,以消除在部署之前由编码错误引起的漏洞。Robert 是六本书的作者,包括CERT C 编码标准,第二版(Addison-Wesley,2014 年)和C 和 C++ 安全编码,第二版(Addison-Wesley,2013 年)。Robert 是 Linux 基金会的顾问委员会成员,也是 ISO/IEC JTC1/SC22/WG14 C 编程语言国际标准化工作组的专家。

相关文章

通过针眼传递一种语言
- Roberto Ierusalimschy 等人。
Lua 的可嵌入性如何影响其设计
https://queue.org.cn/detail.cfm?id=1983083

跨语言互操作性的挑战
- David Chisnall
语言之间的接口变得越来越重要
https://queue.org.cn/detail.cfm?id=2543971

语言、级别、库和寿命
- John R. Mashey
新的编程语言每天都在诞生。为什么有些会成功,而有些会失败?
https://queue.org.cn/detail.cfm?id=1039532<

版权所有 © 2016 归所有者/作者所有。出版权已许可给 。

acmqueue

最初发表于 Queue 第 14 卷,第 6 期
数字图书馆 中评论本文





更多相关文章

Matt Godbolt - C++ 编译器中的优化
向编译器提供更多信息需要在权衡中做出选择:这可能会使编译速度变慢。诸如链接时优化之类的技术可以为您提供两全其美的优势。编译器中的优化在不断改进,间接调用和虚函数分派方面即将到来的改进可能很快就会带来更快的多态性。


Ulan Degenbaev, Michael Lippautz, Hannes Payer - 作为合资企业的垃圾回收
跨组件跟踪是解决跨组件边界的引用循环问题的一种方法。只要组件可以形成具有跨 API 边界的非平凡所有权的任意对象图,就会出现此问题。CCT 的增量版本已在 V8 和 Blink 中实现,从而能够以安全的方式有效且高效地回收内存。


David Chisnall - C 语言不是一种低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间来研究根本原因。这两种漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能被添加进来,是为了让 C 程序员继续相信他们正在用低级语言编程,尽管这种情况已经几十年没有发生了。


Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 你不应该依赖我
大多数网站都使用 JavaScript 库,其中许多库已知存在漏洞。了解问题的范围以及包含库的许多意外方式,只是朝着改善这种情况迈出的第一步。此处的目的是,本文中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 保留所有权利。

© . All rights reserved.