你的手機可以在生產環境中偵測記憶體損壞。不是用你在 CI 中執行的完整插樁,也不是針對每一次分配。但你口袋裡的硬體在幾年前就已經搭載了必要的原語,而且越來越多的生產應用正在悄悄啟用它們。

簡而言之:AddressSanitizer 對生產環境來說太貴了。ARM Memory Tagging Extension 則不然。如果你還無法依賴 MTE,GWP-ASan 能以幾乎零開銷提供機率性覆蓋。它們共同回答了一個過去只有令人沮喪答案的問題。

記憶體損壞是你 shipped 出去的 bug

use-after-free、buffer overflow 和 heap corruption 是那種能通過每一套測試套件的 bug。它們存在於原生程式碼中,難以復現,而且經常在遠離實際錯誤的地方崩潰。當使用者回報崩潰時,最初的損壞已經發生,你唯一的證據是一段損壞的 stack trace,或者一個看起來隨機的位址上的 SIGSEGV。

傳統工具能及早發現這些問題。Valgrind 準確但緩慢。ASan 更快,但仍會增加大約 2-3 倍的記憶體開銷和 2 倍的 CPU 開銷。這對 fuzzing 或單元測試來說沒問題。對一台執行 Instagram 的電池供電裝置來說則不行。

生產環境歷來是一個盲點。你要麼在本地復現了 bug,要麼就只能猜測。

ARM MTE 如何在硬體層面為記憶體打標籤

在 ARMv8.5-A 中引入的 ARM Memory Tagging Extension 為 CPU 提供了一種低成本的方式,在每次記憶體存取時檢查指標的有效性。它的運作方式是為記憶體的每個 16 位元組顆粒分配一個 4 位元標籤,並在指標的最高位元組中儲存一個匹配的標籤。

在每次 load 或 store 時,CPU 會比較指標標籤和記憶體標籤。如果兩者不同,硬體就會觸發 fault。這不是軟體檢查。它發生在記憶體子系統中,開銷通常低於 5%。

64 位元指標的最高位元組原本就被硬體忽略。MTE 重新利用了這些位元。像 0xb7f0_0000_1234_5678 這樣的指標攜帶標籤 0xb7。該位址處的記憶體必須攜帶相同的標籤,否則存取就會 fault。

MTE 在搭載 ARMv8.5-A 或更新版本的裝置上可用。這包括 Google Pixel 8 及更新機型、iPhone 15 Pro 及更新機型,以及越來越多中階 Android 裝置。它尚未普及,但已不再稀奇。

核心透過 prctl 旗標暴露 MTE。應用程式可以請求同步或非同步檢查。同步模式在標籤不匹配時立即 fault。非同步模式將 fault 排入queue稍後投遞,成本更低但會略微延遲偵測。

GWP-ASan:當你無法為所有記憶體打標籤時

MTE 很好,但它需要相容的硬體和整個程式碼庫中的標籤化分配。如果你在舊裝置上發布,或者只需要有針對性的保護,GWP-ASan 是一個務實的替代方案。

GWP-ASan 代表 Guarded Write Protection AddressSanitizer。它是一個取樣分配器,將一小部分堆積分配放入被毒化 redzone 包圍的受保護頁面中。如果 use-after-free 或 buffer overflow 觸及這些 redzone,硬體 MMU 就會立即觸發 fault。

核心洞察在於機率。GWP-ASan 可能每 10,000 次分配中保護 1 次。這聽起來毫無用處,直到你意識到一個有 bug 的應用在崩潰前會損壞記憶體數千次。只要有足夠多的使用者工作階段,即使 0.01% 的取樣率也能在生產環境中捕獲真正的 bug。

Google 已在 Chrome 和 Android 系統服務中執行 GWP-ASan 多年。它發現了數百個從測試和 fuzzing 中逃掉的 use-after-free bug。CPU 開銷可以忽略不計,因為只有極小部分的分配會經過受保護路徑。記憶體開銷也是有限的,因為受保護池很小,而且分配最終會被回收。

沒人願意談論的權衡取捨

MTE 和 GWP-ASan 不是免費的。它們只是足夠便宜,值得使用。

MTE 需要每個指標的最高位元組,這意味著你的程式碼庫必須用 -fsanitize=memtag-march=armv8.5-a+memtag 編譯。如果你有對指標進行遮罩、雜湊或不帶正確標籤就透過 JNI 傳遞的程式碼,你會得到誤報。Android NDK 和現代 libc 實作能正確處理這一點,但自訂分配器或指標打包方案會出問題。

MTE 也只能捕獲你明確標記的堆疊和堆積區域內的 bug。一個跳到未標記 mmap 區域的 wild pointer 不會被捕獲。這聊勝於無,但並非完全覆蓋。

GWP-ASan 有相反的問題。它只捕獲恰好落入受保護池的分配。損壞未受保護分配的 bug 會被忽略。你還需要為取樣集合的分配和釋放支付少量延遲成本,並且需要在遙測管道中優雅地處理崩潰。

這兩種工具都不能取代 CI 管道中的 ASan。它們是對 ASan 的補充。ASan 在測試期間提供確定性的高覆蓋偵測。MTE 和 GWP-ASan 在現場提供安全網。

在現代 Android 裝置上啟用 MTE

如果你有 Pixel 8 或更新機型,今天就可以測試 MTE。Android NDK 僅用一個編譯器旗標就支援它。

首先,檢查你的裝置是否支援 MTE:

#include <sys/prctl.h>
#include <linux/prctl.h>
#include <sys/auxv.h>
#include <asm/hwcap.h>

bool mte_supported() {
    unsigned long hwcap2 = getauxval(AT_HWCAP2);
    return (hwcap2 & HWCAP2_MTE) != 0;
}

然後在 CMakeLists.txt 中啟用 MTE 來建置你的原生程式庫:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=memtag")

如果你想要同步 faulting——這是你在生產環境中通常想要的——在啟動時請求它:

#include <sys/prctl.h>

void enable_mte() {
    if (mte_supported()) {
        prctl(PR_SET_TAGGED_ADDR_CTRL,
              PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
              0, 0, 0);
    }
}

PR_MTE_TCF_SYNC 旗標告訴核心在標籤不匹配時立即 fault。標籤遮罩 0xfffe 排除零標籤,一些系統程式碼仍在使用它。

為生產環境設定 GWP-ASan

GWP-ASan 更容易啟用,因為它不需要特殊硬體。在 Android 上,你可以連結到 GWP-ASan 分配器包裝器,或在新 API 層級上透過系統分配器啟用它。

對於最小化整合,包裝你的分配器進入點:

extern "C" void* malloc(size_t size) {
    if (__gwp_asan_sample()) {
        return __gwp_asan_guarded_malloc(size);
    }
    return __libc_malloc(size);
}

在實務中,你會使用 Android GWP-ASan 執行階段或 LLVM compiler-rt 實作。關鍵的設定旋鈕是取樣率。保守起步:

// 每 10,000 次分配中受保護 1 次
__gwp_asan_set_sample_rate(10000);

透過現有的遙測系統收集產生的崩潰。GWP-ASan 崩潰看起來像一個標準 SIGSEGV,但 faulting 位址會落在一個受保護頁面中。你的崩潰回報器可以透過檢查 faulting 位址是否落在 GWP-ASan 池中來偵測這一點。

你真正應該做的事

如果你在行動裝置上發布原生程式碼,你應該已經在 CI 和 fuzzing 中執行 ASan。問題是發布之後會發生什麼。

如果你支援的最低裝置包含 ARMv8.5-A 晶片,在同步模式下啟用 MTE。開銷低到使用者不會察覺,而且你得到的崩潰將包含準確的標籤不匹配資訊,而不是隨機的堆積損壞。

如果你支援舊裝置,以保守的取樣率啟用 GWP-ASan。它不會捕獲每一個 bug,但會捕獲其他手段無法捕獲的 bug。在數百萬工作階段中,這不是理論上的。Chrome 去年就是這樣在其影像解碼器中發現了一個 use-after-free。

你手機中的硬體已經具備捕獲你最害怕的記憶體安全 bug 的能力。唯一的問題是你是否啟用了它。