Rust安全编码实践 Secure Coding Practices in Rust

作者:禅与计算机程序设计艺术

1.简介

Rust编程语言被称为可保证内存安全的系统编程语言,它在编译期间通过类型系统确保数据不出错。因此,Rust语言开发者需要掌握一些安全编码实践,如内存安全、访问控制、输入验证等。本文将对这些安全编码实践进行详细介绍,并结合Rust代码实例加以说明。
本文涉及以下主题:
1.内存安全
a) 概念
b) 检查器
2.访问控制
a) 可信任的代码
b) 输入验证
3.线程安全
Rust提供两种方法帮助检查线程安全问题:1)特征(Traits) 2)内部同步机制。第一种方法与C++中的模板类类似,可以为结构体或枚举添加额外的约束条件,比如 Send 和 Sync 。第二种方法则利用标准库中提供的原子化类型(atomic types),如 AtomicUsize ,Atomicsi32, Atomicsi64 等。本文将对两者进行详细说明。
最后,本文还会给出一些常见的 Rust 的安全编码误区和提示,希望能够引起读者的注意力,并使他们能够更好的编写安全的代码。

2.基本概念术语说明

2.1 Rust 内存安全和栈上内存分配

在C/C++等传统的编程语言中,变量一般都存储在堆上,也就是说,当函数运行时,需要先从堆上申请内存,然后再使用这个内存空间。但是在Rust中,所有的变量都存储在栈上,或者叫做静态内存分配。这一点要特别注意,因为栈上的内存比堆上的内存容易受到攻击。
当一个函数返回后,其使用的栈内存就会自动释放掉。这种行为称作栈上的内存分配。所以栈上的内存分配跟堆上的内存分配不同,它的生命周期始终持续到程序结束,而不会因函数调用而释放掉。

fn add(a: i32, b: i32) -> i32 {let c = a + b; // 使用栈上内存return c;
}
fn main() {println!("{}", add(1, 2)); // 输出: 3
}

函数add()声明了一个变量c,这个变量存储的是两个整数相加后的结果。由于栈上的内存分配,变量c仅存活于当前栈帧,直到当前函数执行完成。当函数main()执行完毕,变量c就没用了,它的栈内存就会被释放掉。

栈溢出

在栈上内存分配方式下,如果递归层次过多,函数可能导致栈溢出(Stack Overflow)。由于栈内存分配采用的是线性分配的方式,当递归调用层级过多时,可能导致栈内存占满,进而导致栈溢出。
可以通过增加编译器标志-fstack-protector选项,开启栈溢出的缓冲区溢出检测功能,检测到栈溢出时会立即退出程序。可以使用命令行参数禁用栈溢出检测,但这样做可能会导致溢出漏洞。

Rust 借用检查器

借用检查器(Borrow Checker)是 rustc 中用于检测数据竞争和内存泄漏的工具。借用检查器根据编译期间的分析结果,判断哪些位置的数据存在数据竞争或内存泄漏的风险,并发出警告或错误。借用检查器默认开启,可以通过 #![feature(borrow_checker)] 关闭。
借用检查器检测的数据竞争包括:

  • 对相同数据的同时读写;
  • 数据借用超过其生命周期;
  • 对拥有不可变引用的可变对象进行写入。

悬垂指针

悬垂指针(Dangling Pointers)是指程序运行时出现的指向已失效内存位置的指针,通常是在把此指针赋给新对象的过程中发生的。解决方案:

  • 使用 Option 来避免空悬指针,并在必要时对指针进行处理;
  • 通过生命周期注解来限制对象生存期,避免对象被释放后再使用;
  • 使用 mem::forget() 将对象转移到其他地方,确保其生命周期至少和该对象一样长。

2.2 Rust 中的访问控制

Rust 中的访问权限由三个关键字来控制:

  • pub : 对外公开的,可从任何地方进行访问;
  • private : 只能在模块内进行访问,不能从外部访问;
  • crate : 当前 crate 内有效,外部 crate 不可访问。
    除了这些关键字外,Rust 还提供了三个访问级别(Access Level)来限制对结构体字段的访问:
  • pub(crate): 在整个 crate 中都可访问;
  • pub(super): 可以在父模块和祖父模块访问;
  • pub(in path::to::module): 指定路径才能访问。
    默认情况下,模块的所有成员都是私有的,只有标记为 pub 时才对外公开。

最佳实践:可信任的代码

一般来说,Rust 主要应用于运行环境敏感的、有一定性能要求的程序。为了确保安全性,开发人员应该编写可信任的代码。可信任的代码应该符合如下规则:

  • 尽量减少对资源的手动管理,例如打开文件、数据库连接等;
  • 用结构体表示数据而不是元组;
  • 使用 trait 对象替代泛型,避免使用裸指针;
  • 保证所有传入的参数都有效;
  • 提供易用的 API 接口,降低使用难度;
  • 把复杂任务拆分成多个小函数,提高可维护性;
  • 提供文档、测试和示例代码;
  • 使用特性(Trait)和默认实现来实现可重用的功能组件;

输入验证

输入验证(Input Validation)是指对用户输入数据进行合法性校验,防止恶意攻击。Rust 提供了 FromStr trait 来解析字符串,可以通过实现 FromStr trait 来实现自定义类型的输入验证。例如,假设有一个自定义类型Foo,其需要从用户输入解析整数属性x。可以定义如下结构体:

#[derive(Debug)]
struct Foo {
x: u32,
}
impl FromStr for Foo {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {Ok(Foo {x: s.parse().map_err(|_| ParseIntError)?,})
}
}

用户输入的字符序列可以通过from_str方法转换为Foo实例。如果转换失败,Result会返回ParseIntError。这里也可以使用expect()来替代unwrap(),并且带上更详细的报错信息。

fn parse_foo(input: &str) -> Option<Foo> {
input.parse::<Foo>().ok()
}

如果parse()解析失败,则返回None,否则返回Some(Foo)

2.3 Rust 线程安全

概念

Rust 中的线程安全(Thread Safety)指的是多个线程同时访问同一个对象时,无需外部同步机制协助,也能正确工作。Rust 提供了三种方式来帮助检查线程安全问题:
1.特性:使用 SendSync 特征来限制类型是否可以在线程之间发送和共享。
2.内部同步机制:借助 Rust 标准库中提供的原子化类型(atomic types)来实现互斥锁、信号量和其他线程同步机制。
3.unsafe rust:允许程序员跳过编译器的安全检查,以便于更灵活地进行线程同步。

特征

Rust 提供了 SendSync 两个特征,它们可以用来判断某个类型是否可以跨越线程边界发送和共享,具体的规则如下:

  • Send 特征表示类型的所有权可以在线程之间传输,也就是说,类型的所有权的所有权可以在线程之间移动,但是数据本身不能共享。
  • Sync 特征表示类型的值在多个线程之间共享是安全的,也就是说,对于同一份数据,多个线程可以同时读取,但是无法修改它。
    比如,以下代码展示了如何实现一个线程安全的链表:
use std::sync::{Arc, Mutex};
use std::rc::Rc;
enum Node<T> {
Data(T),
List(Mutex<Vec<Node<T>>>),
}
unsafe impl<T: Send> Send for Node<T> {}
unsafe impl<T: Send> Sync for Node<T> {}
struct LinkedList<T>(Arc<Node<T>>);
impl<T> LinkedList<T> {
pub fn new() -> Self {let list = Arc::new(Node::List(Mutex::new(vec![])));LinkedList(list)
}
pub fn append(&self, value: T) {let mut list = self.0.clone();loop {match Arc::get_mut(&mut list) {Some(Node::Data(_)) => panic!("cannot append to data node"),Some(Node::List(ref mut sub_list)) => {let mut sub_list = sub_list.lock().unwrap();if sub_list.is_empty() {break;} else {list = Rc::make_mut(&mut sub_list[sub_list.len()-1]).0.clone();}},None => panic!("list is dropped")}}let new_node = Rc::new(Node::Data(value));list.append(new_node);
}
}
fn main() {
let mut lst = LinkedList::new();
lst.append("hello");
assert_eq!(lst.head(), "hello");
}

此代码实现了一个线程安全的链表,其中每个节点是一个 Arc 引用计数智能指针包裹着另一个 Node 值。在链表上调用 append() 方法时,首先获取节点的独占引用,如果节点为 Data 类型,则代表已经到了链表末尾,直接插入值即可;如果节点为 List 类型,则获取列表的互斥锁,并在列表非空时,取最后一个元素的引用计数智能指针,对其进行克隆,然后对克隆后的节点调用 append() 方法。
由于 LinkedList 是线程安全的,因此可以在多个线程中并发调用 append() 方法。

内部同步机制

除了使用特征,Rust 还提供了内部同步机制,让程序员自己来控制线程同步。Rust 中的同步机制包括三种:互斥锁(mutex lock),原语(primitive),消息传递(message passing)。

互斥锁

互斥锁(Mutex Lock)是一种同步机制,在任意时刻只能有一个线程对其加锁,其他线程必须等待锁定解除才能继续访问共享资源。Rust 标准库中提供了 Mutex 互斥锁,它是原语,使用方法如下:

use std::thread;
use std::sync::Mutex;
const MAX_COUNT: i32 = 100;
static COUNTER: Mutex<i32> = Mutex::new(0);
fn increment() {
let mut counter = COUNTER.lock().unwrap();
while *counter >= MAX_COUNT {thread::yield_now();*counter = 0;
}
*counter += 1;
}
fn decrement() {
let mut counter = COUNTER.lock().unwrap();
while *counter <= MIN_COUNT {thread::yield_now();*counter = MAX_COUNT;
}
*counter -= 1;
}
fn count() -> i32 {
let counter = COUNTER.lock().unwrap();
*counter
}
fn main() {
let mut threads = vec![];
for _ in 0..100 {threads.push(thread::spawn(|| {for _ in 0..MAX_COUNT / 100 {increment();}}));
}
for t in threads {t.join().unwrap();
}
assert_eq!(count(), MAX_COUNT);
}

此代码使用互斥锁来确保并发访问 COUNTER 时,只能有一个线程对其加锁。在 increment()decrement() 方法中,分别使用循环来尝试获取锁,并在锁不可用时,使用 thread::yield_now() 来让出线程。在 count() 方法中,获取锁后,获取 COUNTER 值。
此代码创建了 100 个线程,并让每一个线程连续执行 MAX_COUNT / 100increment() 操作,最后断言 count() 方法得到的值等于 MAX_COUNT

信号量

信号量(Semaphore)是另一种同步机制,允许多个线程同时访问共享资源。Rust 标准库中提供了 Semaphore 信号量,使用方法如下:

use std::sync::Semaphore;
const MAX_THREADS: usize = 10;
const THREADS_PER_INC: usize = 5;
lazy_static!{
static ref SEMAPHORE: Semaphore = Semaphore::new(MAX_THREADS);
}
fn increment() {
for _ in 0..THREADS_PER_INC {SEMAPHORE.acquire();do_some_work();SEMAPHORE.release();
}
}
fn decrement() {
SEMAPHORE.acquire();
do_some_work();
SEMAPHORE.release();
}
fn main() {
for _ in 0..MAX_COUNT {increment();decrement();
}
}

此代码使用信号量来确保只允许固定数量的线程同时访问共享资源。在 increment() 方法中,获取信号量,然后执行一定数量的 do_some_work() 操作,最后释放信号量;在 decrement() 方法中,获取信号量,然后执行一次 do_some_work() 操作,最后释放信号量。
此代码创建 MAX_COUNTincrement() 操作和 MAX_COUNTdecrement() 操作,共 2*MAX_COUNT 次操作。

Unsafe Rust

为了便于进行线程同步,Rust 提供了 unsafe 关键字,允许程序员跳过编译器的安全检查。但是,由于 unsafe 的存在,不确定性往往会带来更大的危险性,因此,建议在编写 Rust 程序时,不要滥用 unsafe

3.具体代码实例和解释说明

前面几节介绍了Rust的安全编码实践基础,接下来,我们基于这些基础知识来看一下Rust具体的安全编码实践。

(1)避免空悬指针

问题描述

在Rust中,如果某块内存没有被初始化,或者被释放掉之后依然被引用,那么这块内存就是空悬指针。对于空悬指针,最常见的表现形式之一是,程序在运行的时候崩溃或者抛出异常。另外,空悬指针还可能会导致程序出现逻辑错误。
什么时候会出现空悬指针呢?下面给出几种典型场景:

  1. 某个函数的参数没有初始化;
  2. 初始化为NULL的指针;
  3. 申请了内存,却忘记释放;
  4. 迭代器失效;
  5. 字符串以空结尾,但是没有指定长度;
  6. 容器迭代时,指针丢失;
    解决空悬指针问题,一般有以下三种方法:
  7. 使用 Option 来避免空悬指针。
  8. 通过生命周期注解来限制对象生存期。
  9. 使用 mem::forget() 将对象转移到其他地方,确保其生命周期至少和该对象一样长。

例子:实现插入排序算法

下面我们用Rust来实现一个简单的插入排序算法。首先,我们定义一个结构体来保存链表的节点,包括数据域和链接域:

struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
impl ListNode {
fn new(val: i32) -> Self {Self { val, next: None }
}
fn insert(&mut self, val: i32) {let new_node = Box::new(ListNode::new(val));let old_next = mem::replace(&mut self.next, Some(new_node));if let Some(mut last_node) = old_next {loop {if last_node.val < val || last_node.next.is_none() {break;}let tmp = last_node.next.take();last_node.next = tmp.as_ref().map(|n| n as &mut ListNode).cloned();last_node = last_node.next.as_mut().unwrap();}last_node.next = Some(old_next.unwrap());}
}
}

结构体中,val域保存节点的值,next域保存下一个节点的地址。insert()方法接受一个值的入参,创建一个新的节点,插入到当前节点之后,如果当前节点已经存在下一个节点,则按照顺序找到合适的位置插入。
上面的代码存在两个潜在的问题。第一个问题是,如果last_node.next为空,那么程序会崩溃,因为在这种情况下if let Some(last_node)表达式不会匹配,而且None类型没有as_ref()方法。第二个问题是,loop循环太长,冗余。
为了解决第一个问题,我们可以改用match表达式来匹配last_node.next的值。第二个问题,我们可以引入一个let...表达式来简化循环。最终的代码如下:

impl ListNode {
fn insert(&mut self, val: i32) {let new_node = Box::new(ListNode::new(val));let old_next = mem::replace(&mut self.next, Some(new_node));if let Some(mut last_node) = old_next {let (mut left, right) = (&mut last_node, &mut old_next);while let Some(_) = left.next {left = left.next.as_mut().unwrap();}left.next = right;}
}
}

这样就可以很清晰地看到插入操作的过程。
更进一步,我们可以使用Option来封装头结点和尾节点,从而简化代码。

pub struct LinkedList {
head: Option<Box<ListNode>>,
tail: Option<&'a mut Box<ListNode>>,
}
impl LinkedList {
pub fn new() -> Self {Self { head: None, tail: None }
}
pub fn push(&mut self, val: i32) {let new_node = Box::new(ListNode::new(val));if let Some(tail_ptr) = self.tail {**tail_ptr = (*tail_ptr).clone().link(Some(new_node));} else {self.head = Some(new_node);}self.tail = Some(unsafe { &mut *(self.head.as_mut().unwrap()) });
}
}
impl<'a> LinkedList {
pub fn iter_mut(&'a mut self) -> IterMut<'a, i32> {IterMut {cur: self.head.as_deref_mut(),phantom: PhantomData,}
}
}
pub struct IterMut<'a, V> {
cur: Option<&'a mut Box<ListNode>>,
phantom: PhantomData<&'a mut V>,
}
impl<'a, V> Iterator for IterMut<'a, V> {
type Item = &'a mut V;
fn next(&mut self) -> Option<Self::Item> {self.cur.take().map(|node| {self.cur = (**node).next.as_deref_mut();&mut (**node).val})
}
}
pub struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
impl ListNode {
fn new(val: i32) -> Self {Self { val, next: None }
}
fn link(&self, other: Option<Box<ListNode>>) -> Box<ListNode> {let mut new_box = Box::new(ListNode::default());*Box::leak(new_box.as_mut()) = ListNode {val: self.val,next: other,};new_box
}
}
impl Default for ListNode {
fn default() -> Self {ListNode { val: 0, next: None }
}
}

我们创建了一个新的结构体LinkedList,里面保存了头结点和尾节点的指针。我们还实现了一个IterMut结构体,用来遍历链表。链表里面的每一个节点都是一个指针包裹着值的Box
push()方法接收一个值的入参,创建一个新的节点,如果当前链表不存在尾节点,那么直接将新节点作为头结点。否则,先创建一块新的内存空间,将原尾节点复制到新节点,设置新节点的val域为入参的值,然后链接新节点和原尾节点。我们还使用Box::leak()来销毁Box,确保其生命周期至少和原始节点一样长。
iter_mut()方法返回一个IterMut结构体的实例,可以遍历链表的每一个节点,并且返回节点的值的可变引用。

(2)确保数据完整性

数据完整性是指数据的准确、正确和完整。在网络上传输或存储数据时,数据完整性非常重要,因为数据损坏或遭到篡改,可能造成严重的后果。以下是数据完整性的一些常见问题:

  1. 数据被篡改:指数据在传输过程中被修改、插入或删除。例如,黑客攻击、中间人攻击、缓存投毒等。
  2. 数据泄露:指在加密或签名等过程中,数据泄露给第三方。例如,数据被泄露后,黑客可以盗取用户的信息。
  3. 数据重放攻击:指攻击者可以复制之前的网络通信记录,再次发送给服务器,影响服务器的正常业务。
    为了确保数据完整性,我们可以采取以下方法:
  4. 使用加密算法保证数据机密性。
  5. 使用数字签名证书保证数据完整性。
  6. 使用鉴权机制保障数据的真实性。

例子:数字签名证书

下面,我们用Rust来实现一个简单的数字签名证书验证机制。首先,我们定义几个数据结构,包括证书,请求,响应:

use anyhow::Result;
use ring::signature::*;
#[derive(Clone)]
pub struct Certificate {
pub public_key: PublicKey,
}
#[derive(Clone)]
pub struct Request {
message: String,
}
#[derive(Clone)]
pub struct Response {
signature: Signature,
}
impl Certificate {
pub fn sign(&self, request: &Request) -> Result<Response> {let message = request.message.as_bytes();let sig = self.private_key.sign(SHA256_PKCS1_SIGNING, message)?;Ok(Response { signature: sig })
}
}
impl Request {
pub fn verify(&self, certificate: &Certificate, response: &Response) -> bool {let message = self.message.as_bytes();let verified = certificate.public_key.verify(SHA256_PKCS1_SIGNING,message,response.signature.as_ref(),);verified.is_ok()
}
}

结构体Certificate保存了公钥,可以用来验证签名;结构体Request保存了待签名的消息,可以用来生成签名;结构体Response保存了签名结果。
sign()方法接受一个Request实例,生成一个Response实例,其中保存了签名的结果。verify()方法接受一个Certificate实例和一个Response实例,用来验证签名是否正确。
使用以上数据结构,我们可以实现一个简单的文件验证系统。假设我们要上传一个文件,服务器首先向客户端请求一个签名证书。客户端使用私钥生成一个签名,并将签名和文件内容一起发送给服务器。服务器收到请求后,验证证书的合法性,然后验证签名的正确性,以此来保证文件的真实性。
总结:数字签名证书是保障数据完整性的一种有效方法,它可以提供身份认证、防止数据被篡改、重放攻击等安全功能。但是,该方法使用复杂的算法和加密技术,可能增加服务器的计算负担。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部