Cライブラリ内でのたった一つのヌルポインタ参照が、アプリケーション全体をクラッシュさせることがある。そのライブラリがユーザー入力を解析したり、画像を展開したり、ネットワークプロトコルを扱うのであれば、一つの異常なパケットがクラッシュを引き起こす危険性がある。コンテナはこの問題を解決するが、一つの依存関係のためにDockerを起動するのは、観葉植物にボディガードを雇うようなものだ。オーバーヘッドなしの隔離が必要である。

WebAssemblyとWASIが最も実用的な答えだ。ライブラリをWASMにコンパイルし、サンドボックス化されたランタイム内で実行すれば、ゲストがセグメンテーションフォールトを起こしてもホストプロセスは生き残る。メモリアクセスには境界チェックが行われる。ファイルシステムアクセスはケイパビリティベースだ。そしてサンドボックスはライブラリコールであり、システムコールではない。

Cライブラリのサンドボックス化が実際に意味すること

Cにはメモリ安全性がない。依存関係内のバッファオーバーフローは、スタックを破壊したり、実行フローを乗っ取ったり、ホストをクラッシュさせたりする可能性がある。ASANのような従来の緩和策はruntime時にバグを発見するが、オーバーヘッドを増やし、失敗をプロセスの残りから隔離しない。seccompやLandlockはシステムコールを制限できるが、Linux専用であり、一部の操作にはroot権限が必要で、許可されたシステムコール内でのメモリ破壊は止められない。

WebAssemblyは異なるアプローチをとる。明示的なメモリ境界、構造化された制御フロー、境界外アクセスに対する未定義動作のない仮想命令セットにコンパイルされる。WasmtimeやWAMRのようなWASMランタイムは、実行前にバイトコードを検証する。ゲストが所有していないメモリに触れようとすれば、ランタイムはトラップを発生させる。ホストは動作し続ける。

これはエミュレーションではない。最新のWASMランタイムはCraneliftやLLVMを介してネイティブマシンコードにコンパイルする。計算負荷の高いコードでは、通常1.1倍から2倍のパフォーマンス低下にとどまる。I/O負荷の高いライブラリでは、その差は誤差の範囲に消えることが多い。

WASIがファイルシステムアクセスをケイパビリティに変える仕組み

WASIはWebAssembly System Interfaceである。サンドボックス化されたモジュールに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ファイルはサンドボックス化されたモジュールである。ランタイムが明示的にアクセスを許可しない限り、どのファイルも開けない。線形メモリ領域の外側にメモリを割り当てることもできない。WASIはexecveを定義していないため、任意のシェルコマンドを実行することもできない。

Rustからサンドボックス化されたライブラリを実行する

Wasmtimeは、CおよびPythonバインディングを備えたRust製の本番環境対応WASMランタイムである。以下は、コンパイル済みの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呼び出しはパス文字列をそのサンドボックス化されたアドレス空間にコピーする。checksum_fileがメモリの終端を越えて読み取ろうとした場合、Wasmtimeはトラップを発生させる。/etc/passwdを開こうとした場合、WASIはENOENTを返す。なぜなら、そのパスはプリオープンされていないからだ。

メモリモデルが実際に保護する場所

WASMモジュールは、通常64KBから始まり必要に応じて拡張する単一の連続した線形メモリを使用する。すべてのメモリアクセスはランタイムによって境界チェックされる。Cライブラリ内のバッファオーバーフローは、ホストのスタックやヒープを破壊できない。任意のコードへのジャンプもできない。自分に割り当てられた領域内のメモリにしか触れられない。

これは完全なプロセス分離よりも弱い。ゲストとホストは同じOSプロセスを共有する。CPUレベルのサイドチャネル攻撃や投機的実行のバグが、理論的には境界を越えてデータを漏洩させる可能性がある。しかし、「このCライブラリにバグがありクラッシュする可能性がある」という脅威モデルに対しては、WASMの隔離は真実であり実用的である。

フォールト時のトラップ動作が最大のセールスポイントだ。ゲストがヌルポインタを参照したり、バッファをオーバーフローさせたりした場合、Wasmtimeはそれを捕捉してエラーを返す。ホストプロセスはセグメンテーションフォールトを起こさない。ウェブサーバーは再起動しない。エラーをログに記録して次に進めばよい。

作業を遅らせるトレードオフ

WASMは透過的ではない。スレッド、シグナル、setjmp/longjmp、生のmmapを使用するCコードは、書き換えなしではWASIにコンパイルできない。WASI SDKはオプションフラグを介してpthreadをサポートしているが、まだ実験的であり、ホストランタイムが有効にする必要がある。

FFI境界は手動で行う必要がある。.wasmファイルをCやRustプロジェクトにリンクして、通常のライブラリのようにその関数を呼び出すことはできない。引数を線形メモリにマーシャリングし、インデックスで関数を呼び出し、結果を読み戻す必要がある。wit-bindgenのようなツールはインターフェース定義から接着コードを生成するが、それでも複雑性の代償は支払わなければならない。

パフォーマンスは状況によって異なる。整数中心のコードは、WASMにネイティブに近い速度でコンパイルされる。頻繁にホストコールを行うコード、多くのメモリ割り当てを行うコード、SIMDに依存するコードは、より大きな速度低下が見られる。WasmtimeはSIMD提案をサポートしているが、すべてのターゲットが均一にサポートしているわけではない。

最後に、WASIはまだ進化している。WASI Preview 1は安定して広くサポートされている。Preview 2はより洗練されたコンポーネントモデルインターフェースを導入するが、まだ普遍的ではない。今日出荷するのであれば、Preview 1に留まるべきだ。

知っておくべき代替手段

WebAssemblyが手間に感じられる場合、異なるトレードオフを持つより軽い選択肢がある。

seccomp-bpfは、Linuxプロセスのシステムコールをホワイトリスト化できる。高速でカーネルによって強制される。しかし、Linux専用であり、許可されたシステムコール内でのメモリ破壊は防げず、正しいseccompフィルタの作成は悪名高く間違いやすい。

Landlockは、特権を必要とせずにファイルシステムアクセスを制限する新しいLinux機能だ。seccompよりシンプルで、「このライブラリがSSHキーを読めるか」という問題をうまく処理する。しかし、依然としてLinux専用であり、メモリのサンドボックス化は行わない。

別プロセスによるforkやワーカープールは、真のOS分離を提供する。子プロセスでのセグメンテーションフォールトは子プロセスのみを殺す。欠点はシリアライゼーションのオーバーヘッドとプロセス管理の複雑性だ。高スループットのシナリオでは、コールごとのプロセスはスケールしない。

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にバッファオーバーフローが含まれていれば、コアダンプではなくトラップメッセージが得られる。

ホスト統合には、Rust、C、またはPythonのWasmtime埋め込みAPIを使用する。一つのエクスポート関数から始める。手書きのマーシャリングでは足りなくなったらwit-bindgenを追加する。ホットパスにWASMを採用する前に、オーバーヘッドをプロファイルする。

FAQ

C++ライブラリも同じ方法でサンドボックス化できるか?

できる。WASI SDKにはclang++が含まれており、C++17のほとんどをサポートしている。例外とRTTIは機能するが、スレッドローカルストレージにはいくつかの制限がある。同じフラグで--target=wasm32-wasiを指定してコンパイルする。

ネットワークが必要なライブラリはどうか?

WASI Preview 1にはソケットが含まれていない。一部のランタイムは非標準の拡張機能を提供するか、インポート関数を介してホストを経由してネットワークリクエストをプロキシできる。Preview 2ではソケットが追加されるが、各ランタイムでのサポートはまだ展開中である。

コンパイルされたWASMバイナリのサイズはどの程度か?

通常、同じCコードのネイティブバイナリ(ストリップ済み)の2倍から5倍程度である。Wasmtimeは初回実行時にネイティブコードをコンパイルしてキャッシュできるため、起動オーバーヘッドはバイナリバージョンごとに一度きりのコストである。

これはコンテナを置き換えるものか?

いいえ。WASMはプロセスレベルのサンドボックスである。コンテナはOSレベルの隔離である。プロセス内の信頼できないコードを隔離するにはWASMを使用する。プロセスをシステムの残りから隔離するにはコンテナを使用する。両者は積み重ね可能であり、異なる脅威モデルに対応する。

ゲストはWASIの脆弱性を介して脱出できるか?

攻撃対象面はWASMランタイムとWASI実装であり、ゲストコードそのものではない。WasmtimeはRustで書かれており、ランタイム内の多くのメモリ安全性バグを排除する。WebAssembly仕様も形式的検証を受けている。サンドボックスは完璧ではないが、WASMランタイムはほとんどのネイティブライブラリよりも優れたセキュリティ実績を持っている。

C言語でのセグメンテーションフォールトは、生産停止ではなく封じ込められた障害であるべきだ。完全なOS隔離が必要な場合、コンテナが正しい答えである。一つのライブラリがプロセスを乗っ取るのを防ぐだけであれば、WebAssemblyはデプロイが速く、理解しやすく、実際に効果的だ。