本文经授权转自公众号CSDN(ID:CSDNnews)
作者 | Ivan Lozano、Dominik Maier
不久前,开源维护者 Wedson Almeida Filho 决定退出一直参与的 Rust for Linux 项目构建,起因是社区中一些 C 语言内核开发者“似乎决心让 Rust 维护者的工作变得非常艰难,因为他们不觉得 Rust 有价值,甚至希望它消失”,还有人觉得使用 Rust 来改写代码真的太难了。
矛盾激化之下,不少人也将关注重点放到了难以下手的 Rust 身上。然而,没想到的是,另一家对 Rust 感兴趣的 Google,其 Android 工程师于近日直接发了一篇文章展示了 Rust 改写 Android 固件的过程,还直言:太简单了!
来源:https://security.googleblog.com/2024/09/deploying-rust-in-existing-firmware.html
在当前的编程语言中,Rust 可谓风头正劲,国内外不少大厂都在深度拥抱。
随着企业技术栈的转变,身处一线的开发人员也要随之学习新的技术才能跟上时代的潮流。然而,对于以学习曲线陡峭著称的 Rust 编程语言来说,“简单 ”并不是一个常听到的词。
因此,用安全的编程语言 Rust 替换原有的 C、C++ 代码,操作难度究竟如何?日前,来自 Google 的 Android 工程师 Ivan Lozano 和 Dominik Maier 发文分享了自己在现有固件代码中部署 Rust 的实践经历。
Lozano 和 Maier 坦言:“通过本文,你会发现,使用 Rust 直接替换来提高安全性是多么容易,我们甚至会演示 Rust 工具链如何处理专门的裸机目标。”
以下是他们的实践全文:
1、固件的内存安全
固件是硬件与高级软件之间的接口。由于缺乏上层软件标准的软件安全机制,固件代码中的漏洞可能被恶意行为者利用,造成危险。现代手机包含许多负责处理各种操作的协处理器(coprocessor),每个协处理器都运行自己的固件。
通常情况下,固件由用 C 或 C++ 等内存不安全语言编写的大型传统代码库组成。内存不安全是导致 Android、Chrome 浏览器和许多其他代码库出现漏洞的主要原因。
Rust 是 C 和 C++ 的内存安全替代语言,性能和代码大小相当。此外,它还支持与 C 的互操作性,且没有任何开销。
2、逐步采用 Rust
在替换编程语言过程中,Android 团队采用了渐进式方法,首先侧重于替换新的和风险最高的现有代码(例如,处理外部不受信任输入的代码),可以以最少的投入获得最大的安全效益。只需用 Rust 编写任何新代码,就能减少新漏洞的数量,随着时间的推移,还能减少未解决漏洞的数量。
你可以通过编写一个 Rust shim(轻量级的中间层)来替换现有的 C 代码功能,该 shim 可在现有 Rust API 和代码库所需要的 C API 之间进行转换。shim 会复制并导出 C API,以供现有代码库使用。shim 代码充当 Rust 库 API 的包装器,在现有 C API 和 Rust API 之间架起桥梁。这是用 Rust 替代库重写或替换现有库时常用的方法。
3、挑战和注意事项
在固件代码库中引入 Rust 之前,你需要考虑几个挑战。下文将介绍 no_std Rust(即裸机 Rust 代码)的一般情况、如何找到合适的现成 crate(Rust 库)、将标准 crate 移植到 no_std、使用 Bindgen 生成 FFI 绑定、如何处理分配器,以及如何设置工具链。
Rust 标准库和裸机环境
Rust 标准库由三个包组成:core、alloc 和 std。core 包始终可用。alloc 包需要一个分配器来实现其功能。std crate 假定有一个完整的操作系统,通常不支持裸机环境。第三方 crate 通过 crate-level #![no_std] 属性表示它不依赖 std。这种 crate 被称为 no_std 兼容 crate。
本文的其余部分将重点讨论这些内容。
选择要替换的组件
在选择要替换的组件时,应将重点放在具有强大测试功能的独立组件上。理想情况下,组件的功能可以由支持裸机环境的开源实现提供。
处理标准和常用数据格式或协议(如 XML 或 DNS)的解析器是很好的初始候选组件。这样可以确保初期工作的重点放在将 Rust 与现有代码库和构建系统集成的挑战上,而不是复杂组件的细节上,并简化测试。这种方法可以简化日后引入更多 Rust 的工作。
选择已有的 Crate(Rust 库)
选择合适的开源 crate(Rust 库)来替换所选组件至关重要。需要考虑的事项有:
crate 是否得到了很好的维护,例如,未解决的问题是否得到解决,它是否使用最新的 crate 版本?
crate 的使用范围有多广?这可以作为质量信号,但在以后使用可能依赖 crate 时也要考虑。
crate 是否有可接受的文档?
它是否有可接受的测试覆盖范围?
此外,crate 最好与 no_std 兼容,这意味着标准库要么未使用,要么可以禁用。虽然有很多兼容 no_std 的 crate,但也有一些尚不支持这种操作模式的情况——在这种情况下,请参阅下一节关于将标准库转换为 no_std 的内容。
按照惯例,可选支持 no_std 的 crate 将会提供一个 std 特性,以指示是否应使用标准库。类似地,alloc 特性通常表示使用分配器是可选的。
注意:即使库在源代码中声明了 #![no_std],也不能保证其依赖库不依赖于 std。我们建议查看依赖关系树,以确保所有依赖关系都支持 no_std,或测试库是否可编译为 no_std 目标。目前唯一的办法是尝试为裸机目标编译 crate。
例如,一种方法是使用 rustup 提供的裸机工具链运行 cargo check:
$ rustup target add aarch64-unknown-none
$ cargo check --target aarch64-unknown-none --no-default-features
将 std 库移植到 no_std
如果某个库不支持 no_std,仍有可能将其移植到裸机环境中,尤其是文件格式解析器和其他与操作系统无关的工作负载。文件处理、线程和异步代码等高级功能可能会带来更大的挑战。在这种情况下,这些功能可以隐藏在功能标志后面,以便在 no_std 构建中仍能提供核心功能。
将 std crate 移植到 no_std(core+alloc):
在 cargo.toml 文件中添加一个 std 功能,然后将此 std 功能添加到默认功能中
在 lib.rs 文件顶部添加以下几行:
#![no_std]
#[cfg(feature = "std")]
extern crate std;
extern crate alloc;
然后,按如下方法反复修正所有出现的编译器错误:
将所有使用指令从 std 移至 core 或 alloc。
为所有会被 std prelude 自动导入的类型添加使用指令,例如 alloc::vec::Vec 和 alloc::string::String。
在 #[cfg(feature = “std”)] guard 后隐藏任何不存在于 core 或 alloc 中、且无法在 no_std 构建中支持的内容(如文件系统访问)。
任何需要与嵌入式环境交互的内容都可能需要显式处理,例如 I/O 函数。这些函数可能需要置于 #[cfg(not(feature = “std”))] 防护后。
禁用所有依赖项中的 std(也就是说,如果使用 Cargo,则在 Cargo.toml 中更改它们的定义)。
需要对 crate 依赖关系树中尚未支持 no_std 的所有依赖关系重复此操作。
4、自定义目标架构
Rust 编译器有许多官方支持的目标,但其中缺少许多裸机目标。值得庆幸的是,Rust 编译器可以降维到 LLVM IR,并使用 LLVM 的内部副本来降维到机器码。因此,通过定义自定义目标,它可以支持 LLVM 支持的任何目标架构。
定义自定义目标需要将通道设置为 dev 或 nightly 的工具链。Rust 的 Embedonomicon 提供了这方面的大量信息。
简要介绍一下,自定义目标 JSON 文件可以通过查找类似的受支持目标并转储 JSON 表示来构建:
$ rustc --print target-list
[...]
armv7a-none-eabi
[...]
$ rustc -Z unstable-options --print target-spec-json --target armv7a-none-eabi
这将打印出类似的目标 JSON 文件:
$ rustc --print target-spec-json -Z unstable-options --target=armv7a-none-eabi
{
"abi": "eabi",
"arch": "arm",
"c-enum-min-bits": 8,
"crt-objects-fallback": "false",
"data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64",
[...]
}
该输出可作为定义目标的起点。特别值得注意的是,data-layout 字段在 LLVM 文档中有定义。
一旦定义了目标,就必须为新定义的目标从源代码构建 libcore 和 liballoc(以及 libstd,如果适用)。如果使用 Cargo,使用 -Z build-std 构建就能实现这一点,表明这些库应与 crate 模块一起从源代码为目标构建:
# set build-std to the list of libraries needed
cargo build -Z build-std=core,alloc --target my_target.json
5、使用 LLVM 预编译器构建 Rust
如果 Rust 工具链内部捆绑的 LLVM 不支持裸机架构,则可以使用任何支持目标的 LLVM 预编译器生成定制的 Rust 工具链。
在 config.toml 中,llvm-config 必须设置为 LLVM 预编译器的路径。
你可以通过查看发行说明,查找将 LLVM 最低支持版本提升的版本,从而找到特定版本的 LLVM 所支持的最新 Rust 工具链。例如,Rust 1.76 将 LLVM 的最低版本提升至 16,而 1.73 则将 LLVM 的最低版本提升至 15。这意味着使用 LLVM15 预编译器,可以构建的最新 Rust 工具链是 1.75。
6、创建可直接插入的 Rust Shim
要创建一个可直接替换的 C/C++ 函数或 API,Shim 需要做到两点:它必须提供与被替换库相同的 API,而且必须知道如何在固件的裸机环境中运行。
提供相同的 API
第一点可以通过定义具有相同函数签名的 Rust FFI 接口来实现。
我们将实现放在一个安全函数中、并在其周围公开一个较薄的封装类型,从而尽可能减少不安全 Rust 的数量。
例如,FreeRTOS coreJSON 示例包含一个 JSON_Validate C 函数,其签名如下:
JSONStatus_t JSON_Validate( const char * buf, size_t max );
我们可以在 Rust 中编写一个 shim,在它和内存安全 serde_json crate 之间公开 C 函数签名。我们尽量减少不安全代码,并尽早调用安全函数:
#[no_mangle]
pub unsafe extern "C" fn JSON_Validate(buf: *const c_char, len: usize) -> JSONStatus_t {
if buf.is_null() {
JSONStatus::JSONNullParameter as _
} else if len == 0 {
JSONStatus::JSONBadParameter as _
} else {
json_validate(slice_from_raw_parts(buf as _, len).as_ref().unwrap()) as _
}
}
// No more unsafe code in here.
fn json_validate(buf: &[u8]) -> JSONStatus {
if serde_json::from_slice::<Value>(buf).is_ok() {
JSONStatus::JSONSuccess
} else {
ILLEGAL_DOC
}
}
注:这是一个非常简单的示例。对于资源高度紧张的目标,可以避免使用 alloc,而使用 serde_json_core,它的开销更低,但需要预先定义 JSON 结构,以便在堆栈上分配。
回调 C/C++ 代码
为了让任何 Rust 组件都能在基于 C 的固件中发挥作用,它需要回调 C 代码,以便进行分配或日志记录等操作。值得庆幸的是,有多种工具可以自动生成 Rust FFI 与 C 代码的绑定。
标准的方法是使用 Bindgen 工具。你可以使用 Bindgen 解析所有相关的 C 头文件,这些文件定义了 Rust 需要调用的函数。重要的是,在调用 Bindgen 时要使用与相关代码相同的 CFLAGS,以确保正确生成绑定。
此外,Bindgen 还为生成静态内联函数绑定提供了实验支持。
连接固件的裸机环境
接下来,我们需要将 Rust panic 处理程序、全局分配器和关键部分处理程序连接到现有代码库上。这就需要为每种处理程序定义调用现有固件的 C 语言函数。
必须定义 Rust panic 处理程序,来处理意外状态或失败的情况。可通过 panic_handler 属性定义自定义的 panic 处理程序。这是针对目标的,在大多数情况下,应指向当前任务/进程的 abort 函数,或环境提供的 panic 函数。
如果固件中存在分配器,而 crate 依赖于 alloc crate,则可以通过定义一个实现 GlobalAlloc 的全局分配器来连接 Rust 分配器。
如果有问题的 crate 依赖并发,则需要处理关键部分。Rust 的 core 或 alloc crate 并不直接提供定义此功能的方法,但 critical_section crate 常用于处理许多架构的此功能,并可扩展以支持更多架构。
此外,连接日志记录功能也很有用。固件现有日志功能的简单封装器可以将这些功能暴露给 Rust,并用来代替 print 或 eprint 等功能。一个方便的选择是实现日志特质。
易失效的分配和 alloc
Rusts allocate 通常假定分配是无误的(即内存分配不会失败)。然而,由于内存限制,在大多数裸机环境中,这种假设并不成立。在正常情况下,当分配失败时,Rust 会出现 panic 或者终止;这可能是某些裸机环境可以接受的行为,在这种情况下,使用 alloc 时无需考虑其他因素。
但是,如果有明确的理由或要求使用易失效的分配,则需要额外的努力来确保分配不会失败或失败后能得到处理。
一种方法是使用能提供静态易错分配集合的代码板块(如 heapless 代码板块),或动态易错分配(如 fallible_vec)。另一种方法是专门使用 try_* 方法,如 Vec::try_reserve,它可以检查分配是否可行。
Rust 正在正式完善对易错分配的支持,夜间版本中的实验性分配器允许实现处理失败的分配。此外,alloc 的不稳定 cfg 标志名为 no_global_oom_handling,它可以移除不可靠的方法,确保它们不会被使用。
构建优化
使用 LTO 构建 Rust 库是优化代码大小所必需的。向 rustc 传递 -C lto=true 时,现有的 C/C++ 代码库无需使用 LTO 构建。此外,设置 -C codegen-unit=1 还能进一步优化代码的可重复性。
如果使用 Cargo 构建,建议使用以下 Cargo.toml 设置来减少输出库的大小:
[profile.release]
panic = "abort"
lto = true
codegen-units = 1
strip = "symbols"
# opt-level "z" may produce better results in some circumstances
opt-level = "s"
向 rustc 传递 -Z remap-cwd-prefix=. 标志,或在使用 Cargo 构建时通过 RUSTFLAGS env 变量向 Cargo 传递 -Z remap-cwd-prefix=. 标志,以剥离 cwd 路径字符串。
最相关的例子可能是 Rust binder Linux 内核驱动程序,它发现 “Rust binder 的性能与 C binder 相似”。
在将 LTO 的 Rust staticlib 与 C/C++ 链接在一起时,建议确保最终链接中只有一个 Rust staticlib,否则在链接时可能会出现重复符号错误。这可能意味着要将多个 Rust shims 合并到一个静态库中,方法是从封装模块中重新导出它们。
7、当今固件的内存安全
使用本博文中概述的流程,你可以立即开始在大型遗留固件代码库中引入 Rust。使用现成的开源内存安全实现替换安全关键组件,并使用内存安全语言开发新功能,这将减少关键漏洞,同时改善开发人员的体验。
本文转自公众号“CSDN”,ID:CSDNnews
---END---