Have an amazing solution built in RAD Studio? Let us know. Looking for discounts? Visit our Special Offers page!
C++RAD Studio

C++Builder 12.3で安全性を確保: サニタイザーの導入

強化されたClangコンパイラの使用を開始して以来、エンバカデロへ一貫して寄せられている要望があります。それは「実行時チェック機能を追加してほしい」というものです。

この要望は、2つのユーザー層から出ています。

  • 1つは、従来の「クラシック」コンパイラを使っていたユーザーです。この旧コンパイラには「CodeGuard」という、エラーを検出する機能があり、多くのユーザーがそれを頼りにしていました。新しいツールチェーンに移行する際に、それと同等の機能がほしいというのはごく自然な願いです。
  • もう1つは、Clangに精通しているユーザーです。Clangには「サニタイザー(sanitizer)」と呼ばれる、さまざまな種類の実行時チェック機能が標準で備わっており、それが非常に役立つため、これらの導入を求める声が多くありました。

これまでのClangベースのツールチェーンでは、技術的な制約によりサニタイザーは利用できませんでした。しかし、バージョン12.2で導入され、12.3で品質とパフォーマンスがさらに改善された「C++モダンツールチェーン」では、これまで実現できなかった多くの機能が可能になっています。エンバカデロでは、これを「未来への基盤」と位置付けてきました。「これがあれば、今までできなかったことができる」「ユーザーに真の価値を届けられる」と。

そして今、それが実現されつつあります。

前回のリリースでは、CMakeのサポートとコンパイラ性能の大幅向上(今回はさらに最大20%のパフォーマンス改善)が注目されました。今回のリリースでは、それに加えて、これまで提供できなかった新機能が複数追加されています。

そのひとつが「サニタイザーによる実行時チェック」です。C++BuilderおよびRAD Studio 12.3で、ついにサニタイザーが利用可能になりました。今回新しく搭載されたのは、「Addressサニタイザー」と「Undefined Behaviorサニタイザー」という2種類のサニタイザーです。このブログではその両方を見てみましょう。

1 31

Addressサニタイザー

このサニタイザは「asan」と呼ばれており、一般的なメモリアクセスの問題(バッファオーバーフロー、ヌルポインタ参照、二重解放など)を検出するツールです。ヒープ領域とスタック領域の両方を追跡できます。

技術的な理由により、Asanを使うときはアプリをコマンドラインから実行する必要があります。Asanは標準エラー出力(stderr)にエラー情報を書き込むため、通常のGUIアプリではログが表示されません。以下のように実行してください:

myapp 2> asanlog.txt

2> はstderrを「asanlog.txt」ファイルにリダイレクトするという意味です。エラーが発生すると、その内容がログに書き込まれ、アプリは強制終了されます。

たとえば、次のようなコードには、見落としやすいバグを含んでいます。

このコードは、割り当てられていないメモリ領域に書き込もうとするのですが、実行時にアプリケーションがクラッシュしない可能性もあるため、通常は気づくことができません。

Addressサニタイザーをプロジェクトオプション([ビルド] → [C++コンパイラ] → [安全性]])から有効化し、コマンドラインから上記の形式でアプリを実行すると、ログファイルにエラー情報が記録され、アプリが終了します。

多くの情報が出力されるため、ここでは全体の出力から一部を選んで引用します。

エラーが発生しています。Asan(AddressSanitizer)が関与しており、バッファオーバーフロー(割り当てられたメモリの末尾を越えてアクセス)です。0x11e8153a73a4 は、オーバーフローが検出された際にアクセスされていたアドレスです。プログラムの状態に関するいくつかの追加情報を経て、実際に何が起きたのかが表示されます。

最初のスレッド(T0)が、本来読み取るべきでない4バイトを読み込みました。次にコールスタックが表示されます。

この誤った4バイトの読み取りは Unit1.cpp の29行目で発生しています。上述したコード例の 「std::cout << “Accidental access:」のコード箇所です。

その後、さらに情報が出力されます。


これは、そのアドレスが有効な20バイトのメモリ領域([0x11e8153a7390,0x11e8153a73a4))の「右に0バイト」(つまり末尾直後)にあることを示しています。これはそのメモリが vector によって確保されたものであることが分かっています。また、「右に0バイト」という表現は少し奇妙ですが、要するに「末尾のすぐ後」を意味します。

補足として、vector の end() イテレータは最後の要素そのものを指すのではなく、その次を指しています。このバグの原因は、その点を開発者が誤解していたことにあります。

次に、メモリが割り当てられたときのコールスタック(ここでは簡潔にするため多くを省略)が表示されます。

コールスタックのエントリ #9 が、私たちのコードに入る箇所です。ファイル名と行番号が表示されており、上述したコード例の std::vector<int> のコード箇所で作成および初期化されています。

これで、何が起きたのかの概要、そのためのコールスタック、詳細な発生状況、すべての割り当てがどこで起きたのかがわかりました。

その後にサマリーとメモリダンプが表示されます。ここには「シャドウバイト」が表示されます。これは Asan が割り当てられたメモリについてのデータを保存する別のマップであり、つまり私たちがここで見ているのは不正なメモリ自体ではなく、そのメモリに関する情報です。

このシャドウメモリの出力から、メモリのどの部分が有効で、どこが未使用かが分かります。たとえば:

  • fa は「メモリ割り当て外(アクセスすべきでない領域)」を示し、
  • 00 は「8バイトすべてが有効なヒープメモリ」であることを意味します。
  • [04] は「8バイト中、先頭4バイトだけが使われている状態」を表します。

この出力では、有効なメモリが 8バイト×2+4バイト=20バイト 分あることが読み取れます。これは、4バイトの int 型を5つ持つ vector<int> にぴったり一致します。

このように、シャドウメモリの情報から「メモリの使われ方」を視覚的に把握でき、バッファをどこまで使っていたのかが分かります。今回のケースでは、vector の末尾すぐ後ろにアクセスしていたことが明らかであり、コールスタックや行番号の情報と合わせてend() を誤って参照解除したことが原因であると判断できます。

補足: シャドウメモリ構造の詳細を知るには?

より詳細な情報を探している場合は、以下の資料が有用です:

  • 公式論文(Google Research)
    AddressSanitizer: A Fast Address Sanity Checker (2012)
    → セクション 3 に「Shadow Memory Mapping」について詳しい説明があります。
  • LLVM Compiler-rt ソースコード内のコメントや実装
    例: compiler-rt/lib/asan/asan_mapping.h

これらを参考にすることで、「1バイトのシャドウメモリが8バイトの実メモリを監視する」構造や、fa, 00, [04] などのフラグの意味がより詳しく理解できます。

Addressサニタイザはこうした種類の問題を検出してくれます。とても優れたツールで、非常に役立ちます。

Undefined Behaviourサニタイザー

C++ には未定義動作(undefined behaviour)と呼ばれるものが多数存在し、これが発生すると、コンパイラはそのコードを無効とみなして、どのような動作をしても構わないことになります。最適化を行うために、コンパイラが未定義動作に対して、意図的にクラッシュを引き起こすような「トラップ」を埋め込むこともあります。これは、ある状態が常に成立するという前提を得て、より積極的な最適化を行うためです。このようなトラップが生成される具体的なケースについて、ブログで取り上げたことがあります。

こうしたトラップの存在は一見驚くべきものに感じられるかもしれませんが、たとえトラップが挿入されていなくても、未定義動作が実際にどのような結果を引き起こすかは予測できません。そのため、未定義動作を事前に検出するためのコンパイラの警告機能についても別のブログの「コンパイラのセーフティオプションを確認」で紹介しています。

もっとも重要なのは、コードを実際に動かしたときに、こうした未定義動作をどのように検出するかという点です。そのための手段として、Undefined Behavior サニタイザー(UBSan)があります。

未定義動作の代表的な例には、以下のようなものがあります。

  • ヌルポインタの参照解除
    読み書き時には AddressSanitizer によって検出されることがありますが、そもそもヌルポインタを参照解除しようとする行為そのものが未定義動作であり、UBSan の対象となります。
  • 不正なアライメントでのメモリアクセス
    たとえば、ポインタ演算が誤っていて、適切にアラインされていないアドレスにアクセスしてしまうケース。
  • 不適切な型変換
    以下のコードのように、実際には派生クラスでないオブジェクトを動的キャストする場合など。

この例では、Base 型のオブジェクトを Derived* にキャストしていますが、b は実際には Derived 型ではないため、動的キャストは未定義動作になります。また、キャスト結果に対して hello() を呼び出している部分も、実際にはそのメソッドが存在しないため、未定義動作です。hello() の呼び出しは、最適化によってコードが取り除かれないようにするための意図的な処理です。

このようなコードは、IDE(統合開発環境)内からそのまま実行することができますが、実行時に例外が発生するわけではないため、注意が必要です。動作の内容は、イベントウィンドウにログとして記録されます。見逃さないためには、イベントウィンドウを常に表示しておくことをおすすめします。デフォルトのレイアウトを使っている場合でも、アプリケーションの実行中や終了後にログがスクロールされる様子が目に入るようにしておくとよいでしょう。

下図は、一例です。

undefined behaviour sanitizer in 12 3

この出力例の単体では情報量が少ないものの、周囲には関連する他のメッセージも出ており、ここでは簡潔にするために省略しています。この箇所では、UBSan によって vtable に関する潜在的な問題が検出されています。これは、オブジェクトのキャストが正しく行われていなかったことが原因です。

その後、メソッドを実際に呼び出すと、さらなる未定義動作が発生し、それも検出されています。実際、このメッセージの後に続く出力でそれが確認できます。

サニタイザー は使いこなすと、 開発中に潜在的なバグを見つけて潰す強力な味方になってくれます。

サニタイザーの同時使用

新しいサニタイザーは、同時には使わずに排他的に有効化することを推奨しています。これは Clangサニタイザーに関する一般的な推奨事項です。一部のオンライン情報ではAsanとUBSanを同時に有効化できると言及していますが、エンバカデロではこのシナリオをテストしていません。

なお、サニタイザーを有効または無効に切り替える際には、プロジェクト全体のクリーンビルド(フルリビルド)を行うことが望ましいです。

また、新しいツールチェーンで PDB 形式のデバッグ情報を使った場合に、複数のサードパーティ製ツールが問題なく動作しているという報告も、エンバカデロのMVP(最優秀開発者)から数多く寄せられています。こうした互換性は、新しいツールチェーンによって実現された好例と言えるでしょう。

サニタイザーを使うべき場面

サニタイザーの機能は、デバッグ時のみ有効にすることをおすすめいたします。

これらのサニタイザーは実行時チェックとして動作し、アプリケーション内に組み込まれます。そのため、問題が検出されるとアプリが即座に終了することがあります。また、パフォーマンスに影響を与える可能性があり、思わぬ副作用を引き起こす場合もあります。さらに、攻撃対象領域(attack surface)が広がる可能性があるという報告もあるため、インターネットに接続されていない非本番環境で実行するようにしてください。ユーザー向けに出荷する「リリースビルド」に、これらのサニタイザーを有効にしたまま含めるべきではありません。

一方で、アプリを動かしながらエラーを検出する目的には非常に有効です。

そのため、これらの点を踏まえたうえで、安全な開発・デバッグ環境では積極的に活用してください。デバッグビルドでは定期的にサニタイザをオンにし、ユニットテストや統合テストをサニタイザーを有効にした状態で実行するのが望ましいです。新しい機能の検証時にも使えますし、継続的インテグレーション(CI)環境でもサニタイザを有効にしたビルドを行うとよいでしょう。

そうすることで、さまざまな問題を早期に検出でき、アプリケーションの堅牢性や安全性、品質の向上につながります。

まとめ

サニタイザーを使用するメリットをまとめると、以下の通りです。

  • 実行時に深刻なメモリバグを即検出(Address サニタイザー)
    • Addressサニタイザー(Asan)は、バッファオーバーランやヌルポインタ参照、二重解放といった、重大なメモリ不具合を実行中に自動で検出してくれるツールです。ヒープだけでなくスタック領域のアクセスも監視しており、通常では見逃されがちな“静かに発生するバグ”を確実に見つけ出します。特にクラッシュせずに動いてしまうようなコードでも、Asanなら問題を早期に明らかにできます。
  • 未定義動作を見える化して、予測不能なバグを潰す(Undefined Behaviourサニタイザー)
    • Undefined Behaviourサニタイザー(UBSan)は、C++で発生しやすい「不正なキャスト」や「アライメントのずれ」など、振る舞いが保証されない危険なコードを実行時に検出し、ログに警告を出してくれます。未定義動作は、特定条件下で突然クラッシュや誤動作につながるため、見つけにくく再現性も低いのが特徴です。UBSanを使えば、こうした曖昧なコードのリスクを事前に可視化し、安定性の高いアプリ開発が可能になります。
  • エラー発生箇所を詳細にログ出力、原因の特定がスムーズ
    • サニタイザーが検出した問題には、発生したソースコードの行番号やファイル名、スタックトレース、メモリの割り当て元といった詳細な情報が付随します。これにより、どの処理で問題が起きたのか、なぜ起きたのかをすぐに把握することができ、原因の特定と修正が非常にスムーズになります。特に、再現が難しいバグや開発終盤で見つかった問題にも対応しやすく、デバッグ効率の大幅な向上につながります。

関連情報(docwiki)

2つのサニタイザーについては、Clangの公式ドキュメントを参照してください。

See What's New in 12.2 Athens See What's New in 12.3 Athens Dev Days of Summer 2-24

Reduce development time and get to market faster with RAD Studio, Delphi, or C++Builder.
Design. Code. Compile. Deploy.
Start Free Trial   Upgrade Today

   Free Delphi Community Edition   Free C++Builder Community Edition

This site uses Akismet to reduce spam. Learn how your comment data is processed.

IN THE ARTICLES