「マジかよ」GTAサンアンドレアスの20年前のバグがWindows 11最新版で発覚した件
引用元:https://news.ycombinator.com/item?id=43772311
これぞRaymond Chenって感じのことだよねー。マジで最高の褒め言葉!なんでそうなったのか、さらに深く追求して解明してくれたのが嬉しいな。
randomasciiもそうかもね。マジでレジェンドだよ(でも最近、辛いことが続いてるみたい…。良い方向に向かうといいな)。
>https://randomascii.wordpress.com/2024/10/01/life-death-and-…
>https://randomascii.wordpress.com/2016/10/17/vestibular-dysf…
これ読むたびにミニ中年クライシスになってたの思い出すわー。
今回はちゃんと記録したんだよねー…はぁ…
>https://github.com/MatthewJohn/terrareg/commit/2231ba733a7f5…
数年付き合ってる相手と先週末に一緒に住み始めたんだけど、彼の奥さんが9週間で亡くなったっていうブログ記事を読んで、マジでゾッとした…抱きしめてほしい。
マジか…。読むのが辛かった。彼は才能があって尊敬できる人だよね。これから良い方向に進むことを願ってる。
Raymondはマジで魔法使いだよ。彼のブログを長年読んでるけど、彼のスタイルと知識が大好き。
彼はマジでレジェンドなのに、Dave’s GarageのYouTubeチャンネルのインタビューで言ってたけど、Bill Gatesに一度も会ったことがないらしい。会社で長年活躍してる人なら、彼が出席する会社のディナーに招待されてもおかしくないのにね。
Microsoftってでかい会社だし、billgは2000年に一線を退いたんだよね。Raymondはまだ働いてるから、思ってるほど重なってないんじゃない?
彼のブログにはWindows 2の話も載ってるみたいだから、まだ比較的小さかった頃には重なりがあったんだよね。だから、彼らが一度も話したり会ったりしたことがないっていうのは、ちょっと不思議な気がするな。
細かいことだけど、スクリーンショットじゃなくて、実際にコードを書いて例を示してくれるのがすごくいいよね。例えば、https://devblogs.microsoft.com/oldnewthing/20250414-00/?p=11… もっといい例もたくさんあるけど、これは最近見たやつ。
Raymondは何でも知ってるんだね。Alpha AXPのマイクロコードのバグから、テンプレートメタプログラミング、UIまで。
Deloitte、PwC、KPMG、Bain、EY、McKinsey、BCGのコンサルタントが、彼をTop Xのリストに入れたりして、何度か無邪気に彼を『影響を受けた人』の候補に挙げようとしたんだろうね。Yでソートされたスプレッドシートの上位にいたからって。
>こいつの仕事はブログを書くことみたいだな。
>AIで代用して、新しいVisual Enshitify 2.0の製品発表を定期的に宣伝させればいいじゃん。Win win win!ってか?
思うんだけど、契約の一部じゃないものはランダム化されるべきだよね。例えば、マップの反復順序が言語で保証されてないなら、言語はわざとそれをランダム化するべき。そうしないと、脆いコードになっちゃう。動かなくなるまで動くコードね。
未初期化変数を特定の値(またはランダムな値)で初期化するためのコンパイラオプション(-ftrivial-auto-var-initなど)はいろいろあるけど、関数呼び出しごとにスタックの内容全体をランダム化(またはゼロ化)すると、パフォーマンスが著しく低下するから、そんなことはしないんだ。
高速な命令(REP STOSx、AVX zero stores、dc zvaなど)やトリック(MTE、zero pages)はあるけど、関数呼び出し時にスタックを透過的かつ効率的にランダム化またはゼロ化する魔法のようなCPU命令は存在しないんだよね。そういうのがあってもいいと思うし、一部の特殊な高セキュリティシステムにはあると思うけど、どこで見つけられるかわからないな。Telecomには絶対ない。
そういうふうに動作するCPUアーキテクチャも提案されてるよ。例えば、Mill https://millcomputing.com/。ほとんどのCPUが複数の呼び出し規約をサポートしているのに対して、Millはハードウェアで単一の呼び出し規約を強制してるんだ。ハードウェアにはcall
命令があって、すべての作業を直接行うし、関数呼び出しから戻るためのret
命令もある。TLB相当のものを使って、各関数が引数を含むスタックの部分からのみ読み取る許可を与えられてることを保証してる。その領域外を読み取ろうとすると、NaR(Not a Result、浮動小数点NaNに似てる)を返すアクセス許可エラーが発生する。
さらに、新しいスタックフレームは作成時に暗黙的にゼロ化される。これは、呼び出された関数の実行を続行する前に、それらのアドレスに対してCPUキャッシュをゼロで埋めることによって行われると思う。実際のゼロがメインメモリに書き込まれるのを待つ必要はないんだ。
https://millcomputing.com/wiki/Protection#Protecting_Stacks
これマジで興味深いんだけどー。この設計だとスタック参照ってどういう仕組みになってんの?
厳密に言うとスタック全体を読めると思うよ。制限されてるのはスタックの端っこの読み取りだけじゃね? ただ、今のスタックの始まりが本当のスタックの始まりとは限らないから注意して。例えばread
みたいなシステムコールの場合。
ユーザー空間にいて、普通にスタックフレームがあるじゃん。スタックにバッファを確保して(CPU命令がある)、そこに読み込みたいデータを置く。んで、read
をcall
命令で呼ぶ。引数にはバッファのアドレスとかサイズとか渡す。read
はカーネルの一部だから、違う保護ドメインにいるんだよね。CPUが事前に設定されたメタデータを使って、これを”ポータルコール”に変える。ポータルコールの後、スレッドには別の保護ドメインが与えられる。read
から見ると、スタックは始まったばかりで、前のフレームはない。実際には、このスタックフレームは呼び出し元のスタックの一部だけど、アクセスできる範囲が変わってる。前のスタックフレームは存在するけど、読めない。バッファも読めない。カーネルのアクセス範囲にないアドレスにあるから。
だから、アクセス範囲を変更するための命令が必要になる。pass
命令を使うと、呼び出す関数に一時的にバッファへの読み書き権限を渡せる。コールバック後は自動的に権限が取り消される。(毎回手動でやる必要はない。libcにある非ポータルのread
関数を呼ぶ。この関数がポータルコールを担当してて、pass
命令も入れる。) ¹ turfは、ある保護ドメインで実行されているスレッドが読み書きできるアドレスの集合。
ランダムにはできないけど、メモリ、キャッシュ、書き込み行の使用にパフォーマンスの低下があることを前提にすると、プログラム、ライブラリなどのためにスタックアドレスを分離できるかも。ただ、コストがかかる。コンテキストスイッチごとに独自のスタックが必要になり、もう1つレジスタをプッシュ/復元する必要がある。プログラムがそのように動作せず、適切に初期化された(後で上書きされない)メモリ外の値に依存しないことになっているのには、ちゃんとした理由がある。 効率的であるべきだけどね。それがポイント。専用のハードウェアまたは命令を使用すると、スタックを1サイクルでゼロにできるはず。そうじゃなくて、今はもっとコストがかかる。ただ、これだと何かを隠すのに使えちゃう可能性もあるから、未知のエクスプロイトをリバースエンジニアリングできなくなるかも。 なんで専用の命令が必要なの? スタックは他のものと同じようにメモリに保存されてるじゃん。 CPUはすでに ちょっと疎遠になっちゃってるんだけど、C++26には MicrosoftのVisual C++コンパイラには、 このレベルでのランダム化はコストが高すぎる。デバッグ目的でこれを行うツールはあるけど、そのモードだと処理がめっちゃ遅くなる。 Perlについて読んだ記事を見つけるためにググった。これはdictのイテレーション順序に影響すると思う。 それってリリース版に入れるべきじゃないよね、マジで。誰かの”セット”が実はシーケンス(重複あり)で、デバッグビルドでアサートが発生したら、日の目を見ずに終わるバグって結構あるんじゃないかな。 デバッグビルドって問題を見つけるのに役立たないよね。誰が実際に使うのさ?開発者がバグを再現しようとするときに、作業中の個々のバイナリのデバッグビルドを使うくらいじゃない?Windowsチーム含め、どの会社でもデバッグビルドを普段使いする人なんて見たことないよ。 特にゲームだと、最適化されたリリースビルドじゃないとまともに動かないのが普通だよね。 だよねー、統合テストですらoptモードで実行されそう。 契約についてだけど、ここから学べる教訓があるよ。引用すると”内部実装のスタックレイアウトの変更でさえ、アプリケーションにバグがあって、意図せず特定の挙動に依存している場合、互換性に影響を与える可能性がある” でも、ここでのLinuxで言うならカーネルじゃなくてglibcじゃない? 違うよ。hyrumslaw.comを思い出して。 ちょっと似てる話。GoogleのSite Reliability Engineeringの本に載ってたんだけど、あるシステムのパフォーマンスがSLOを超えてたせいで、みんなが100%の稼働率を期待するようになっちゃったんだって。それで、SLAに近づけるためにエラーを発生させて、下流のエンジニアにサービスが理由もなく失敗することがあるってことを受け入れさせたんだとか。どこでもできるわけじゃないけど、面白いと思った。 >If you promise randomization そこがポイントじゃないんだ。実際、ランダム性を提供したら、誰かに依存されるってこと。 それってなんで?単にコーディングの悪い癖が原因? 全部コーディングの悪い癖だよ。だからこうなってるんだって。 ランダム化しないようにランダムにできるってこと? Cみたいな言語の利点の一つは、使う機能にだけコストを払えばよくて、未使用変数の初期化みたいな不要なオーバーヘッドがないこと、って意見もあるかもね。 デバッグモードとかカオスモンキーモードならそういう機能にお金を払ってもいいんじゃない?リリースモードでは払わなくていいし。Rustも同じで、整数のオーバーフローはデバッグモードでは完全にチェックされるけど、リリースモードでは黙ってラップアラウンドするよ。 Adaだと、整数のオーバーフローチェック(実行時)にお金を払うこともできるよ。Ada SPARKを使えば、コードに整数のオーバーフローがないことを証明できるから、実行時チェックは不要になる。 Adaの場合はフラグでチェックを無効にできるし、SPARKなら実行時には何も起こらないよ。 それだと、リストのランダム化に実行時のクロックサイクルを無駄にしてるってことだよ。 まともな言語ならリストのイテレータはリストの順序に従うように設計するでしょ。問題は順序のないハッシュベースのセットやマップ/辞書をイテレートする場合だよ。多くの言語はイテレーションの順序を未定義にしてる。Pythonは途中までそうだったけど、その後辞書(セットは違う)はキーが追加された順にイテレートするように定義したんだ。あと、意図的にプログラムの実行ごとに順序をランダム化して、ユーザーがハッシュテーブルに衝突するキーを詰め込むのを防ぐ言語もある。 最高の変更だよね。セットも順序付けされてたらもっと良かったのに。 C/C++の精神とはちょっと違うかもね。でも、このバグはデバッグビルドで簡単に見つかるはずだよ(20年前でも)。ただ、ゲームだとデバッグビルドは遅すぎて使えなかったのかも。俺のゲーム開発の経験ではそうだった。でも、20年も持つコードは十分すごいけどね! それって別の契約を作るってことにならない?ユーザーがそれがランダムであることを前提にコードを書くかもしれないじゃん。 Perl 5.8の1mloc弱のコードをPerl 5.32(くらい)で動くようにアップデートしたことがあるんだけど、意外と問題は少なかったんだよね。その問題の一つがまさにこれで、ハッシュのイテレーション順序が定義されてないってこと。Perl 5.8では挿入順序が同じならイテレーション順序も同じだったけど、後のバージョンでは意図的にランダム化されたんだ。 booking.comでの話? 確か、Goは意図的にmapの順序をランダムにしてるんだよね。まさに同じ理由で。 そうそう。で、再現できないクラッシュレポートが上がってくるんだ。 >コンパイルの警告を無視しないこと–このコードは、元のコードで警告を発していた可能性が高く、無視または無効にされていました! g++ version 11.4で試したけど、sscanfの戻り値をチェックしなくてもデフォルトでは警告は出ないね。 初期化されていないメモリにアクセスするのは未定義動作だよね。サニタイザーならそれを検出できたはず。 コンパイラはメモリが未定義かどうかを知ることはできないよ。データファイルを検証できない限りね。せいぜい、scanfの戻り値をチェックしてないことを警告するくらいだろうけど、それも違うかも。ファイルの終端をチェックしてる可能性もあるし。一致したパラメータの数をチェックしてなかったんだよ。scanfのセマンティクスを考えると見逃しやすいエラーだよね。 コンパイラがメモリ未定義だってわかるはずないって?そんなことないよ! clang ASANで試したけど何も起きなかったよ。このバグは検出できないみたい。ASANは間違った挙動は検出するけど、正しい挙動がないのは検出できないんだ。 調査ありがとう。MSan(memory sanitizer)が未初期化リードを検出するのに適したツールみたいだね。 どっちも正しいかもね。ASANがscanfとかの標準ライブラリ関数をinstrumentしてないのかも。でも2015年からはやってるはず。 問題は、scanf()の後に定義される変数の数が可変だってこと。 「ローカル変数は宣言時に初期化されてないといけない」ってことは、 初期化のコストが高いケースのために、unsafeなインスタンス化を許可するキーワードがあってもいいと思う。でも、基本的には安全な方がいいよね。 ASANがscanfを置き換えて、実行時に入力が指示する任意のメモリアドレスへの書き込み時に追加のブックキーピングをするってことじゃない? 初期化されてない変数って、マジでよくあるよねー。 初期化されてない変数のポインタがscanfに渡されて、エラーがなければそこに値が書き込まれるってわけか。コンパイラはscanfの宣言だけじゃ、この挙動を理解できないんだね。 デバッグはすごいけど、原因は意外と軽微なんだな。 なるほどねー。読んでるときは、初期化されてないメモリの使用に対する警告が拾うと思ってたわ。 こういう技術的な記事読むの、マジで好き。AI時代になったら、もっと減っちゃうのかなー。 そんなに減るとは思わないなー。常にトップレベルのエンジニアはディープダイブするでしょ。そう願いたいわ。 もしキャリア全体がスタックにほとんどデータを保存しない高水準言語を使うことばかりなら、プログラマが気にする必要ある?ツールの抽象化が進むのは普通のことじゃん。C/C++をたくさん書いたけど、アセンブリ言語なんて一度も必要なかったし。 一般的なソフトウェアエンジニアは職人からtrademensに変わると思うけど、この記事みたいなのは職人芸の極みだよね。 3ヶ月のブートキャンプ卒業の”ソフトウェアエンジニア”をよく見かけるようになったよね。CRUDアプリ作って50万ドル稼ぐのはもう無理かもね。 それって良いことじゃん マジかよ。3ヶ月のブートキャンプ卒業生がCRUDアプリ書いて50万ドルも稼げるわけないだろ。ありえねー JS/CSS/HTMLしか知らなくても、フロントエンドの開発者だってすごい奴いるじゃん。クロスブラウザとかプラットフォームの問題とか、パフォーマンスの調整とかで職人技を発揮できるし。 今のPythonのデベロッパーと60年代のFortranのデベロッパーを比べてみろよ。それくらいの距離感だろ。もっとかもな。でも、この流れは今に始まったことじゃない。 Windowsのこのバージョンで、クリティカルセクションのロック/アンロックの実装がどう変わったのか、そっちの方が気になるわ!もっとコメントを表示(1)
コストがかかるのは、CPUの速度に比べて、値をメモリに書き出すのがめちゃくちゃ遅いから。それと、その書き込みを他のキャッシュと同期させるオーバーヘッドもある。
もしかして、仮想メモリから物理メモリへのルックアップテーブルにある「すべてゼロ」の特別なページのこと考えてる? それも結局は、使われるなら実際に書き込む必要がある。xor reg,reg
をレジスタをゼロにする特殊なケースとして扱って、データ依存性を解消してる。スタックのビットをゼロにすることが一般的になれば、CPUが効率的に処理できるようになると思う(すでにスタックを特別扱いしてるし、push/popとか)-ftrivial-auto-var-init
みたいなのがデフォルトで有効になるみたい。[1]の”safe by default”のセクションを見てみて。
ちなみに、C++26に採用された実際の提案は[2]。パフォーマンスについては一般的にしか議論されてなくて、[3]で詳しく分析されてる。このリファレンスには、時間とコードサイズで約0.5%の低下って書いてある。初期のプロトタイプではもっと大きな低下(最悪の場合”horrendous”)が示唆されてたけど、コンパイラの最適化に力を入れたことで、かなり改善されたみたい。
もちろん、環境によって結果は違うと思うし、0.5%の低下を受け入れられない人もいるかも。でも、C++委員会は、頻繁に発生する未定義の動作を削除するために、許容できるトレードオフだと考えたみたい。/Ge
コンパイラオプションがある(https://learn.microsoft.com/en-us/cpp/build/reference/ge-ena… を見て)。VC2005から非推奨。
このコンパイラオプションを使うと、コンパイラはスタックプローブ関数を呼び出して、十分なスタック領域があることを確認する。
スタックページごとに1回プローブする代わりに、スタックフレームを特定の値(0xBAADF00Dみたいな)で埋める関数を代用できる。値を実行時に好きなように設定できる。
これはgcc/clangの-ftrivial-auto-var-init
と似たような動作になる。
Windowsは、Windowsカーネルや他のいくつかの領域で、ほとんどのスタック変数を自動的に初期化し始めた。
自動的に初期化される型:
スカラー(配列、ポインタ、float)
ポインタの配列
構造体(POD構造体)
自動的に初期化されないもの:
Volatile変数
ポインタ以外の配列(intの配列、構造体の配列など)
PODじゃないクラス
最初のテストで、スタック上のすべての型のデータを強制的に初期化したら、いくつかの主要なシナリオで10%以上のパフォーマンス低下が見られた。
POD構造体だけだと、パフォーマンスはもっとマシになった。コンパイラの最適化によって、基本的なブロック内とブロック間の両方で冗長なストアが排除され、POD構造体によるパフォーマンスの低下はほとんどのテストでノイズレベルまで低下した。
すべての型(特にオプティマイザーが強力になった)をゼロ初期化することを計画してるけど、まだ手が回ってない。
https://web.archive.org/web/20200518153645/https://msrc-blog… を見て
>2012年11月22日 — Perl 5.18では、プロセスごとのハッシュランダム化が導入され、ほぼ確実に新しいハッシュ関数が採用されます。
Linuxカーネルのメンテナがユーザースペースを壊さないように主張する理由もそこにあるんだろうね。
APIの利用者が十分に多い場合、契約で何を約束しようと関係ないんだ。システムで見られるすべての挙動は、誰かに依存されることになる。
ランダム化を約束したら、誰かがそれに依存するんだよ!そして、もう削除できなくなる!
そんなこと約束しないよ。順序は未定義だって言うだけ。もっとコメントを表示(2)
>Check the table at
>https://docs.adacore.com/spark2014-docs/html/ug/en/usage_sce…
>, look for “SPARK builds on the strengths of Ada to provide even more guarantees statically rather than dynamically.”.
>More reading:
>https://docs.adacore.com/spark2014-docs/html/ug/en/tutorial….
>https://learn.adacore.com
>(many books for learning Ada and SPARK) available in PDF, EPUB, and HTML format。
予測可能なハッシュキーのイテレーション順序を前提にしてるところがいくつかあって、テストが50%の確率で失敗してた。エラーを見つけるのがどれだけ面倒かっていうのは、テストでエラーが出る頻度と反比例するってことだね。(Perl 5はコミュニティに見捨てられてて、CPANモジュールが消えたり、古すぎて他のモジュールと連携できなくなったりって問題もあったけど)
どんなコンパイラエラーを期待する?scanfからの戻り値をチェックしてパラメータの数と一致することを確認しないとか?そうでなければ、これはコンパイラが手がかりを持たないデータファイルエラーのように思えます。g++ -Wall -Wextra -Wunused-result
でも小さい例では警告は出ない。-fsanitize=address
ってオプションは色々やってくれるんだ。シャドウメモリってのを確保して、メインメモリの定義状態を記録して、読み書きするアドレスを全部チェックするんだって。コンパイル時と実行時のチェックの組み合わせなんだね。もちろん重いから、デバッグ用でリリースには使わない方がいいね。
>https://clang.llvm.org/docs/AddressSanitizer.html”
>https://learn.microsoft.com/en-us/cpp/sanitizers/asan?view=m…”
use-after-freeとかuse-after-returnとかOOBアクセスとかじゃないしね。これは「確保されたスタック変数が実行時に初期化されずに読まれる」ってケースで、標準的なanalyzerじゃ無理だと思う。
一番いいのは、ローカル変数は全部初期化するってポリシーにするか(ゲームスタジオじゃ無理だろうけど)、-ftrivial-auto-var-init=pattern
みたいなスタック初期化を有効にしてデバッグすることかな。QAですぐ見つかると思う。もっとコメントを表示(3)
>https://stackoverflow.com/questions/68576464/clang-sanitizer… “
自分はUBSanとASanしか使わないから、他の人のコードの監査は知識不足かも。CとC++より新しい言語は、こういう設計ミスを繰り返してないし、後から導入された変なsanitizerツールも必要ないんだよね。
>https://github.com/google/sanitizers/issues/108 “
「ローカル変数は宣言時に初期化されてないといけない」ってルールでも、当時のツールで検出できたと思う(ちょっと強引だけど)。
例えば:
int x, y, z;
int n = scanf(”%d %d %d”, &x, &y, &z);
コンパイル時には、x, y, zのどれが定義されてるか推論できないんだ。nの値によるから。
解決策は色々あるけど、確実に代入するとか、複数の戻り値を許すとか(Pythonみたいに)かな。Javaみたいにスカラーしか返せないのはプログラマーにはつらいけどね。
int n = scanf(”%d %d %d”, &x, &y, &z);
って呼び出しが引っかかるってことだよね。許可するには、事前に変数を初期化する必要がある。
リンクしたPRで解決された問題はそれだと思うけど、確認してないや。
でも、一行全体がsscanfで解析されるから、コンパイラの静的解析は初期化されたと仮定せざるを得ないんだ。これ catching できるような一般的な静的解析のアプローチってなさそうだね。
scanf専用の警告を作って、初期化済みの値を渡すか、戻り値を確認するように強制するとか?
AIも過去50年以上のソフトウェア開発のイノベーションも、彼らをreplace しないと思う。スタックとヒープの違いを知らない開発者なんていっぱいいるし、気にしてない人もいるし。