C常见的代码安全风险
软件开发的Bug定位在软件开发中一直是一个热门的话题,在日常开发中绝大部分的Bug也由内存问题负责,其他则因为操作溢出、逻辑错误、时序等问题引发。据微软的安全中心部门的统计报告显示70%的内存Bug,谷歌安全也在博客中显示在安卓bug中由90%的因内存问题触发。在日常的嵌入式开发中,内存问题尤为关注,一旦出现内存bug,可能导致嵌入式系统轻易崩溃,也因此嵌入式C工程师在写完代码后需要花几倍于写代码的时间去调试定位Bug。
那么在处理这些常见的潜在风险时,Rust与C分别是如何处理的呢?
C/Rust 代码处理对比
操作溢出
对于基础的数据类型来说,每个类型都有有效范围,如果代码中的操作对数据的操作无意超过了该数据的范围,则可能会引起一些隐藏的风险。对于C/C++来说,没有大部分的操作中,没有强制检查数据操作结果是否溢出,从而将溢出的风险可能转移到后续的其他关联的逻辑。
for (int8_t i = 0; i < 256; i++) {
// 进入了死循环
}
// 移位溢出
uint16_t Number = 0;
for (size_t i = 0; i < 32; i++) {
Number <<= 1;
}
int8_t ch = 128;
if (ch > 0) {
// 可能不能进入
}
uint8_t x = 255;
// 溢出
x += 1;
Rust则在数据溢出方面有着严格的限制,通常大部分的溢出能能在编译阶段直接报错,运行时候的溢出逻辑通则panic终止(Debug模式下),有效保证立即发现风险点,能精确准确到问题的根源。
let val: i8 = 128; //编译报错
let mut idx: i8 = 0
loop {
idx += 1;
if idx >= 128 { // 编译报错
break;
}
}
let mut val: u32 = 0xffff_fffe;
let mut inc: u32 = fun();
val += inc; // 运行时候如果溢出则 panic终止
数组写溢出
数组写溢出的问题在C项目可能经常有发生,通常索引的值并不那么明显,在数组修改时可能操作会正常返回,但一旦写到未知的区域,则可能引起连锁的安全事故,且不易定位。通常需要专业的工具如ASan等去实时监控写数组溢出异常,非常耗费内存资源。在嵌入式资源有限的硬件中,通常无法部署。
char buf[] = "hello world";
char buf2[20];
int idx = fun();
buf[idx] = 'H'; // 索引超限,但运行可能修改位置位置的内容程序可继续运行
Rust则利用天生支持索引检测,且运行高效,不会耗费过多的资源。在常量索引中通常在编译时候就能检测出并报错,运行时则会检测索引有效值,非法则panic停止运行,严谨的检查将每个细微的问题准确暴露出来。当然也容许在生产环境中关闭panic避免影响业务。
let mut buf: [u8; 10] = [0; 10];
buf[12] = 1; // 编译报错
buf[fun()]; // 运行可能panic到此行,程序终止
let mut buf:[u8; 10] = [0; 10]; // 使用迭代器遍历数组,高效简洁且安全
for v in &mut buf {
*v = 1;
}
数组访问溢出
数据访问超限通常在C开发中也存在,虽然可能没有写溢出严重性,但也可能给生产带来潜在的Bug风险,需要大量的时间去分析定位。
char buf[] = {'1', '2', '3', '0'}; //没有结束字符'\0'
size_t len = strlen(buf); // 返回错误的长度
char ch = buf[5]; // 返回非法值
Rust在数组的访问也检查严格,通常使用迭代器进行读或写操作,代码既简洁易懂,且运行效率与手写循环无差别,这也是Rust常用和推荐的遍历方法。
let mut buf: [u8; 10] = [0; 10];
let v = buf[12] // 编译报错
let v = buf[fun()]; // 运行可能panic到此行,程序终止
let mut buf:[u8; 10] = [0; 10]; // 使用迭代器遍历数组,高效简洁且安全
for v in &mut buf {
println("{}", v);
指针对齐错误
在ARM处理器中,通常对于数据的取值需要严格的地址对齐,否则会引起总线中断死机。在C开发中经常会遇到一些日志打印问题,为了查看一些地址的值,可能误触发死机。
char buf[] = "1234";
printf ("%d", (int)*(buf + 0)); // 地址可能未对齐,可能进入总线错误中断
struct test_t {
uint16_t a;
uint8_t b;
uint32_t c;
}
struct test_t t;
uint32_t b = (uint32_t)(uint32_t *)(&t.b);
Rust有这很多高级语言的特性,如打印函数通常在编译时候会自动获知数据的类型,无需手动指定打印的类型,既节省了编码量又安全。在数据地址分配方面会自动优化地址,满足安全运行逻辑。
let c = "1234";
println("{}", c);
struct Test { //编译时通常顺序会重新优化,节省空间或注重安全
v16: u16;
v8: u8;
v32: u32;
}
内存申请和释放问题
嵌入式C中通常避免少使用内存分配的接口,主要原因是因为容易引起内存问题,但在一些特殊业务中又不得不大量使用,因此在一些大型工程中,定位内存泄露、内存释放两次、操作已释放的内存、释放非法内存等问题非常困难,通常需要经验丰富的嵌入式工程师才能解决,为避免这种问题,通常不得不采取静态代码分析如cppcheck``splint,sanitize等工具去扫描代码,但是一些内存问题即使通过静态代码分析工具也很难检测出,只能在运行时候出问题才能排查。
uint8_t *ptr = malloc(1024);
free(ptr);
free(ptr); // 释放两次
*(ptr + 1) = 1; // 操作已经释放的问题
uint8_t *buf = malloc(1024); //不释放
buf[1024] = 1; // 操作非法内存
uint8_t *vptr = 232323;
free(vptr) // 非法释放内存
Rust则在在内存安全方面有着无与伦比的优势,利用变量的生命周期原理自动释放空间,高效利用内存。这一点在有限的嵌入式资源中尤为重要。让嵌入式开发如Go、Java等高级语言一样简单,无需过多关注内存的申请和释放问题,只专注于业务功能逻辑,Rust编译器会保证你的代码不会在内存上翻车。
let buf:[u8;10];
printfln!("{}", buf); //编译报错,使用未初始化的内存
{
let mut vec = Vec::new();
}
vec.push(1); // 编译报错,vec已经释放,不给你再操作
学习C很简单,成为大师很难
学习Rust很难,成为大师很简单