C 和 C++ 是廣泛用于系統(tǒng)開發(fā)的傳統(tǒng)強者,但也因為內(nèi)存不安全問題頻頻“背鍋”。那么,使用 Rust,真的能讓軟件變得更安全嗎?系統(tǒng)軟件工程師 Marc 最近做了一項實驗,親自驗證 Rust 在處理真實世界漏洞時能否真正提升軟件的安全性和穩(wěn)定性。
原文鏈接:https://tweedegolf.nl/en/blog/152/does-using-rust-really-make-your-software-safer
作者 | Marc
翻譯工具 | ChatGPT 責(zé)編 | 蘇宓
出品 | CSDN(ID:CSDNnews)
我們常說,Rust 是讓軟件更安全的方式。在這篇博客中,我們將分析一個真實存在的漏洞,把它“用 Rust 重寫”,并展示我們通過實證研究得到的結(jié)果——既有高層次的概覽,也有技術(shù)細節(jié)的深入剖析。
一個現(xiàn)實中的嚴重漏洞
2021 年,有人在西門子出售的 Nucleus 實時操作系統(tǒng)中發(fā)現(xiàn)了一個漏洞。當(dāng)時 Forescout 安全研究人員介紹道(https://www.forescout.com/blog/forescout-and-jsof-disclose-new-dns-vulnerabilities-impacting-millions-of-enterprise-and-consumer-devices/):
(…) 超過30 億臺設(shè)備使用這個實時操作系統(tǒng),包括超聲波設(shè)備、存儲系統(tǒng)、航空電子系統(tǒng)等關(guān)鍵應(yīng)用。
換句話說,這段代碼的使用場景極其廣泛,而且其中不少都是“絕不能發(fā)生事故”的關(guān)鍵系統(tǒng)。那么,到底出了什么問題?
使用 Nucleus 的聯(lián)網(wǎng)設(shè)備需要通過 DNS 服務(wù)器解析域名,比如 tweedegolf.nl。Nucleus 中負責(zé)讀取 DNS 響應(yīng)的那部分代碼,在一切正常的“理想路徑”下可以運行良好:做到真實的響應(yīng),正確處理信息。
但問題是,攻擊者可以偽造 DNS 響應(yīng),在其中故意加入“錯誤”。惡意黑客可以利用這些偽造的響應(yīng)誘使 Nucleus 向不該寫入的內(nèi)存位置寫數(shù)據(jù)。
一旦發(fā)生這樣的事情,后果將非常嚴重:只需覆蓋幾個關(guān)鍵內(nèi)存位置,攻擊者就能讓設(shè)備崩潰。更糟的是,程序本身也是儲存在內(nèi)存中的,技術(shù)更高明的攻擊者甚至可以借此重新編程設(shè)備,讓它做任何他們想讓它做的事。
不過現(xiàn)在不用擔(dān)心!Nucleus 這個漏洞已經(jīng)修復(fù)了,大家可以放心睡覺了。
為什么你應(yīng)該關(guān)心這件事?
問題是,不僅僅是曾經(jīng)的 Nucleus“中招”。另外還有四個網(wǎng)絡(luò)庫也被發(fā)現(xiàn)存在類似的漏洞。這些漏洞被統(tǒng)稱為NAME:WRECK(https://www.forescout.com/research-labs/namewreck/),說明這類代碼的編寫方式本身就存在普遍問題。
我們從安全咨詢公司 Midnight Blue 那里知曉了這個案例。他們向我們提出一個問題:Rust 能避免這種問題嗎?
這篇博客就是我們的回答。前半部分是一個不涉及太多技術(shù)細節(jié)的高層次說明;后半部分面向 C、C++ 或 Rust 程序員,會深入分析 Nucleus 的實際代碼,并演示如何用現(xiàn)代 Rust 編寫等效代碼。
我們的觀點是:Rust 的確可以防止這種問題。但我們不會僅僅停留在“Rust 是內(nèi)存安全的”這一表面(雖然它確實如此)。我們將更進一步!我們做了一次小型的工程實驗,結(jié)果讓我們確信,如果當(dāng)初使用了現(xiàn)代 Rust:
程序員根本不會引入這些漏洞;
即使有人嘗試利用漏洞,也只會觸發(fā)可恢復(fù)的錯誤;
代碼會被更徹底地測試;
節(jié)省時間,也節(jié)省了成本。
根本原因
為什么會出現(xiàn)這樣的錯誤?作為程序員,我們往往容易關(guān)于細節(jié),但從概念上來說,答案其實很簡單:
現(xiàn)有的編程工具并不會主動幫你避免錯誤,反而在你犯錯之后還很難發(fā)現(xiàn)問題;
程序在處理外部輸入時,默認是“信任”的,而不是明確地進行驗證。
我們當(dāng)然可以輕松地指出:“哈哈!又是那些寫 C 的程序員搞出來的緩沖區(qū)溢出!”但也別太苛刻:很多這樣的代碼寫于安全意識還未普及的早期歲月。說到底,誰會想到 DNS 服務(wù)器會發(fā)送有問題的響應(yīng)消息呢?而且 Nucleus 是 1993 年開發(fā)的,當(dāng)時寫實時操作系統(tǒng),難道還有比 C 更現(xiàn)實的選擇嗎?
Rust 在實踐中表現(xiàn)如何?
Rust 是一門內(nèi)存安全的語言。這意味著在大多數(shù)情況下,Rust 編寫的程序可以保證不會讀取或修改本不該訪問的內(nèi)存區(qū)域。
但針對 RFC1035 格式(它的規(guī)則并不是我們平時看到的 "www.example.com" 這種普通字符串,而是一種更底層、更節(jié)省空間的二進制表示方式)的域名解碼問題,我們的假設(shè)是:除了天然具備內(nèi)存安全性之外,Rust 還有兩個額外優(yōu)勢:
它是一種更具表現(xiàn)力的算法語言,換句話說,用慣用的 Rust 寫出的解決方案,往往比用 C 寫的解決方案包含更少需要“特殊注意”的地方。
寫單元測試和模糊測試非常簡單,這會鼓勵程序員對自己的代碼進行更批判性的審視。
實驗過程
我們決定用自己作為小白鼠來驗證這個假設(shè)。首先,我們整理了 RFC1035 風(fēng)格的 DNS 消息編碼的描述,然后把它作為一個編程練習(xí),發(fā)給了幾位同事,要求他們在 3 到 4 小時內(nèi)完成。參與者包括兩位實習(xí)生和兩位正式員工。
與此同時,我們分析了該 DNS_Unpack_Domain_Name 函數(shù),并基于它的所有問題,設(shè)計了一套壓力測試。同時,我們還編寫了一個模糊測試工具,用來發(fā)現(xiàn) DNS 實現(xiàn)中常見的一些其他漏洞。這些內(nèi)容我們都對參與者保密。
題目本身是故意留有空白的:只給出了一個 RFC1035 的鏈接,但沒有強制要求他們研究文檔。我們想模擬的是一種“在周五下午隨便搞搞”的編程場景——信息不完整,時間也有點緊——正是漏洞最容易滋生的條件。
(順便說一句,我們也把這道題丟給了 ChatGPT 玩玩,不過這就是另一個故事了!)
實驗結(jié)果
我們的測試集中包含:
6 個“正常路徑”測試用例(Nucleus NET 可以通過);
12 個“異常路徑”測試用例,這些用例會導(dǎo)致崩潰、錯誤結(jié)果,或引發(fā) Nucleus NET 中可被利用的漏洞。
下表總結(jié)了每組代碼在這些測試中的表現(xiàn),并與 Nucleus NET 原始實現(xiàn)進行了對比:
?綠色表示測試通過:程序?qū)斎胱龀隽苏_的處理。正常路徑的測試中,這意味著域名被正確解析;壓力測試中則表示輸入被正確拒絕。
橙色表示“普通的測試失敗”:程序錯誤地拒絕了合法輸入,或接受了解析出錯的內(nèi)容。這種屬于小 bug,不至于能被黑客利用。
紅色則是更嚴重的失敗:比如運行時崩潰(Rust 中的 panic!)、陷入死循環(huán),或向不該寫的內(nèi)存地址寫數(shù)據(jù)。簡而言之,紅色就意味著“存在可利用漏洞”。
一些觀察結(jié)果:
所有參與的工程師都使用了模糊測試(fuzzing)來檢測程序是否會 panic,因此,沒有任何一個 Rust 實現(xiàn)出現(xiàn)了紅色標記。
第七個壓力測試讓 Nucleus NET 陷入了死循環(huán),僅這一點就足以造成拒絕服務(wù)(DoS)攻擊。即使沒有提前提醒,所有參與者都發(fā)現(xiàn)了這個問題,其中三位工程師是通過模糊測試發(fā)現(xiàn)的。
大多數(shù)剩下的“普通 bug”其實是對 RFC1035 規(guī)范的細微違反,比如忽略了長度限制。
第六個壓力測試相對較為“較真”:它測試 DNS 解碼器是否能基于 RFC1035 中對“prior”這個詞的嚴格解讀,拒絕某種雖然看起來合理但不規(guī)范的解碼。
在某些測試用例中,RFC1035 本身沒有明確該怎么處理。在這些情況下,如果能做出兩種都算合理的反應(yīng),都可以被視為通過(綠色)。
讓我們回顧一下最開始提出的四個論點:
Rust 更不容易產(chǎn)生漏洞:確實如此,沒有任何工程師引入了任意代碼執(zhí)行的漏洞;沒人感到有必要使用 unsafe Rust。
任何利用嘗試都會變成可恢復(fù)的錯誤:所有的實現(xiàn)都具備 panic 安全性,即程序不會異常終止。
Rust 代碼經(jīng)過更徹底的測試:所有工程師都在限定時間內(nèi)編寫了單元測試并進行了模糊測試,其中幾位就是通過這些測試發(fā)現(xiàn)了關(guān)鍵錯誤。
使用 Rust 節(jié)省了時間和金錢:所有這些實現(xiàn)都開發(fā)得很快。我們也嘗試讓一位有經(jīng)驗的 C 程序員寫出等效的 C 版本,即便借助本次實驗積累的所有知識,寫出一個安全的版本仍然耗費了三倍以上的時間。而且還沒算上:二十年后打補丁的維護成本,或者如果這些漏洞真的被利用,可能造成的經(jīng)濟損失和社會影響。
這些發(fā)現(xiàn),對于寫過 Rust 的人或研究過軟件安全的人來說也許并不意外。但我們希望,這些結(jié)果能幫助你從一個新的角度看待 Rust ——它不僅僅是“那個限制特別多的語言”。
在我們公司內(nèi)部,我們使用 Rust,不只是因為它能防止我們犯錯,更因為它讓我們能寫出更安全的軟件,而且寫得更快。
更深入的技術(shù)探討
我們已經(jīng)聽到程序員們的呼聲了:“給點代碼看看!”我們在這里簡單地說明一下問題的本質(zhì)。
簡單來說,RFC1035 在 DNS 消息中,一個域名是由一系列標簽(label)構(gòu)成的,每個標簽前面都有一個長度字節(jié)。把這些標簽拼接起來(中間用點 . 分隔),就構(gòu)成了人類可讀的域名。一個 0 字節(jié)表示域名的結(jié)束。
比如,域名 google.com 可以表示為:
下面是一個用 C 寫的、非常粗略的 DNS 域名解碼函數(shù):
uint8_t *unpack_dns(uint8_t *src) {
char *buf, *dst;
int len;
buf = dst = malloc(strlen(src) + 1);
while((len = *src++) != 0) {
while(len--)
*dst++ = *src++;
*dst++ = '.';
}
dst[-1] = 0;
return buf;
}
(注:這個函數(shù)其實是參考了 Nut/OS 中的實現(xiàn),Nut/OS 是一款嵌入式操作系統(tǒng),曾經(jīng)也因為其 TCP/IP 協(xié)議棧中類似的實現(xiàn)而曝出一系列漏洞——所以這段代碼非常貼近現(xiàn)實!)
在你準備好之前,先花點時間看看:這段代碼中有哪些地方可能導(dǎo)致寫入非法內(nèi)存?
潛在錯誤:
攻擊者可以在“域名”的某些部分嵌入空字節(jié)(null bytes),這會讓 strlen 報告錯誤的字符串長度,導(dǎo)致 malloc 分配的內(nèi)存不足,實際寫入數(shù)據(jù)時就可能發(fā)生溢出。
在 while 循環(huán)中,沒有檢查 len 是否超出了 buf 的容量,也就是沒有邊界檢查。
最后一行的 dst[-1] = 0 也有問題:如果 src 正好指向一個空字節(jié)(即字符串結(jié)束),這個操作就會寫入 malloc() 分配內(nèi)存之前的地址,屬于典型的越界寫入。
你可以試著把這段代碼翻譯成一個 Rust 函數(shù),并且觀察:僅僅通過使用 Rust,就可以大幅提升這段代碼的安全性,過程并不復(fù)雜。
fn unpack_dns(mut src: &[u8]) -> Option > { todo!() }
值得一提的是:Nucleus NET 中的實際代碼比這段更復(fù)雜一些,因為它還實現(xiàn)了 RFC1035 中定義的一種壓縮方案:
如果一個長度字節(jié)的高兩位是 1 (即字節(jié)值大于等于 0xC0 ),那它和緊接著的下一個字節(jié)共同構(gòu)成了一個 14 位的偏移地址 ,這個地址指向 DNS 消息中域名的剩余部分。也就是說,這種編碼支持“后跳轉(zhuǎn)”,可以通過偏移來重用前面已經(jīng)解析過的域名部分。
舉個例子,如果在 DNS 響應(yīng)的偏移地址 0x14A 處存放的是 a.net,那么 0x14A 就編碼了 a.net,如果 0x152 是跳轉(zhuǎn)到 0x14A,那么 0x152 表示的是 b.net。
你也應(yīng)該能看出來:如果不加檢查就盲目接受輸入中提供的偏移地址,很容易就會訪問超出邊界的內(nèi)存。
雖然我們很想深入講解 DNS 實現(xiàn)中可能出現(xiàn)的各種災(zāi)難性問題,但老實說這已經(jīng)有人做得很好了:
RFC9267(2022年發(fā)布,https://datatracker.ietf.org/doc/rfc9267/):對這些問題進行了深入討論,內(nèi)容非常易讀,而且還列舉了不少真實世界中曾經(jīng)發(fā)生的錯誤。
我們對 RFC1035 本身也有一些吐槽。雖然它是基礎(chǔ)協(xié)議文檔,但我們認為它有幾個明顯的設(shè)計缺陷:
某些編碼方式完全沒有實際意義,卻依然被協(xié)議允許。
舉個例子:我們更希望文檔明確禁止使用“跳到另一個跳轉(zhuǎn)偏移地址”(double jumping)或者跳到空字節(jié)的行為。
在一些壓力測試中,我們特意用了這些無用但合法的編碼——因為它們能讓 Nucleus NET 崩得很精彩。但我們同時也接受另一種結(jié)果:如果程序正確解析了它,或者拋出了錯誤,都是合理的。
甚至連“空的域名是否有效”這種問題,RFC1035 也沒講清楚。
漏洞示例:Nucleus NET 的 C 代碼(舊版本)
最后,我們放出原始的 Nucleus NET 漏洞代碼(v5.2 之前的版本,漏洞已在后續(xù)版本中修復(fù)),這段代碼摘自 Forescout 報告,我們對類型做了一些簡化,并添加了注釋以便閱讀。
int DNS_Unpack_Domain_Name(uint8_t *dst, uint8_t *src, uint8_t *buf_begin) {
int16_t size;
int i, retval = 0;
uint8_t *savesrc;
savesrc = src;
while(*src) {
size = *src;
while((size & 0xC0) == 0xC0) {
if(!retval) {
retval = src - savesrc + 2;
}
src++;
src = &buf_begin[(size & 0x3F) * 256 + *src]; /* ! */
size = *src;
}
src++;
for(i=0; i < (size & 0x3F); i++) { /* ! */
*dst++ = *src++;
}
*dst++ = '.';
}
*(--dst) = 0; /* ! */
src++;
if(!retval) {
retval = src - savesrc;
}
return retval;
}
讓我們來列一下這段代碼中的幾個問題:
該表達式&buf_begin[(size & 0x3F) * 256 + *src]; 存在多個嚴重缺陷:
它完全信任輸入中提供的偏移量,并直接跳轉(zhuǎn)到那個內(nèi)存地址。
它可能跳回已經(jīng)訪問過的內(nèi)存位置,從而導(dǎo)致我們之前提到的“無限循環(huán)”問題。
如果這行代碼讓 src 指向了一個包含空字節(jié)的內(nèi)存地址,這個空字節(jié)會被直接跳過,代碼還會“很有勇氣地”往結(jié)果里寫一個空的域名部分,然后繼續(xù)往后解析……
for 循環(huán)中也存在兩個問題:
沒有任何邊界檢查來確認解析結(jié)果是否會超出 dst 指向的緩沖區(qū),也沒有檢查是否超過了 RFC1035 中規(guī)定的最大域名長度(255 字節(jié))。
for 循環(huán)條件中的 size & 0x3F 只掩蓋了長度字節(jié)的高兩位,但沒有真正檢查該長度值是否合法。比如一個無效的長度指示符 65 會被當(dāng)成 1 來處理,而之后的一切行為就都由輸入控制了。
如果 *src 指向的是空字節(jié),那么這段代碼和我們前面提到的“快又臟”版本一樣,會出錯:
在這種情況下,函數(shù)末尾的 *(--dst) = 0 很可能會寫入內(nèi)存分配器內(nèi)部使用的區(qū)域,屬于經(jīng)典的越界寫入漏洞。
這段代碼用 Rust 來實現(xiàn)會是什么樣子?
綜合我們幾位工程師寫出的版本,我們整理出一個“示范性”的 Rust 實現(xiàn),來解決上面提到的這些問題。
pub fn decode_dns_name<'a>(mut input: &'a [u8], mut backlog: &'a [u8]) -> Option
{ let mut result = Vec::with_capacity(256); loop { match usize::from(*input.first()?) { 0 => break, prefix @ ..=0x3F if result.len() + prefix <= 255 => { let part; (part, input) = input[1..].split_at_checked(prefix)?; result.extend_from_slice(part); result.push(b'.'); } 0xC0.. => { let (offset_bytes, _) = input.split_first_chunk()?; let offset = u16::from_be_bytes(*offset_bytes) & !0xC000; (backlog, input) = backlog.split_at_checked(usize::from(offset))?; } _ => return None, } } result.pop()?; Some(result) }
如果有嵌入式程序員看到我們在這里分配了一個向量(Vec),或許會笑我們的話,但其實用 heapless::Vec 替代 Vec 完全沒問題。真的,試試看!事實上,用它反而能讓代碼更簡潔,因為這樣就不需要 match 表達式中第二個分支的 if 條件判斷了。
當(dāng)然我們承認有些偏向 Rust,但我們也確實認為,這個 Rust 版本的實現(xiàn),更清晰地表達了它在做什么。
總結(jié)
“C 語言存在內(nèi)存安全問題”、“現(xiàn)實中確實有很多危險的內(nèi)存不安全代碼”、“Rust 可以解決這個問題”——這些說法并不新鮮。甚至連大公司都已經(jīng)拿出了實打?qū)嵉淖C明。
但這次我們接受了一個挑戰(zhàn),自己做了一次實驗。即便給工程師的時間和說明都很有限,最終寫出來的 Rust 代碼,確實避開了那些跟內(nèi)存安全相關(guān)的漏洞。如果你愿意,也完全可以自己試試看。
我們一直說“Rust 是我們打造更安全軟件的方式”。希望這篇文章的整體介紹或技術(shù)細節(jié)分析,能夠幫你理解我們?yōu)槭裁催@么說,以及它到底是怎么做到的。
特別聲明:以上內(nèi)容(如有圖片或視頻亦包括在內(nèi))為自媒體平臺“網(wǎng)易號”用戶上傳并發(fā)布,本平臺僅提供信息存儲服務(wù)。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.