コンパイラが嫌いだ
(xeiaso.net)- Anubis は、Web サイト保護用のプルーフ・オブ・ワークを SHA-256 の外へ拡張するにあたり、クライアントとサーバーが 同じ WebAssembly 検査ロジック を実行するよう設計している
- WebAssembly が無効な環境も排除しないために JavaScript 再コンパイル経路 を用意したが、WebAssembly より遅く、JIT まで無効ならさらに遅くなる可能性がある
- Linux ディストリビューションの
wasm2jsが古く、Homebrew 版と出力が異なっていたため、再現可能なビルドのために wasi-sdk でビルドしたwasm2jsをバンドルすることになった - C/C++ ビルドでは、
__DATE__、__TIME__、$PATH上のwasm-opt、例外処理コードのポインタ順序によって、同じ入力でも バイト単位の出力 が揺らぐことがある - 最終的な実装では、
--no-wasm-opt、setarch --addr-no-randomize、x86_64・arm64 別の SHA-256 検証、CI での再ビルド確認により アーキテクチャ内での決定性 を確保した
Anubis の WebAssembly プルーフ・オブ・ワークと JavaScript 代替経路
- Anubis は、管理者が SHA-256 ではないプルーフ・オブ・ワーク方式を Web サイト保護に使えるよう、WebAssembly ベースの proof-of-work 検査 を追加しようとしている
- 中核となる目標は、検査ロジックをクライアントとサーバーで別々に実装せず 1 か所だけに定義 すること
- クライアントとサーバーは同じ WebAssembly に接続して検査ロジックを実行する
- 両者が lockstep で動作しているか確認する構造を目指している
- WebAssembly が無効なクライアントも考慮対象
- ユーザーを事実上 Web サイトから締め出したくないという制約がある
- Anubis はユーザー体験、管理者体験、開発者体験のあいだでバランスを取る必要がある
- 選ばれた回避策は、WebAssembly を JavaScript に再コンパイルする方式
- The Birth and Death of JavaScript から着想を得ている
- 結果として得られる JavaScript は、同等の WebAssembly より遅い
- WebAssembly を無効化すると JavaScript JIT も一緒に無効になる場合があり、さらに遅くなる可能性がある
- 低性能ハードウェアで従来の JavaScript より効率的かどうかは、追加調査が必要
wasm2js をバンドルする必要があった理由
- 必要なツールは binaryen プロジェクトの
wasm2js wasm2jsは Linux ディストリビューションでパッケージとして存在するが、ディストリビューション版が古く、開発環境の Homebrew 版と同じ出力を生成できなかった- 再現可能なビルドには、出力の 決定性 が必須
- Anubis リポジトリにコミットされる
wasm2jsバイナリをユーザーやパッケージャーが信頼するには、同じバージョンを自分でビルドして同じバイト列を得られる必要がある - 可能なら、他人のマシンでも同じバイト列が出るべき
- Anubis リポジトリにコミットされる
- そのため
wasm2jsを wasi-sdk で WebAssembly 向けにビルドしたコピーを同梱している
C/C++ ビルドで再現性が壊れやすいポイント
- 同じソースバイトと同じ入力を与えても、コンパイラ出力が常に同じバイト列になるとは限らない
- C/C++ では
__DATE__や__TIME__のような組み込みマクロだけでも 非決定的な出力 が生じる- 例の
hello.cppは、ビルド時点の日付と時刻を出力するよう書かれている - あるビルドは
Jun 18 2026 00:00:59を出力し、別のビルドはJun 18 2026 00:01:11を出力した - ソースコードのバイト列は同じでも、コンパイラ出力は異なっていた
- 例の
- 小規模なコンパイラなら理論上は決定的にできるかもしれないが、実際のコンパイラにはもっと複雑な変数が多い
Clang が $PATH 上の wasm-opt を密かに実行した問題
- binaryen には
wasm2jsのほかに、WebAssembly コンパイラ出力を最適化するwasm-optがある - Clang はビルド中に
wasm-optを shell out で実行する- 通常は性能向上のための合理的な動作
- 今回は
$PATH上にあるwasm-optのバージョン差が再現性を壊した
- DGX Spark の
wasm-optは/usr/bin/wasm-optの version 108 で、ワークステーションの Homebrewwasm-optは version 130 だった - wasi-sdk と binaryen は WebAssembly Exceptions extension に依存している
- Can I use によると、ブラウザユーザーの 93.86% がこれをサポートするブラウザエンジンを使っている
- C++ は例外を多用する言語なので、WebAssembly ネイティブ例外処理はボイラープレート削減に役立つ
- wasmtime と wazero では例外サポートを明示的に有効化する必要がある
- wasmtime には
-W exceptions=yを渡せる - wazero にはカスタムランナーハーネスが必要
- wasmtime には
- arm マシンの古い
wasm-optは例外処理命令に遭遇すると終了し、ビルドが失敗した - リンク段階に
--no-wasm-optを渡し、この 非再現な経路 を除去した
アドレス配置が例外処理コード生成に与えた影響
- 使用中の Clang バージョンは、
wasm2jsコンパイル過程の例外処理経路でアドレスに敏感なコード生成を示した - 生ポインタ値が一部の
try_tableブロックの出力順序に影響した- ビルドごとに約 29 バイトの差が発生した
- 計算自体はほぼ同じだが、バイト順序が変わり、catch 参照も変わる
- arm64 マシンで同じ固定バージョンの
wasm2jsをビルドしても、ポインタの反復順序がワークステーションと異なり、同じ問題が発生した - 回避策は 2 つある
setarch --addr-no-randomizeでそのビルドの アドレス空間配置ランダム化 を無効にする- 信頼できるマシンで x86_64 と arm64 それぞれの known-good SHA-256 チェックサムを生成する
- CI は
./utils/wasm/wasm2jsで./build.shを実行したあと、チェックサムを検証するshasums.x86_64と一致すれば x86_64 チェックサム通過として扱うshasums.arm64と一致すれば arm64 チェックサム通過として扱う- どちらとも一致しなければ
wasm-opt_130.wasmとwasm2js_130.wasmの SHA-256 を出力して失敗する
- この CI ジョブは x86_64 と arm64 の両ホストで実行される
- ホスト全体をまたぐ再現性はまだ確保されておらず、この問題は upstream LLVM バグとして残っている
- 現時点では少なくとも アーキテクチャ内 ではビルドが決定的に動作する
1件のコメント
Lobste.rsの意見
clangがこっそり$PATH上のwasm-optを実行するなんて初めて知ったし、本当にありえないと感じたこのせいで
zig ccにも影響があるのか確認したが、幸いclangを リンカードライバ として使うときだけ実行されるので該当しなかったclangがアドレス配置に依存して順序を決めるのなら、個人的には バグ だと見なすし、最新リリースでも再現するならそう報告すると思うこういう問題をなくそうとする取り組みは何年も続いてきた
clang.exeを クロスコンパイラ として安定して使おうとすると、さらに気が狂いそうになるclang にはネイティブシステム向けにビルドする前提のやり方が 500 個くらいある
批判したいわけではなく、オープンソースで OP が人気のあるサービスを無料で提供している点は尊重している
それでも Web がこう変わっていくのは本当に嫌だ。Web サイトに入るたびに Anubis のローディングページ が派手に表示されるのが当たり前になってきたが、人気サイトごとにプルーフ・オブ・ワークのスプラッシュ画面を見せられる Web を望んでいるのか分からない
AI クローラが押し寄せ続けているので代案が何かも分からないが、プルーフ・オブ・ワークが本当に AI クローラを防げるという証拠があるのかも疑わしい。彼らは莫大な資金を持っており、ページを読むためにすでにずっと多くの計算をしているのだから、プルーフ・オブ・ワークを解くコストはごく小さいように見える
Anubis パイロットでは望ましくないトラフィックに対して確かに効果的な抑止手段であり、ほぼデフォルトに近いルールだけで 3 つのアプリケーションへのリクエストの約 90% を継続的に遮断したという。DDR は 71.0%、ArcLight は 94.6%、Catalog は 92.4% だった
5 月 30 日にボットトラフィックが急増し、6 月 3 日に Anubis を適用するまで Catalog は事実上サービス不能になっていた。6 月 1 日のピーク時には 210 万のユニーク IP から 340 万件の HTTP リクエストがあり、ページ読み込み時間は 70 秒超まで跳ね上がった。6 月 4 日に Anubis を適用した後は再びユーザーにサービスを提供できるようになり、アプリケーションが処理した総リクエスト数は 12.5 万件、ページ読み込み時間は 2.12 秒に改善した
https://lobste.rs/s/ncyfcp/anubis_pilot_project_report_june_2025
別の事例でも Anubis をデプロイした直後に問題が解決し、モニタリングで正確な時点を確認でき、その後はアラートが 1 件もなかったという。攻撃は続いていたがサーバー負荷は最低水準で、Anubis は AI スクレイパーの遮断だけでなく DDoS 防御 としても機能したと見られている
https://lobste.rs/s/67ijih/day_anubis_saved_our_websites_from_ddos
https://orib.dev/tmp/bandwidth.png
meta refreshタグやクリックしなければならないボタンだけで防げる場合もある。だから Anubis は機能するのだが、要点は プルーフ・オブ・ワーク そのものではなく、想定外の動作にあるJavaScript を無効にしたブラウザで Web を使っていた頃よりも、さらに苦しいものになっている。Web はただ文書中心であってほしいのに、今ではどこでも Cloudflare、Anubis、CAPTCHA の関門を通らなければならない
ボットは常に WAF を回避する方法を見つけ、実際のユーザーはローディング画面で CPU サイクルを無駄にすることになるという内容だ
残念だが驚きではない。コンパイラのツールチェーンは、「ローカルな文脈がただ正しくなければならない」というような、ばかげた暗黙の依存に頼ってきた長い歴史がある
ただ、LLVM はそうした依存を取り除く先頭に立ってきた側なので、clang でこういうものを見るのは奇妙だ。そのおかげで、たとえば Rust コンパイラは別個の クロスコンパイラ という概念なしでも成り立つようになった
既存のビルドツールに頼らずに OS をブートストラップしてみると、すぐに明らかになる。カーネルを作り、そのカーネル向けの libc とコンパイラを作って実行し、新しい OS 上でもう一度すべてをビルドし直す過程は、暗黙の前提だらけの途方もなく複雑で繊細な作業だ
OS とコンパイラの開発者にしか主に関わらない珍しい問題なので、良いツールやベストプラクティスもほとんどなく、コンパイラ + OS の組み合わせごとに実際に全体を理解している人は世界に 5 人くらいしかいない気がする
Zig のツールチェーンもそうした機能の一部を LLVM から得ているのだと思っていたし、もちろんそれをさらにきれいに分離するために多くの作業をしたのは理解している。今はもう LLVM を使っていないのかも気になる
しかし clang にも同じ問題があるなら、LLVM からもっとクリーンな構造を受け継いだわけではなかったのかと思ってしまう
Nix を使っているはずだが、環境の変動性を少しでも減らすために Nix に触れたり使ったりしなかった理由が気になる
たとえば
$PATH上のwasm-opt問題のようなものは Nix で緩和できた気がするが、使っていて自分が見落としているだけだろうか?素朴には wasm を asm.js に移すのは「簡単」だろうと思っていたが、今日新しく学んだ
ブログのタイトルは クリックベイト っぽいが、内容は良い
クリックベイトは本当に嫌いだ