lucy1668      2024年06月28日 星期五 下午 14:03

近年来向开发者建议放弃 C++、使用内存安全语言的声音越来越多。面对这个情况,本文作者 Sean Baxter 提出了解决问题的关键:并非完全转向 Rust,而是要创建一个包含严格安全子集的 C++ 超集——Circle C++ 由此诞生。

截至目前,Circle 编译器在 GitHub 已拥有 2.3k+ Star,在 HN 上也有许多开发者对此表示支持。

le-lang.org/site/intro/

声明:未经允许,禁止转载。

作者 | Sean Baxter

翻译 | 郑丽媛

在过去两年中,美国政府愈发迫切地警告开发者不要采用内存不安全的编程语言。据了解,在美国许多关键基础设施都依赖于用 C 和 C++ 编写的软件,但其内存并不安全,政府认为这将导致系统很容易被对手利用:

2022 年 11 月 10 日,NSA 发布《关于如何防范软件内存安全问题指南》;

2023 年 9 月 20 日,发布《软件产品内存安全的迫切需》要;

2023 年 12 月 6 日,CISA 发布《软件制造商联合指南》:内存安全路线图案例;

2024 年 2 月 26 日,发布《未来软件应具有内存安全性》;

2024 年 5 月 7 日,发布《国家网络安全战略实施计划》

以上这些政府文件得到了许多行业研究的支持。例如微软的漏洞遥测显示,70% 的漏洞可通过内存安全的编程语言来阻止;谷歌研究也发现,68% 的 0day 漏洞与内存损坏漏洞有关。

于是乎,不少安全专业人士大声疾呼,要求项目摒弃 C++,开始使用内存安全语言——但这绝不是喊喊口号而已。

要知道,由 C++ 所支持的产品创造了数万亿美元的价值,如今有大量的 C++ 程序员和 C++ 代码。鉴于 C 和 C++ 代码的广泛传播,业界究竟能做些什么来提高软件质量和减少漏洞?在现有项目中引入新的内存安全代码并加固现有软件的方案又有哪些?

有一种系统级/非垃圾回收语言能提供严格的内存安全性,那就是 Rust。但是,C++ 和 Rust 却大相径庭,互操作能力也有限,因此从 C++ 向 Rust 的增量迁移是一个缓慢而艰苦的过程。

Rust 缺乏函数重载、模板和继承,C++ 则缺少 traits、重定位和生命周期参数。这些差异造成了两种语言接口时的“阻抗失配”。大多数跨语言绑定的代码生成器都没有尝试用一种语言来表示另一种语言的特征。它们通常会确定一些特殊的词汇类型,具有一流的特性但也限制了其他语言的功能。

对于职业 C++ 开发者来说,Rust 是一种陌生的语言,加上缺乏互操作工具,想要用 Rust 来重写关键部分以加固 C++ 应用是非常困难的。所以说,为什么在语言内没有一个能解决内存安全问题的方法?为什么没有一个安全的 C++(Safe C++)呢?

一、为安全而扩展 C++

我的目标是创建一个包含严格安全子集的 C++ 超集。无论是启动一个新项目,还是在现有项目中编写安全代码,都可以在 C++ 中实现——这样编写的 C++ 代码,将与用 Rust 编写的安全代码一样,具有强大的安全保证。事实上,生命周期安全是通过借用检查(borrow checking)静态执行的,这正是由 Rust 首次引入的签名安全技术。

选择 Rust 的理由是:这是一种为安全而设计的全新语言。

选择 Safe C++ 的理由是:它提供了与 Rust 同样严格的安全保证,但由于它是对 C++ 的扩展,因此可以无缝地与现有代码互操作。

我们的目标是编写稳健且可靠的软件。虽然 Rust 已被证明是实现这一目标的有效工具,但 Safe C++ 也能成为另一种可行的选择——不可行的,是继续添加不安全、漏洞百出的代码。

你可能要问了,Safe C++ 具体有哪些特点?

(1)一种包含安全子集的 C++ 超集。在安全子集中,禁止出现未定义的行为。

(2)语言的安全部分和不安全部分界限分明,用户必须明确需离开安全部分才能进行不安全操作。

(3)安全子集必须保持实用性。如果我们去除了一些关键的不安全技术,比如联合或指针,就必须提供一个安全的替代方案,比如选择类型或借用。如果一种语言过于缺乏表达能力,即便它非常安全也无法完成工作,那它也无用的。

(4)新系统不能破坏现有代码。如果用 Safe C++ 编译器编译现有的 C++ 代码,那这些代码就必须能够正常编译,用户也可以选择是否使用新的安全机制。一定要记住,Safe C++ 是对 C++ 的扩展,而不是一种新语言。

#feature on safety

#include “std2.h”

int main() safe {

std2::vector

vec { 11, 15, 20 };

for(int x : vec) {

// Ill-formed. mutate of vec invalidates iterator in ranged-for.

if(x % 2)

vec^.push_back(x);

unsafe printf(“%d\n”, x);

}

}

$ circle iter3.cxx

safety: iter3.cxx:10:10

vec^.push_back(x);

^

mutable borrow of vec between its shared borrow and its use

loan created at iter3.cxx:7:15

for(int x : vec) {

^

考虑上面这个用 Safe C++ 写的示例,它可以捕捉到迭代器失效,而通常迭代器失效会导致 use-after-free 错误。接下来让我们逐行分析:

第 1 行:#feature on safety – 在当前文件中启用与安全相关的新关键字。而翻译单元中的其他文件不受影响,这就是 Safe C++ 避免破坏现有代码的方式——-所有内容都是选择性的,包括新的关键字和语法。这个安全特性改变了函数定义的对象模型,使其支持对象重定位、部分初始化和延迟初始化。它将函数定义转换为中级中间表示(MIR),并在此基础上进行借用检查,以标记检查引用中可能潜在的 use-after-free 漏洞。

第 2 行:#include “std2.h” – 引入新的安全容器和算法。加强安全性就是要减少你对不安全 API 的依赖,当前的标准库中充满了各种不安全的 API,而命名空间 std2 中的新标准库会提供相同的基本功能,但其容器将具备生命周期感知和类型安全的特性。

第 4 行:int main() safe – 新的 safe 说明符是函数类型的一部分,类似于 noexcept 说明符。对于调用者来说,这个函数被标记为安全,因此可以在安全的上下文中调用。main 的定义开始于一个安全的上下文,因此不允许执行不安全的操作,例如取消引用指针或调用不安全的函数等。Rust 的函数默认是安全的,而 C++ 的函数默认是不安全的,但这只是语法上的区别。一旦在 C++ 中使用 safe 说明符进入安全上下文,就会得到与 Rust 同样严格的安全保证。

第 5 行:std2::vector

vec { 11, 15, 20 }; – 一个内存安全向量的列表初始化。该向量能够感知生命周期参数,因此借用检查将扩展到有生命周期的元素类型。该向量的构造函数没有使用 std::initializer_list

,因为这种类型有两个问题:首先,用户会得到指向参数数据的指针,而从指针读取数据是不安全的;其次,std::initializer_list 不拥有其数据,无法进行重定位。基于这些原因,Safe C++ 引入了 std2::initializer_list

,它可以在安全上下文中使用,并支持我们的所有权对象模型。

第 7 行:for(int x : vec) – 对向量进行基于范围的 for 循环。标准机制返回一对迭代器,它们实际上是用类封装的指针。C++ 的迭代器并不安全,总以 begin 和 end 成对出现,但不共享共同的生命周期参数,因此对它们进行借用检查不太现实。而 Safe C++ 版本使用切片迭代器,类似于 Rust 的迭代器。这些安全类型使用生命周期参数,因此可以很好地防止迭代器失效。

第 10 行:vec^.push_back(x); – 向向量中添加一个值。这里的 ^ 是什么意思?这是一个后缀对象操作符,表示对成员函数调用的对象参数进行可变借用。当启用了 #feature on safety 时,所有的修改操作都是显式的,这样在选择对象的共享借用还是可变借用时更精确。Rust 不支持函数重载,因此会隐式借用(可变或共享)成员函数的对象;而 C++ 支持函数重载,所以需要显式指定以获取我们想要的重载。

第 12 行:unsafe printf(“%d\n”, x); – 调用 printf。这是一个非常不安全的函数,但由于我们在安全的上下文中,所以必须用 unsafe 关键字来转义。Safe C++ 不会锁定 C++ 语言的任何部分,你可以用 unsafe 关键字,但前提是要明确声明。用了 unsafe 意味着你承诺遵守函数的前置条件,而不是依赖编译器来确保这些前置条件。

如果 main 在语法上通过了检查,其 AST 会被下放到 MIR,并在那里进行借用检查。为 ranged-for 循环提供动力的隐藏迭代器,在循环执行期间保持初始化状态。push_back 通过修改迭代器的约束位置(即向量),使迭代器失效。当下次从迭代器中加载值 x 时,借用检查器会报错:在共享借用和使用之间,vec 存在可变借用。借用检查器程序可以防止 Circle 编译出可能存在未定义行为的程序——所有这些都是在编译时完成的,不会影响程序的大小或速度。

上面这个示例虽然只有几行,但我引入了许多新的机制和类型。近年来安全专家不断提醒我们说 C++ 非常不安全,这的确是事实。因此我们才需要付出系统性的努力,提供一个带有安全子集的语言超集,同时确保其具有足够的灵活性和表现力。

二、内存安全的价值主张

内存安全语言的前提,是一个对人类行为的基本观察:人们倾向于先尝试一下,如果不行再寻求帮助。放在编程中,就是开发者会先尝试用一个库,只有在不能用时才会阅读文档。事实证明,这种做法非常危险,因为能工作的代码并不一定真正安全。

许多 C++ 函数都有一些前置条件,只有在仔细阅读文档后才能了解。前置条件可能千奇百怪,开发者也不能自动知道安全的使用方式是怎样的。即使表面看起来无害,但违反这些前置条件会导致未定义行为,从而使你的软件面临攻击风险。

我认为,软件的安全和保障不应依赖于程序员是否严格遵守文档。

基于此,我想提出一个价值主张:编译器和库供应商也需要付出额外努力,提供一个稳健的环境,这样用户就不必阅读文档了。无论他们如何使用语言和库,都不会引发未定义行为,也就不会使软件面临安全相关的漏洞。当然,没有一个系统能防止所有误用,匆忙编写的代码可能会有很多逻辑错误,但这些逻辑错误不会导致内存安全漏洞。

上周,我就犯了一个低级错误:问题出在 std::isprint 函数的使用上。这个函数的参数是 int 类型,而我当时传入的是 UNICODE 代码点,没有考虑到前置条件——参数必须在 -1 到 255 之间。

我承认这是我的问题,但不得不说库的设计也违背了人性:不要期望每个程序员在使用函数之前都会仔细阅读文档!如果内存安全语言能提供一个安全稳健的环境,就能防止这种情况的发生了。

有些内存安全问题,比如上述问题,很容易修复。但在像 ISO C++ 这样不安全的语言中,有些问题是无法修复的。仅靠阅读文档、遵循 C++ 核心指南或编写单元测试是不够的。为了解决生命周期和线程安全问题,需要引入新的语言技术,而这些是全局性问题,需要系统性的解决方案。

三、这是一条未走过的路

许多库都在尝试缓解未定义行为的问题,例如检测器(sanitizers)就是一种特殊的构建目标,它们能在运行时标记出未定义行为,这类项目作为防御 C++ 代码漏洞的第一道防线非常有效。

但是,有哪些努力是直接将内存安全特性引入 C++ 语言的呢?除了 Circle C++,目前没有任何正在进行的项目试图扩展 C++ 以提供工业和政府安全研究人员所需的严格内存安全保证。没有人尝试在 MSVC、Clang 和 GCC 等主流编译器中构建安全上下文,还有那些依赖 C++ 发展的公司,如微软、谷歌、Nvidia、英特尔、Adobe 和彭博社,也都没有采取相关措施。甚至 C++ ISO 委员会对这个问题也没有任何见解和应对策略。

为什么编译器供应商和标准化工作者不肯认真对待 C++ 中日益严重的安全漏洞问题?我认为,这是因为这个问题看起来太难,任何单一的努力都难以取得实质性进展:

(1)解决方案要适用的范围太广。内存安全漏洞种类繁多,涉及生命周期安全、边界安全、线程安全和各种类型安全等多个方面。每种漏洞都需要单独处理,这导致整体扩展的工作量非常大。因此有人认为,这些变更综合起来,对于委员会或供应商来说工作量实在过于庞大。

(2)需要对工具进行重大升级。不仅编译器前端需要全面改造,还需要一个新的中端来支持借用检查和对象重定位。同时,还必须编写一个新的标准库,逐步取代旧的库,减少不安全操作的风险——前端、后端加上标准库的改造,远远超出了编译器开发者的常规工作范围。

(3)新技术难度很大。Rust 安全模型中最独特的部分是 NLL 借用检查器,这是一个非常复杂的功能,光读一本 Rust 入门书籍或摸索着用这门语言,都无法理解它的工作原理。这个功能的复杂性吓退了很多人,他们根本不敢考虑将这项技术集成到 C++ 编译器中。虽然这是一个美好的想法,但如果你是一名前端工程师,还没有广泛接触过控制流图,那就有点像在逼你掌握外星技术了。

(4)主流编译器对实验来说过于繁琐。C++ 经过了 50 多年的演变,从 K&R C 到 C++23,语言极其复杂,编译器的编写和维护也同样很困难,在 MSVC、Clang 或 GCC 上进行这种级别的实验非常难。而 Circle 编译器只有大约 31 万行代码,相比之下非常紧凑。每一行代码都是我写的,我对每个部分的功能都非常了解,相比在主流工具链上工作的人,在这个方面我具有巨大的灵活性优势。

(5)C++ 用户的傲慢态度。C 和 C++ 的从业者常有一种“要做好”的心态,他们认为如果你把事情搞砸了,那就是你自己的错,解决办法就是提高自己。然而,软件设计是一个需要协作的过程,即使是像我这样的独立开发者也不例外。你总是依赖于他人的代码,不可能理解它是如何工作的。在大型项目中,期望每个代码都完美无误是不现实的。此时,若使用内存安全语言会让错误的代价大大降低:你的程序不会出现未定义行为,无法通过编译器检查健全性的结构会被标记为可能不安全,从而给程序员重新思考设计的机会,或许用一些内存安全的 API 重新实现这些操作。

在《国家网络安全实施计划》中曾提到:

为了开始制定安全软件开发的监管标准,政府将推动制定一个适应性安全港框架,保护那些安全开发和维护其软件产品和服务的公司免受责难……政府将与国会和私营部门合作,制定立法,确定软件产品和服务的责任。

C++ 的机构用户应该感到担忧,安全社区正在呼吁淘汰这种语言,政府也正在讨论对发布漏洞代码的公司追究责任,并为制定安全策略的公司提供免责保护。此外,禁止在某些行业中使用 C++ 的立法看起来也有可能成为现实,但 ISO 委员会并没有重视这个问题的严重性,也没有应对策略或领域专家。

我认为,光是口头反对国家安全局的说法并不能解决问题,我们需要做点实事。我希望能与 C++ 相关公司合作,努力解决根本问题。要想让 C++ 成为开发者在未来几十年中既能使用又愿意使用的语言,我们需要付出大量努力。

四、开发者热议:Circle C++ 是有价值的,但无人投资

Sean Baxter 对 Circle C++ 的设想在开发者圈内引起热议,其中有不少人对此表示支持:

“我认为 Sean 的作品是有价格的,但目前还没有人愿意投资。”

“Circle 是唯一一个拥有类似 Typescript 进化路径的 C++ 后继者,而且还能提供高质量的编译器。不幸的是,WG21 似乎对 Circle 早期提出的任何想法都有意见,他们应该不愿意采纳 Sean 的工作。这就很可惜,因为 Sean 单枪匹马就比整个 C++ 编译器领域的人交出了更多的成果。”

但同时,也有人对 Circle 拥有 2.3k+ Star、却 7 个月没更新的现状提出质疑:“我对开发者对Circle 的热爱感到非常困惑——有一个 GitHub 已经 7 个月没有更新了,而且还没有许可证,它真的有用户吗?看起来这只是一个雏形项目,其理念并未被 cpp 采用,编译器资源库也没更新。”

对此,有人回应称 7 个月没更新是因为 Circle 并非开源项目,并补充道企业也不会在意它是否开源:“如果 Circle 真的能实现其既定目标,那么 C++ 内存安全超集的价值主张就是巨大的。很多使用 C++ 编写关键软件的公司不会在意 Circle 是否开源,只要它能满足他们的所有要求(认证、审核等),他们就能给予它强大的企业支持。”


GitHub 星标 2.3k+!具有内存安全的 Circle C++ 引热议,开发者评价:确有价值,但无人投资 本文内容来自网络,仅供学习、参考、了解,不作为投资建议。股市有风险,投资需谨慎!