C 程式庫內部的一次空指標解參照就可能導致整個應用程式當掉。如果該程式庫負責剖析使用者輸入、解壓縮影像或處理網路 protocol,那麼一個格式錯誤的封包就足以引發當機。container 確實能解決這個問題,但僅為了一個相依項目就啟動 Docker,無異於為盆栽雇用保鏢。你需要的是沒有額外負擔的隔離。

WebAssembly 加上 WASI 是最實用的答案。將程式庫編譯為 WASM,在sandboxed執行環境中執行,即使來賓發生段錯誤,宿主行程也能安然無恙。記憶體存取會經過邊界檢查。檔案系統存取基於權能機制。而且這個 sandbox是一次程式庫呼叫,而非系統呼叫。

為 C 程式庫做 sandbox 隔離究竟意味著什麼

C 語言沒有記憶體安全性。相依項目中的buffer overflow可能破壞你的堆疊、劫持執行流程或導致宿主當機。ASAN 等傳統緩解措施能在 runtime 時發現缺陷,但會帶來負擔,且無法將故障與行程其餘部分隔離。seccomp 和 Landlock 可以限制系統呼叫,但它們僅適用於 Linux,某些操作需要 root 權限,且無法阻止允許範圍內的系統呼叫內部的記憶體破壞。

WebAssembly 採取了不同的方法。它編譯為具有顯式記憶體邊界、結構化控制流程、且越界存取不存在未定義行為的虛擬指令集。Wasmtime 或 WAMR 等 WASM 執行環境在執行前會驗證位元組碼。如果來賓碰觸了不屬於自己的記憶體,執行環境會觸發設陷。宿主繼續執行。

這不是模擬。現代 WASM 執行環境透過 Cranelift 或 LLVM 編譯為本機機器碼。對於計算密集程式碼,效能損失通常在 1.1 倍到 2 倍之間。對於 I/O 密集程式庫,差距往往淹沒在雜訊中。

WASI 如何將檔案系統存取轉化為權能

WASI 即 WebAssembly 系統介面。它為sandboxed模組提供類 POSIX 的 API,但每個資源都是一項權能。不存在全域檔案系統。如果你想讓來賓讀取 ./data/input.txt,必須明確預先開啟該目錄並傳入檔案描述元。

以下是一個讀取檔案並傳回檢查碼的最小化 C 程式庫:

// libchecksum.c
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint32_t checksum_file(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return 0;

    uint32_t sum = 0;
    int c;
    while ((c = fgetc(f)) != EOF) {
        sum += (uint32_t)c;
    }
    fclose(f);
    return sum;
}

使用 WASI SDK 將其編譯為 WebAssembly:

# Install wasi-sdk from https://github.com/WebAssembly/wasi-sdk
/opt/wasi-sdk/bin/clang \
  --target=wasm32-wasi \
  -Wl,--export=checksum_file \
  -o libchecksum.wasm libchecksum.c

產生的 .wasm 檔案是一個sandboxed模組。除非執行環境明確授予存取權限,否則它無法開啟任何檔案。它無法在線性記憶體區域之外分配記憶體。由於 WASI 沒有定義 execve,它也無法執行任意 shell 指令。

從 Rust 執行sandboxed程式庫

Wasmtime 是一個面向生產環境的 WASM 執行環境,使用 Rust 撰寫,並帶有 C 和 Python 繫結。以下是一個宿主程式,它載入編譯後的 C 程式庫,授予其對 ./data/ 的唯讀存取權限,並呼叫 checksum_file

use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};

fn main() -> anyhow::Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "libchecksum.wasm")?;

    // Build a WASI context that preopens ./data as /data inside the guest
    let mut builder = WasiCtxBuilder::new();
    builder.preopen_dir("./data", "/data", wasmtime_wasi::DirPerms::READ_ONLY, wasmtime_wasi::FilePerms::READ_ONLY)?;
    let wasi = builder.build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;

    // Call the exported function with a guest-allocated string
    let checksum_fn = instance.get_typed_func::<(i32, i32), i32>(&mut store, "checksum_file")?;

    // Write the path string into guest memory
    let memory = instance.get_memory(&mut store, "memory").unwrap();
    let path = b"/data/input.txt\0";
    let ptr = 0;
    memory.write(&mut store, ptr, path)?;

    let result = checksum_fn.call(&mut store, (ptr as i32, path.len() as i32))?;
    println!("Checksum: {}", result);

    Ok(())
}

來賓 C 程式碼在其獨立的 32 位元線性記憶體中執行。memory.write 呼叫將路徑字串複製到該sandboxed位址空間。如果 checksum_file 試圖讀取超出自身記憶體末端的位置,Wasmtime 會觸發設陷。如果它試圖開啟 /etc/passwd,WASI 會傳回 ENOENT,因為該路徑從未被預先開啟。

記憶體模型真正保護你的地方

WASM 模組使用單一的連續線性記憶體,通常從 64KB 開始,按需增長。每次記憶體存取都會由執行環境進行邊界檢查。C 程式庫中的buffer overflow無法破壞宿主的堆疊或堆積。無法跳轉到任意程式碼。只能碰觸自己已分配區域內的記憶體。

這弱於完整的行程隔離。來賓與宿主共享同一個作業系統行程。CPU 層級的旁通道攻擊或推測執行漏洞理論上可能跨越邊界洩漏資料。但對於「這個 C 程式庫有缺陷並可能當機」的威脅模型而言,WASM 隔離是真實且實用的。

故障時設陷的行為是關鍵賣點。如果來賓解參照空指標或發生buffer overflow,Wasmtime 會捕獲錯誤並傳回異常。宿主行程不會發生段錯誤。你的網頁伺服器不會重新啟動。記錄錯誤並繼續即可。

會拖慢你的權衡

WASM 並非透明。使用執行緒、訊號、setjmp/longjmp 或原始 mmap 的 C 程式碼不經改寫無法編譯到 WASI。WASI SDK 透過選用旗標支援 pthreads,但仍是實驗性的,且需要宿主執行環境啟用。

FFI 邊界是手動的。你不能簡單地把 .wasm 檔案連結到 C 或 Rust 專案中,像普通程式庫一樣呼叫其函式。你需要將參數整理到線性記憶體中,按索引呼叫函式,再讀取結果。wit-bindgen 等工具可以從介面定義生成黏合程式碼,但你仍需承擔複雜性代價。

效能表現各異。整數密集程式碼編譯為 WASM 後速度接近原生。頻繁進行宿主呼叫、大量分配或依賴 SIMD 的程式碼則會出現更明顯的減速。Wasmtime 支援 SIMD 提案,但並非所有目標平台都統一支援。

最後,WASI 仍在演進。WASI Preview 1 穩定且廣泛支援。Preview 2 引入了更簡潔的元件模型介面,但尚未普及。如果今天就要交付,請繼續使用 Preview 1。

值得了解的替代方案

如果 WebAssembly 感覺過於複雜,還有更輕量的選擇,只是權衡不同。

seccomp-bpf 允許你為 Linux 行程的系統呼叫設定白名單。速度快且由核心強制執行。但僅適用於 Linux,無法阻止允許的系統呼叫內部的記憶體破壞,而且編寫正確的 seccomp 過濾器 notoriously 容易出錯。

Landlock 是 Linux 較新的功能,可在無需特權的情況下限制檔案系統存取。它比 seccomp 更簡單,能很好地處理「這個程式庫能否讀取我的 SSH 金鑰」的問題。但仍然是 Linux 專用,且不對記憶體做 sandbox 隔離。

獨立行程 透過 fork 或工作池提供真正的作業系統隔離。子行程中的段錯誤只會殺死子行程。缺點是序列化負擔和行程管理複雜度。對於高吞吐量場景,每次呼叫一個行程是不可擴展的。

WebAssembly 處於中間地帶。比 seccomp 更強的隔離,比 Landlock 更可攜,比獨立行程更輕量。正確選擇取決於你的瓶頸是系統呼叫延遲、記憶體安全還是部署複雜度。

實用的起點

你不必重寫整個應用程式。挑選一個處理不可信輸入的 C 相依項目,將其編譯為 WASM,並用一個輕量的宿主墊片封裝起來。

# 1. Install wasi-sdk (or use the prebuilt release)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-24/wasi-sdk-24.0-x86_64-linux.tar.gz
tar xzf wasi-sdk-24.0-x86_64-linux.tar.gz

# 2. Compile your C library
./wasi-sdk-24.0/bin/clang --target=wasm32-wasi \
  -O2 -Wl,--export-all -o parser.wasm parser.c

# 3. Run it with wasmtime
wasmtime run --dir=./input::/input parser.wasm /input/untrusted.dat

--dir=./input::/input 語法將宿主的 ./input 目錄掛載到來賓內部的 /input。來賓看不到你檔案系統上的其他任何內容。如果 parser.c 包含buffer overflow,你會收到一條設陷訊息,而非核心傾印。

對於宿主整合,請使用 Rust、C 或 Python 中的 Wasmtime 嵌入 API。從一個匯出函式開始。當手動整理不再夠用的時候,再引入 wit-bindgen。在將 WASM 用於熱路徑之前,先分析其負擔。

FAQ

我能用同樣的方式給 C++ 程式庫做 sandbox 隔離嗎?

可以。WASI SDK 包含 clang++,支援大部分 C++17 特性。例外和 RTTI 可用,但執行緒區域儲存有一些限制。使用相同的旗標和 --target=wasm32-wasi 編譯即可。

需要網路的程式庫怎麼辦?

WASI Preview 1 不包含通訊端。某些執行環境提供非標準擴充,或者你可以透過匯入函式經由宿主代理網路請求。Preview 2 增加了通訊端支援,但各執行環境的支援仍在逐步推出。

編譯後的 WASM 二進位檔有多大?

通常是相同 C 程式碼的剝離後原生二進位檔大小的 2 到 5 倍。Wasmtime 可以在首次執行時編譯並快取本機程式碼,因此啟動負擔是每個二進位版本一次性付出的代價。

這會取代 container嗎?

不會。WASM 是行程級 sandbox。container 是作業系統級隔離。用 WASM 隔離行程內的不可信程式碼。用 container 將行程與系統其餘部分隔離。兩者可以疊加,服務於不同的威脅模型。

來賓能否透過 WASI 漏洞逃脫?

攻擊面在於 WASM 執行環境和 WASI 實作本身,而非來賓程式碼。Wasmtime 使用 Rust 撰寫,消除了執行環境中的許多記憶體安全缺陷。WebAssembly 規範也經過形式化驗證。沒有 sandbox是完美的,但 WASM 執行環境的安全記錄優於大多數原生程式庫。

C 語言中的段錯誤應該是一個受控的故障,而非生產事故。當你需要完整的作業系統隔離時,container 是正確的答案。當你只需阻止單一程式庫接管行程時,WebAssembly 部署更快、更容易理解、而且真正有效。