🫥

WebAssembly の 0 番地を訪れる

に公開2

WebAssembly の 0 番地を訪れる

こんにちは、cyokozai (X: @cyokozai0427) です
千葉にある工業大学に通っています
最近になってようやく WebAssembly (WASM) の勉強を始めました^^
手当たり次第に色々なブログや記事を読み漁っていたところ、@gorilla0513 さんの記事で気になる一文を発見しました

データは (data $memory $offset $data) という書式となっていて、今回の例では、0番目のメモリの0バイト目から Hello, World!\n という文字列が配置される。

引用 (一部省略):
https://zenn.dev/skanehira/books/writing-wasm-runtime-in-rust/viewer/04_wasm_binary_structure#data-section

あれ?これっていわゆる ぬるぽ ってやつなんじゃないか? 🤔

   ∧_∧
  ( ´∀`)< ぬるぽ

   ( ・∀・)   | | ガッ
  と    )    | |
    Y /ノ    人
     / )    <  >__Λ∩
   _/し' //. V`Д´)/ ←>>1
  (_フ彡        /

しかし調べてみると、どうやら WASM の世界ではガッとされないようです、ほっ... 😅
それにしても不思議ですね、WASM のメモリとは一体どのようなものなのでしょうか?
ちょっと深掘りしていきましょう!

WebAssembly の線形メモリ

WASM におけるメモリとは、主に線形メモリ (Linear Memory) のことを指します
仕様書には以下のような一文があります

2.2. Structure - Linear Memory:
Linear memories are contiguous, byte-addressable ranges of memory...
線形メモリは、連続したバイトアドレス可能なメモリ範囲である...

線形メモリは次のように定義されます

  • 0 から始まる連続したバイト列であり 0 番地は特別扱いされない
  • ページ (64KiB) 単位で拡張される
  • load / store 命令でのみアクセス可能

従来の OS のようなページフォルトや Null Pointer 保護は WASM では提供しません
代わりに WASM はあらゆる言語を抽象化するために必要な命令セットを提供します

この辺の詳細は@wbcchsyn さんの記事@T5uku5hi さんの記事 の解説をご覧ください
どちらも非常にわかりやすくまとまっており、より理解が深まるかと思います

直接 WASM を記述する方法

WAT

WASM ファイルは単なるバイナリデータですが、WAT (WebAssembly Text format) を直接書くことでも WASM を作成することができます
WAT とは、 S 式 (S-expressions) によって記述される WASM バイナリのテキスト表現です
通常は Rust や Go から直接 WASM にコンパイルするため、WAT ファイルを目にすることはないかもしれません
例えば、以下の S 式

(module (memory 1) (func))

これは、WASM のモジュールを表しており、モジュール内で以下の要素を定義しています

  • (module ...)
  • (memory 1)
  • (func)

S式は、modulememoryfunc といった各構成要素をネストしたリストとして表現します

  • (module ...):

    WASM モジュールの開始を宣言する要素です
    WASM の実行環境にロードされる単位で、モジュール内には関数、メモリ、グローバル変数、エクスポート、インポート...などありとあらゆる要素が含まれます

    (module
      ;; モジュールの定義がここに含まれる
    )
    

    この例では、モジュール内に (memory 1)(func) の2つの要素が定義されています

  • (memory 1):

    WASM モジュールに 1 ページのメモリを定義します
    1 ページは通常 64 KiB (65536 Byte) です

    • memory:

      WASM の線形メモリを定義するための命令で、メモリサイズ (ページ数) を指定します
      ページ数は memory の後に指定されます

      (memory 1)  ;; 1ページの線形メモリ
      
  • (func):

    WASM モジュール内で関数を定義するための命令です
    引数や戻り値の型を指定しない場合、これは引数なし・戻り値なしの関数を定義していることになります

    (func)  ;; 引数のない空の関数 (プロシージャ)
    

まとめると、以下のような意味になります

(module        ;; モジュールの開始
  (memory 1)   ;; 1ページの線形メモリを定義
  (func)       ;; 関数を定義
)

このコードは、以下の内容を含むWebAssemblyモジュールを定義しています:

  • 1 ページ (64 KiB) の線形メモリ
  • 引数なし・戻り値なしの空の関数

検証

では、実際にコードを書いて実行し、どのような振る舞いをするのか観察しましょう
今回の検証は以下の環境で行います

  • 実行環境

    項目 詳細
    OS macOS Tahoe 26.1
    アーキテクチャ arm64
    仮想環境 Docker (OrbStack) 28.5.2
    ディストリビューション Ubuntu 24.04 LTS
  • WebAssembly

    ツール バージョン
    wasmtime 39.0.1
    wat2wasm / wasm2wat 1.0.34
  • 使用した言語・コンパイラ

    言語 コンパイラ / 実装 バージョン ターゲット
    C / C++ clang 18.1.3 aarch64-unknown-linux-gnu
    Rust rustc 1.92.0 wasm32-unknown-unknown
    Go TinyGo 1.25.4 / 0.40.1 linux/arm64

今回の検証で使用したソースコードなどは GitHub のリポジトリで公開しています

https://github.com/cyokozai/wasm-null-pointer

WAT で直接 0 番地に値を書き込む

はじめに、WAT で 0 番地に値を書き込む処理を記述し、コンパイルを試みます
sample01.wat がそのコードです

sample01.wat
(module
  (memory (export "memory") 1)
  (func (export "test_zero") (result i32)
    (i32.store (i32.const 0) (i32.const 2025)) ;; Write 2025 to memory address 0
    (i32.load (i32.const 0))                   ;; Read from memory address 0 and push onto the stack
  )
)

先ほど述べた S 式で記述されています
このコードで行なっている処理は非常に単純で、

  1. (memory (export "memory") 1): 1 ページのメモリ領域を確保
  2. (i32.store (i32.const 0) (i32.const 2025)): スタックから値を取り出し、メモリのアドレス 0 番地に値 2025 を書き込む
    • (i32.const 0): スタックにメモリのアドレス 0 を積む
    • (i32.const 2025): スタックに書き込みたい値 (今回は 2025) を積む
  3. (i32.load (i32.const 0)): メモリのアドレス0番地にある値 2025 を読み出してスタックに積む
    • (i32.const 0): 再度アドレス 0 をスタックに積む

となっています
頑張れば読めますね 💪

早速 WAT を WASM にコンパイルして実行してみましょう

wat2wasm sample01.wat -o sample01.wasm
wasmtime run --invoke 'test_zero' sample01.wasm

2025 が出力されたら成功です

では、WASM のオブジェクトファイルを確認してみましょう

wasmtime compile sample01.wasm
wasmtime objdump sample01.cwasm --addresses --bytes --addrmap > output01.dat

このようなファイルが生成されます

output01.dat
00000000 wasm[0]::function[0]:
         0: fd 7b bf a9                  stp     x29, x30, [sp, #-0x10]!
         4: fd 03 00 91                  mov     x29, sp
         8: 24 fd 80 52                  mov     w4, #0x7e9
                                          ╰─╼ addrmap: 0x37
         c: 45 1c 40 f9                  ldr     x5, [x2, #0x38]
                                          ╰─╼ addrmap: 0x3a
        10: a4 00 00 b9                  str     w4, [x5]
                                          ╰─╼ trap: MemoryOutOfBounds
        14: 22 fd 80 52                  mov     w2, #0x7e9
        18: fd 7b c1 a8                  ldp     x29, x30, [sp], #0x10
                                          ╰─╼ addrmap: 0x42
        1c: c0 03 5f d6                  ret

これは AArch64 (ARM64) 命令に WASM のトラップ情報 (trap) を対応付けて表示した逆アセンブル結果です
重要なのは str (Store Register) 命令が生成されている行です

        10: a4 00 00 b9                  str     w4, [x5]
                                          ╰─╼ trap: MemoryOutOfBounds

これは、x5 (線形メモリの先頭アドレス) に対し、オフセット 0 で書き込みを実行する、という意味です
ちなみに trap: MemoryOutOfBounds とは、WASM ランタイムが「確保されているメモリ領域外への読み書き」を検知すると強制終了する、というトラップ情報です
このトラップ情報はメモリの Bounds check に失敗した場合のみ有効であるため、通常時は無視されます

他言語から WASMの 0 番地に値を書き込む

WASM は Rust や Go など他の言語で書かれた機能を、ブラウザ側で実行することが主な利用目的です
では、他の言語からコンパイルした WASM バイナリは、0 番地に値を読み書きすることはあり得るのでしょうか?
今回は、C言語 (Clang)、Rust、TinyGo の 3 種類の言語でそれぞれアドレス 0 へ値を読み書きするコードを作成し、WASM バイナリへコンパイルした後、先ほどと同様の検証を行います
また、比較のために WASM から WAT を生成して sample01.wat ファイルと中身を比較してみましょう

⚠️ 以降は治安の悪いソースコードが出てきます
掲載されているコードは、どれもメモリアドレス 0 への直接参照および書き込みを行っています
環境外で実行した場合の動作は一切保証しません

今回プログラムを作成する上で @hikalium さんの 記事 を参考にさせて頂きました
久しぶりにC言語書いたけど、この言語やはり難しすぎる....

1. C 言語 (Clang)

C / C++ は Clang で WASM にコンパイルすることが可能です
以下のコードをコンパイルします

sample02.c
int test_zero(void) {
  volatile int *p = (int *)0;
  *p = 2025;
  return *p;
}

Clang で最適化 -O3 を有効にすると未定義動作 (UD2) となる場合があります
volatile 修飾をすることによって最適化を回避し、load / store 命令を強制的に残すことができます

では、コードをコンパイルして実行します
コンパイルには -fno-delete-null-pointer-checks フラグを必ずつけましょう
これは「NULLポインタチェックの削除を行わない」というオプションで、これによりコンパイラに 0 番地へのアクセス命令を無視させます

clang --target=wasm32 -O3 -nostdlib -Wl,--no-entry -Wl,--export-all -fno-delete-null-pointer-checks -o sample02.wasm sample02.c
wasmtime run --invoke 'test_zero' sample02.wasm

無事に 2025 が出力されました!
これは期待通りです

続いて WAT ファイルを確認しましょう
以下のコマンドで生成します

wasm2wat sample02.wasm -o sample02.wat

一部分を抜粋して掲載します

sample02.wat (一部抜粋)
(module $sample02.wasm
  (type (;0;) (func))
  (type (;1;) (func (result i32)))
  (func $__wasm_call_ctors (type 0))
  (func $test_zero (type 1) (result i32)
    i32.const 0
    i32.const 2025
    i32.store
    i32.const 0
    i32.load)
~~~

記法は違いますが、test_zero 関数内部の命令が const, store, load で完全一致していることがわかるかと思います

output01.dat (test_zero 関数内部)
    (i32.store (i32.const 0) (i32.const 2025))
    (i32.load (i32.const 0))

C 言語から生成した WAT ファイルの処理と自分で書いた WAT ファイルの処理が一致する結果となりました

なんとなく結果はわかりますが、一応オブジェクトファイルの中身もみておきましょう
以下のコマンドで生成します

wasmtime compile sample02.wasm
wasmtime objdump sample02.cwasm --addresses --bytes --addrmap > output02.dat
output02.dat (一部抜粋)
00000020 wasm[0]::function[1]::test_zero:
        20: fd 7b bf a9                  stp     x29, x30, [sp, #-0x10]!
        24: fd 03 00 91                  mov     x29, sp
        28: 24 fd 80 52                  mov     w4, #0x7e9
                                          ╰─╼ addrmap: 0x117
        2c: 45 1c 40 f9                  ldr     x5, [x2, #0x38]
                                          ╰─╼ addrmap: 0x11a
        30: a4 00 00 b9                  str     w4, [x5]
                                          ╰─╼ trap: MemoryOutOfBounds
        34: 22 fd 80 52                  mov     w2, #0x7e9
        38: fd 7b c1 a8                  ldp     x29, x30, [sp], #0x10
                                          ╰─╼ addrmap: 0x122
        3c: c0 03 5f d6                  ret

当然のことながら output01.dat と全く同じ store 命令が実行されていることがわかります

        30: a4 00 00 b9                  str     w4, [x5]
                                          ╰─╼ trap: MemoryOutOfBounds

ということで、Clang は C で書かれた 0 番地へアクセスする処理を WASM にコンパイルすることが可能ということがわかりました

2. Rust

Rust は比較的新しい言語であり、WASM を主要なコンパイルターゲットの一つとして早い段階から位置付けています
そのため、公式ツールチェーンによる WASM 生成と関連エコシステムが整備されており、ドキュメントも豊富です
Rust のコードを以下に掲載します

sample03.rs
#![no_main]
#[no_mangle]
pub unsafe extern "C" fn test_zero() -> i32 {
  let ptr = 0 as *mut i32;
  *ptr = 2025;
  *ptr
}

処理内容は

  1. 明示的に 0 をポインタにキャスト
  2. そのポインタを逆参照して書き込み・読み込み

となっています
*mut i32 は生ポインタと呼び、直接メモリを参照します
当然 Rust は生ポインタの逆参照を禁止しているため、unsafe を宣言する必要があります

では、WASM にコンパイルして実行しましょう

rustc --target wasm32-unknown-unknown -O --crate-type cdylib -o sample03.wasm sample03.rs
wasmtime run --invoke 'test_zero' sample03.wasm

実行するとエラーとなります

$ wasmtime run --invoke 'test_zero' sample03.wasm
Error: failed to run main module `sample03.wasm`

Caused by:
    0: failed to invoke `test_zero`
    1: error while executing at wasm backtrace:
           0:     0x72 - sample03.wasm!test_zero
       note: using the `WASMTIME_BACKTRACE_DETAILS=1` environment variable may show more debugging information
    2: wasm trap: wasm `unreachable` instruction executed

最後の一文の unreachable というトラップ情報が気になります
では調査のため WAT ファイルとオブジェクトファイルを生成しましょう

wasm2wat sample03.wasm -o sample03.wat 
wasmtime compile sample03.wasm
wasmtime objdump sample03.cwasm --addresses --bytes --addrmap > output03.dat
sample03.wat (一部抜粋)
(module $sample03.wasm
  (type (;0;) (func (result i32)))
  (func $test_zero (type 0) (result i32)
    unreachable)
  (table (;0;) 1 1 funcref)
  (memory (;0;) 16)
~~~
output03.dat
00000000 wasm[0]::function[0]::test_zero:
         0: fd 7b bf a9                  stp     x29, x30, [sp, #-0x10]!
         4: fd 03 00 91                  mov     x29, sp
         8: 1f c1 00 00                  .byte   0x1f, 0xc1, 0x00, 0x00
                                          ├─╼ addrmap: 0x72
                                          ╰─╼ trap: UnreachableCodeReached

sample03.wat をみると test_zero 関数が呼び出された行に例の unreachable という命令があるだけで、関数の処理は一切見当たらないのがわかるかと思います
output03.dat も同様で、トラップ情報には UnreachableCodeReached とあります
これはプログラムの実行を即座に停止し、実行時エラーとする命令です

Rust のコンパイラは「test_zero 関数の中身を実行することはありえない」と判断し、unreachable 命令に置き換える措置を行なったようです
よって、Rust で 0 番地へアクセスする処理を WASM にコンパイルすることは不可能でした

3. Go

Go と TinyGo では、それぞれの界隈で WASM への対応が行われています

sample04.go
package main
import "unsafe"
//export test_zero
func test_zero() int32 {
	ptr := (*int32)(unsafe.Pointer(uintptr(0)))
	*ptr = 2025
	return *ptr
}
func main() {}

//export test_zero は TinyGo 独自のコメント指示で、この関数を WASM の export 関数として公開するための特別なコメントです
なので、これがない状態でコンパイルすると invoke で関数を呼び出せなくなります (1敗)
unsafe.Pointer(uintptr(0)) は整数値 0 をポインタとして解釈し、*int32 型として扱うように定義しています
結果、ptr はアドレス 0 を指す int32 ポインタとなり、そこに数値 2025 を書き込みます
ちなみに、Go 言語では main 関数が必須なため、空の関数を書いておく必要があります

tinygo build -o sample04.wasm -target=wasm -no-debug -panic=trap sample04.go
wasmtime run --invoke 'test_zero' sample04.wasm

やはり、実行するとエラーとなります

$ wasmtime run --invoke 'test_zero' sample04.wasm
Error: failed to run main module `sample04.wasm`

Caused by:
    0: failed to invoke `test_zero`
    1: error while executing at wasm backtrace:
           0:   0x3411 - main!test_zero.command_export
    2: wasm trap: wasm `unreachable` instruction executed

エラー文から分かるように、Rust と同様 unreachable トラップによる強制終了のようです
念の為 WAT ファイルとオブジェクトファイルを生成して中身を確認しましょう

wasm2wat sample04.wasm -o sample04.wat 
wasmtime compile sample04.wasm
wasmtime objdump sample04.cwasm --addresses --bytes --addrmap > output04.dat
sample04.wat (一部抜粋)
~~~
  (func $test_zero.command_export (type 3) (result i32)
    unreachable)
~~~
output04.dat (一部抜粋)
~~~
00005e00 wasm[0]::function[35]::test_zero.command_export:
      5e00: fd 7b bf a9                  stp     x29, x30, [sp, #-0x10]!
      5e04: fd 03 00 91                  mov     x29, sp
      5e08: 1f c1 00 00                  .byte   0x1f, 0xc1, 0x00, 0x00
                                          ├─╼ addrmap: 0x3411
                                          ╰─╼ trap: UnreachableCodeReached
~~~

はい、ということで Rust と同様 UnreachableCodeReached による実行時エラーであることがわかります
したがって、TinyGo のコンパイラも Rust コンパイラと同様に、0 番地へアクセスする処理を WASM にコンパイルすることは不可能でした

まとめ

結果を以下の表にまとめました

言語 / 記述方法 ソース addr 0 store / load 実行結果 objdump Trap
WAT (手書き) sample01.wat 可能 2025 str w4, [x5] が生成 MemoryOutOfBounds
C (Clang) sample02.c 可能 2025 str w4, [x5] が生成 MemoryOutOfBounds
Rust sample03.rs 不可能 ERROR unreachable 命令 UnreachableCodeReached
Go (TinyGo) sample04.go 不可能 ERROR unreachable 命令 UnreachableCodeReached

WASM の 0 番地について

WAT や C 言語の結果が示す通り、線形メモリの 0 番地は単なるオフセット 0 の有効なアドレスです
OS の Segfault のような、ハードウェアレベルでの 0 番地アクセス拒否の仕組みは WASM の仕様には存在しないことがわかります

観点 WAT, C Rust, TinyGo
0 番地の扱い 通常のアドレス(特別扱いなし) 未定義動作としてコンパイル時に排除
安全性保証 ランタイムの Bounds check のみ コンパイル時に UB を除去
実行結果 正常動作💀 実行前に unreachable に置換

コンパイラの親切でエラーになる

Rust や TinyGo はバックエンドに LLVM を使用しており、LLVM の最適化パスにおいて Null ポインタへのストアが未定義動作として検出され、unreachable に置換されたと考えられます
この仕組みにより、Rust と TinyGo はメモリアクセス違反を未然に防いでいることがわかります

おまけ: WASM バイナリのファイルサイズ

ファイルサイズをそれぞれ比較して、サイズの比較をGeminiに行なってもらいました

Rust が極端に大きいのは、標準ライブラリとパニックハンドラなどが含まれているためです
一方で TinyGo は比較的コンパクトに収まっています
C と WAT は必要最小限の命令しか書かれていないため、その分圧倒的に小さいことがわかります

ファイル名 ファイルサイズ (bytes)
sample01.wasm 67
sample02.wasm 471
sample03.wasm 1348743
sample04.wasm 14695

おわりに

今回の検証では、0 番地への読み書きをいろんな言語で実行しました
モダンな言語のコンパイラ (LLVM) は、未定義動作に対して厳格なチェックが行われており、フェイルセーフな仕組みが有効に働いていることがわかります
対して、C 言語でも同様にフェイルセーフな仕組みは導入されており、プログラマの裁量で unsafe なコンパイルを実行できるようになっています
これから WASM をさらに勉強していく上で、言語仕様と WASM 仕様のギャップを意識することが大切かなと思います
あと、WebAssembly の世界において「ぬるぽ」はガッされませんが、ソースコードを読んだ人からガッされる可能性が極めて高いのでお手柔らかにお願いします 🙇‍♂️

線形リストが使用される具体例

普段私たちはどのように線形メモリを使っているのでしょうか?
最後に少しだけ、構造体を使った例 (sample05.c) を見てみましょう

sample05.c
struct Vector3 {
  float x;
  float y;
  float z;
};
float get_z() {
  volatile struct Vector3 v = {1.0f, 2.0f, 3.0f};
  return v.z;
}

このコードをコンパイルすると、コンパイラは線形メモリ上の特定のアドレスに 1.0, 2.0, 3.0 というデータを順番に書き込む store 命令を生成します
WASM には構造体という型は存在せず、構造体や配列、オブジェクトといった概念は、線形メモリの上に、コンパイラが分けた区画に沿って形成されます

参考資料

https://webassembly.github.io/spec/core/index.html

https://developer.mozilla.org/ja/docs/WebAssembly

https://qiita.com/wbcchsyn/items/3c9f331cefbd139a239e#null-ポインターにアクセス可能である

https://mi-progress-ooo.hatenablog.com/entry/2018/12/30/113800

https://developer.mozilla.org/ja/docs/WebAssembly/Guides/Understanding_the_text_format

https://hikalium.hatenablog.jp/entry/2020/02/15/120942

Discussion

kanaruskanarus

よって、Rust で 0 番地へアクセスする処理を WASM にコンパイルすることは不可能でした

は言い過ぎで、例えば

#![no_main]

#[no_mangle]
pub unsafe extern "C" fn test_zero() -> i32 {
    let ptr = core::hint::black_box(0) as *mut i32;
    *ptr = 2025;
    *ptr
}

のようにすると動くはずです:

$ rustc --version
rustc 1.92.0 (ded5c06cf 2025-12-08)

$ wasmtime run --invoke 'test_zero' sample03.wasm
warning: using `--invoke` with a function that returns values is experimental and may break in the future
2025
2
cyokozaicyokozai

ご指摘いただきありがとうございます
Rustの知識が浅く、black_box関数の存在を普通に知りませんでした💦
年内に修正します

1