- メモリ安全性とスレッド安全性は切り離せる概念ではなく、スレッド安全性がなければ真のメモリ安全性は達成できない
- Goのようなスレッド安全でない言語では、単なるスレッド問題だけでもメモリ安全性が破られうる
- Javaなど一部の言語は並行性メモリモデルによってデータレースさえも定義済みの動作として扱い、言語レベルの安全性を確保している
- Goはデータレースに脆弱で、実際のメモリ安全性侵害事例が存在する
- 本当に重要視すべき性質はUndefined Behavior(未定義動作)の不在である
スレッド安全性なしにメモリ安全性は保証できない
概念の混同: メモリ安全性 vs スレッド安全性
- 近年メモリ安全性が大きな注目を集めているが、実際に何を意味するのか定義は明確ではない
- 伝統的にメモリ安全性とは、use-after-free や out-of-bounds メモリアクセスを防ぐ言語を指す
- 一方、スレッド安全性は並行性バグのないプログラムを意味し、両者はしばしば別個のものとして扱われる
- 筆者はこの区別は実質的に役に立たないと主張し、私たちが本当に求めているのはUndefined Behavior(UB)の不在であると強調する
データレースによるメモリ安全性侵害: Goの例
- メモリ安全性とスレッド安全性を別々に扱ってきた問題点を示すため、Go言語の例を提示
- Goはメモリ安全な言語に分類されるが、以下のようなプログラムではデータレースだけでもメモリエラーが発生する
globalVar를 반복적으로 다른 타입 값(Int, Ptr)으로 변경하면서 동시에 별도 고루틴에서 이를 읽어 메서드를 호출
- 2つのスレッドが重なって
globalVar の内部にある2つのポインタ(データ、vtable)を別々に更新するため、途中で読み取ると混在状態が生じ、誤ったメモリアクセスが発生する
- 結果として誤ったアドレス(例:
0x2a、16進数で42)を参照しようとして、プログラムはエラー終了する
- この現象はGoのインターフェイスやスライスなどでも同様で、複数フィールドを原子的に更新していないことにより発生する
他言語の並行性処理方式とメモリ安全性
- Javaなど他の言語でもデータレースの可能性はあるが、定義された並行性メモリモデルを適用することで、プログラムが言語自体を壊さないことを保証している
- 例: Javaはマルチスレッド環境でもランタイムエラー(例: 強制的なセグメンテーションフォルト)に陥らないよう、メモリモデルを精密に設計している
- ほとんどの言語は、以下2つの方法のいずれかで並行性問題を制御している
- すべての並行プログラムで一貫した動作が保証されるようメモリモデルを定義する(その代わりコンパイラ最適化の制約や実装負担が増える)
- Java, C#, OCaml, JavaScript, WebAssembly など
- 強力な型システムで大半のデータレースを禁止し、少数の例外だけを安全に扱う(Rust、Swift の strict concurrency)
- Goは上記2つの選択肢のいずれにも従っていない
- データレースがない場合にのみメモリ安全を保証する
- データレース検出ツールはあるが、実際のプログラムではあらゆる状況をテストで検証することに限界がある
- 研究結果や現場の経験では、実際のメモリ安全性違反事例が多数報告されている
Goのメモリモデルと文書化の問題
- Goメモリモデルの公式文書は、大半のレースでは結果が限定的だとは述べているものの、一部のデータレースでは結果が無限に広がりうることを明確に説明していない
- Java/JavaScriptに似ているという主張もあるが、両言語はGoに比べて並行性安全性の確保のために遥かに多くの努力を払っている
- 文書中の一部の詳細セクションでのみ、限定的に一部のデータレースが完全な未定義動作を引き起こしうることに触れている
結論: Undefined Behavior(UB)の不在こそが真の目標
- 実質的にユーザーが本当に望んでいる性質は、**プログラムが言語自体を壊さないこと(UBの不在)**である
- メモリ安全性侵害によって生じる各種のセキュリティ脆弱性は、UBが実際に発生したために起こる
- UBが発生した瞬間、それ以降のすべての動作は予測不能となり、攻撃者がこれを悪用できる
- 「安全」な言語と「安全でない」言語を分ける本質的な違いは、UB発生の可能性にある
- メモリ安全性、スレッド安全性、型安全性などに細分化して区別するよりも、UBが発生するかどうか自体が核心である
- 実際には安全性にもスペクトラムが存在し、GoはCより安全だが完全な安全性を保証するわけではない
- データに基づいてGoの実際の安全性を「証明」するのは非常に難しく、各言語が取った選択の非直感的な結果を正しく知ることが重要である
1件のコメント
Hacker Newsの意見
Swiftでも同じ問題があり、Swiftが共有データ構造体にアクセスするとsegfaultを非常に起こしやすいことを示すプログラムを書いたことがある
RustやJavaのような意味でGoがメモリセーフだと言うのは少し誇張だ
Dropboxで起きた問題状況について詳しく聞いてみたい
メモリセーフティはPLT(プログラミング言語理論)の概念というより、ソフトウェアセキュリティの用語だ
結局Goプログラマーもこの違いは十分理解していて、だからGoは「共有によって通信するな、通信によって共有せよ」というアプローチを基本premiseとしている
もちろん現実にはこのコンセプトは十分には実現されておらず、Goも現代的には共有が多く、同期が必要だということを皆理解している
実際、Goを何年も運用してきて、この種のバグが実際に発生したことはほとんどないと思う
UberがGoコードで発生したバグを詳しくまとめており、この記事では問題が実際にどの程度頻繁に起きるのかを表で整理している
Goでの大半の同時mapまたはsliceアクセス問題は同じスライスに対して発生し、「torn read」現象が必要になるため、実際にはまれだ
それでも人々がこうした問題をうまく避けている理由は、たぶん普通は十分に注意していて、変数を同時アクセス状況で再代入する危険性をよく認識しているからだと思う
言語自体にatomics、channel、mutexがあるので、実際には同時アクセス状況で誤用するケースは少なく、race detectorもあるので、こうした問題があればすぐ見つけられる
性能低下があったとしても、torn readの問題は単純に修正できる問題だと思うし、実運用中のGoコードでは大きな問題ではなかった
関連動画
レースディテクタも何も見つけられず、誰も何が起きているのか理解できなかった
結局、ループカウンタがオーバーフローして同じ計算を膨大に繰り返し、リクエストが時々100msではなく3分かかる現象が発生していた
本番環境でperfを使って間接的に問題を知ることができ、プラットフォーム開発者としてのデバッグ経験がチームに大いに役立った
さまざまなGoのレース状況に数多くさらされてきたので、個人的にはRustがあらゆる場所に導入されてほしいという気持ちだ
たとえばこのissueは、コンパイラの大規模なリファクタリングが必要で時間がかかっている
実際にはまだ並行Zigコードが少ないため問題が大きく表面化していないだけで、今後async機能がより広く使われれば、多くの問題が一気に噴出するかもしれないと思う
もちろんCよりはバグが減るが、これはC++でも同じで、誰もC++がメモリセーフだとは言わない
もちろんそれが危険自体が完全にないことを意味するわけではないが、Goアプリケーションのセキュリティ面では優先度の高い問題ではない可能性を示している
一方でC/C++コードでは、現実の脆弱性の60〜75%がメモリセーフティ問題から生じている
メモリセーフティも連続体であり、ある水準を超えると効用が逓減すると考えている
悪用不能なバグであっても、バグは結局直さなければならない
初期開発より保守にずっと多くの時間がかかるので、保守を減らせるなら初期リリースが遅れても価値があると思う
一方Goでは、スレッドセーフティがCVEの主要因ではない
理論的には根拠があるが、現実では大きく目立たない
メモリを共有するとき、データ構造体を壊せば、別のスレッドでunsafeあるいは誤った動作が起こりうる
たとえばあるスレッドでベクタのサイズを変えている間に別のスレッドがアクセスすると、逐次実行では安全な操作でも並行性の下では危険になる
Goもこれを免れない
一方、スレッドセーフティ問題がsegfaultで終わるなら、単なるDoS(サービス拒否)攻撃にとどまることもある
レースコンディションがより強力な攻撃につながることもあるが、トリガーするのははるかに難しい
これがデータ破壊とレースの主因だ
多くの状況ではプロセスベースのほうがスレッドより良い並行性モデルだが、重すぎるという欠点がある
もし各スレッドに必要なデータをすべてメッセージパッシングで渡すのが基本だったなら、こうした問題の大半は消えていたと思う
いずれにせよ、我々はプラットフォーム上でグローバル変数と共有メモリを使う自由があるのだから、自分で使わなければよい
Rustの元々の目標はメモリセーフなシステム言語ではなく、スレッドセーフなシステム言語であり、メモリセーフティは自然についてきた結果だった
Rustでは構造化並行性を
thread::scopeなどで使え、スレッド作業が非常にやりやすいこの文書参照
実例: 上のコードで
buf.Bytes()は内部メモリをそのまま参照して渡しており、Reset()の呼び出しでbacking memoryが再利用されるため、processDataとmainの両方が同時に同じメモリへアクセスすることになり、データレースが発生するRustではこのようなコードは2つのmutable referenceになるため、そもそもコンパイルされず、所有権移動かコピーを強制される
Goでは紛らわしく、
bytes.Buffer.ReadBytes("\n")や.String()はコピーを返すので安全だが、.Bytes()はこのように危険だRustのchannelはこの問題を所有権/転送の概念によって根本的に防ぐが、Goにはこうした安全装置がない
結果としてmutexより遅く、Go初心者には正しく使うのがより難しい体験を与えているように思う
つまり、「安全な」レースや「安全な」デッドロックのほうがむしろ一般的になる
PL理論ではRustのレースフリーダムへのアプローチが魅力的かもしれないが、現実のアプリではどうせ重要なデータはすべてRDBMSにあり、たとえばSELECTにFOR UPDATEを使わなければレースはいくらでも起きる
Rustアプリがunsafeをまったく使わなくても、DB次第でレースは依然として存在する
Goはメモリ破壊バグをほとんど許さない構造であることが、実際にexploitがほぼ存在しないことからわかる
この記事の主張に従うなら、多くの高級言語(記事ではJavaだけ例外扱いしている)もメモリセーフではないことになる
RustがGoより「より」安全でありうるとしても、「memory safety」は連続的スペクトラムではなく合格/不合格の概念だ
もしある言語がメモリunsafeだと主張するなら、POCを必ず示すべきだ
記事に出てきた例は、intをポインタと誤認することでメモリ破壊が容易に起こりうることを示している
デモではわざと42を使ってsegfaultにしているが、実アドレス値を使っていれば本物の破壊が起きる
したがってデータレースが起こりうる言語はメモリセーフだとは言えない
このようなケースでメモリセーフだと呼べるのか疑問だ
こうした問題を避けるために「ガウス曲率(Gaussian Curvature)」「リーマン積分(Riemann Integrals)」のように人名を付ける場合がある
「初期の意味を狭く残したまま、広い意味へ拡張された例」は「Galois Group」のように存在する
このようにメモリセーフティも例外ではない
具体例を求めたい
FAQなどのmemory safetyへの言及やunions関連の回答ではメモリセーフだと示唆しているが、実際に何を意味するのか明確ではない
Rob Pikeの2012年の発表では"Not purely memory safe"と言っていたが、'purely'の意味すら定義されていない
Goのrace detector文書でも"safe"の定義が曖昧だ(例の文書)
外部ではむしろGoを「memory-safe programming language」と強く主張する例が多い
例としてfly.ioのセキュリティ文書や、memorysafety.orgのGoをmemory safeと分類した文書などがある
しかし同じ文書では「Out of Bounds Reads and Writes」もメモリセーフティ問題として述べており、記事で指摘されたGoのエラーはこの条件に当てはまる
少なくともGoとコミュニティは「memory safety」の正確な意味を明確にしておく必要があると思う
このようなケースがある以上、Goを説明なしにメモリセーフ言語と呼ばないほうが望ましい
Goが作られた当時は「ガベージコレクタがあればメモリセーフ」という見方が主流で、C/C++と比べればはるかにセーフだった