簡介:
模糊測試(Fuzzing),是一種通過向目標系統提供非預期的輸入並監視異常結果來發現軟體漏洞的方法。
為什麼有如此大量能夠使 Microsoft Word 崩潰的壞檔案。少數位元組錯位,會使整個應用程式毀於一旦。在舊式的、無記憶體保護的作業系統中,整個計算機通常就這樣宕掉了。Word 為什麼不能意識到它接收到了壞的數據,並發出一條錯誤信息呢?為什麼它會僅僅因為少數位元組被損壞就破壞自己的棧、堆呢?當然,Word 並不是惟一一個面對畸形檔案時表現得如此糟糕的程式。
在模糊測試中,用隨機壞數據(也稱做 fuzz)攻擊一個程式,然後等著觀察哪裡遭到了破壞。模糊測試的技巧在於,它是不符合邏輯的:自動模糊測試不去猜測哪個數據會導致破壞(就像人工測試員那樣),而是將儘可能多的雜亂數據投入程式中。由這個測試驗證過的失敗模式通常對程式設計師來說是個徹底的震憾,因為任何按邏輯思考的人都不會想到這種失敗。
模糊測試是一項簡單的技術,但它卻能揭示出程式中的重要 bug。它能夠驗證出現實世界中的錯誤模式並在您的軟體發貨前對潛在的應當被堵塞的攻擊渠道進行提示。
模糊測試如何運行
模糊測試的實現是一個非常簡單的過程:
1。準備一份插入程式中的正確的檔案。
2。用隨機數據替換該檔案的某些部分。
3。用程式打開檔案。
4。觀察破壞了什麼。
可以用任意多種方式改變該隨機數據。例如,可以將整個檔案打亂,而不是僅替換其中的一部分,也可以將該檔案限制為 ASCII 文本或非零位元組。不管用什麼方式進行分割,關鍵是將大量隨機數據放入應用程式並觀察出故障的是什麼。
可以手動進行初始化測試,但要想達到最佳的效果則確實需要採用自動化模糊測試。在這種情況下,當面臨破壞輸入時首先需要為應用程式定義適當的錯誤行為。(如果當輸入數據被破壞時,您發現程式正常運行,且未定義發生的事件,那么這就是第一個 bug。)隨後將隨機數據傳遞到程式中直到找到了一個檔案,該檔案不會觸發適當的錯誤對話框、訊息、異常,等等。存儲並記錄該檔案,這樣就能在稍後重現該問題。如此重複。
測試基於 C 的應用程式
當字元串包含額外的零時,許多用 C 編寫的程式都會出問題 —— 這類問題太過頻繁以至於額外的零能夠徹底隱藏代碼中其他的問題。一旦驗證出程式存在零位元組問題,就可以移除它們,從而讓其他的問題浮現出來。
儘管模糊測試通常需要一些手動編碼,但還有一些工具能提供幫助。例如,清單 1 顯示了一個簡單的 Java™ 類,該類隨機更改檔案的特定長度。我常願意在開始的幾個位元組後面啟動模糊測試,因為程式似乎更可能注意到早期的錯誤而不是後面的錯誤。(您的目的是想找到程式未檢測到的錯誤,而不是尋找已經檢測到的。)
清單 1. 用隨機數據替換檔案部分的類import java . io . * ;
import java.security.SecureRandom;
import java.util.Random;
public class Fuzzer {
private Random random = new SecureRandom();
private int count = 1;
public File fuzz(File in, int start, int length) throws IOException
{
byte[] data = new byte[(int) in.length()];
DataInputStream din = new DataInputStream(new FileInputStream(in));
din.readFully(data);
fuzz(data, start, length);
String name = "fuzz_" + count + "_" + in.getName();
File fout = new File(name);
FileOutputStream out = new FileOutputStream(fout);
out.write(data);
out.close();
din.close();
count++;
return fout;
}
// Modifies byte array in place
public void fuzz(byte[] in, int start, int length) {
byte[] fuzz = new byte[length];
random.nextBytes(fuzz);
System.arraycopy(fuzz, 0, in, start, fuzz.length);
}
}
模糊測試檔案很簡單。將其傳至應用程式通常不那么困難。如 AppleScript 或 Perl 腳本語言通常是編寫模糊測試的最佳選擇。對於 GUI 程式,最困難的部分是辨認出應用程式是否檢測出正確的故障模式。有時,最簡單的方法是讓一個人坐在程式前將每一個測試通過或失敗的結果都標記下來。一定要將所有生成的隨機測試用例單獨地命名並保存下來,這樣就能夠重現這個過程中檢測到的任何故障。
關於代碼
我可以用很多種方式最佳化 清單 1 中的代碼。例如,有著 java.nio 的記憶體映射檔案是一個相當不錯的選擇。我也能夠改進這個錯誤處理及可配置性。因為不想讓這些細節混淆這裡所要說明的觀點,所以我將代碼保持了原樣。
防護性編碼
可靠的編碼遵循了這樣的基本原則:絕不會讓程式中插入未經過一致性及合理性驗證的外部數據。
如果從檔案中讀入一個數字並期望其為正數,那么,在使用其進行進一步處理前對其先驗證一下。如果期望字元串只包含 ASCII 字母,請確定它確實是這樣。如果認為檔案包含一個四位元組的整數倍的數據,請驗證一下。一定不要假設任何外部提供的數據中的字元都會如您所料。
最常見的錯誤是做出這樣的假設:因為程式將該數據寫出,該程式就能不用驗證再一次將該數據讀回去。這是很危險的!因為該數據很可能已經被另一個程式在磁碟上複寫過了。它也可能已經被一個故障磁碟或壞的網路傳輸所破壞了或已經被另一個帶 bug 的程式更改過了。它甚至可能已經被故意更改過以破壞程式的安全性。所以不要假設任何事,要進行驗證。
當然,錯誤處理及驗證十分令人生厭,也很不方便,並被全世界程序員們所輕視。計算機的誕生已進入了六十個年頭,我們仍舊沒有檢查基本的東西,如成功打開一個檔案及記憶體分配是否成功。讓程式設計師們在閱讀一個檔案時測試每一個位元組和每一個不變數似乎是無望的 —— 但不這樣做就會使程式易被模糊攻擊。幸運的是,可以尋求幫助。恰當使用現代工具和技術能夠顯著減輕加固應用程式的痛苦,特別是如下三種技術更為突出:
1。校驗和
2。基於語法的格式,如 XML
3。驗證過的代碼如 Java
用校驗和進行的模糊試驗
能夠保護程式抵禦模糊攻擊的最簡單的方法是將一個檢驗和添加到數據中。例如,可以將檔案中所有的位元組都累加起來,然後取其除以 256 的餘數。將得到的值存儲到檔案尾部的一個額外位元組中。然後,在輸入數據前,先驗證檢驗和是否匹配。這項簡單模式將未被發現的意外故障的風險降低到約 1/256 。
健壯的校驗和算法如 MD5 和 SHA 並不僅僅取其除以 256 的餘數,它完成的要多得多。在 Java 語言中,java.security.DigestInputStream 和 java.security.DigestOutputStream 類為將一個校驗和附屬到數據中提供了便捷的方式。使用這些校驗和算法中的一種可以將程式遭受意外破壞的機率降低到少於十億分之一(儘管故意攻擊仍有可能)。
XML 存儲及驗證
將數據以 XML 形式存儲是一種避免數據損壞的好方法。XML 最初即著力於 Web 頁面、書籍、詩歌、文章及相似文檔,它幾乎在每個領域都獲取了巨大的成功,從金融數據到矢量圖形到序列化對象等等。
使 XML 格式抵制模糊攻擊的關鍵特徵是一個對輸入不做任何 假設的解析器。這就是真正想在一個健壯的檔案格式中所獲得的。設計 XML 解析器是為了讓任何輸入(格式良好的或無格式的,有效的或無效的)都以定義好的形式處理。XML 解析器能夠處理任何 位元組流。如果數據首先通過了 XML 解析器,則僅需要準備好接受解析器所能提供的東西。例如,不需要檢查數據是否包含空字元,因為 XML 解析器絕不會傳送一個空值。如果 XML 解析器在其輸入中看到一個空字元,它就會發出異常並停止處理。當然還需要處理這個異常,但編寫一個 catch 塊來處理檢測到的錯誤比起編寫代碼來檢測所有可能的錯誤來說要簡單得多。
不切實際的限定
如果真想要破壞一個 XML 解析器,有幾種方法可以試試。例如,大多數 XML 解析器服從於特定的最大尺寸。如果一個元素名長度超過 22 億字元(Java String 的最大尺寸),SAX 解析器將會失敗。儘管如此,在實踐中這些極限值如此之高,以至於在達到之前記憶體就已經耗盡。
為使程式更加安全,可以用 DTD 和/或模式來驗證文檔。這不僅檢查了 XML 是否格式良好,而且至少與所預期更加接近。驗證並不會告知關於文檔所需了解的一切,但它卻使編寫大量簡單檢查變得很簡單。用 XML,很明顯能夠將所接受的文檔嚴格地限定為能夠處理的格式。
儘管如此,還有多段代碼不能用 DTD 或模式進行驗證。例如,不能測試發票上商品的價格是否和資料庫中庫存商品的價格一致。當從客戶接收到一份包含價格的訂單文檔時,不論其是 XML 格式或是其他格式,在提交前通常都會檢查一下,以確保客戶並未修改價格。可以用定製代碼實現這些最後的檢查。
基於語法的格式
使 XML 能夠對模糊攻擊具有如此的抵禦能力的是其使用巴科斯-諾爾範式(Backus-Naur Form,BNF)語法仔細且標準地定義的格式。許多解析器都是使用如 javacc 或 bison 等解析器-生成器工具直接從此語法中構建的。這種工具的實質是閱讀一個任意的輸入流並確定其是否符合此語法。
如果 XML 並不適合於您的檔案格式,您仍可以從基於解析器的解決方案的健壯性中獲益。您必須為檔案格式自行編寫語法,隨後開發自己的解析器來閱讀它。相比使用唾手可得的 XML 解析器,開發自己的解析器需要更多的工作。然而它是一個更為健壯的解決方案,而不是不根據語法正式地進行驗證就將數據簡單地裝載到記憶體中。
Java 代碼驗證
由模糊測試導致的許多故障都是記憶體分配錯誤及緩衝器溢出的結果。用一種安全的垃圾收集語言(在如 Java 或 managed C# 等虛擬機上執行的)來編寫應用程式避免了許多潛在問題。即使用 C 或 C++ 來編寫代碼,還是需要使用一個可靠的垃圾收集庫。在 2006 年,台式機程式設計師或伺服器程式設計師不應該還需要管理記憶體。
Java 運行時對其自身的代碼起到了額外保護層的作用。在將一個 .class 檔案裝載到虛擬機之前,該檔案要由一個位元組符驗證器或一個可選的 SecurityManager 進行驗證。Java 並不假設創建 .class 檔案的編譯器沒有 bug 且運轉正常。設計 Java 語言之初就是為了允許在一個安全沙箱中運行不信任的、潛在惡意的代碼。它甚至不信任其自身編譯過的代碼。畢竟,也許有人已經用十六進制編輯器手工修改了位元組符,試圖觸發緩衝器溢出。我們大家都應該對我們的程式也有對輸入這樣的偏執。
以敵人的角度思考
之前介紹的每項技術都在阻止意外破壞方面造詣頗深。將它們綜合起來恰當地實現,會將未被發現的非蓄意破壞發生的可能性幾乎減少到零。(當然,並不會減少到零,但其發生的可能性就如同一束偏離軌道的宇宙射線將您 CPU 運算 1+1 的結果變為 3 的可能性一樣微乎其微。)但不是所有的數據損壞都是非蓄意的。如果有人故意引入壞數據來破壞程式的安全性又該如何呢?以一個攻擊者的角度進行思考是防護代碼的下一個步驟。
轉回到一個攻擊者的角度進行思考,假設要攻擊的應用程式是用 Java 程式語言編寫的、使用非本地代碼且將所有額外數據都以 XML(在接受前經過徹底驗證)形式存儲,還能成功攻擊嗎?是的,能。但用隨機改變檔案位元組的低級方法顯然不行。需要一種更為複雜的方法來說明程式自身的錯誤檢測機制及路徑。
當測試一個抵禦模糊攻擊的應用程式時,不可能做純黑盒測試,但通過一些明顯的修改,基本的想法還是可以套用的。例如,考慮校驗和,如果檔案格式包含一個校驗和,在將檔案傳至應用程式前僅僅修改此校驗和就可以使其同隨機數據相匹配。
對於 XML,試著模糊單獨的元素內容和屬性值,而不是從文檔中挑選一部分隨機的位元組進行替換。一定要用合法的 XML 字元替換數據,而不要用隨機位元組,因為即使一百位元組的隨機數據也一定是畸形的。也可以改變元素名稱和屬性名稱,只要細心地確保得到的文檔格式仍是正確的就可以了。如果該 XML 文檔是由一個限制非常嚴格的模式進行檢查的,還需要計算出該模式沒有 檢查什麼,以決定在哪裡進行有效的模糊。
一個結合了對剩餘數據進行代碼級驗證的真正嚴格的模式也許不會留下可操縱的空間。這就是作為一個開發人員所需要追求的。應用程式應能夠處理所傳送的任何有意義的位元組流,而不會因權利上( de jure ) 無效而拒絕。
結束語
模糊測試能夠說明 bug 在程式中的出現。並不證明不存在這樣的 bug。而且,通過模糊測試會極大地提高您對應用程式的健壯性及抵禦意外輸入的安全性的自信心。如果您用 24 小時對程式進行模糊測試而其依然無事,那么隨後同種類型的攻擊就不大可能再危及到它。(並不是不可能,提醒您,只是可能性很小。)如果模糊測試揭示出程式中的 bug,就應該進行修正,而不是當 bug 隨機出現時再對付它們。模糊測試通過明智地使用校驗和、XML、垃圾收集和/或基於語法的檔案格式,更有效地從根本上加固了檔案格式。
模糊測試是一項用於驗證程式中真實錯誤的重要工具,也是所有意識到安全性問題且著力於程式健壯性的程式設計師們的工具箱中所必備的工具。