只要你做過協定分析,記憶體毀損(memory corruption)大概就是你最常遇到的一類安全漏洞。應用程式把當下的狀態存在記憶體裡,一旦這塊記憶體能被以受控的方式破壞,任何一種漏洞分類都可能成立——輕則讓應用程式崩潰(阻斷服務),重則讓攻擊者在目標系統上執行程式碼。
記憶體安全與記憶體不安全的程式語言#
記憶體毀損漏洞高度取決於應用程式是用什麼語言寫的。就記憶體毀損而言,語言之間最大的差別在於它(及其執行環境)是否為記憶體安全(memory safe)。
記憶體安全語言如 Java、C#、Python、Ruby,通常不要求開發者處理低階記憶體管理。它們有時會提供執行不安全操作的函式庫或語法結構(例如 C# 的 unsafe 關鍵字),但使用這些機制必須是明示的,因此可以針對這些用法進行安全稽核。記憶體安全語言通常也會對記憶體緩衝區的存取做邊界檢查(bounds checking),以防止越界讀寫。
記憶體不安全語言如 C 與 C++ 幾乎不做記憶體存取驗證,也缺乏健全的自動記憶體管理機制。結果就是各種記憶體毀損都可能發生。至於這些漏洞有多容易被利用,則取決於作業系統、使用的編譯器,以及應用程式本身的結構。
語言是記憶體安全的,不代表它完全免疫於記憶體毀損。只是這時候毀損比較可能是語言執行期(runtime)本身的臭蟲,而不是原開發者的失誤。
這一點會直接影響你的分析策略:面對一個 Java 應用程式,你找到記憶體毀損的機率就低得多,此時該把力氣放在其他類型的根本成因上。
記憶體緩衝區溢位(Memory Buffer Overflows)#
最知名的記憶體毀損漏洞大概就是緩衝區溢位(buffer overflow)。當應用程式試圖把超過某塊記憶體區域容量的資料塞進去時,就會發生溢位。緩衝區溢位可被用來執行任意程式、或繞過使用者存取控制之類的安全限制。
緩衝區溢位的成因有兩種:
- 固定長度緩衝區溢位(fixed-length buffer overflow):應用程式錯誤地假設輸入資料一定塞得進已配置的緩衝區。
- 可變長度緩衝區溢位(variable-length buffer overflow):所配置緩衝區的大小算錯了。

圖表 9-1:緩衝區溢位造成的記憶體毀損
固定長度緩衝區溢位#
最單純的溢位,發生在應用程式沒有正確地把外部資料的長度與記憶體中固定長度緩衝區的大小做比較時。那塊緩衝區可能位於堆疊(stack)、由堆積(heap)配置,或是編譯期定義的全域緩衝區。關鍵在於:記憶體長度是在知道實際資料長度之前就決定的。
def read_string()
{
byte str[32]; /* 配置 32 位元組的堆疊緩衝區 */
int i = 0;
do
{
str[i] = read_byte(); /* 從網路讀一個位元組寫入遞增索引 */
i = i + 1;
}
while(str[i-1] != 0); /* 只在讀到 0 才結束,完全沒檢查長度 */
printf("Read String: %s\n", str);
}迴圈的結束條件只看「最後讀到的位元組是不是 0」,完全不驗證目前長度,因此會把網路上所有可得的資料一路讀進來,造成記憶體毀損。根源當然是不安全語言不會對陣列做邊界檢查。若又沒有堆疊 cookie 之類的編譯器緩解措施來偵測毀損,這個漏洞會非常好利用。
C 語言沒有定義字串型別,而是用指向
char串列的記憶體指標,以零值字元標示結尾。這本身不是問題,但當初開發字串處理函式庫時並未考慮安全性,導致許多字串函式在安全關鍵的應用中極度危險。以複製字串的
strcpy為例:它只吃兩個參數——來源字串指標與目的緩衝區指標——完全沒有任何參數表示目的緩衝區有多大,而 C 又不追蹤緩衝區大小。若程式設計師把一個比目的緩衝區更長的字串(尤其來自外部不可信來源)複製過去,記憶體毀損就發生了。較新的 C 編譯器與標準加入了較安全的版本,例如多了目的長度參數的
strcpy_s。但只要應用程式還在用strcpy、strcat、sprintf這類舊函式,出現嚴重記憶體毀損漏洞的機會就很高。
即使開發者做了長度檢查,也不保證檢查是對的。沒有自動邊界檢查的情況下,驗證每一次讀寫都是開發者的責任。下面是修正版,考慮了超過緩衝區長度的字串——但仍潛藏一個漏洞:
def read_string_fixed()
{
byte str[32];
int i = 0;
do
{
str[i] = read_byte();
i = i + 1;
}
while((str[i-1] != 0) && (i < 32)); /* 已讀滿 32 位元組就離開迴圈 */
/* Ensure zero terminated if we ended because of length */
str[i] = 0; /* 問題出在這裡 */
printf("Read String: %s\n", str);
}開發者加了「讀滿 32 位元組就跳出」的檢查,然後為了確保字串有正確結尾,把一個零位元組寫入緩衝區。此時 i 的值是 32;但由於 C 這類語言的陣列索引從 0 起算,這實際上是寫入緩衝區的第 33 個元素,造成毀損。
這就是差一錯誤(off-by-one error),在採用零基索引的記憶體不安全語言中相當常見。如果被覆寫掉的值恰好很重要——例如是該函式的返回位址——這個漏洞就可能被利用。

圖表 9-2:差一錯誤造成的記憶體毀損
可變長度緩衝區溢位#
應用程式不一定要用固定長度緩衝區來存放協定資料。多數情況下它可以為資料配置正確大小的緩衝區,但若這個大小算錯了,就會出現可變長度緩衝區溢位。
既然緩衝區長度是根據協定資料在執行期算出來的,你可能覺得這種漏洞不太會發生在真實世界。但它仍有多種發生途徑:最單純的是應用程式就是算錯了;更麻煩的是這個計算觸發了語言或平台的未定義行為。
def read_uint32_array()
{
uint32 len;
uint32[] buf;
// Read the number of words from the network
len = read_uint32();
// Allocate memory buffer
buf = malloc(len * sizeof(uint32));
// Read values
for(uint32 i = 0; i < len; ++i)
{
buf[i] = read_uint32();
}
printf("Read in %d uint32 values\n", len);
}程式先讀一個 32 位元整數當作後續 32 位元值的個數,接著算出總配置大小並配置緩衝區,最後在迴圈中把每個值讀進去。問題出在那個乘法。
整數溢位(Integer Overflows)#
在處理器指令層級,整數算術通常採用模數算術(modulo arithmetic):當值超過某個上限(稱為模數,modulus)就會回繞。處理器只支援 32 位元或 64 位元之類的原生整數大小時就會如此,意即任何算術運算的結果都必須落在該固定大小整數的範圍內。
以 8 位元整數為例,它只能表示 0 到 255。若把原始長度 0x41(65)乘以 4,結果是 0x104(260),塞不進 8 位元。處理器會丟掉溢出的那個位元(或更常見地把它存進某個標示溢位的旗標),結果變成 4——完全不是我們預期的值。同樣的邏輯也適用於 32 位元整數。

圖表 9-3:一個簡單的整數溢位
處理器可能會發出溢位錯誤,但記憶體不安全的程式語言通常直接忽略這種錯誤。事實上在 x86 之類的架構上,整數回繞本身就被用來表示運算的帶號結果。高階語言可能會回報錯誤,也可能根本不支援整數溢位——例如按需擴增整數大小。
回到上面的 read_uint32_array:若攻擊者挑一個恰當的長度值,乘以 4 就會溢位,導致配置到的記憶體小於實際要傳輸的資料量。而在把值從網路讀進緩衝區時,解析器用的是「原始長度」。長度與配置大小對不起來,值就會被寫到緩衝區之外,造成記憶體毀損。
如果配置長度算出來是零位元組會怎樣?
你可能以為配置會直接失敗,畢竟不能配置一個零長度緩衝區。但如同 C 語言的許多議題,答案是「看實作」(那可怕的 implementation-defined behavior)。以 C 的配置函式 malloc 為例,傳入零作為請求大小,可能回傳失敗,也可能回傳一塊大小不確定的緩衝區——這實在很難讓人安心。
越界緩衝區索引(Out-of-Bounds Buffer Indexing)#
越界索引的根本成因與前面不同:不是把資料的大小指定錯了,而是攻擊者對存取緩衝區的位置有某種控制權。若對存取位置沒有做正確的邊界檢查,漏洞就成立。
byte app_flags[32]; /* 固定的 32 個旗標 */
def update_flag_value()
{
byte index = read_byte(); /* 從網路讀索引,範圍 0~255 */
byte value = read_byte();
printf("Writing %d to index %d\n", value, index);
app_flags[index] = value; /* 沒有邊界檢查 */
}這段程式讓用戶端更新一組共用旗標(也許是用來控制某些伺服器屬性)。索引是從網路讀來的一個位元組,可能是 0 到 255 的任意值,而緩衝區只有 32 個元素——攻擊者送出超出範圍的索引,就能做到選擇性的記憶體毀損。
越界索引不限於寫入。用錯誤索引從緩衝區讀值一樣有效:如果讀到的值會被回傳給用戶端,就構成單純的資訊洩漏漏洞。
若索引是用來查表決定「要執行應用程式中的哪個函式」,後果會特別嚴重。這種設計很常見,例如把命令識別碼當索引、在緩衝區中存放一堆函式的記憶體指標,再用索引查出處理該網路命令的函式。越界索引會從記憶體中讀到非預期的值,而這個值會被當成函式指標使用,很容易導致可利用的遠端程式碼執行漏洞。攻擊者要做的,往往只是找到某個索引值,使讀出的「函式指標」指向他能輕易控制的記憶體位置。
資料膨脹攻擊(Data Expansion Attack)#
即使是現代高速網路,也會壓縮資料以減少實際傳送的位元組數——為了縮短傳輸時間,或為了降低頻寬成本。而資料總得在某處被解壓縮;若壓縮是由應用程式自己處理,資料膨脹攻擊(data expansion attack)就有機可乘。
void read_compressed_buffer()
{
byte buf[];
uint32 len;
int i = 0;
// Read the decompressed size
len = read_uint32();
// Allocate memory buffer
buf = malloc(len);
gzip_decompress_data(buf)
printf("Decompressed in %d bytes\n", len);
}壓縮資料的前面附帶了「解壓縮後的總大小」。程式從網路讀出這個大小、據此配置緩衝區,然後呼叫 gzip 之類的串流演算法把資料解壓到該緩衝區——卻沒有檢查解壓後的資料是否真的塞得下。
這種攻擊並不限於壓縮。任何會改變資料大小的轉換流程——加密、壓縮、文字編碼轉換——都可能導致膨脹攻擊。
動態記憶體配置失敗#
系統記憶體是有限的,當記憶體池耗盡時,動態配置機制必須處理「應用程式還想要更多」的情況。在 C 語言中,這通常表現為配置函式回傳一個錯誤值(多半是 NUL 指標);在其他語言中則可能導致環境終止或拋出例外。
沒有正確處理動態記憶體配置失敗,會衍生數種漏洞,其中最明顯的就是應用程式崩潰,進而形成阻斷服務。