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已經釋放,不給你再操作