7 ポイント 投稿者 GN⁺ 2025-07-25 | 1件のコメント | WhatsAppで共有
  • メモリ安全性スレッド安全性は切り離せる概念ではなく、スレッド安全性がなければ真のメモリ安全性は達成できない
  • Goのようなスレッド安全でない言語では、単なるスレッド問題だけでもメモリ安全性が破られうる
  • Javaなど一部の言語は並行性メモリモデルによってデータレースさえも定義済みの動作として扱い、言語レベルの安全性を確保している
  • Goはデータレースに脆弱で、実際のメモリ安全性侵害事例が存在する
  • 本当に重要視すべき性質はUndefined Behavior(未定義動作)の不在である

スレッド安全性なしにメモリ安全性は保証できない

概念の混同: メモリ安全性 vs スレッド安全性

  • 近年メモリ安全性が大きな注目を集めているが、実際に何を意味するのか定義は明確ではない
  • 伝統的にメモリ安全性とは、use-after-freeout-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件のコメント

 
GN⁺ 2025-07-25
Hacker Newsの意見
  • Dropboxの自分のチームであったことだが、Goサーバーでデータ構造体に同期なしで書き込みをしていて、新しく入ってきたエンジニアが繰り返しsegfaultを起こすのが一種の通過儀礼になっていた
    Swiftでも同じ問題があり、Swiftが共有データ構造体にアクセスするとsegfaultを非常に起こしやすいことを示すプログラムを書いたことがある
    RustやJavaのような意味でGoがメモリセーフだと言うのは少し誇張だ
  • Swiftはこの問題を解決しようとしているが、現実世界にはすでに多くのunsafeなコードが存在するため、変化は非常に遅く苦しい
  • 気になるのだが、通常mapのような基本構造はスレッドセーフではないので、変更時には注意が必要だという点はGoの仕様にもよく書かれている
    Dropboxで起きた問題状況について詳しく聞いてみたい
  • ここで言う「RustやJavaの意味でのメモリセーフティ」は、厳密な意味での用語定義ではないという点を強調したい
    メモリセーフティはPLT(プログラミング言語理論)の概念というより、ソフトウェアセキュリティの用語だ
    結局Goプログラマーもこの違いは十分理解していて、だからGoは「共有によって通信するな、通信によって共有せよ」というアプローチを基本premiseとしている
    もちろん現実にはこのコンセプトは十分には実現されておらず、Goも現代的には共有が多く、同期が必要だということを皆理解している
  • 観点を定めるために、Goでメモリセーフでない変種ケースがどれほどあるのか、あるいはGoプログラムが実際にメモリセーフでない確率がどれほどなのか、自問してみたい
  • JavaもRustの意味ほどにはメモリセーフではない
  • この問題はしばしばRustのsoundness holeの問題にも似た形で繰り返し出てくるもので、無意味な問題では決してないが、偶然遭遇する可能性はかなり低い
    実際、Goを何年も運用してきて、この種のバグが実際に発生したことはほとんどないと思う
    UberがGoコードで発生したバグを詳しくまとめており、この記事では問題が実際にどの程度頻繁に起きるのかを表で整理している
    Goでの大半の同時mapまたはsliceアクセス問題は同じスライスに対して発生し、「torn read」現象が必要になるため、実際にはまれだ
    それでも人々がこうした問題をうまく避けている理由は、たぶん普通は十分に注意していて、変数を同時アクセス状況で再代入する危険性をよく認識しているからだと思う
    言語自体にatomics、channel、mutexがあるので、実際には同時アクセス状況で誤用するケースは少なく、race detectorもあるので、こうした問題があればすぐ見つけられる
    性能低下があったとしても、torn readの問題は単純に修正できる問題だと思うし、実運用中のGoコードでは大きな問題ではなかった
    関連動画
  • Goでデータレースのバグを捕まえるのに数か月かかった経験がある
    レースディテクタも何も見つけられず、誰も何が起きているのか理解できなかった
    結局、ループカウンタがオーバーフローして同じ計算を膨大に繰り返し、リクエストが時々100msではなく3分かかる現象が発生していた
    本番環境でperfを使って間接的に問題を知ることができ、プラットフォーム開発者としてのデバッグ経験がチームに大いに役立った
    さまざまなGoのレース状況に数多くさらされてきたので、個人的にはRustがあらゆる場所に導入されてほしいという気持ちだ
  • Rustのメンテナーたちもsoundness holeをバグとして認めている
    たとえばこのissueは、コンパイラの大規模なリファクタリングが必要で時間がかかっている
  • UberがGoプログラムはJavaマイクロサービスに比べて「8倍多くの並行性を露出する」と言っているが、ここで並行性を可算名詞のように使うのはどういう意味なのか気になる
  • Zigもメモリセーフだと主張しているが、RustのSend/Sync型のような概念がない
    実際にはまだ並行Zigコードが少ないため問題が大きく表面化していないだけで、今後async機能がより広く使われれば、多くの問題が一気に噴出するかもしれないと思う
  • ReleaseSafeでビルドしたシングルスレッドのZigプログラムでさえ、たとえばローカル変数の寿命が切れたポインタを逆参照すると、すべての最適化モードにおいてメモリ破壊の危険から自由ではない
  • Zigのメモリセーフティ主張は冗談に近い
    もちろんCよりはバグが減るが、これはC++でも同じで、誰もC++がメモリセーフだとは言わない
  • 実際のコードで、悪意をもって設計されたのでない限り、データレースによる脆弱性を持つGoコードは見たことがない
    もちろんそれが危険自体が完全にないことを意味するわけではないが、Goアプリケーションのセキュリティ面では優先度の高い問題ではない可能性を示している
    一方でC/C++コードでは、現実の脆弱性の60〜75%がメモリセーフティ問題から生じている
    メモリセーフティも連続体であり、ある水準を超えると効用が逓減すると考えている
  • 実際にデータレースが原因で脆弱になったGoコードを見た経験がある
  • メンテナンスの苦痛のほうがCVEよりはるかに大きいと感じている
    悪用不能なバグであっても、バグは結局直さなければならない
    初期開発より保守にずっと多くの時間がかかるので、保守を減らせるなら初期リリースが遅れても価値があると思う
  • メモリセーフティが重要なのは、たいていのCプログラムのCVEがメモリセーフティバグから生じるためだ
    一方Goでは、スレッドセーフティがCVEの主要因ではない
    理論的には根拠があるが、現実では大きく目立たない
  • 実際にはスレッドで何ができるのかが重要だ
    メモリを共有するとき、データ構造体を壊せば、別のスレッドでunsafeあるいは誤った動作が起こりうる
    たとえばあるスレッドでベクタのサイズを変えている間に別のスレッドがアクセスすると、逐次実行では安全な操作でも並行性の下では危険になる
    Goもこれを免れない
  • Cの典型的なメモリセーフティ問題はRCE(リモートコード実行)につながる可能性が高い
    一方、スレッドセーフティ問題がsegfaultで終わるなら、単なるDoS(サービス拒否)攻撃にとどまることもある
    レースコンディションがより強力な攻撃につながることもあるが、トリガーするのははるかに難しい
  • CVEのほうがより致命的ではあるが、スレッディングバグによるデータ破壊やクラッシュも、結局は誰かがトリアージし、分析し、修正しなければならないバグだ
  • たいていのスレッドを使う言語が、グローバル変数と無制限の共有メモリアクセスをデフォルトで提供しているのは悲しい現実だ
    これがデータ破壊とレースの主因だ
    多くの状況ではプロセスベースのほうがスレッドより良い並行性モデルだが、重すぎるという欠点がある
    もし各スレッドに必要なデータをすべてメッセージパッシングで渡すのが基本だったなら、こうした問題の大半は消えていたと思う
    いずれにせよ、我々はプラットフォーム上でグローバル変数と共有メモリを使う自由があるのだから、自分で使わなければよい
  • Rustはスレッドセーフティを型システムに組み込める代表的な現代言語だ
    Rustの元々の目標はメモリセーフなシステム言語ではなく、スレッドセーフなシステム言語であり、メモリセーフティは自然についてきた結果だった
    Rustでは構造化並行性をthread::scopeなどで使え、スレッド作業が非常にやりやすい
  • メッセージパッシングはメモリ共有より論理的問題(レースコンディションやデッドロックなど)をむしろ多く引き起こすことがあるので、万能の解決策ではない
  • Goでは直接的なメモリ共有より、goroutine間通信(channelなど)を重視する傾向が強い
    この文書参照
  • goroutine間でchannelを通じてオブジェクトを渡しても、Goにはsendable型、所有権、read-only参照のような概念がないため、安全に書くのは簡単ではない
    実例:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    上のコードでbuf.Bytes()は内部メモリをそのまま参照して渡しており、Reset()の呼び出しでbacking memoryが再利用されるため、processDataとmainの両方が同時に同じメモリへアクセスすることになり、データレースが発生する
    Rustではこのようなコードは2つのmutable referenceになるため、そもそもコンパイルされず、所有権移動かコピーを強制される
    Goでは紛らわしく、bytes.Buffer.ReadBytes("\n").String()はコピーを返すので安全だが、.Bytes()はこのように危険だ
    Rustのchannelはこの問題を所有権/転送の概念によって根本的に防ぐが、Goにはこうした安全装置がない
    結果としてmutexより遅く、Go初心者には正しく使うのがより難しい体験を与えているように思う
  • 実際のgolangプログラムでは、「共有による通信」パターンが論理的問題を大量に発生させ、結局メモリ共有が一般的になる
    つまり、「安全な」レースや「安全な」デッドロックのほうがむしろ一般的になる
  • 並行性バグの議論は、たいていのアプリで本当に重要な大半のバグが、DB内部でロック、トランザクション、トランザクション分離レベルなどを誤って適用したことで生じるという問題を無視しがちだ
    PL理論ではRustのレースフリーダムへのアプローチが魅力的かもしれないが、現実のアプリではどうせ重要なデータはすべてRDBMSにあり、たとえばSELECTにFOR UPDATEを使わなければレースはいくらでも起きる
    Rustアプリがunsafeをまったく使わなくても、DB次第でレースは依然として存在する
  • 「メモリセーフティ」という用語はもともと複雑な概念を説明するために登場したが、時間が経つにつれて意味が拡張されたり縮小されたりしてきた
    Goはメモリ破壊バグをほとんど許さない構造であることが、実際にexploitがほぼ存在しないことからわかる
    この記事の主張に従うなら、多くの高級言語(記事ではJavaだけ例外扱いしている)もメモリセーフではないことになる
    RustがGoより「より」安全でありうるとしても、「memory safety」は連続的スペクトラムではなく合格/不合格の概念だ
    もしある言語がメモリunsafeだと主張するなら、POCを必ず示すべきだ
  • メモリセーフティという用語で重要なのが「型混同(type confusion)」なら、Goも例外ではない
    記事に出てきた例は、intをポインタと誤認することでメモリ破壊が容易に起こりうることを示している
    デモではわざと42を使ってsegfaultにしているが、実アドレス値を使っていれば本物の破壊が起きる
  • データレースは、言語仕様が認識しない状態(たとえばSIGSEGVによる強制終了)にプログラムが陥りうるので、メモリセーフティ違反である
    したがってデータレースが起こりうる言語はメモリセーフだとは言えない
  • 記事で挙げられているように、型混同によるfat pointerのtorn readや、sliceのtorn readによるout-of-bounds writeも実現可能だ
    このようなケースでメモリセーフだと呼べるのか疑問だ
  • 用語が発展し意味が変わることは、数学や物理でもよくある
    こうした問題を避けるために「ガウス曲率(Gaussian Curvature)」「リーマン積分(Riemann Integrals)」のように人名を付ける場合がある
    「初期の意味を狭く残したまま、広い意味へ拡張された例」は「Galois Group」のように存在する
    このようにメモリセーフティも例外ではない
  • 著者の定義に従うとJavaがメモリunsafeだという根拠が気になる
    具体例を求めたい
  • Go自体も公式にはメモリセーフティの定義が曖昧だ
    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を説明なしにメモリセーフ言語と呼ばないほうが望ましい
  • memory safetyの定義も時代によって少し変わる
    Goが作られた当時は「ガベージコレクタがあればメモリセーフ」という見方が主流で、C/C++と比べればはるかにセーフだった