Learning Objectives
- 描述堆疊框架的基本結構,並說明區域變數與回傳位址的相對位置
- 由緩衝區大小計算寫入超過長度時會覆蓋到哪些資料
- 說明 Stack Canary、NX、ASLR 各自能解決與不能解決的問題
- 將不安全的複製改為有長度限制的版本,並說明記憶體安全語言的作用
堆疊框架的結構
作業系統軌介紹過,函式被呼叫時,系統會在堆疊上配置一塊空間,稱為堆疊框架(stack frame)。框架裡有函式的區域變數,也有函式結束時需要的控制資訊:保存的框架指標與回傳位址。回傳位址記錄函式結束後要回到程式的哪個位置。
資料與控制資訊相鄰存放,這是本課主題的根源。C 語言的陣列沒有內建的長度檢查,下列函式把使用者的輸入複製到 8 位元組的緩衝區:
void check_user(const char *input) {
int is_admin = 0;
char buf[8];
strcpy(buf, input); // 複製到遇見結尾的 NUL 為止,不檢查 buf 的大小
if (is_admin) grant_admin();
puts(buf);
}
C 字串以一個值為 0 的位元組(NUL)結尾,所以 buf[8] 最多放 7 個字元。若輸入更長,strcpy 會繼續往高位址寫入,覆蓋相鄰的資料。這類弱點稱為緩衝區溢位(buffer overflow),寫入發生在堆疊上時稱為堆疊緩衝區溢位(CWE-121)。在 C 標準中,這屬於未定義行為,實際結果取決於編譯器與環境,可能沒有明顯症狀,也可能造成程式停止或資料毀損。
本課的模擬環境。 下方的記憶體完全由瀏覽器內的模型算出,不是真的記憶體,沒有真實位址,也不執行機器碼。輸入內容固定由程式產生(A、B、C…),只能調整長度。本課只說明原理與防護,不涉及任何攻擊的實作方式。未經授權測試他人的系統可能觸犯法律(第 1 課)。
溢位造成的影響
剛才的練習顯示了兩種層次的影響:
- 相鄰變數被改變:
is_admin從 0 變成非 0,程式的判斷隨之改變。這種情形不需要程式停止,也不一定有明顯症狀,影響的是程式邏輯 - 控制資訊被改變:回傳位址或框架指標毀損後,函式無法正確返回。模擬程式在此停止;真實程式可能異常終止,在沒有任何防護的環境裡,這也是歷史上攻擊者試圖取得程式控制權的途徑。本課不討論這些手法的細節,重點放在如何預防
溢位也可能發生在堆積(heap)與全域資料,讀取超過範圍同樣是問題。Heartbleed(CVE-2014-0160)即屬於讀取超過範圍的弱點,可洩漏行程記憶體中的敏感資訊。
模型中的變數順序是固定的。真實的框架由編譯器決定,C 語言標準沒有規定區域變數在記憶體中的排列方式,會受到最佳化與對齊的影響,所以不能假設某個變數一定相鄰於緩衝區。
防護機制分成幾層:最根本的作法是不寫出範圍(邊界檢查、安全函式、記憶體安全語言);其次是在寫出範圍時降低後果(Stack Canary、NX、ASLR)。第二類機制都是緩解措施:CWE-121 指出編譯器的偵測機制與 ASLR 都不是完整的解法,Microsoft 的文件也說明 DEP 並非針對所有攻擊的完整防護。
Stack Canary、NX 與 ASLR
| 機制 | 作用方式 | 無法解決的問題 |
|---|---|---|
| Stack Canary | 編譯器在控制資訊之前放一個檢查值,函式返回前比對;不符時印出錯誤訊息並結束程式(GCC 的 -fstack-protector 系列) |
只在函式返回時檢查;寫入沒有到達檢查值時不會被偵測;程式被中止,服務仍然中斷 |
| NX(No-eXecute,又稱 DEP) | 由處理器與作業系統將堆疊等資料區標為不可執行 | 不阻止覆寫,只限制資料區不能被當成指令執行 |
| ASLR | 每次啟動時將堆疊、函式庫等區域放在隨機的位置 | 不阻止覆寫,只讓位置難以預測;記憶體內容洩漏等弱點可以使它失效 |
這三者都不會讓溢位消失。它們假設錯誤可能發生,並提高利用的難度或把程式提早中止,屬於縱深防禦。CWE-121 也指出,Canary 只能偵測特定類型的溢位,偵測後通常是結束程式,這本身仍是一種服務中斷。
在 GCC 中,-fstack-protector-strong 會保護更多函式,例如含有區域陣列定義的函式。編譯選項是否預設開啟,會因編譯器版本與作業系統發行版本而異,專案應明確設定並確認。
啟用這項保護的編譯器通常也會調整變數的排列,把陣列移到靠近檢查值的位置,讓溢位不容易碰到其他變數。以本課的 check_user 為例,在 Compiler Explorer 以 x86-64 的 GCC 13.3、14.2 與 Clang 18.1、19.1 編譯:關閉保護時,buf 的位址比 is_admin 低 8 位元組,寫出 buf 會碰到 is_admin;加上 -fstack-protector-strong 後,is_admin 移到 buf 的低位址方向,寫出 buf 碰不到它。其他編譯器與版本可能不同。所以下方練習的版面是教學模型,用來說明「寫入沒有到達檢查值時不會被偵測」這項限制,真實程式不一定出現這個版面。結構內部的溢位、相鄰的陣列仍然不受這層保護。
有長度限制的複製與記憶體安全語言
最直接的修補是在寫入時限制長度。常見的函式行為並不相同:
| 寫法 | 長輸入時的行為 |
|---|---|
strcpy(buf, input) |
不檢查長度,寫出範圍 |
strncpy(buf, input, sizeof(buf)) |
不寫出範圍,但計數用完時不會加上結尾的 NUL,之後當成字串讀取會超出範圍 |
strncpy(buf, input, sizeof(buf) - 1) 後設定 buf[sizeof(buf) - 1] = '\0' |
不寫出範圍,字串有正確的結尾 |
snprintf(buf, sizeof(buf), "%s", input) |
最多寫入 sizeof(buf) - 1 個字元並加上結尾的 NUL;回傳值可用來判斷內容是否被截斷 |
長度參數是另一個容易出錯的地方:傳入的數字必須是目的地的大小,不是輸入的長度。memcpy(buf, input, strlen(input)) 依輸入長度複製,等於沒有限制。截斷本身也可能造成邏輯問題,例如路徑或帳號被截短後指向其他對象,程式應該檢查並拒絕過長的輸入。
記憶體安全語言從語言層面處理這類問題:執行時檢查索引範圍,或由編譯器保證存取的合法性。美國 CISA、NSA 與多國資安機關的公開文件建議軟體廠商規劃採用記憶體安全語言,例如 C#、Go、Java、Python、Rust、Swift。文件同時指出這不是絕對的保護:這些語言通常仍保留不安全的操作與對既有函式庫的呼叫。索引超出範圍時程式仍會丟出例外或中止,可用性問題需要另外處理。
既有的 C 與 C++ 程式還可以搭配編譯器警告、靜態分析、執行期檢查工具(例如 AddressSanitizer)與模糊測試,在開發階段找出這類錯誤。
重點整理
- 堆疊框架中,區域變數與回傳位址等控制資訊相鄰存放,C 語言的陣列沒有內建的長度檢查
- 寫入超過緩衝區長度屬於未定義行為,可能改變相鄰變數,也可能毀損控制資訊
- 真實的變數順序由編譯器決定,模型只用來說明原理
- Stack Canary 偵測檢查值是否改變,寫入沒有到達檢查值時不會被偵測;編譯器通常會把陣列移到靠近檢查值的位置,但結構內部與相鄰陣列仍不受保護;偵測後程式中止,服務仍然中斷
- NX 與 ASLR 不阻止覆寫,只提高利用的難度;三者都是緩解措施,不是修補
- 修補應限制寫入長度並確保字串結尾;
strncpy不保證有結尾的 NUL,長度參數必須是目的地的大小 - 記憶體安全語言從語言層面處理這類問題,但不代表沒有其他弱點
下一課討論作業系統層級的權限,以及機密資訊的管理方式。
小測驗
char buf[8] 搭配 strcpy(buf, input),輸入恰好 8 個字元時會發生什麼?
程式開啟了 Stack Canary。下列哪一種情形可能不會被它偵測到?
ASLR 開啟後,下列敘述何者正確?
將 strcpy 改成 strncpy(buf, input, sizeof(buf)) 後,還需要處理什麼?
關於 NX(不可執行)的敘述,下列何者正確?