WebAssembly Component Model (WASI 0.2) 線形メモリ境界分離と Capability-Based Security の数理検証
著者:加藤 明生(株式会社オープンナレッジ 代表取締役CTO)
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 要旨と脅威モデル:なぜ従来の共有メモリ型プロセスはサプライチェーン侵害に脆弱なのか | 一般的なLinuxコンテナはホストOSのシステムコール境界(数百個のsyscall)を共有し、起動に数十ミリ秒〜数秒と数十MBのメモリを要します。一方、WebAssembly Component Model(WASI 0.2)は、(1) 64KiBページ単位の線形メモリ境界検査と仮想メモリガードページ(PROT_NONE)によるハードウェア隔離、(2) デフォルトでファイル・ソケット・環境変数へのアクセス権を一切持たないCapability-Based Security(権限ベース安全設計)、および (3) 数十マイクロ秒でのコールドスタートを実現します。 |
| 2. 現場での進め方と対策 | 線形メモリ境界検査・仮想メモリガードページ・Canonical ABI の数理定式化 | はい。従来のC/C++やNode.js、Python等のランタイムでは、同一プロセス内のすべての依存パッケージが同じメモリ空間とOS環境変数(データベース接続文字列やAPI秘密鍵)を共有する「Ambient Authority(暗黙の全権限)」構造になっていました。Component Modelでは各モジュールが独立した線形メモリ空間(Shared-Nothing)を持ち、Canonical ABIによる型安全な値コピーのみが許可されるため、別コンポーネントのメモリ盗み見や外部通信は構造的に不可能です。 |
| 3. 関連する制度とルール | WASI 0.2 リソースハンドル表と権限減衰(Capability Attenuation)の形式検証 | Rust 2024製のホストランタイムにおいて、WASM命令ブロックごとに決定論的な燃料カウンタ(Fuel Instrumentation)を挿入しています。1回の呼び出しあたりに消費可能な命令数上限(Fuel Budget)と線形メモリ拡張上限(N_max Pages)を厳密にバインドし、上限超過時はホストプロセスに影響を与えずマイクロ秒単位で該当インスタンスのみを安全に中断(Trap)させます。 |
| 4. 詳しい技術仕様としくみ | WASI 0.2 世代付き Capability ハンドル表・線形メモリ境界検査・Fuel 決定論的実行制御の Rust 2024 実装 | 著者:加藤 明生(株式会社オープンナレッジ 代表取締役CTO) |
1. 要旨と脅威モデル:なぜ従来の共有メモリ型プロセスはサプライチェーン侵害に脆弱なのか
現代のソフトウェアサプライチェーン攻撃(NPM / PyPI / Crates.io 等における依存パッケージ侵害)が甚大な被害をもたらす根本原因は、POSIX互換OSプロセスの「Ambient Authority(暗黙の包括的権限)」と「単一フラット仮想アドレス空間」にあります。たとえば、日付文字列の整形やPDF帳票のレイアウト計算を行うだけの小さなサードパーティ製ライブラリであっても、ひとたびWebアプリケーションと同じOSプロセス内でリンクされると、そのプロセスが保持するすべての環境変数(DATABASE_URL や署名用秘密鍵)、ヒープメモリ上の他テナントの平文データ、および外向きTCPソケット発行権限(connect(2))へ無制限にアクセスできてしまいます。
この構造的欠陥をコンパイル時および命令セット(ISA)レベルで解消するのが、W3C WebAssembly 2.0 および Bytecode Alliance が策定した WebAssembly Component Model(WASI 0.2 / Preview 2) です。当社のエッジ基盤(ap01.ok.st)およびブラウザ内ローカル解析エンジン(PII自動マスキング・8ch表面筋電位STFT演算・契約書構造検証)では、すべてのプラグインおよび非信頼データパーサを独立した WebAssembly コンポーネントとして分離実行しています。
本論文では、(1) 64KiBページ境界と仮想メモリガードページによるソフトウェア・フォルト・アイソレーション(SFI)の数理、(2) ポインタ共有を一切許さない Canonical ABI の Lifting / Lowering 変換規則、および (3) 偽造不可能なハンドルインデックスに基づく Capability-Based Security の形式証明と Rust 2024 実装を提示します。
| ISOLATION DIMENSION | 従来型同一プロセス依存(Node.js / PHP / Python) | Linux Container(Docker / namespaces) | WASM Component Model(WASI 0.2 Sandbox) |
|---|---|---|---|
| Memory Space | 全ライブラリが同一ヒープを共有(任意メモリ読取可) | コンテナ単位で分離(同一コンテナ内は共有) | コンポーネント単位の Shared-Nothing 線形メモリ |
| Authority Model | Ambient Authority(全環境変数・全ソケットへ到達可) | Linux Capabilities / seccomp-bpf(約300 syscall) | Fine-Grained Handle Capability(初期権限 = 空集合 ∅) |
| Control-Flow Integrity | JIT Spraying / ROP / JOP のリスクあり | ゲストバイナリ内のROPはネイティブ同様に成立 | 構造化制御フロー + ホスト管理 Call Stack 分離 |
| Cold-Start & Footprint | 数十ms 〜 数百ms / 30MB〜150MB | 150ms 〜 数秒 / 数十MB〜数百MB | 50µs 以下(事前AOTコンパイル時)/ 数十KiB〜数MB |
2. 線形メモリ境界検査・仮想メモリガードページ・Canonical ABI の数理定式化
WebAssembly の各コアモジュールインスタンス mi は、ホストのポインタ空間から完全に切り離されたバイト配列としての線形メモリ(Linear Memory) Mi ∈ {0, …, 255}Li(ただし Li = Npages × 65,536 バイト)のみを参照できます。実行スタック(オペランドスタックおよび関数リターンアドレス)は線形メモリ Mi の外側のホスト管理領域に配置されるため、線形メモリ内のバッファオーバーフローによって関数リターンアドレスを書き換える古典的なスタック・スマッシング(ROP攻撃)は原理的に成立しません。
64ビットホストCPU(x86_64 / AArch64)上では、各線形メモリの基底仮想アドレス BaseVA(Mi) から 4GiB の実効領域と 4GiB のハードウェア・ガードページ領域(mmap(..., PROT_NONE)、物理RAM消費はゼロ)を予約することで、32ビット基底アドレスと32ビット静的オフセットの和 EA が萬一境界を超過しても、明示的な比較分岐命令なしにCPUのMMUページフォルト(SIGSEGV / SIGBUS → WASM Trap変換)で即座に捕捉されます。
Component Model Canonical ABI:Lifting と Lowering による Shared-Nothing 合成
WebAssembly Component Model(WIT: Wasm Interface Types)では、コンポーネント CA とコンポーネント CB は互いの線形メモリ MA, MB へのポインタを一切共有できません。CA が CB のエクスポート関数を呼び出す際、ランタイムは CA の線形メモリ表現から高水準の正規形値(Abstract Value v ∈ ValWIT)へとLift(抽象化・UTF-8妥当性検証・境界検査)し、続いて CB の再確保関数 cabi_realloc を呼び出して CB 側の独立メモリ MB へとLower(具象化コピー)します。
3. WASI 0.2 リソースハンドル表と権限減衰(Capability Attenuation)の形式検証
WASI 0.2(Preview 2)において、ファイルディスクリプタ、HTTP送信クライアント、乱数生成器などの外部リソースはすべて WIT の resource 型(偽造不可能な所有権付きハンドル)として表現されます。ゲストコンポーネントが受け取るハンドル値 h ∈ ℕ32 は、ホスト空間のポインタではなく、そのコンポーネントインスタンス専用のローカルハンドルテーブル Ti への世代付きインデックス(Generational Arena Index)です。
これにより、以下の3つのセキュリティ不変条件が数学的に成立します。第1に「権限の無からの生成不能性(No Fabrication)」:ゲストが任意の32ビット整数を捏造してホスト関数に渡しても、Ti 内の世代番号(Generation Counter)および型タグと一致しない限り即時 Trap となります。第2に「単調権限減衰(Monotonic Attenuation)」:親ハンドルから子ハンドルを派生させる際、権限ビットマスクは部分集合(Rightschild ⊆ Rightsparent)にしかなり得ず、権限昇格は構造的に起こりません。第3に「借用(borrow<T>)と所有(own<T>)のライフタイム追跡」:アクティブな借用ハンドルが存在する間に親所有ハンドルを破棄(Drop)しようとした場合、ホストテーブルが即座にプロトコル違反を検知して実行を遮断します。
| WASI 0.2 INTERFACE | DEFAULT SANDBOX POLICY | 権限減衰と境界制御仕様(Host Enforcement) |
|---|---|---|
| wasi:filesystem/types | DENY ALL (No Preopens) | ホストのファイルシステムツリーは一切マウントせず、必要な入力はインメモリ wasi:io/streams としてのみ注入 |
| wasi:sockets/tcp | DENY ALL (Zero Raw Socket) | 生のTCP/UDPソケット生成を確実に遮断し、サプライチェーン侵入時のC2サーバ通信(Reverse Shell)を無力化 |
| wasi:http/outgoing-handler | ALLOWLISTED ORIGIN ONLY | 外部連携が必要なコンポーネントにのみ、ホスト署名済みオリジンリストに一致するHTTPSリクエストのみを中継 |
| wasi:clocks/monotonic-clock | COARSE RESOLUTION (1ms) | ナノ秒単位の高精度タイマーを1ミリ秒粒度に量子化し、Spectre等のキャッシュタイミング計測攻撃を阻止 |
WASMコンポーネント内部およびホストランタイムのメモリ安全性を支える型理論。
ブラウザ内でのミリ秒演算とZero-JS静的配信を両立するエッジ実行アーキテクチャ。
CVE-2024-4577等の動的ランタイム脆弱性を静的ビルドとWASM分離で根絶する比較検証。
WASI 0.2 世代付き Capability ハンドル表・線形メモリ境界検査・Fuel 決定論的実行制御の Rust 2024 実装
#![forbid(unsafe_code)]
pub const WASM_PAGE_BYTES: u64 = 65_536;
pub const MAX_COMPONENT_HANDLES: usize = 256;
/// Fine-grained WASI 0.2 capability rights bitmask.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CapabilityRights(pub u32);
impl CapabilityRights {
pub const NONE: Self = Self(0);
pub const STREAM_READ: Self = Self(1 << 0);
pub const STREAM_WRITE: Self = Self(1 << 1);
pub const HTTP_OUTGOING_GET: Self = Self(1 << 2);
pub const MONOTONIC_CLOCK: Self = Self(1 << 3);
#[inline]
pub const fn contains(self, required: Self) -> bool {
(self.0 & required.0) == required.0
}
/// Monotonic capability attenuation: child rights can never exceed parent rights.
#[inline]
pub const fn attenuate(self, mask: Self) -> Self {
Self(self.0 & mask.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceKind {
MemoryInputStream,
MemoryOutputStream,
ScopedHttpEndpoint { allowed_origin_hash: u64 },
}
/// Unforgeable generational handle passed across the Canonical ABI boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GuestHandle {
pub slot_index: u16,
pub generation: u16,
}
#[derive(Clone, Copy, Debug)]
struct HandleSlot {
generation: u16,
occupied: bool,
active_borrows: u16,
kind: ResourceKind,
rights: CapabilityRights,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SandboxTrap {
OutOfBoundsLinearMemory { effective_addr: u64, limit_bytes: u64 },
MemoryPageQuotaExceeded { requested_pages: u32, max_pages: u32 },
FuelExhausted { budget: u64 },
InvalidOrForgedHandle,
UseAfterDropGenerationMismatch,
CapabilityRightsDenied { required: CapabilityRights, actual: CapabilityRights },
CannotDropBorrowedResource { active_borrows: u16 },
HandleTableCapacityExhausted,
InvalidUtf8CanonicalLift,
}
/// Isolated WASI 0.2 Component Instance State (Shared-Nothing Memory + Handle Table + Fuel).
pub struct WasiComponentSandbox {
linear_memory: Vec<u8>,
current_pages: u32,
max_pages: u32,
fuel_remaining: u64,
initial_fuel_budget: u64,
handle_table: [HandleSlot; MAX_COMPONENT_HANDLES],
}
impl WasiComponentSandbox {
pub fn new(initial_pages: u32, max_pages: u32, fuel_budget: u64) -> Result<Self, SandboxTrap> {
if initial_pages > max_pages {
return Err(SandboxTrap::MemoryPageQuotaExceeded {
requested_pages: initial_pages,
max_pages,
});
}
let byte_len = (initial_pages as usize) * (WASM_PAGE_BYTES as usize);
let empty_slot = HandleSlot {
generation: 1,
occupied: false,
active_borrows: 0,
kind: ResourceKind::MemoryInputStream,
rights: CapabilityRights::NONE,
};
Ok(Self {
linear_memory: vec![0u8; byte_len],
current_pages: initial_pages,
max_pages,
fuel_remaining: fuel_budget,
initial_fuel_budget: fuel_budget,
handle_table: [empty_slot; MAX_COMPONENT_HANDLES],
})
}
/// Consumes deterministic instruction fuel; traps immediately on exhaustion.
#[inline]
pub fn consume_fuel(&mut self, instructions: u64) -> Result<(), SandboxTrap> {
if self.fuel_remaining < instructions {
self.fuel_remaining = 0;
return Err(SandboxTrap::FuelExhausted {
budget: self.initial_fuel_budget,
});
}
self.fuel_remaining -= instructions;
Ok(())
}
/// Verifies W3C WebAssembly 2.0 spatial bounds: 0 <= addr + offset + len <= N_pages * 65536.
#[inline]
pub fn verify_memory_slice(
&self,
base_addr: u32,
static_offset: u32,
byte_len: u32,
) -> Result<&[u8], SandboxTrap> {
let effective_addr = (base_addr as u64) + (static_offset as u64);
let end_addr = effective_addr + (byte_len as u64);
let limit_bytes = (self.current_pages as u64) * WASM_PAGE_BYTES;
if end_addr > limit_bytes || end_addr > (self.linear_memory.len() as u64) {
return Err(SandboxTrap::OutOfBoundsLinearMemory {
effective_addr: end_addr,
limit_bytes,
});
}
Ok(&self.linear_memory[(effective_addr as usize)..(end_addr as usize)])
}
/// Canonical ABI Lift for UTF-8 strings across Shared-Nothing component boundary.
pub fn canonical_lift_utf8(
&mut self,
ptr: u32,
len: u32,
) -> Result<String, SandboxTrap> {
self.consume_fuel(16 + (len as u64))?;
let slice = self.verify_memory_slice(ptr, 0, len)?;
core::str::from_utf8(slice)
.map(str::to_owned)
.map_err(|_| SandboxTrap::InvalidUtf8CanonicalLift)
}
/// Grants a scoped resource capability into the component's local handle table.
pub fn insert_capability(
&mut self,
kind: ResourceKind,
rights: CapabilityRights,
) -> Result<GuestHandle, SandboxTrap> {
for (idx, slot) in self.handle_table.iter_mut().enumerate() {
if !slot.occupied {
slot.occupied = true;
slot.active_borrows = 0;
slot.kind = kind;
slot.rights = rights;
return Ok(GuestHandle {
slot_index: idx as u16,
generation: slot.generation,
});
}
}
Err(SandboxTrap::HandleTableCapacityExhausted)
}
/// Attenuates an existing handle into a restricted child handle (Rights_child <= Rights_parent).
pub fn attenuate_handle(
&mut self,
parent: GuestHandle,
mask: CapabilityRights,
) -> Result<GuestHandle, SandboxTrap> {
let (kind, parent_rights) = self.verify_capability(parent, CapabilityRights::NONE)?;
let child_rights = parent_rights.attenuate(mask);
self.insert_capability(kind, child_rights)
}
/// Validates handle index, generation counter, and required capability rights bitmask.
pub fn verify_capability(
&self,
handle: GuestHandle,
required: CapabilityRights,
) -> Result<(ResourceKind, CapabilityRights), SandboxTrap> {
let idx = handle.slot_index as usize;
if idx >= MAX_COMPONENT_HANDLES {
return Err(SandboxTrap::InvalidOrForgedHandle);
}
let slot = &self.handle_table[idx];
if !slot.occupied {
return Err(SandboxTrap::UseAfterDropGenerationMismatch);
}
if slot.generation != handle.generation {
return Err(SandboxTrap::UseAfterDropGenerationMismatch);
}
if !slot.rights.contains(required) {
return Err(SandboxTrap::CapabilityRightsDenied {
required,
actual: slot.rights,
});
}
Ok((slot.kind, slot.rights))
}
/// Drops an owned capability handle and increments slot generation to prevent Use-After-Close.
pub fn drop_capability(&mut self, handle: GuestHandle) -> Result<(), SandboxTrap> {
let idx = handle.slot_index as usize;
if idx >= MAX_COMPONENT_HANDLES {
return Err(SandboxTrap::InvalidOrForgedHandle);
}
let slot = &mut self.handle_table[idx];
if !slot.occupied || slot.generation != handle.generation {
return Err(SandboxTrap::UseAfterDropGenerationMismatch);
}
if slot.active_borrows > 0 {
return Err(SandboxTrap::CannotDropBorrowedResource {
active_borrows: slot.active_borrows,
});
}
slot.occupied = false;
slot.rights = CapabilityRights::NONE;
slot.generation = slot.generation.wrapping_add(1).max(1);
Ok(())
}
}