范围。 为了更好地讨论C++当前的安全问题和解决方案,我需要包括所有软件所面临的广泛安全威胁的背景。 我是ISO C++标准委员会的主席,我在微软工作,但这些是我个人的意见,我希望他们能在编程语言和安全社区之间邀请更多对话。
致谢。 非常感谢来自C、C++、C#、Python、Rust、MITRE以及其他语言和安全社区的人们,他们对本材料草稿的反馈非常宝贵,其中包括:Jean-François Bastien、Joe Bialek、Andrew Lilley Brinker、Jonathan Caves、Gabriel Dos Reis、Daniel Frampton、Tanveer Gani、丹尼尔·格里芬、Russell Hadley、Mark Hall、, Tom Honermann、Michael Howard、Marian Luparu、Ulzii Luvsanbat、Rico Mariani、Chris McKinsey、Bogdan Mihalcea、Roger Orr、Robert Seacord、Bjarne Stroustrup、Mads Torgersen、Guido van Rossum、Roy Williams、Michael Wong。
术语 (请参见 ISO/IEC 23643:2020标准 ) . ” 软件安全 “(或“网络安全”或类似内容)意味着使软件能够保护其资产免受恶意攻击者的攻击。 ” 软件安全 “(或”生命安全“或类似的)意味着使软件免受对人类、财产或环境造成意外伤害的不可接受的风险。 ” 编程语言安全 “指一种语言(包括其标准库)的静态和动态保证,包括但不限于类型和内存安全,这有助于我们使软件更加安全。 当我说 “安全” 这里的不合格,我指的是编程语言安全,这对软件安全和软件安全都有好处。
我们必须使我们的软件基础设施更加安全,以防网络攻击(如电网、医院和银行)的增加,并随着软件在生命关键系统(如自动驾驶汽车和自动武器)中的使用增加,使其更安全,以防意外故障。
尤其是过去两年,人们更加关注编程语言的安全性,将其作为帮助构建更加安全可靠的软件的一种方式; 关于内存安全语言(MSL)的真正好处; C和C++语言安全需要改进-我同意。
但也有一些误解,包括过于狭隘地将编程语言安全作为我们行业的主要安全问题——事实并非如此。 最近许多最具破坏性的安全漏洞都发生在MSL中编写的代码上(例如。, 日志4j )或者与编程语言无关(例如。, 存储在公共GitHub回购上的Kubernetes机密 ).
在这种情况下,我将重点介绍C++并尝试:
强调需要注意什么(C++的问题是什么),以及我们如何通过构建已经在进行中的解决方案来实现这一目标;
解决一些常见的误解(C++的问题“不是”什么),包括对MSL的实际考虑; 和
为使用所有语言的程序员留下行动号召。
tl; 博士: 我不希望C++限制我可以高效表达的内容。 我只想C++默认情况下让我执行我们众所周知的安全规则和最佳实践,并让我明确选择退出。 然后我仍然可以使用完全现代的C++……只是更好。
让我们深入挖掘。
直接的问题“是”在C++中编写安全漏洞“太容易了”,这些漏洞会被更严格的已知规则所捕获 类型、边界、初始化, 和 一生 语言安全
在C++中,我们需要从改进这四个类别开始。 这些是NIST/NSA/CISA/等推荐使用的所有MSL提供的四个主要改进来源,而不是C++( 例子 )因此,根据定义,解决这四个问题将使用C++解决NIST/NSA/CISA/等直接问题。 (在下面的“问题不是……(1)”中对此进行了详细介绍。)
近年来,包括2023年在内(见图1中突出显示的四行和图2),这四行占经常引用的70% CVE公司 与语言记忆不安全相关的(常见[安全]漏洞和暴露)。 (然而,“70%的语言记忆不安全CVE”具有误导性;例如,在图1中 MITRE 2023年“最危险的弱点” 不涉及语言安全,因此超出了分母范围。 在下面的“问题不是……(3)”中对此进行了详细介绍。)
C++指南文献已经广泛同意这些类别中的安全规则。 确实存在一些相互冲突的指导文献,特别是在禁止异常或运行时类型支持的环境中,因此使用一些替代规则。 但对于核心安全规则,例如禁止不安全的强制转换、未初始化的变量和边界外访问,人们达成了共识(见附录)。
C++应该提供一种默认情况下强制执行它们的方法,并在需要时要求显式opt-out。 我们可以并且确实可以用C++编写“好”的代码和安全的应用程序。 但是,即使是经验丰富的C++开发人员也很容易意外地编写出C++默默接受的“坏”代码和安全漏洞,这将被其他语言视为违反安全规定而拒绝。 我们需要标准语言通过实施已知的最佳实践来提供更多帮助,而不是依赖其他非标准工具来推荐它们。
这并不是我们应该解决的语言安全的四个方面。 它们只是直接的,是一组明显的低挂果实,既有明确的需求,也有明确的改进方法(见附录)。
注: 当然,安全类别是相互关联的。 例如,完全类型安全(即访问的对象是其类型的有效对象)要求消除对未分配对象的边界外访问。 但是,相反,完全边界安全(即访问的内存在分配的边界内)同样需要消除对更大的派生类型对象的类型安全向下转换,这些派生类型对象似乎会扩展到实际分配之外。
软件安全也很重要。 网络攻击迫在眉睫,所以最近的讨论自然更多地集中在安全和CVE上。 但是,当我们指定和发展默认的语言安全规则时,我们还必须包括那些深切关注功能安全问题的利益相关者,这些问题没有反映在主要的CVE桶中,但在代码中留下时对生命和财产同样有害。 编程语言安全有助于软件安全和软件安全,我们应该从某个地方开始,所以让我们从已知的安全CVE痛点开始(但不是结束)。
在这四个桶中,提高10-50倍(减少90-98%)就足够了
如果C++类型/边界/初始化/生存期漏洞减少90-98%,我们就不会讨论这个问题。 所有语言都有CVE,C++只是有更多(C语言更多)。 [更新:删除了2024 Rust与C/C++CVE的计数,因为MITRE.org搜索无法准确计算后者。] 所以零不是目标; 为了实现与MSL提供的语言安全级别同等的安全性,有必要减少90%,减少98%就足够了……而且我认为这有很大的好处 完美的反向链接兼容性 (即,不改变C++的对象模型及其生命周期模型,它不依赖于通用跟踪垃圾收集,也不限于基于树的数据结构),这对于我们能够像采用其他新版本的C++一样容易地采用现有C++项目中的改进是至关重要的 之后,我们可以对其他桶进行额外的改进,例如线程安全和溢出安全。
在这四个桶中实现100%或零CVE是一个错误:
100%是不必要的,因为我们被告知使用的MSL也不存在。 在下面的“问题不是……(2)”中有更多关于这方面的内容。
100%是不够的,因为许多网络攻击利用了内存安全之外的安全弱点。
最后2%的成本太高了 因为它需要放弃与当今C++代码的链接兼容性和无缝互操作性(或“互操作”)。 例如,Rust的对象模型和借用检查器提供了很好的保证,但需要与C++基本不兼容,因此使互操作难以超越通常的C互操作级别。 一个原因是Rust的安全语言指针仅限于表示没有循环的树型数据结构; 独特的所有权对于实现强大的语言强制别名保证至关重要,但它还要求程序员对任何比树更复杂的东西使用“其他东西”(例如,使用Rc或使用整数索引作为替代指针); 这不仅仅是关于 链接列表 但这是一个简单而著名的示例。
如果我们能够得到98%的改进,并且仍然与现有C++具有完全兼容的互操作,那将是一个值得认真投资的圣杯。
在新的/更新的C++代码中可以实现这四个类别中98%的减少,在现有代码中也可以实现部分减少
至少从2014年起,Bjarne Stroustrup就主张通过“超集的子集”来解决C++中的安全性问题:即首先“超集”添加C++中不可用的基本项,然后“子集”排除现在都有替换项的不安全结构。
从C++20开始,我相信我们已经实现了“超集”,特别是通过标准化 跨度 , 字符串视图(_V) 、概念和边界软件范围。 我们可能还需要一些其他功能,例如以null结尾的zstring_view,但主要的附加功能已经存在。
现在我们应该“子集”:使C++程序员能够围绕类型和内存安全实施最佳实践,默认情况下,在新代码和代码中,他们可以更新以符合子集。 默认情况下启用安全规则不会限制该语言的功能,但需要对非标准实践进行明确的选择,从而减少意外风险。 它可能会随着时间的推移而进化,这一点很重要,因为C++是一种活的语言,对手会不断改变他们的攻击。
ISO C++的发展已经在进行 C的安全配置文件++ 附录中的建议是对其的改进,以证明具体的实施,并尝试最大限度地提高其可采用性和有用的影响。 例如,每个人都同意许多安全漏洞需要修改代码才能修复。 然而,有多少安全漏洞可以在不手动更改源代码的情况下修复,因此只需重新编译启用了安全配置文件的现有代码就能带来一些安全好处? 例如,当.size()存在且a是连续容器时,我们可以在每个下标表达式a[b]上默认插入一个调用边界检查0<=b<.size; 该检查将对每个相邻的C++标准容器、span、string_view和第三方自定义容器进行开箱即用,不需要库更新(因此也不需要担心ABI中断)。
类似附录中总结的规则可以防止(在编译时、测试时或运行时)我在类型、边界和初始化类别中查看的大多数过去的CVE,并且可以防止许多生命周期的CVE。 我估计,通过定义良好且标准化的方式,C++可以在默认情况下启用安全规则,同时保持完美的向后链接兼容性,从而使这些类别的数据量减少大约98%。 有关更详细的说明,请参阅附录。
我们可以也应该强调C++代码的可接受性和好处,因为C++代码不容易更改。 为遵守安全规则而进行的任何代码更改都会带来成本; 更糟糕的是,并不是所有的代码都可以很容易地进行更新以符合安全规则(例如,它很旧而且不被理解,它属于不允许更新的第三方,它属于共享项目,不接受上游更改,也不容易分叉)。 这就是为什么上面(以及附录中)我强调C++应该认真尝试在不需要手动更改源代码的情况下尽可能多地提供安全改进, 特别是,在清楚的情况下,自动让现有代码做正确的事情(例如,上面提到的边界检查,或者在不需要更改代码的情况下将static_cast指针向下转换为有效的dynamic_cast), 通过提供程序员可以选择应用的自动修复(例如,将staticcast指针向下转换的源代码更改为dynamiccast)。 尽管在许多情况下,程序员需要深思熟虑地更新代码,以替换无法自动修复的固有不安全结构,但我相信,在一定比例的情况下,我们可以通过以安全规则默认模式重新编译现有代码来提供安全改进, 我们应该尝试,因为这对于最大限度地提高安全配置文件的可采用性和影响至关重要。
问题“不是”:一些常见的误解
(1) 问题“不是”定义我们所说的“C++最紧迫的语言安全问题”。我们知道最迫切需要改进的四种安全:类型、边界、初始化和终身安全。
我们知道这四个是低垂的果实(参见上面的“问题”是“……”)。 的确,这些仅仅是二十四种“安全”类别中的四种,其中包括安全整数运算。 但是:
其他大多数问题要么是小得多的问题来源,要么主要是因为它们促成了这四个主要类别。 例如,我们最关心的整数溢出是索引和大小,它们在安全范围内。
大多数MSL也没有解决默认情况下使这些安全的问题,通常是由于检查成本。 但所有语言(包括C++)通常都有解决这些问题的库和工具。 例如,Microsoft发布了 SafeInt库 用于C++处理整数溢出,这是opt-in。C#有一个 检查算术语言功能 处理整数溢出,这是opt-in。默认情况下,Python的内置整数是溢出安全的,因为它们会自动扩展; 然而,流行的NumPy固定大小整数类型在默认情况下不检查溢出,并且需要使用选中的函数,这是opt-in。
线程安全显然也很重要,我没有忽视它。我只是指出它不是顶级目标存储桶之一:NIST/NSA/CISA/等推荐的大多数MSL都优于C++(唯一的Rust除外,Python的影响较小) 用户 数据 有关C++的损坏。 MSL的主要改进是程序数据竞争不会破坏语言本身 虚拟机 (而在C++中,数据竞赛目前是完全没有定义的行为)。 一些语言确实提供了一些额外的保护,例如Python保证两个竞争线程不会看到整数的撕裂写入,并减少了由于全局解释器锁(GIL)而导致的其他可能的交错。
(2) 问题“不在于”C++代码没有被正式证明是安全的。
是的,默认情况下,C++代码使编写无声的安全代码变得太容易了(请参阅上面的“问题是”…“)。
但我看到一些人声称,我们需要要求语言在形式上可以证明是安全的,这将是一座过犹不及的桥梁。 让CS理论家们非常懊恼的是,主流商业编程语言在形式上并不安全。 考虑一些示例:
如前一节所述,我们视为MSL的广泛使用的语言(唯一的Rust除外)都没有声称在结构上是线程安全和无种族的。 然而,我们仍然将C#、Go、Java、Python和类似语言称为“安全的”。因此,正式保证线程安全属性并不是被视为足够安全的语言的必要条件。
这是因为语言对安全保证的选择是一种权衡:例如,在Rust中,安全代码仅使用基于树的动态数据结构。 与其他安全语言相比,此功能使Rust提供了更强的线程安全保证,因为它可以更容易地推理和控制别名。 然而,同样的特性也要求Rust程序更频繁地使用不安全代码来表示通用数据结构,这些数据结构不需要在其他MSL(如C#或Java)中表示不安全代码,等等 30%至50%的锈蚀板条箱使用不安全代码 ,例如与 25%的Java库 .
C#、Java和其他MSL仍然存在使用前初始化和使用后销毁类型的安全问题:它们保证不访问 记忆 超出其分配的生存期,但 对象 lifetime是内存生命周期的子集(对象是在原始内存分配和释放之后构造的,在之前销毁/释放;在构造之前和释放之后,内存被分配,但包含可能不代表其类型的有效对象的“原始位”)。 如果你有疑问,请跑(不要走)并询问ChatGPT 关于Java和C#问题:访问-非构造对象错误(例如,在这些语言中,构造函数中的任何虚拟调用都是“深层”的,并且在派生对象的状态初始化之前在派生对象中执行); 使用后处理错误; “复活”虫子; 以及为什么这些语言告诉人们永远不要使用终结器。 然而,这些都是很棒的语言,我们理所当然地认为它们是安全的语言。 因此,正式保证no-use-before-initialized和no-us-eafter-dispose不能被视为足够安全的语言。
Rust、Go和其他语言 支持消毒器 也包括ThreadSanitizer和 未定义的行为消毒剂 以及fuzzer等相关工具。 众所周知,消毒剂仍然是语言安全的补充,而不仅仅是当程序员使用“不安全”代码时; 此外,它们不仅仅是发现内存安全问题。 据我所知,大规模使用铁锈也会强制使用消毒剂。 因此,使用消毒剂不能表明语言是不安全的,我们应该对用任何语言编写的代码使用支持的消毒剂。
注: “使用消毒剂”并不意味着一直使用所有消毒剂。 有些消毒剂相互冲突,所以一次只能使用一种。 有些消毒剂很昂贵,所以只能定期运行。 一些消毒剂不应在生产中运行,包括因为它们的存在可能会产生新的安全漏洞。
(3) 问题“不是”将世界上的C和C++代码迁移到内存安全语言(MSL)可以消除70%的安全漏洞。
MSL太棒了! 它们不是银弹。
经常引用的数字 是70%吗 编程语言引起的 C和C++代码中的CVE(报告的安全漏洞)是由于语言安全问题造成的。 这个数字是真实的、可重复的,但在新闻界却被严重曲解:我认识的安全专家都不相信,如果我们能挥舞魔杖,立即将世界上所有的代码转换为MSL,那么我们的CVE、数据泄露和勒索软件攻击就会减少70%。 (例如,请参见 2024年2月的示例分析文件 .)
考虑一些原因。
这70%是 子集 可以通过编程语言安全性来解决的安全CVE。 再次参见图1:2023年前10个“最危险的软件缺陷”中的大多数与内存安全无关。 2023年许多最大的数据泄露、其他网络攻击和网络犯罪与编程语言无关。 2023年,攻击者减少了恶意软件的使用,因为软件变得更加坚固,端点保护有效(CRN) 袭击者会追捕群中速度最慢的动物。 中列出的大多数问题 NISTIR-8397公司 平等地影响所有语言,因为它们超越了内存安全(例如。, 日志4j )甚至编程语言(例如,自动测试、硬编码机密、启用操作系统保护、字符串/SQL注入、软件材料清单)。 有关更多详细信息,请参阅 微软对NISTIR-8397的回应 ,我是其中的编辑。 (更多信息请参阅《行动呼吁》。)
MSL也会出现CVE,尽管肯定会更少(同样,例如。, 日志4j )。 例如,请参见 锈蚀CVE的MITRE列表 其中包括2024年迄今为止的6个。 所有程序都使用不安全的代码; 例如,请参见 结论 第节,共节 Firouzi等人。 研究了C#在StackOverflow上的不安全使用和漏洞的普遍存在,以及所有程序最终调用受信任的本地库或操作系统代码。
大声说出沉默的部分:众所周知,简历是一个不精确的指标。 我们使用它是因为它是我们的度量标准,至少对于安全漏洞来说是这样,但我们应该小心使用它。 这可能会让你感到惊讶,就像我一样,因为我们听说了很多关于简历的事情。 但每当我建议改进C++并通过减少CVE来衡量“成功”时(包括本文中), 安全专家坚持认为,CVE不是一个很好的衡量标准……包括之前向我引用70%CVE数字的那些专家; 但是,即使没有合理的漏洞利用,也可能会有压力将漏洞报告为漏洞,因为在CVE上获得姓名的好处。 2023年8月 Python软件基金会成为CVE编号机构(CNA) 对于Python和pip发行版,现在可以更好地控制Python和pip CVE。 C++社区还没有这样做。
CVE只针对软件安全漏洞(网络攻击和入侵),我们还需要考虑软件安全(生命关键系统和对人类的意外伤害)。
(4) 问题“不是”C++程序员不够努力/使用现有工具不够好。 挑战是如何更容易地启用它们。
今天,我们为C++代码提供的缓解措施和工具是一个不均衡的组合,默认情况下都是关闭的:
善良。 它们是静态工具、动态工具、编译器开关、库和语言功能的混合体。
收购。 它们是以多种方式获得的:C++编译器中的内置、可选下载、第三方产品,以及一些需要通过谷歌搜索才能发现的产品。
准确性。 现有规则集混合了低误报和高误报的规则。 后者实际上是程序员无法接受的,而且它们的存在使得“仅仅采用这整套规则”变得困难
决定论。 一些规则,例如依赖于完整调用树的过程间分析的规则,本质上是不确定的(因为当完全评估案例超过可用的空间和时间时,实现会放弃;也称为“尽力”分析)。 这意味着相同规则的两个实现可以为相同代码提供不同的答案(因此非确定性规则也不可移植,请参见下文)。
效率。 现有规则集混合了诊断成本低和高(有时不可能)的规则。 效率不够高而无法在编译器中实现的规则将始终降级为可选的独立工具。
便携性。 并非所有供应商都支持所有规则。 “符合ISO/IEC 14882(标准C++)”是每个C++工具供应商唯一支持的可移植性。
为了解决所有这些问题,我认为我们需要C++标准来指定一种一致同意的低或零错误正确定性规则的模式,这些规则的成本足够低,可以在构建时在机箱中实现。
行动呼吁
一般来说,作为一个行业,我们必须在编程语言内存安全方面做出重大改进——我们会这样做的。
具体来说,在C++中,我们应该首先瞄准四个关键安全类别,这四个类别是我们的常年经验攻击点(类型、边界、初始化和寿命安全),并将这四个领域中的漏洞降低到新的/更新的C++代码的噪音中,我们可以做到这一点。
但我们也必须认识到,编程语言安全并不是实现网络安全和软件安全的灵丹妙药。 这是一场长期战争中的一场战役(甚至不是最大的战役):每当我们硬化我们系统的一部分并使其攻击成本更高时,攻击者总是会转而攻击群中速度最慢的动物。 2023年发生的许多最严重的数据泄露事件都与恶意软件无关,但都是由存储不充分的凭据(例如。, Kubernetes的秘密 公共GitHub回购)、配置错误的服务器(例如。, 暗光束 , 儿童安全 )缺乏测试、供应链漏洞、社会工程以及其他独立于编程语言的问题。 苹果的白皮书 大约2023年网络犯罪的增长强调改进处理,而不是程序代码的处理,而是数据的处理:“组织必须考虑限制以可读格式存储的个人数据的数量,同时更加努力保护他们确实存储的敏感消费者数据,[包括使用]端到端[E2E] 加密。”
无论我们使用什么编程语言,安全卫生都至关重要:
做 使用您的语言的静态分析器和净化程序。 永远不要假装使用静态分析器和消毒剂是不必要的,“因为我使用的是一种安全的语言。”如果您使用的是C++、Go或Rust,那么请使用这些语言支持的分析器和消毒器。 如果你是一名经理,不要让你的产品在没有使用这些工具的情况下发货。 (再次声明:这并不意味着一直运行所有消毒剂;有些消毒剂会发生冲突,因此不能同时使用,有些很昂贵,因此应该定期使用,有些只应在测试中运行,而不应在生产中运行,包括因为它们的存在可能会产生新的安全漏洞。)
做 保持所有工具的更新。 定期修补不仅适用于iOS和Windows,也适用于编译器、库和IDE。
做 保护您的软件供应链。 做 对库依赖项使用包管理。 做 跟踪项目的软件物料清单。
不要这样 在代码中存储机密。 (或者,看在上帝的份上,在GitHub上!)
做 正确配置服务器,尤其是面向公共互联网的服务器。 (启用身份验证!更改默认密码!)
做 保持非公开数据加密,无论是在静止状态(磁盘上)还是在移动状态(理想情况下是E2E……并反对提议的立法,该立法试图用“只有好人才会使用的后门”来中性化E2E加密,因为不存在这样的事件)。
做 保持长期投资以保持您的威胁建模最新,以便您可以在对手不断尝试不同攻击方法时保持自适应。
我们需要提高整个行业的软件安全性和软件安全性,特别是通过提高C和C++中的编程语言安全性,而在C++中,在中期内可以实现四个最常见问题领域98%的改进。 但是,如果我们只关注编程语言的安全性,我们可能会发现自己正在经历昨天的战争,而忽略了影响以任何语言编写的软件的更大的过去和未来安全隐患。
可悲的是,坏演员太多了。 在可预见的未来,我们的软件和数据将继续受到攻击,以任何语言编写并存储在任何地方。 但我们可以保护我们的程序和系统,我们也会这样做。
一切安好,愿我们都能继续努力,让2024年更加安全。
附录:说明为什么降低98%是可行的
本附录旨在支持为什么我认为C++代码中类型/边界/初始化/生存期CVE减少98%是可信的。 这不是一个正式的建议,而是对在新的和可更新的代码中实现这种改进的具体方法的概述,以及在现有代码中实现改进的方法,我们不能更新,但可以重新编译。 这些注释与ISO C++安全小组目前正在进行的建议相一致,如果它们在正在进行的讨论和实验中实现了我的预期,那么我打算在未来的论文中进一步详细介绍它们。
所有四个bucket中的一些建议都有运行时和代码大小开销,特别是检查边界和强制转换。 但是,没有理由认为C++中的这些开销必然比其他语言更糟糕,而且我们可以默认启用它们,并且仍然提供了一种选择退出的方法,以便在需要时重新获得完全性能。
注: 例如,当使用优化器不提升边界检查的编译器时,边界检查可能会对一些热循环造成重大影响; 循环不仅会引发冗余检查,而且可能不会得到其他优化,例如不被矢量化。 这就是为什么默认情况下启用边界检查是好的,但所有面向性能的语言还需要提供一种方式来表示“信任我”,并在需要时明确选择越界检查。
本附录是指 C++核心指南安全配置文件 这是一套关于类型和内存安全的大约24条可执行规则,我是其中的一员。 我仅将其作为示例,以展示“什么”是我们可以实施的已知规则,并支持我声称的改进是可能的。 它们与其他来源的规则大体一致,例如: C++编程语言 关于类型安全的建议; C++编码标准 “关于类型安全的章节; 这个 联合攻击战斗机编码标准 ; 高完整性C++ ; 这个 关于安全配置文件的C++核心指南部分 (一小套可执行的安全规则); 和 最近发布的MISRA C++:2023 .
“如何”让程序员控制启用这些规则的最佳方式(例如,通过源代码注释、编译器开关和/或其他方式)是一个正交的用户体验问题,目前C++标准委员会和社区正在积极讨论这个问题。
类型安全
强制执行 赞成的意见。 类型安全配置文件 默认情况下。 这包括禁止或检查所有不安全的类型转换和转换(例如,static_cast指针向下转换、reinterpret_cast),包括通过C union和vararg进行隐式不安全类型双关。
然而,这些规定尚未在行业中得到系统的执行。 例如,近年来,我痛苦地观察到一组重要的类型安全导致的安全漏洞,其根本原因是代码使用static_cast而不是dynamic_cast进行指针向下转换,以及“C++” 即使实际问题是没有遵循广为人知的指南来使用该语言现有的安全推荐功能,也会受到指责。 现在是使用标准化C++模式的时候了,默认情况下会强制执行这些规则。
注: 在某些平台上,对于某些应用程序, dynamic_cast(动态_广播) 有妨碍其使用的问题空间和时间开销。 许多实现捆绑包 dynamic_cast(动态_广播) 与所有C++运行时类型化(RTTI)特征(例如。, 类型ID ),因此需要存储全部潜在的重量级RTTI数据,即使 dynamic_cast(动态_广播) 只需要一小部分。 一些实现还使用了不必要的低效算法 dynamic_cast(动态_广播) 自身。 因此,标准必须鼓励(并且,如果可能的话,强制执行一致性,例如通过设置算法复杂性要求) dynamic_cast(动态_广播) 实现更加高效,并且与其他RTTI开销解耦,这样程序员就没有合理的性能理由不使用安全特性。 这种脱钩可能需要ABI中断; 如果这是不可接受的,标准必须提供替代的轻量级设施,例如 快速动态广播 它与(其他)RTTI分开,以最小的空间和时间成本执行动态转换。
约束安全性
强制执行 赞成的意见。 绑定安全配置文件 默认情况下,以及保证边界检查。 我们还应保证:
禁止使用指针算法(改用std::span); 这强制要求指针指向单个对象。 如果允许,数组到指针的衰减将仅指向数组中的第一个对象。
只允许使用有界选择的迭代器算法(也可以使用首选范围)。
通过让编译器对形式为a[b]的每个表达式注入自动下标边界检查,所有下标操作都在调用位置进行边界检查,其中a是带有size/ssize函数的连续序列,b是整数索引。 当发生冲突时,可以使用全局边界冲突处理程序自定义所采取的操作; 一些程序将要终止(默认),其他程序将要记录并继续,抛出异常,与特定于项目的关键故障基础设施集成。
重要的是,后者显式地避免了对每个单独的容器/范围/视图类型进行侵入性的边界检查。 在调用站点以非侵入方式自动实现边界检查,可以对每个现有的标准和用户编写的容器/范围/视图类型进行全面的边界检查:向量、span、deque、, 或第三方和公司内部库中类似的现有类型将在检查模式下可用,而不需要任何库升级。
在库继续在每个库中添加更多的下标边界检查之前,现在添加自动调用方检查很重要,这样可以避免在调用站点和被调用方重复检查。 作为一个反例,C#花了很多年时间来消除重复的调用方和调用方检查,但成功了。 NET Core现在更好地解决了这个问题; 通过更快地提供自动呼叫端检查,我们可以避免大多数重复的检查消除优化工作。
像range-for循环这样的语言构造在构造上已经是安全的,不需要检查。
在边界检查对性能产生影响的情况下,代码仍然可以在这些路径中显式选择不进行边界检查,以保持完全性能,并且在应用程序的其余部分中仍然进行完全边界检查。
初始化安全
默认情况下,强制进行初始化。 这很容易静态地保证,除了延迟构建的数组/向量存储中未使用的部分的某些情况。 我们可以强制执行两个简单的替代方案(任何一个都足够):
根据要求初始化声明 赞成的意见。 类型 和 欧洲标准.20 ; 并且可能会默认为零初始化数据,如当前在 第2723页 这两个很好,但也有一些缺点; 对于需要从未使用过但优化器很难消除的“伪”写入的情况,两者都有一些性能成本,而后者有一些正确性成本,因为它“修复”了一些未初始化的情况,其中零是有效值,但掩盖了其他零不是有效初始化器的情况,因此行为仍然是错误的, 但因为零点被塞进了,所以消毒器很难检测到。
有保证的初始化是预使用的,类似于Ada和C#成功完成的操作。这仍然易于使用,但可以更高效,因为它避免了人工“伪”写入的需要,并且可以更灵活,因为它允许为不同路径上的同一对象使用替代构造函数。 有关详细信息,请参见: 示例诊断 ; 定义首次使用规则 .
终身安全
强制执行 赞成的意见。 终身安全简介 默认情况下,禁止手动分配,并保证空检查。 Lifetime配置文件是一种静态分析,可以诊断许多常见的悬空和无需使用的源,包括迭代器和视图(不仅仅是原始指针和引用),其效率足以在编译期间运行。 它可以作为迭代和进一步改进的基础。 我们还应保证:
默认情况下禁止所有手动内存管理(new、delete、malloc和free)。 推论:默认情况下禁止“拥有”原始指针,因为它们需要删除或释放。 请改用RAII,例如通过调用make_unique或make_shared。
所有取消引用均为空。 编译器对形式为*p或p->的每个表达式注入一个自动检查,其中p可以与nullptr进行比较,从而对调用位置的所有引用进行null检查(类似于上面的边界检查)。 当发生冲突时,可以使用全局null冲突处理程序自定义所采取的操作; 一些程序将要终止(默认),其他程序将要记录并继续,抛出异常,与特定于项目的关键故障基础设施集成。
注: 当针对已经捕获空解引用的平台(例如将内存不足的页面标记为不可寻址的平台)时,编译器可以选择不发出此检查(并且不执行从该检查中受益的优化)。 一些C++特性(如delete)总是进行调用端空值检查。
减少未定义的行为和语义错误
在策略上,减少一些未定义的行为(UB)和其他语义错误(陷阱),以便我们能够自动诊断甚至修复已知的反模式。 并非所有UB都是坏的; 任何以表演为导向的语言都需要一些。 但我们知道,程序员的意图明确,任何UB或陷阱都是一个明确的错误,因此我们可以做以下两件事之一:
(A–好)让陷阱成为一个诊断错误,零误报-每个违规都是一个真正的错误。 上面提到的两个示例是自动检查a[b]是否在边界内,*p和p->是否为非空。
(B–理想)让代码真正做到程序员想要的,零误报,即通过重新编译来修复它。 在最近的ISO C++2023年11月会议上讨论过的一个例子是,默认为隐式 返回*this; 当程序员为其类型C编写赋值运算符时,该运算符返回C&(注意:类型相同),但忘记编写返回语句。 今天,这是未定义的行为。 但很明显,程序员的意思是返回*这个;- 没有其他东西是有效的。 如果我们退货*这个; 默认情况下,所有意外忽略返回的现有代码不仅是“不再是UB”,而且保证会做正确和预期的事情。
(A)和(B)的示例 是为了支持 链式比较 ,这使得数学上有效的链能够正确工作,并在编译时拒绝数学上无效的链。 现实世界的代码编写这样的链是偶然的(参见: 【a】 【b】 [中] [日] [英] 【f】 [克] [小时] [一] [j] [克] ).
对于(A):我们可以拒绝所有数学上无效的链,如A!= 编译时b>c。 这会自动诊断现有代码中试图执行这种无意义链的错误,并且准确无误。
对于(B):我们可以修复所有现有的代码,这些代码编写了类似0<=index<max这样的可能正确的链。现在,这些代码可以静默编译,但完全是错误的,我们可以让它们表示正确的东西。 只需重新编译现有代码,即可自动修复这些错误。
这些例子并不详尽。 我们应该查看标准中的UB列表,以获得可以自动修复(理想情况下)或诊断的更彻底的案例列表。
总结:C的更好默认值++
C++可以在默认情况下启用安全规则,从而使代码:
对于终身安全,这是四种安全中最难的,我预计这些类别中的剩余漏洞主要存在于:
完全零安全,
完全没有原始指针,
使用生命周期安全静态分析诊断最常见的指针/迭代器/视图生命周期错误;
最后:
使用较少未定义的行为,包括通过重新编译默认启用安全的代码来自动修复现有的错误。
所有这些都是可以有效实施的,并且已经实施。 大多数Lifetime规则都是在Visual Studio和CLion中实现的,我正在原型化C++的概念验证模式,该模式默认在我的 cppfront编译器 以及其他安全改进,包括实施ISO C++合同的当前提案。 我还没有大规模使用原型。 然而,我可以报告,我从早期用户收到的第一个主要更改请求是将边界检查和空检查从opt-in(默认为off)更改为opt-out(默认为on)。
注: 请不要因为cppfront使用C++的实验性替代语法而分心。 这是因为我还想看看我们是否能达到第二个正交目标:使C++语言本身更简单,并且不需要教授大约90%的与语言复杂性和怪癖相关的C++指导文献。 然而,本文的语言安全改进与此正交,可以同样应用于当今的C++语法。
解决方案需要区分(A)“新的或可更新代码的解决方案”和(B)“现有代码的解决方法”
(A) “新的可更新代码解决方案”意味着为了帮助现有代码,我们必须更改/重写代码。 这不仅包括“(重新)用C#/Rust/Go/Python/…编写”,还包括“用 SAL公司 “或”更改代码以使用std::span。“
(A)的成本之一是,每当我们编写/更改代码来修复错误时,我们也会引入新的错误; 变化从来都不是免费的。 我们需要认识到,将我们的代码更改为使用std::span通常意味着非私有地重写它的某些部分,这也可能会产生其他错误。 即使注释我们的代码,也意味着编写可能有错误的注释(这是我见过的大规模使用的注释语言(例如SAL)中的常见经验)。 所有这些都是重大的采用障碍。
事实上,改用另一种语言意味着失去一个成熟的生态系统。 C++是一条老生常谈的道路:它是被教授的,人们知道它,工具存在,互操作工作,当前的法规围绕着C++有一个行业(例如功能安全)。 至少要再过十年,另一种语言才能成为一条成熟的道路,而更好的C++及其对整个行业的好处可能会更快出现。
(B) “现有代码的解决方案”强调了无需手动更改代码的可采用性优势。 它包括任何可以通过“只需重新编译”使现有代码更安全的内容(即,没有二进制/ABI/链接问题;例如,ASAN、启用堆栈检查的编译器开关、只产生正确结果的静态分析,或可靠的自动化代码现代化器)。
无论新语言或新C++类型/注释多么成功,我们仍然需要(B)。 并且(B)具有更容易采用的强大优势。 要使CVE减少98%,需要(a)和(B)两个条件,但如果我们仅使用(B)就可以实现30%的减少,这将是采用CVE的主要好处,并对难以更改的大型现有代码库产生有效影响。
考虑一下本附录前面的想法如何映射到(A)和(B):
在C++中,默认情况下强制… (A) 新代码/更新代码的解决方案(可能需要更改代码-无链接/二进制更改) (B) 现有代码的解决方案(只需要重新编译-无需手动更改代码,无需更改链接/二进制) 类型安全 禁止所有固有的不安全类型转换 使用安全的替代方案进行不安全的转换 约束安全性 禁止指针算法禁止未经检查的迭代器算法 检查所有允许的迭代器算法的边界检查所有下标操作的边界 初始化安全 要求初始化所有变量(在声明时或首次使用之前) — 终身安全 静态诊断许多常见的指针/迭代器生存期错误情况 检查所有指针引用的非空 较少未定义的行为 静态诊断已知的UB/错误案例,以在现有代码中的实际错误上出错,只需重新编译一次,误报为零: 禁止数学上无效的比较链 (添加UB附录审查中的其他案例) 自动修复已知的UB/错误案例,使现有代码中的当前错误实际上是正确的,只需重新编译和零误报: 定义数学上有效的比较链 默认返回*this; 对于返回C的C赋值运算符& (添加UB附录审查中的其他案例)
通过优先考虑可采用性,我们可以通过重新编译现有代码至少获得一些安全好处,并使总体改进更容易部署,即使需要代码更新。 我认为这使它成为一个值得追求的宝贵战略。
最后,请再次查看主要帖子的结论: 行动呼吁 .