この記事は NTTドコモソリューションズ AdventCalendar 2025 の 2 日目の記事です。
普段は RHEL の問合せサポートやカーネル関連の技術調査をしています。OS 担当といえば、OS 上で動作するという理由だけで様々なこぼれ球を拾う担当としてもおなじみのため、幅広い知識が求められます(持論)。今回は WebAssembly のセキュリティ機能について検証してみました。
はじめに
WebAssembly(以降 Wasm) は、W3C によって標準化が進められている仮想命令セットおよびバイナリフォーマットです。
いろいろな言語のコンパイルターゲットとして利用されていて、たとえば Rust や C 言語からも Wasm バイナリを生成できます。一般的には Rust や C 言語でコンパイルしたら ELF バイナリを生成しますが、Wasm もそのような汎用的なバイナリフォーマットです。
ただし ELF バイナリは、次のように CPU が直接実行できる一方で、
$ file /usr/bin/date
/usr/bin/date: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=40b5cff5fd5f36660b638da8ffcfce3831e8265d, for GNU/Linux 3.2.0, stripped
$ /usr/bin/date
2025年 11月 26日 水曜日 13:50:47 JST
Wasm は仮想の命令セットのため、直接は実行できません。
$ file main.wasm
main.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)
$ ./main.wasm
-bash: ./main.wasm: バイナリファイルを実行できません: 実行形式エラー
これを実行するのが Wasm ランタイムです。Wasm はかつては WEB ブラウザで主に実行されていましたが、最近は WASI という規格の制定が進んだおかげで、サーバサイドでコンテナのように使えるようになってきました。今回は主にサーバサイド用途で検証します。
調査すること
https://webassembly.org/ によると、Wasm の特徴は次の通りです。
- Efficient and fast
- 効率的なバイナリフォーマット
- 幅広いプラットフォームで利用可能な一般的なハードウェア機能を利用しネイティブ速度に近い速度を目指す
- Safe
- メモリセーフ
- (上記 URL には書いてませんが) 関数呼び出しの書き換えなどによる意図しない制御フローの乗っ取りが起きづらい 参考 webassembly.org > Security
- Open and debuggable
- デバッグしやすい(WAT という読みやすいバイナリ表現がある。参考 MDN WebAssembly テキスト形式の理解)
- Part of the open web platform
- バージョンレス、機能テスト済み、後方互換性を維持するよう設計されている
利用者目線では、この中でも Safe の部分(セキュリティ関連)が気になるところです。Wasm を活用すれば、既存の脆弱なアプリケーションもコストをかけずに、セキュアになるかもしれません!ということで、今回は C 言語で書いた脆弱なアプリケーションが、Wasm に載せるだけでセキュアになるかを調べてみました。
検証条件
- C 言語で書かれた脆弱なアプリケーションを用意します
-
gccでコンパイルして、脆弱性があることを確認します - wasi-sdk 27.0 を利用して Wasm バイナリを生成します1
- Wasm ランタイムで実行して脆弱性の有無を確認します
- Wasm ランタイムには wasmer 6.1.0 を利用します
リターンアドレスの書き換え
次のアプリケーションは、fgets の引数が誤っており、バッファオーバフローが発生します。これにより、リターンアドレスの書き換えが可能です(関数 A から 関数 B を呼び出した。関数 B の処理が終わったので関数 A に戻ろうとしたけど、なぜか関数 C に戻ってしまった)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void private_function(){
printf("SECRET\n");
}
void vuln() {
char buf[64];
fgets(buf, 1000, stdin);
printf("%s\n", buf);
}
int main(int argc, char **argv){
printf("type: ");
vuln();
printf("Done\n");
return 0;
}
コンパイルすると警告をだしてくれますが、いったん無視します。
$ gcc -g3 -o buffer_overflow buffer_overflow.c
buffer_overflow.c: 関数 ‘vuln’ 内:
buffer_overflow.c:11:5: 警告: ‘fgets’ writing 1000 bytes into a region of size 64 overflows the destination [-Wstringop-overflow=]
11 | fgets(buf, 1000, stdin);
| ^~~~~~~~~~~~~~~~~~~~~~~
buffer_overflow.c:10:10: 備考: destination object ‘buf’ of size 64
10 | char buf[64];
| ^~~
次のファイルから読み込み: buffer_overflow.c:1:
/usr/include/stdio.h:586:14: 備考: in a call to function ‘fgets’ declared with attribute ‘access (write_only, 1, 2)’
586 | extern char *fgets (char *__restrict __s, int __n, FILE *__restrict __stream)
| ^~~~~
詳細は省略しますが、ユーザが入力を工夫すれば、本来は実行できない private_function が実行できます。
$ (python -c "print('a' * 0x48 + '\x46\x11\x40\x00\x00\x00\x00\x00');") | ./buffer_overflow
type: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaF@
SECRET
Segmentation fault (コアダンプ)
これを Wasm ランタイムで実行してみます。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -o buffer_overflow.wasm buffer_overflow.c
$ (python -c "print('a' * 0x48 + '\x46\x11\x40\x00\x00\x00\x00\x00');") | wasmer ./buffer_overflow.wasm
⠁ Compiling to WebAssembly
type: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaF@
Done
攻撃を防げたようです。
なぜ gcc でコンパイルしたものがリターンアドレスを書き換えられたのかを振り返って考えると、関数を呼び出したときに(callq 命令実行時に)、呼び出し元の次のアドレスがスタックに積まれ、vuln() 関数はそのスタックの延長でメモリを操作しているからです。つまり、次の図のようになります。
ここで、Wasm は呼び出しスタックがヒープスタックとは別領域にあるようです。
A protected call stack that is invulnerable to buffer overflows in the module heap ensures safe function returns.
出典: "WebAssembly.org > Security"https://webassembly.org/docs/security/(参照日:2025.11.27)
つまり、リターンアドレスはスタックの連続した領域にありません。
そのため、リターンアドレスは書き換えられないようです。
呼び出し関数の書き換え
次のアプリケーションは、fgets の引数が誤っており、バッファオーバフローが発生します。今回は関数ポインタを書き換えてみます。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void private_function(){
printf("SECRET\n");
}
void public_function() {
printf("HELLO\n");
}
void vuln() {
void (*p)(void);
p = &public_function;
char buf[64];
fgets(buf, 1000, stdin);
printf("%s\n", buf);
p();
}
int main(int argc, char **argv){
printf("type: ");
vuln();
printf("Done\n");
return 0;
}
コンパイルすると警告をだしてくれますが、いったん無視します。
$ gcc -g3 -o function_call function_call.c
function_call.c: 関数 ‘vuln’ 内:
function_call.c:18:5: 警告: ‘fgets’ writing 1000 bytes into a region of size 64 overflows the destination [-Wstringop-overflow=]
18 | fgets(buf, 1000, stdin);
| ^~~~~~~~~~~~~~~~~~~~~~~
function_call.c:17:10: 備考: destination object ‘buf’ of size 64
17 | char buf[64];
| ^~~
次のファイルから読み込み: function_call.c:1:
/usr/include/stdio.h:586:14: 備考: in a call to function ‘fgets’ declared with attribute ‘access (write_only, 1, 2)’
586 | extern char *fgets (char *__restrict __s, int __n, FILE *__restrict __stream)
| ^~~~~
詳細は省略しますが、ユーザが入力を工夫すれば、本来は実行されるはずのない private_function が実行できます。ひとつ前の例と同じじゃないの?という感じもしますが、前回はリターンアドレスの書き換えで、今回はジャンプする関数の書き換えという点が違いです。
$ (python -c "print('a' * 0x48 + '\x46\x11\x40\x00\x00\x00\x00\x00');") | ./function_call
type: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaF@
SECRET
Done
Segmentation fault (コアダンプ)
これを Wasm ランタイムで実行してみます。clang のデフォルトでは、利用しない関数はコンパイル時に消されてしまうので、残すオプション(--no-gc-sections)でコンパイルしています。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -Wl,--no-gc-sections -o ./function_call.wasm ./function_call.c
$ (python -c "print('a' * 0x48 + '\x46\x11\x40\x00\x00\x00\x00\x00');") | wasmer ./function_call.wasm
⠁ Compiling to WebAssembly
type: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaF@
RuntimeError: uninitialized element
RuntimeError: uninitialized element
at vuln (function_call.wasm[49]:0x976)
at main (function_call.wasm[50]:0x9e7)
at __main_void (function_call.wasm[64]:0x37e0)
at _start (function_call.wasm[46]:0x88a)
おや RuntimeError: uninitialized element が発生しました。
Wasm バイナリの WAT 表現(objdump -D 的なやつ)を見てみると、メモリの使い方が gcc でコンパイルしたものとは違うようです。WAT 表現を見ても一般人には意味がわからないので、LLM を活用しつつ、fputs で操作する変数から 76 バイト離れたところに関数ポインタのアドレスがあるとアタリをつけます。
$ wasm-tools print function_call.wasm
...
(func $vuln (;49;) (type 0)
(local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
global.get $__stack_pointer
local.set 0
i32.const 96
local.set 1
local.get 0
local.get 1
i32.sub
local.set 2
local.get 2
global.set $__stack_pointer
i32.const 1
local.set 3
local.get 2
local.get 3
i32.store offset=92
i32.const 16
local.set 4
local.get 2
local.get 4
i32.add
local.set 5
local.get 5
local.set 6
i32.const 0
local.set 7
local.get 7
i32.load offset=1244
local.set 8
i32.const 1000
local.set 9
local.get 6
local.get 9
local.get 8
call $fgets
drop
i32.const 16
local.set 10
local.get 2
local.get 10
i32.add
local.set 11
local.get 11
local.set 12
local.get 2
local.get 12
i32.store
i32.const 1085
local.set 13
local.get 13
local.get 2
call $printf
drop
local.get 2
i32.load offset=92
local.set 14
local.get 14
call_indirect (type 0)
i32.const 96
local.set 15
local.get 2
local.get 15
i32.add
local.set 16
local.get 16
global.set $__stack_pointer
return
)
...
また関数呼び出しには call_indirect という命令を利用していました。これは引数に関数テーブルのインデックスを取るようです。関数テーブルは、次のようになっていました。
$ wasm-tools print function_call.wasm
(module $function_call.wasm
...
(table (;0;) 7 7 funcref)
...
(elem (;0;) (i32.const 1) func $public_function $__stdio_close $__stdio_read $__stdio_seek $__stdio_write $__stdout_write)
たとえば関数テーブルのインデックス 2 を実行したければ、次のようにすれば良さそうです。
$ python -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\x02\x00\x00\x00' + b'\n')" | wasmer ./function_call.wasm
⠁ Compiling to WebAssembly
type: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
RuntimeError: indirect call type mismatch
at vuln (function_call.wasm[49]:0x976)
at main (function_call.wasm[50]:0x9e7)
at __main_void (function_call.wasm[64]:0x37e0)
at _start (function_call.wasm[46]:0x88a)
実行してみると RuntimeError: indirect call type mismatch が発生しました。実行する関数は差し替えられたようですが、どうやら関数シグネチャが異なる呼び出しはできないようです。これは Wasm の次の特徴に該当しています。
Indirect function calls are subject to a type signature check at runtime; the type signature of the selected indirect function must match the type signature specified at the call site.
出典: "WebAssembly.org > Security"https://webassembly.org/docs/security/(参照日:2025.11.27)
private_function を実行してみようと思いましたが、関数テーブルに載らない関数はそもそもジャンプも指定できないため、どうやっても実行できませんでした。ということでセキュアになったようです。
補足検証
関数テーブルに載っていて、差し替え前の関数とシグネチャが合えば実行できます。ということは、例えば次のようなケースでは脆弱性が残りそうです。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void private_function(){
printf("SECRET\n");
}
void public_function() {
printf("HELLO\n");
}
void vuln() {
void (*p)(void);
p = &private_function;
p = &public_function;
char buf[64];
fgets(buf, 1000, stdin);
printf("%s\n", buf);
p();
}
int main(int argc, char **argv){
printf("type: ");
vuln();
printf("Done\n");
return 0;
}
Wasm ランタイムで実行すると、脆弱性が残っていることが分かります。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -Wl,--no-gc-sections -o ./function_call.wasm ./function_call.c
$ wasm-tools print function_call.wasm
(module $function_call.wasm
...
(table (;0;) 8 8 funcref)
...
(elem (;0;) (i32.const 1) func $private_function $public_function $__stdio_close $__stdio_read $__stdio_seek $__stdio_write $__stdout_write)
# index 1 番目が private_function
$ python -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\x01\x00\x00\x00' + b'\n')" | wasmer ./function_call.wasm
⠁ Compiling to WebAssembly
type: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
SECRET
Done
変数の上書き
次のアプリケーションは、バッファオーバフローにより、変数が上書きされます。
#include <stdio.h>
#include <string.h>
int main() {
int flag = 99;
char buffer[10];
char *buflong = "this is too long text here";
printf("Before buffer overflow: %d\n", flag);
strcpy(buffer, buflong);
printf("After buffer overflow: %d\n", flag);
return 0;
}
実行すると flag 変数が書き換えられました。
$ gcc -o buffer_overflow_variables buffer_overflow_variables.c
$ ./buffer_overflow_variables
Before buffer overflow: 99
After buffer overflow: 1701995880
これを Wasm ランタイムで実行してみます。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -o buffer_overflow_variables.wasm buffer_overflow_variables.c
$ wasmer buffer_overflow_variables.wasm
⠁ Compiling to WebAssembly Before buffer overflow: 99
After buffer overflow: 1869357167
flag 変数が書き換えられました。脆弱性が残っています。
WAT 表現をみてみると、main 関数はここらへんのようです。
$ wasm-tools print buffer_overflow_variables.wasm
...
(func $__original_main (;7;) (type 8) (result i32)
(local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
global.get $__stack_pointer
local.set 0
i32.const 48
local.set 1
local.get 0
local.get 1
i32.sub
local.set 2
local.get 2
global.set $__stack_pointer
i32.const 0
local.set 3
local.get 2
local.get 3
i32.store offset=44
i32.const 99
local.set 4
local.get 2
local.get 4
どうやら int flag = 99; は i32.const 99 や local.set 4 あたりのようです。https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/Variables/local.set によると、local.set はスタックに積まれた値(今回は99)を変数領域(今回は$4)に確認する命令のようです。
LLM を活用しつつ続きを読んでいくと、結局 strcpy を実行する際は、local.set を用いて変数をリニアメモリ上に配置しているようです。
$ wasm-tools print buffer_overflow_variables.wasm
...
i32.const 99
local.set 4
local.get 2
local.get 4
i32.store offset=40
i32.const 1061
local.set 5
local.get 2
local.get 5
i32.store offset=24
local.get 2
i32.load offset=40
local.set 6
local.get 2
local.get 6
i32.store
i32.const 1132
local.set 7
local.get 7
local.get 2
call $printf
drop
i32.const 30
local.set 8
local.get 2
local.get 8
i32.add
local.set 9
local.get 9
local.set 10
local.get 2
i32.load offset=24
local.set 11
local.get 10
local.get 11
call $strcpy
次のドキュメントによると、Wasm はローカル変数(今回でいえば $4)を独自の領域に確保しており、この領域に対してはバッファオーバフローの影響はありません。しかし、リニアメモリはバッファオーバフローが影響します。
Compared to traditional C/C++ programs, these semantics obviate certain classes of memory safety bugs in WebAssembly. 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. 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. Thus, common mitigations such as data execution prevention (DEP) and stack smashing protection (SSP) are not needed by WebAssembly programs.
出典: "WebAssembly > Security"https://webassembly.org/docs/security/(参照日:2025.11.27)
C 言語上はローカル変数なのでバッファオーバフロー耐性があるのかと思いましたが、C 言語でローカル変数使っていようが、Wasm ではリニアメモリ上で操作してるので意味ありませんでした。ということになりそうです。
ゼロ除算エラー
次のアプリケーションは、ゼロ除算エラーが発生してプロセスが停止します。ゼロ除算エラー自体が脆弱性といえるかは微妙ですが、意図しないプロセス停止は DoS 攻撃(Denial of Service attack)につながります。
#include <stdio.h>
int main(int argc, char **argv){
printf("%d\n", 100/0);
printf("Done\n");
return 0;
}
コンパイルすると警告をだしてくれますが、いったん無視します。
$ gcc -g3 -o zero_div zero_div.c
zero_div.c: 関数 ‘main’ 内:
zero_div.c:4:23: 警告: ゼロ除算が発生しました [-Wdiv-by-zero]
4 | printf("%d\n", 100/0);
| ^
実行すると、プロセスが途中でダウンしました。
$ ./zero_div
浮動小数点例外 (コアダンプ)
これを Wasm ランタイムで実行してみます。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -o zero_div.wasm ./zero_div.c
./zero_div.c:4:23: warning: division by zero is undefined [-Wdivision-by-zero]
4 | printf("%d\n", 100/0);
| ^~
1 warning generated.
$ wasmer zero_div.wasm
⠁ Compiling to WebAssembly
0
Done
プロセスは途中で終了せず、正常に終了しました。セキュアになりました。
(本当はドキュメントに書いてるとおりに Trap されると思ってたのですが、なぜかそのまま成功しました。…なぜ? 他の Wasm ランタイム(wasmtime)でも動作確認しましたが、正常に終了しました。Trap をランタイムがどう扱うか次第なんだと思うんですが、ここでは、起きた事実だけを記載しておきます。)
. Operations that can trap include:
specifying an invalid index in any index space,
performing an indirect function call with a mismatched signature,
exceeding the maximum size of the protected call stack,
accessing out-of-bounds addresses in linear memory,
executing an illegal arithmetic operations (e.g. division or remainder by zero, signed division overflow, etc).
出典 "WebAssembly.org > Security"https://webassembly.org/docs/security/(参照日:2025.11.27)
ランタイムによるアクセス制御
次のアプリケーションは /tmp/passwd ファイルを読み取ります。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
const char *path = "/tmp/password.txt";
FILE *fp = fopen(path, "r");
if (!fp) {
perror("fopen");
fprintf(stderr, "Failed to open: %s\n", path);
return EXIT_FAILURE;
}
char buf[4096];
while (fgets(buf, sizeof(buf), fp)) {
fputs(buf, stdout);
}
if (ferror(fp)) {
perror("read error");
fclose(fp);
return EXIT_FAILURE;
}
fclose(fp);
return EXIT_SUCCESS;
}
実行すると、/tmp/password.txt が読み取れます。
$ gcc -o read_passwd read_passwd.c
$ echo "SECRET" > /tmp/password.txt
$ ./read_passwd
SECRET
これを Wasm ランタイムで実行します。
$ ./wasi-sdk-27.0-x86_64-linux/bin/clang --sysroot ./wasi-sdk-27.0-x86_64-linux/share/wasi-sysroot -o read_passwd.wasm read_passwd.c
$ wasmer read_passwd.wasm
⠁ Compiling to WebAssembly
fopen: No such file or directory
Failed to open: /tmp/password.txt
fopen: No such file or directory になりました。これは Wasm ランタイムがアプリケーションからアクセスできるディレクトリを制限しているためです。
--dir でアクセス範囲を指定するとアクセスできます。ファイルのほかにも、環境変数なども隔離されており、Docker コンテナの隔離とそっくりです。
$ wasmer run --dir /tmp read_passwd.wasm
⠁ Compiling to WebAssembly
SECRET
Wasm は下回りの操作(ファイルアクセスやネットワーク通信など)を直接実行できません。そのような下回りの操作は WASI API を通して Wasm ランタイムに委譲する形で、これらの隔離を実現しています。そのため、適切にアクセス範囲を制限しておけば、何かしらの脆弱性があった場合の影響範囲を局所化できそうです。
もちろんコンテナ技術と組み合わせて namespaces の隔離も併用すれば、多層でのセキュリティも実現できます。
まとめ
今回の検証結果をまとめます。
| 検証パターン | GCC バイナリ | Wasm |
|---|---|---|
| リターンアドレス書き換えによる制御乗っ取り | 脆弱 | セキュア |
| 関数ポインタ書き換えによる制御乗っ取り | 脆弱 | 条件付きでセキュア |
| 変数上書き | 脆弱 | 脆弱(な場合がある) |
| ゼロ除算エラーによるクラッシュ | クラッシュ | セキュア |
| ファイルアクセス | 脆弱 | セキュア(適切にアクセス範囲を制御していれば) |
Wasm は、仕様レベルで既存のバイナリよりもセキュアな仕組みになっており、これまで問題になっていた脆弱性の一部は防げそうです。とくに CFI(control-flow integrity) 対策などが強力そうです。今回触れてない範囲でもいろいろな対策が組まれていますので、詳細は https://webassembly.org/docs/security/ を参照してください。
しかし、いくつかのパターン(リニアメモリ上での操作や、call_indirect で関数差し替え前後のシグネチャが一致するパターンなど)では脆弱性が残ることもわかりました。万能ではないので、仕組みを理解して、どこまでセキュリティを担保できるかを検討しながら使う必要がありそうです。
また今回は高級アセンブラで有名な C 言語から Wasm に変換しましたが、言語によっては言語仕様やランタイムでいろいろ追加のセキュリティ機能があるはずです。特定言語と組み合わせれば、より強力なセキュリティが実現できると思います。
参考
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。
-
wasi-sdk は clang ベースなので、元のアプリケーションとは微妙に条件が違う点に注意してください。たとえばメモリ配置などが異なる場合があります。ただし、今回の検証範囲では、
clangでもgccでも同じ脆弱性があります。 ↩

