WebAssembly の 0 番地を訪れる
WebAssembly の 0 番地を訪れる
こんにちは、cyokozai (X: @cyokozai0427) です
千葉にある工業大学に通っています
最近になってようやく WebAssembly (WASM) の勉強を始めました^^
手当たり次第に色々なブログや記事を読み漁っていたところ、@gorilla0513 さんの記事で気になる一文を発見しました
データは
(data $memory $offset $data)という書式となっていて、今回の例では、0番目のメモリの0バイト目からHello, World!\nという文字列が配置される。
引用 (一部省略):
あれ?これっていわゆる ぬるぽ ってやつなんじゃないか? 🤔
∧_∧
( ´∀`)< ぬるぽ
↓
( ・∀・) | | ガッ
と ) | |
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式は、module、memory、func といった各構成要素をネストしたリストとして表現します
-
(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 のリポジトリで公開しています
WAT で直接 0 番地に値を書き込む
はじめに、WAT で 0 番地に値を書き込む処理を記述し、コンパイルを試みます
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 式で記述されています
このコードで行なっている処理は非常に単純で、
-
(memory (export "memory") 1): 1 ページのメモリ領域を確保 -
(i32.store (i32.const 0) (i32.const 2025)): スタックから値を取り出し、メモリのアドレス 0 番地に値 2025 を書き込む-
(i32.const 0): スタックにメモリのアドレス 0 を積む -
(i32.const 2025): スタックに書き込みたい値 (今回は 2025) を積む
-
-
(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
このようなファイルが生成されます
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 にコンパイルすることが可能です
以下のコードをコンパイルします
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
一部分を抜粋して掲載します
(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 で完全一致していることがわかるかと思います
(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
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 のコードを以下に掲載します
#![no_main]
#[no_mangle]
pub unsafe extern "C" fn test_zero() -> i32 {
let ptr = 0 as *mut i32;
*ptr = 2025;
*ptr
}
処理内容は
- 明示的に 0 をポインタにキャスト
- そのポインタを逆参照して書き込み・読み込み
となっています
*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
(module $sample03.wasm
(type (;0;) (func (result i32)))
(func $test_zero (type 0) (result i32)
unreachable)
(table (;0;) 1 1 funcref)
(memory (;0;) 16)
~~~
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 への対応が行われています
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
~~~
(func $test_zero.command_export (type 3) (result i32)
unreachable)
~~~
~~~
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) を見てみましょう
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 には構造体という型は存在せず、構造体や配列、オブジェクトといった概念は、線形メモリの上に、コンパイラが分けた区画に沿って形成されます
参考資料
Discussion
は言い過ぎで、例えば
のようにすると動くはずです:
ご指摘いただきありがとうございます
Rustの知識が浅く、black_box関数の存在を普通に知りませんでした💦
年内に修正します