对比C++,Rust在内存安全上做的努力

简介

近年来,越来越多的组织表示,如果新项目在技术选型时需要使用系统级开发语言,那么不要选择使用C/C++这种内存不安全的系统语言,推荐使用内存安全的Rust作为替代。

谷歌也声称,Android 的安全漏洞,从 2019 年的 223 个降低到 2022 年的 85 个,经过分析,谷歌认为内存漏洞减少的情况,主要与 Rust 代码的比例增加有关。在 Android 13 中,就已经有约 21%的新原生代码以 Rust 开发

微软也宣布,Rust 将正式入驻 Windows 系统内核;AWS在其基础设施中越来越多地使用 Rust ;2022 年 12 月,Linux 内核 6.1 发布,包括最初的 Rust 支持. . .

作为后来者,Rust是怎么做到内存安全,且受到越来越多人的青睐呢?要知道,换做使用C/C++开发,可能只有高级C/C++开发人员写出的代码才能如此稳定,Rust是怎么保证任何一个使用它的人都能写出内存安全的代码的呢?

下面,针对在C/C++中几种常见的内存安全问题为例,简单分析下。


悬空指针

悬空指针主要是指,在C/C++中,某个对象已经被释放了,但是在某个角落还有一个指针指向这个对象,这个指针就是一个悬空指针 。当代码运行到这个地方,解引用这个悬空指针时,就会出现未定义的行为

Rust解决这个问题的办法就是Rust的精髓所在------生命周期

C++ 复制代码
int main()
{
	std::string *ptr = nullptr;
	{
		std::string = str;
		ptr = &str;
	}
	printf("%s", ptr->c_str());
	return 0;
}

上面是典型的C++中出现野指针的场景,这段代码编译器不会发出任何抱怨。

程序入口定义了一个std::string类型的指针ptr,并初始化为nullptr。进入代码块后,在代码块中创建一个局部变量str,并且让ptr指向这个局部变量。当执行流结束这个代码块后,栈上的变量str将会被释放,但是此时指针ptr 还是指向这个局部变量str,代码块后续任何解引用指针ptr的地方都将是一个不可预期的行为。

我们用Rust实现一下这段代码。

rust 复制代码
fn main()
{
	let str_ref;
	{
		let str_obj: String = String::new();
		str_ref = &str_obj;
	}
	println!("{str_ref }");
}

相同的逻辑,只是Rust中将指针改为了引用(引用就是一个指针)。当执行流结束代码块之后str_obj将会被释放,但是此时str_ref 还指向这个局部变量。尝试编译一下。

不出意外的,Rust的生命周期检查器发现了这个问题,报错信息是borrowed value does not live long enough,他说str_obj的生命周期不够长,引用str_ref在str_obj的生命结束后还在使用。

Rust在编译时会尝试为每个引用和被引用的对象分配一个生命周期,生命周期完全是Rust在编译期虚构的产物,在运行期,引用就是一个地址,所以生命周期不会有任何运行期开销。有了生命周期,在编译期,生命周期检查器就会对比被引用对象和引用之间的生命周期关系,如下:

黄色框表示str_obj的生命周期;引用str_ref 的生命周期是,str_ref 从初始化开始到str_ref 最后被使用的地方之间的代码块就是str_ref 的生命周期,所以这里白色方块表示引用str_ref 的生命周期。生命周期检查器准则之一是,引用的最大生命周期不能超过被引用对象的生命周期,很明显,这里违反了这条规则,所以无法通过编译。

Rust解决野指针最重要的方法就是生命周期,这里只是介绍了最简单的一个场景,在学习Rust时,一定要理解生命周期的含义。


缓冲区溢出

在C++中,以vector为例,想要以索引的方式访问某个对象时,我们通常会使用vector的at方法进行访问,at方法会进行数组越界检测,这很安全。

但是vector可以通过data方法返回一个C/C++的原生数组,当我们对原生数组进行索引操作时,完全是一种走钢丝的行为。

因为没有任何越界检测,此时如果发生缓冲区溢出,将会是一个未定义的行为。如果影响了其他变量,那么这将会是一个非常难排查的问题;如果改动了不可写的地址,那么会导致程序崩溃;如果运气好溢出的部分没有影响到任何对象,那看起来将会是一切安好,但是我们并不总是有那么好的运气。

这种未定义的行为绝对不是我们想要的。来看看Rust是怎么做的。

rust 复制代码
fn main() {
    let vec: Vec<i32> = vec![1,2,3];
    let vec_ref: &[i32] = &vec[0 ..];
    for i in 0 .. 4 {
        println!("{}", vec_ref[i]);
    }

上面是在rust中创建了一个vector------vec,其长度为3(内容为1、2、3),然后一个引用vec_ref(指针)指向这个vec。

紧接着使用引用vec_ref故意进行了一次缓冲区溢出的轮询操作, 此时我们能够正常通过编译。这当然能够编译通过,千万不要妄想Rust能够在编译期解决缓冲区溢出这种主要在运行期出现的问题

但是cargo run运行时

可以清楚的看到导致了panic,提示长度是3,但是index也是3,出现了缓冲区溢出的访问。也就是说Rust对于缓冲区溢出的访问会有一个已定义的行为------导致线程panic。但是新问题又来了,为什么一个引用(指针)vec_ref也有长度信息呢?

如果只是一个普通的引用当然不会有长度信息,但是这里的引用vec_ref是对一个连续数据vec的引用 。在Rust中,vec_ref准确的说是一个切片。对一个连续数据的引用(切片),引用本身是一个胖指针,即该引用占两个机器字(普通引用只是一个普通指针,内存上只占用一个机器字)的内存,第一个机器字是被引用的连续数据的首地址;第二个机器字是连续数据的长度。

下面是打印两种引用占用内存大小的代码。

rust 复制代码
fn main() {
    let vec: Vec<i32> = vec![1,2,3];
    let vec_ref: &[i32] = &vec[0 ..];

    let num: i32 = 3;
    let num_ref: &i32 = &num;

    println!("vec_ref size_of:{} num_ref size_of:{}",
    		std::mem::size_of_val(&vec_ref), std::mem::size_of_val(&num_ref))
}

输出为vec_ref size_of:16 num_ref size_of:8。说明,引用(切片)vec_ref占用16Bytes,引用num_ref占用8Bytes,我的电脑是64位的电脑,刚好是两个机器字和一个机器字。

除了切片之外,Rust中的原生数组也是带有长度信息的,所以在使用原生数组出现缓冲区溢出时,也会导致已定义的行为。

综上,因为缓冲区溢出主要是一个运行期的行为,所以Rust也没办法做到在编译期解决这个问题,但是通过胖指针的方式,Rust做到了在运行期如果出现缓冲区溢出,那一定会有一个已定义的行为------线程panic。这肯定好过C/C++中缓冲区溢出后,各种未定义的奇葩问题。


对空指针进行解引用

C/C++中对空指针解引用导致的崩溃问题更多的是开发人员个人编程习惯导致的。

在C/C++中,一个更好的编程习惯是在解引用指针之前,先对指针进行判空操作,但是这样简单的一个判断逻辑常常因为开发同学的"自信",导致在很多地方偷懒忽略,然后直接对指针解引用后开始操作。往往越是自信不会为空的地方越是会给我们带来最承重的打击。

针对空指针解引用,首先Safe Rust中只有引用没有指针,这里的引用和C++中的引用类似,本质也是一个指针。Safe Rust中,在使用一个引用之前,必须对引用赋值,否则无法通过rustc的检测

rust 复制代码
fn main() {
    let s: String = String::new();
    let s_ref: &String = &s;
}

s是一个String类型的变量,s_ref是对s的一个引用。只有对s_ref赋值后才能对s_ref进行使用。rustc通过强制检测你的编码实现,杜绝了空指针的使用。

当然,一定存在一个场景。某个引用,其需要引用的对象可能在程序运行之初并没有被创建,随着程序的运行才创建,创建后还需要让这个引用指向这个刚创建的对象,也就是说需要Rust支持引用一开始为空,随着程序的运行才被赋值的情况。

上面这种场景下需要采用OptionRust中,一切可能为空的东西都需要使用Option进行包裹,不仅仅是引用。

rust 复制代码
fn main() {
    let mut s_ref_option: Option<&String> = None;
    
    let s: String = String::new();
    s_ref_option = Some(&s);
}

这一次s_ref_option因为可能为空,所以被声明为Option<&String>类型的None,语义为,有一个T&String类型的Option,这个Option目前包裹的值是None,但是后面可能会赋值,所以后续要想获取s_ref_option中包裹的&String时,你需要进行检查,因为不确定后面会不会赋值。

紧接着,s才被创建,然后使用Some包裹后赋值给s_ref_option。

通过Option获取其包裹的值通常有两种做法,一种是安全的,一种是不安全的。安全的操作是在使用之前对Option进行判空,显而易见这很安全。

rust 复制代码
// 使用 s_ref_option 时判空
if let Some(v) = s_ref_option {
	//... v 是&String
}

但这在Rust中也不是强制的,开发人员也可以以一种不安全的方式使用Option------直接获取Option中包裹的值。

rust 复制代码
// 不判空直接获取Option中包裹的值
let s_ref: &String = s_ref_option.unwrap();

这和C/C++中直接进行空指针解引用并没有什么区别。

但是好在可以通过rustc中内置的静态代码检测工具clippy,对代码进行扫描,如果检测到代码中有使用unwrap,那么直接报error,clippy帮助检查代码中是否有这种危险的使用。这可以理解为是Rust编程的一种规范,让不写unwrap作为Rust编程规范的一部分。

clippy中可以通过设置clippy::restriction集中的unwrap_used这条规范达到我们的目的,具体可以看我的另一篇博客 Rust代码静态分析工具Clippy浅析

综上,Rust通过编译器,强制检测引用(指针)在使用之前必须赋值解决了这个问题。对于可能为空的对象,配合clippy使用,对于是否可以直接解引用可能为空的对象的选择权留给开发者,也不为是一种比较好的方案。


非法释放内存

C/C++中存在非法释放内存的情况,比如double free、非法释放栈上的内存等等,这些操作都会导致程序的崩溃。

作为非GC系的语言,Rust也面临释放内存资源的问题。但是当你真正开始使用Safe Rust时会发现,你基本不需要关心内存的释放,因为Rust将C++中的精华RAII发挥到了极致。

对于需要进行内存管理的对象类型,其都会实现Drop 特型,定义如下:

rust 复制代码
pub trait Drop {
    // Required method
    fn drop(&mut self);
}

实现该特型的类型,其实例在被释放前都会调用这个方法,类型的实现者可以在drop中释放自己管理的资源,这和C++中的析构函数一样。RAII在Rust中被大量采用,所以作为一个Rust的开发者,在Safe Rust中,你基本不需要再去进行内存管理。

总结

Rust作为一颗冉冉升起的新星,已经得到了越来越多人的认可,将其压入你的技术栈,一定会是一个不错的选择。


相关推荐
AirDroid_qs5 分钟前
Wordperss漏洞 DeDeCMS漏洞
安全·网络安全
吴秋霖1 小时前
浅谈某平台多场景下反爬虫与风控业务
爬虫·安全·反爬虫
咔咔咔的2 小时前
1847. 最近的房间
c++
Gpluso_od2 小时前
算法常用库函数——C++篇
数据结构·c++·算法
思麟呀5 小时前
在C语言基础上的C++第二章(类和对象)
c语言·开发语言·c++·学习
hjxxlsx6 小时前
备战 C++ 编程区赛 指南
开发语言·数据结构·c++·算法·贪心算法·区赛
网络安全Ash6 小时前
网络安全专有名词详解_3
安全·web安全·php
酷酷的崽7986 小时前
【递归与回溯深度解析:经典题解精讲(中篇)】—— LeetCode
c++·算法·leetcode
励志成为大佬的小杨7 小时前
初始c语言第一个c语言项目
c语言·c++·算法
Hacker_Oldv7 小时前
网络安全-企业环境渗透2-wordpress任意文件读&&FFmpeg任意文件读
安全·web安全·ffmpeg