🧐

Wasmはなぜセキュアなのか?

2022/11/28に公開2

Wasmはなぜセキュアなのか?

前回Wasmのバイナリを読んでみたが、実行時にどのようにセキュアに実行しているのか気になったので調べてみた。
https://zenn.dev/0kate/articles/7716f37f7fc327

とりあえず今回も公式ドキュメントを見ながら整理しつつ、実際のコードも無理なく辿れそうなところはwasmerの実装を参考に見ていきたいと思う。

Wasmの目指すセキュリティ

とりあえず公式の記載を脳死で読解していく。

The security model of WebAssembly has two important goals: (1) protect users from buggy or malicious modules, and (2) provide developers with useful primitives and mitigations for developing safe applications, within the constraints of (1).

Wasmの達成したいセキュリティ上の目標は、大きく分けると2つ。

  1. ユーザーを悪意のあるWasmモジュールから守ること
  2. 開発者が安全なWasmアプリケーションを開発できる環境を提供すること

公式ドキュメントではそれぞれUsersDevelopersとして、「ユーザー向けのセキュリティ機構」と「開発者向けのセキュリティ機構」のように書き分けられていたが、個人的に 「ランタイムの仕様としてのセキュリティ機構」「バイナリの仕様としてのセキュリティ機構」 と捉えたほうがしっくり来たのでこの視点で見ていく。

ランタイムの仕様としてのセキュリティ機構

まずは、WebブラウザやWasmerなどのランタイム側で実装され、ユーザーの保護を実現している機構について見ていく。

サンドボックス環境での実行

Each WebAssembly module executes within a sandboxed environment separated from the host runtime using fault isolation techniques. This implies:

  • Applications execute independently,and can’t escape the sandbox without going through appropriate APIs.
  • Applications generally execute deterministically with limited exceptions.

Wasmモジュールはサンドボックス環境で空間で動作し、サンドボックス外の何かしらにアクセスするためには予め用意されたAPIを呼び出すしかないようになっている。

環境ごとのセキュリティポリシーへの準拠

Additionally, each module is subject to the security policies of its embedding. Within a web browser, this includes restrictions on information flow through same-origin policy. On a non-web platform, this could include the POSIX security model.

サンドボックス内での実行に加え、ランタイムごとに課されるセキュリティ機構にも準拠するようになっている。
例えば、WebブラウザであればSame-origin policy、ブラウザ以外の環境でもPOSIXのセキュリティモデル(ユーザーに付与されるPermissionとか実行権限とか)などが適用されるとのこと。

実装をちょっと見てみる

実際にサンドボックスであるか確認するには、特別な呼び出しでホスト上の機能にアクセスしている様子が確認できればよさそう?(OSのカーネル空間とユーザー空間がシステムコールで制限されているような感じで)

いろいろ探っていたらこんな記載を見つけた。

//! If you're looking for a sandboxed, POSIX-like environment to execute Wasm
//! in, check out the [`wasmer-wasi`] crate for our implementation of WASI,
//! the WebAssembly System Interface.

「POSIX的なサンドボックス環境を探してるならwasmer-wasiクレートを見てね」とのこと。
大人しく従ってこのクレートを見に行く。

wasmer-wasiクレートはlib/wasiの階層にある。
例えば、lib/wasi/src/syscalls/mod.rsにある環境変数を参照する箇所を確認してみる。

/// ### `environ_get()`
/// Read environment variable data.
/// The sizes of the buffers should match that returned by [`environ_sizes_get()`](#environ_sizes_get).
/// Inputs:
/// - `char **environ`
///     A pointer to a buffer to write the environment variable pointers.
/// - `char *environ_buf`
///     A pointer to a buffer to write the environment variable string data.
pub fn environ_get<M: MemorySize>(
    ctx: FunctionEnvMut<'_, WasiEnv>,
    environ: WasmPtr<WasmPtr<u8, M>, M>,
    environ_buf: WasmPtr<u8, M>,
) -> Errno {
    debug!(
        "wasi::environ_get. Environ: {:?}, environ_buf: {:?}",
        environ, environ_buf
    );
    let env = ctx.data();
    let (memory, mut state) = env.get_memory_and_wasi_state(&ctx, 0);
    trace!(" -> State envs: {:?}", state.envs);

    write_buffer_array(&memory, &state.envs, environ, environ_buf)
}

たしかに、まさしくシステムコールのような形でホスト上の機能にアクセスしているように見える。(これ実際に動作させてみてもう少し追ってみたい)
こんな感じで一層噛ませることで、直接ランタイム外にはアクセスできないように制御しているのか?

バイナリの仕様としてのセキュリティ機構

ランタイム上で実現されているセキュリティ機構だけでなく、バイナリの仕様として危険な挙動から保護するためのセキュリティ機構も存在する。

CFI(Control Flow Integrity)

Wasmバイナリのセキュリティには、基本的にCFIと呼ばれる考え方が適用されている。
ざっくり言うと、関数呼び出しやリターンアドレスが書き換えられ制御フローが乗っ取られるのを防止するための概念。(脳死)
コンパイル時にそれらを検証するためのコードが差し込まれ、実行の際に逐次検証しながら適切な制御フローを進んでいることが保証される。

YouTubeのS2021 - Understanding Control Flow Integrityという動画が個人的にわかりやすかった。(これはこれで別記事でまとめても面白そう)

関数呼び出しの制限

シグネチャチェック

Modules must declare all accessible functions and their associated types at load time, even when dynamic linking is used.

Wasmモジュールが読み込まれるタイミングや呼び出しのタイミングで、全てのアクセス可能な関数に対してシグネチャ(引数や戻り値も含めた型の組み合わせ)のチェックが行われる。
これは動的にリンクされる関数に対しても同様。(DLL-hijackのように、実行時に悪意のあるコードを差し込むような攻撃に対する対策?)

こっちも実装を見てみる

実際に関数が呼ばれているコードは以下のように書かれている。(lib/api/src/sys/externals/function.rs:470)

    #[cfg(feature = "compiler")]
    /// Call the `Function` function.
    ///
    /// Depending on where the Function is defined, it will call it.
    /// 1. If the function is defined inside a WebAssembly, it will call the trampoline
    ///    for the function signature.
    /// 2. If the function is defined in the host (in a native way), it will
    ///    call the trampoline.
    ///
    /// # Examples
    ///
    /// ```
    /// # use wasmer::{imports, wat2wasm, Function, Instance, Module, Store, Type, Value};
    /// # use wasmer::FunctionEnv;
    /// # let mut store = Store::default();
    /// # let env = FunctionEnv::new(&mut store, ());
    /// # let wasm_bytes = wat2wasm(r#"
    /// # (module
    /// #   (func (export "sum") (param $x i32) (param $y i32) (result i32)
    /// #     local.get $x
    /// #     local.get $y
    /// #     i32.add
    /// #   ))
    /// # "#.as_bytes()).unwrap();
    /// # let module = Module::new(&store, wasm_bytes).unwrap();
    /// # let import_object = imports! {};
    /// # let instance = Instance::new(&mut store, &module, &import_object).unwrap();
    /// #
    /// let sum = instance.exports.get_function("sum").unwrap();
    ///
    /// assert_eq!(sum.call(&mut store, &[Value::I32(1), Value::I32(2)]).unwrap().to_vec(), vec![Value::I32(3)]);
    /// ```
    pub fn call(
        &self,
        store: &mut impl AsStoreMut,
        params: &[Value],
    ) -> Result<Box<[Value]>, RuntimeError> {
        let trampoline = unsafe {
            ...
        };
        let mut results = vec![Value::null(); self.result_arity(store)];
        self.call_wasm(store, trampoline, params, &mut results)?;
        Ok(results.into_boxed_slice())
    }

Depending on where the Function is defined, it will call it.

  1. If the function is defined inside a WebAssembly, it will call the trampoline for the function signature.
  2. If the function is defined in the host (in a native way), it will call the trampoline.

コメントにあるように「トランポリン」(トランポリンってなによ)によって呼び出しているらしい。
Wasmモジュール内で定義されている関数については関数シグネチャからのトランポリン呼び出し、ホスト上で定義されている関数についてはそのままトランポリン呼び出しとなっているらしい。(トランポリンってなによ)

これらの関数についてはlib/api/src/lib.rsにこんな記載もある。

//! There are 2 types of functions in `wasmer`:
//! 1. Wasm functions,
//! 2. Host functions.
//!
//! A Wasm function is a function defined in a WebAssembly module that can
//! only perform computation without side effects and call other functions.
//!
//! Wasm functions take 0 or more arguments and return 0 or more results.
//! Wasm functions can only deal with the primitive types defined in
//! [`Value`].
//!
//! A Host function is any function implemented on the host, in this case in
//! Rust.

wasmer上では関数を2種類に分けており、「Wasmモジュール上で定義されている関数」と「Rust上で定義されている関数 (Rustのライブラリとして提供されている関数とか?)」としているらしい。
先のコメントで言っていた2種類の呼び分けはこれらに対して行われるものなのだろう。

さらに、実際に呼び出しを行っている実装部分を見てみると以下のようになっている。
定義されているパラメーターの数・戻り値の数を、渡されている数と比較して合わないものはエラーとしているのが確認できる。

    fn call_wasm(
        &self,
        store: &mut impl AsStoreMut,
        trampoline: VMTrampoline,
        params: &[Value],
        results: &mut [Value],
    ) -> Result<(), RuntimeError> {
        ...
        // TODO: Avoid cloning the signature here, it's expensive.
        let signature = self.ty(store);
        if signature.params().len() != params.len() {
            return Err(RuntimeError::new(format!(
                "Parameters of type [{}] did not match signature {}",
                format_types_for_error_message(params),
                &signature
            )));
        }
        if signature.results().len() != results.len() {
            return Err(RuntimeError::new(format!(
                "Results of type [{}] did not match signature {}",
                format_types_for_error_message(results),
                &signature,
            )));
        }

このようなチェックを通って初めて、トランポリンによる呼び出しが最終的に行われることになる。(トランポリンってなによ)

要素番号による呼び出し対象の指定

Function calls must specify the index of a target that corresponds to a valid entry in the function index space or table index space.

Local variables with fixed scope and global variables are represented as fixed-type values stored by index.

バイナリを読解した時に見た内容だが、Wasmバイナリにおいて関数(外部からリンクされるものも含む)はテーブル(配列のようなデータ構造)で管理されている。
呼び出し対象の指定は、基本的にテーブル内の要素番号による指定で行われる。

これによりテーブルに無いような要素番号で呼び出すことはできず(やろうとするとロード時に起動失敗、もしくはTrapにより異常終了する)、予期せぬ実行フローに心配がない。

分岐先の有効チェック

Branches must point to valid destinations within the enclosing function.

条件分岐によって任意のコードにジャンプする場合、ジャンプ先が有効なコードセクション内であるかなどチェックされる。
これにより、Linear Memoryなどに任意のコードが差し込まれて制御が奪われるなどを防止できる?

変数の取り扱い

ローカル変数・グローバル変数のそれぞれにおいても、厳密な取り扱いによって安全性を担保している。

ローカル変数

ローカル変数は宣言時に自動的に0クリアされ、コールスタック内に確保される。これにより宣言だけして予期せぬ値が紛れ込んだ状態になることが避けれる。
ローカル変数への参照もスタック上の要素番号によって指定する。

Local variables with unclear static scope (e.g. are used by the address-of operator, or are of type struct and returned by value) are stored in a separate user-addressable stack in linear memory at compile time.
This is an isolated memory region with fixed maximum size that is zero initialized by default. References to this memory are computed with infinite precision to avoid wrapping and simplify bounds checking

グローバル変数

グローバル変数は関数同様テーブルによって管理され、こちらも要素番号によるアクセスで予期せぬ参照を回避することができる。

異常時の即時終了

Traps are used to immediately terminate execution and signal abnormal behavior to the execution environment.

ランタイムが以下のような異常な挙動を検知した場合、トラップによって即座に実行が停止されるように設計されている。

  • 関数やグローバル変数を参照する際、無効な要素番号が指定された時
  • 外部からリンクされた関数を参照する際、シグネチャが一致しない時
  • コールスタックが予期された最大サイズを超過した場合
    • 例えば、再帰呼出しなどで深すぎる場合など
  • Linear Memoryの範囲外を参照しようとした時
  • 異常な算術演算を実行した場合
    • 例えば、0除算など

Webブラウザの場合、TrapはJavaScriptの例外としてキャッチされる。

メモリ安全性

メモリ空間の取り扱いについても、いろいろな配慮がされている。

バッファオーバーフローへの対策

Buffer overflows, which occur when data exceeds the boundaries of an object and accesses adjacent memory regions, cannot affect local or global variables stored in index space, they are fixed-size and addressed by index.

バッファオーバーフローについても、Wasmでは要素番号による参照によって対策している。
仮にバッファオーバーフローが発生し、スタック上のローカル変数やグローバル変数が溢れて別の変数に影響したとしても、テーブル上の要素番号には影響せず、各変数にはスタックやテーブルの要素番号によって参照されるため、参照先が予期せぬ変数に書き換えられるといった挙動が起こり得ない。

Linear Memory上のコード実行の防止

Data stored in linear memory can overwrite adjacent objects, since bounds checking is performed at linear memory region granularity and is not context-sensitive. However, the presence of control-flow integrity and protected call stacks prevents direct code injection attacks.

Linear Memory上のデータについても、任意のコードがLinear Memory上に配置され実行フローが移されてしまうような現象は、Wasmの分岐先のチェックなどで対策している。
Code section以外での実行が発生思想になったタイミングで、基本的にはTrapが発生するため、データ領域の実行を防止するDEPが必要なかったりするらしい。

無効なメモリ領域へのアクセス防止

ポインタによってメモリの参照を取り扱う場合、「確保されていないはずのメモリ領域へのアクセス」や「開放済みのメモリ領域へのポインタ」などの異常が懸念されるが、Wasmではここでも要素番号での参照を参照することで無効なメモリ領域へのアクセスに対策している。
仮に要素番号が無効である場合、ロード時もしくは実行時に検証され即座にTrapによって実行が停止される。

最後に

とりあえず浅く広くWasmのセキュリティについて調べてみたが、普通にメモリの取り扱い周りの勉強になった。これまで、先人たちがC/C++などで遭遇しては対策してきた知見が詰め込まれているんだろうと感動する。
もう少し深く調べたい箇所もいくつかあったが、一先ずここまでとして今後もう少し深堀ってみたい。(トランポリンとかトランポリンとか、あとトランポリンとか)
実際に動かしながらWASIの呼び出しを追ってみるとか面白そう。

Discussion

VVIIVVII

トランポリン呼び出しは
目的関数を呼び出す際に直接呼び出すのではなくて、
まず動的に事前処理を行うコードを生成して、そのコードが目的の関数を呼び出すという技法だったはずです

多分今回に関してはエラーチェックのためのコードが呼び出し前に生成されることを言っているんだと思います

0kate0kate

なるほど、、!確かに実装の方も見てみると、おっしゃる通り事前事後に何かしら処理を差し込んで対象の関数いるのが見えますね👀
ありがとうございます!!

継続の理解も深めていきたい所存です😇

    #[cfg(feature = "compiler")]
    fn call_wasm(
        ... (引数省略)
    ) -> Result<(), RuntimeError> {
        ... (事前処理省略)

        // Call the trampoline.
        let vm_function = self.handle.get(store.as_store_ref().objects());
        if let Err(error) = unsafe {
            wasmer_call_trampoline(
                store.as_store_ref().signal_handler(),
                vm_function.anyfunc.as_ptr().as_ref().vmctx,
                trampoline,
                vm_function.anyfunc.as_ptr().as_ref().func_ptr,
                values_vec.as_mut_ptr() as *mut u8,
            )
        } {
            return Err(RuntimeError::from_trap(error));
        }

        // Load the return values out of `values_vec`.
        for (index, &value_type) in signature.results().iter().enumerate() {
            unsafe {
                results[index] = Value::from_raw(store, value_type, values_vec[index]);
            }
        }

        Ok(())
    }