在理想世界裡,所有語言都會使用同一種文字編碼。但我們不住在理想世界,實際上仍在使用多種編碼——如第 3 章談過的 ASCII 與各種 Unicode 變體。

有些編碼之間的轉換無法來回還原(round-trip):從一種編碼轉到另一種會遺失重要資訊,導致反向轉換時無法回到原始文字。從 Unicode 這種寬字元集轉到 ASCII 這種窄字元集時特別嚴重——畢竟用 7 個位元根本不可能編碼整個 Unicode 字元集。

文字編碼轉換用兩種方式處理這個問題:

  • 佔位符替換:最簡單的做法,把無法表示的字元換成一個佔位字元,例如問號(?)。若該資料值出現在「問號具有特殊意義」的場合,就可能出問題——例如 URL 解析中問號代表查詢字串的開始。
  • 最佳匹配對映(best-fit mapping):用於新編碼中存在相似字元的情況。例如 Unicode 的引號有左向與右向兩種形式,對應到 U+201CU+201D 兩個碼位;它們落在 ASCII 範圍之外,但轉換成 ASCII 時通常會被換成等價字元 U+0022(一般引號)。

文字稍微走樣對使用者通常沒什麼影響,但當轉換後的文字要被應用程式拿去處理時,這個自動轉換過程就可能讓應用程式誤處理資料。

這裡真正關鍵的實作問題是:應用程式用某一種編碼形式驗證安全條件,卻用另一種編碼形式執行實際動作(例如讀取資源或執行命令)。

def add_user()
{
  string username = read_unicode_string();      /* 讀入 Unicode 字串 */

  // Ensure username doesn't contain any single quotes
  if(username.contains("'") == false)           /* 在 Unicode 形式上做檢查 */
  {
    // Add user, need to convert to ASCII for the shell
    system("/sbin/add_user '" + username.toascii() + "'");  /* 卻用 ASCII 形式執行 */
  }
}

這段程式讀入一個代表待新增使用者的 Unicode 字串。它打算把值傳給 add_user 命令,而且為了避免命令注入漏洞,先確認使用者名稱不含可能被誤解的單引號字元。確認沒問題後,它把字串轉成 ASCII(Unix 系統通常使用窄字元集,儘管許多系統支援 UTF-8),並用單引號包起來以免空白被誤解。

問題就在這裡:若最佳匹配對映規則會把其他字元轉回單引號,攻擊者就能提前終止被引號包住的字串,回到前面談過的那種命令注入漏洞。安全檢查在轉換之前做,而轉換之後字串已經不是被檢查過的那一個了。

結語#

本章說明了漏洞可以有非常多種根本成因,而野外的變體幾乎無窮無盡。即使某個東西乍看之下不像有漏洞,也請堅持下去——漏洞會出現在最令人意外的地方。

我們涵蓋的範圍,從讓應用程式行為偏離原始設計的記憶體毀損,到阻止合法使用者取得服務的各類耗盡攻擊。要把這些問題全數辨識出來,是個相當複雜的過程。

作為協定分析者,你有許多可切入的角度,而因應目標調整策略至關重要。先確認應用程式是用記憶體安全還是不安全的語言寫的——舉例來說,在 Java 應用程式中找到記憶體毀損的機會就低得多,力氣該花在別的地方。