動的解析によりソフトウェア脆弱性を効率的にデバッグ

 

本稿では、動的解析のコンセプトと、実行時エラーの確認、およびそれらが組込みアプリケーションの開発で有用な理由についてご紹介します。C-RUNはIARシステムズ製品ポートフォリオの中で、動的解析と実行時エラーの確認を便利に提供する製品です。ソフトウェア脆弱性への対策として有効です。

背景

ソフトウェアにエラーが含まれていることはご存知のとおりです。また開発の中で、しばしば過度に長い時間をテスト、とくに非常にトリッキーな問題のデバッグに対応するために使っていることもご存知でしょう。この状況は、いわゆる80対20の法則で知られています。開発期間の80%をコードの実装に、そして残り20%をテストとデバッグに使っています。という冗談はさておき、テストとデバッグの期間を削減できれば、堅牢なソフトウェアを早くリリースすることができます。

ソフトウェア脆弱性の基本

ソフトウェアのエラーについて話すとき、どのエラーについて話すのかを整理しておくのが便利です。本稿では、仕様のエラーについては取り扱いません。もちろん、それ自体が興味深いエラーであり、複雑な要件を収集・整理・作業・実装およびコードでテストする方法について多くのことが書かれています。しかし、ここでは、データ操作の問題、つまり、高レベルの仕様を実装する際に、より低いレベルでうまくいかない可能性があることに焦点をあてます。

最初に、既知および分類されたソフトウェア脆弱性の情報を収集するコミュニティの取り組みについて紹介します。この取り組みはCommon Weakness Enumerationと呼ばれ、SANS InstituteとMITRE Corporationが主催しており、cwe.mitre.orgで見ることができます。データベースは、世界中のソフトウェア分野から脆弱性に関する情報を集めています。大規模なサーバであったり、クライアント側のソフトウェアであったり、身近な組込み端末であったりを網羅します。リストアップされた問題のうち、私たちの日常生活にはあまり関係のないものがかなりあるのですが、2011年に発表された最も危険とされる25のソフトウェアエラーと、それに続く15のソフトウェアエラーのリストを見ると、非常に興味深いことがわかります。以下の表を見てみましょう。

convenient_runtime_analysis_1.png

このリストを見ると、C言語の知識が少しでもある人は、不安な気持ちになるかもしれません。突然のメモリ破壊や予期せぬ出力値の原因を追いかけるのに、数え切れないほどの時間を費やしたことがない人はいないでしょう。C言語の「ある意味で面白い」ところは、上記のような問題とそれ以上の問題が多かれ少なかれ言語に組込まれていることです。例えば、符号なし整数型変数をオーバーフローさせたり、ラップアラウンドさせたりすることは、完全に正当で、何かを達成するために最も効率的で便利な方法であることが多いのですが、時にはこの性質が私たちを苦しめることがあるのです。さらに、符号付き整数型変数をオーバーフローさせることは正当ではありませんが、コンパイラは、少なくとも最適化を低く抑えるかオフにする限り、オーバーフローに対して賢いコードを出力する可能性が高いです。例えば、大きな符号付き型から小さな符号付き型への代入は、オーバーフローや切り詰めがない限り問題ないことを利用することができる、という事実に頼るかもしれません。大きな型は再利用可能なAPIの一部であり、値が小さな型をオーバーフローすることはないと仮定しているが、数回先のAPIリビジョンでは、これはもはや真実ではないかもしれない。厄介なのは、オーバーフローしたり切り詰められたりした値が、自分たちのコードでは完全に正当な入力である場合があることです。したがって、コードが正しく動作しているように見え、テストではエラーに気づかないかもしれません。

ポインタは非常に便利です。基本的にはポインタは何でも指すようにでき、ポインタに対して種々の演算が可能です。また基本的にアクセス制御はハードウェアが例外を生成した場合のみという事実は、正しいプログラムを作る上であまり役には立たないのです。ヒープの管理の話が加わるとさらに複雑なります。

ストレイポインタ (不正な値を持つポインタ)を調査することは文字どおり永遠です。なぜなら、ストレイポインタを経由した書き込みは、その書き込みを実行したプログラムロジックとは全く関係のないデータにまで影響を及ぼす可能性があるからです。切り詰められたデータの読み取りと同様に、データが有効であると解釈できる場合、または読み取り自体がハードウェア例外を引き起こさない場合、不正な場所からのデータの読み取りは、テストにおいてエラーを引き起こさないかもしれません。

動的に割り当てたメモリを扱う場合にも同様の状況が発生します。例えば、すでにヒープに戻したブロックに書き込むことがあります。プログラムの別の部分が同じブロックまたはその一部を割り当て、後で読み返すことを想定して何かを書き込んでいるかもしれません。

convenient_runtime_analysis_2.png

ポインタとヒープにまつわる問題は特に厄介なものです。なぜなら、たった一度の境界を越えた書き込みがプログラムを不安定にしたり、外部の攻撃に対して隙を与えることがあるからです。例えば、あるプログラムがスタック上の固定サイズのバッファをオーバーランするようなバッファコピーを行うように仕向ければ、これを利用して悪意のあるコードの一部をスタック上にコピーすると同時に、正しい戻り先アドレスを自分の好きなアドレスに置き換えることができるかもしれないのです。上の表にあるように、このような攻撃はシステムを破壊する古典的な方法であり、実際CWE-120はSQLインジェクションやOSコマンドインジェクションの脆弱性でないリストの中で最も高いランクに位置する脆弱性です。

脆弱性の原因となる問題の分類

前述のとおり、このようなタイプの厄介なエラーは、単体テストと統合テストの両方で生き残るのが非常に得意です。というのも、悪い動作の引き金となるのは、プログラムの外部インタフェースにおける予期せぬ動作であることが多く、また、誤りのある状況が、システムに目に見える大混乱を引き起こすことなく発生する可能性があるからです。多くのプロジェクトでは、テストは機能仕様によって進められるため、想定するユースケースと関連するシナリオに重点が置かれます。さらに、稼働中のシステムで目に見えるエラーを誘発するようなネガティブテスト(無効なデータに対してアプリケーションを検証するテスト)を作成するのは、非常に大変な作業で時間がかかることが多いのです。このように、想定される入力に対する動作を確認するだけでなく、システムをクラッシュさせたり、危険にさらしたりすることを目的としたテストを設計し、コンフォートゾーンから抜け出すことは難しいことです。

今回取り上げたような問題の発見を支援するためには、どうすればよいのでしょうか。上記で取り上げたCWEの問題は、大きく分けて「演算の問題」「境界の問題」「ヒープチェック」の3つに分類されます。

演算の問題

このカテゴリには、オーバーフロー、ラップアラウンド、型変換エラー、ゼロ除算、そして奇妙なことにswitch文のデフォルトラベルの欠落が含まれます。これらのエラーは、インストゥルメンテーションを、エラーが起こりうる場所に挿入することで検出できます。ソースレベルのインストゥルメンテーションでは、しばしば条件をチェックするif文または同等のもの挿入し、さらにエラーのログを取るために標準出力に出力したり、ポートに特別な値を書き込んだりします。同様に、コンパイラは、条件をチェックする命令を挿入し、実行時に何かしらのレポートをすることができます。このようなチェックは、ソースコードで実装されるかコンパイラで実装されるかにかかわらず、比較的簡単に実行でき、一般にRAMの要件やスタックの深さに影響を与えることはありません。 コードサイズは、チェックする数に対して多かれ少なかれリニアに増加します。 例えば、コンパイラによるゼロ除算のチェックは、除算を行う前に除数を0と比較する程度のものであり、完全なチェックは基本的に比較のための1命令と何らかのレポートをするコードへの分岐のための1-2命令で済みます。

境界の問題

境界問題は、配列の定義された境界の外への書き込みや読み出しといった典型的な境界外の問題を含む、非常に幅広い問題のカテゴリです。しかし、out-of-boundsの概念は、型やサイズに関係なくポインタを通してアクセスされるものすべてに対処できるように一般化することができます。スタック上のスカラオブジェクトへのポインタなども含まれるため、たまたまスタック上の何かへのポインタを変更したり、悪意のある誰かが変更した場合、最先端の境界チェッカは、ポインタの新しい値が有効なオブジェクトの境界内にあるかどうかを検出することができるのです。これは、ポインタを追跡するだけでなく、ポインタが指し示すオブジェクトの有効範囲を追跡することを意味します。

ポインタを追跡するのは簡単ではありません。ソースレベルでも可能ですが、コンパイラで行うのが得策です。前述のように、高速で信頼性の高い境界チェックは、ポインタと関連する範囲を追跡する必要があり、また、ポインタのように見えるものを介して読み取りまたは書き込みを行うたびにこの情報を使用する必要があります。(C言語の配列は、ポインタに付加的なセマンティクスを加えたものに過ぎないことを忘れないでください)

境界チェックは、性能だけでなく、コードサイズやRAMの要件にも影響を与えます。なるべく小さくするのがコツです。

convenient_runtime_analysis_3.png

さらに、オブジェクト形式やアセンブリ言語でしか利用できないライブラリとのインタフェースを通過するポインタをどう扱うかも、境界チェックの複雑な点です。 このような状況には、必ずユーザーが対応しなければなりませんが、どのようなツールを使うかによって、複雑さや使いやすさにかなりの差があります。

ヒープの問題

ヒープのチェックとは、ヒープが整合性を保ち、時間の経過とともに割り当てられたブロックがリークしていないことを確認する技術です。効率的なヒープチェックは、基本的にライブラリの実装の練習になりますが、いくつかの機能が他のコンパイラの組込み関数と同じように扱えるのであれば、関連するコンパイラの内部を知ることは有益なことです。CとC++の世界では、malloc・free・その仲間を呼び出すたびに、整合性チェックが行われるのが一般的です。しかし、優れたヒープチェックパッケージは、すでに解放されたメモリブロックからの書き込みや読み出し、割り当てられたブロックの境界外への書き込みなどを検出することも可能です。また、特定のメモリブロックをリーク検出の対象外としたり、ユーザーが決めた任意の実行ポイントでリークやヒープの整合性をチェックしたりすることも可能です。ヒープ整合性チェックはヒープ全体を巡回するため、ヒープが大きいとパフォーマンスの足を引っ張ることになります。

動的解析アドオンツールC-RUN

これまで、前文を除いて、実行時解析やエラーチェックのための製品C-RUNについて、何も語っていません。ここまで読んで、C-RUNが算術チェック、境界チェック、ヒープチェックをカバーしていると推測されたかもしれませんが、その通りです。C-RUNは、既存のIAR Embedded Workbenchの上に追加する別製品で、C-RUNを含むようにライセンスをアップグレードすることで、追加することができます。C-RUNは、IAR Embedded Workbench for ARMおよびRXで利用可能で、有効なサポートおよびアップデート契約付きのスタンダード版を持っていれば、サイズ制限モードでC-RUNを簡単に評価することができます。(2022年3月1日時点。最新の評価版に関する提供状況についてはお問い合わせください)

では、C-RUNはユーザーにとってどのような存在なのでしょうか。偏見があるかもしれませんが、私たちのコンパイラやデバッガ技術のごく自然な延長線上にあると考えます。典型的なユースケースでは、必要なオプションを設定し、プロジェクトを再構築し、デバッガで実行して、チェックで問題が発見されるかどうかを確認するだけです。デバッガは、何が問題だったのかを正確に詳しく教えてくれ、どこから来たのかコールグラフで確認することができます。これだけシンプルに実行できます!ツールを統合する手間、互換性のないツールのバージョン、ビルドツール特有のキーワードを無視する方法、インクルードファイルやシンボルを見つける方法など、新しいパーサーを学ぶことに煩わされることはありません。しかし、デバッガを付けて本格的な統合テストを行うのは、電気的な絶縁や分離などの問題で、実現できないかもしれないと思うでしょう。C-RUNのデフォルトのレポート方法は、デバッガで使用するために最適化されていますが、そのメカニズムを、例えばメモリやファイルへのロギング、printfの使用、専用ポートへの書き込みなど、あなたの環境に合った方法で問題を報告するものに簡単に置き換えることができますので、心配はありません。

C-RUNは、従来の編集・ビルド・デバッグのサイクルで作業していても、単体テストでも、統合テストでも、邪魔にならないよう、日々の開発ワークフローの一部として自然に活用できるように設計されています。

では、コンパイラ指向のインストゥルメンテーションは、ソースレベルのインストゥルメンテーションとどのように違うのでしょうか?表面的にはほとんど同じことで、ツールはチェックする価値のある箇所にインストゥルメンテーションコードを挿入しなければなりません。これは、オーバーフローする可能性のある代入や、境界外である可能性のあるポインタを介しての読み取りなどが対象です。そして、次のような違いがあります。

  • コンパイラは、アプリケーションのコードと実行時のチェックに必要なコードの違いを把握しています。つまり、コンパイラはまず最適化を行い、残った部分にインストゥルメンテーションコードを挿入し、元のソースとの対応を維持することができます。

  • ソースレベルでインストゥルメンテーションを行う場合、インストゥルメンテーションコードは、元のコードで可能であったはずの最適化をかなり壊してしまう可能性が高いです。基本的に上記と同じことが原因です。コンパイラは、見ているコードの大部分が特別なものであり、したがって特別な扱いを受けることができるということを知らないからです。

  • 上記の2項目はいずれも最適化に焦点を当てたものですが、それがどのような意味で重要なのでしょうか?最終的には、ターゲットとするコードの量や必要とするリアルタイム性能によって、本番用ハードウェアで実行できるテストの種類や、インストゥルメンテッドテストビルドの作成と実行に必要な組み合わせの数が決定されます。最悪の場合、インストゥルメンテーションによるオーバーヘッドが大きすぎて、ハードウェア上でチェックしたテストを実行できないかもしれません。

    とはいえ、潜在的に誤った操作が起こらないように、コンパイラがコードを最適化するようにかもしれません。そして、これはインストゥルメントの方法に関係なく起こることかもしれません。そのため、時間とスペースがあれば、本番ビルドに使用するレベルよりも低い最適化レベルでテストすることも有益です。

  • ビルド環境との統合。これは頭痛の種です。というのも、計測ツールは基本的にユーザのコードとビルド環境について、ビルドツールチェーンと同じ情報を持っている必要があるからです。また、ビルドツールと同じ言語をサポートする必要があります。これは当たり前のことのように聞こえますが、言語の拡張や言語規格の解釈の違いなどを考えると、決して当たり前のことではありません。複雑なビルド依存関係、ヘッダーファイルの透過性、複雑なモジュールとインクルード階層を追加すると、日々の開発で同時に機能するものをセットアップするのは大変なことです。このようなツールの中には、ビルドとテストのプロセス全体を管理しようとするものもあり、事実上、あなたのツールボックスに別のIDEを導入することになります。

  • ビルドツールチェーンに統合されているため、C-RUNチェックの一部または全部を含む1つまたは複数のビルド構成を設定し、これらの構成と元のビルド構成を切り替えることが非常に簡単です。

勘違いしてはいけないのが ソースレベルで動作する実に素晴らしい最先端のテスト・解析ツールがいくつか販売されていますが、C-RUNは決してこれらのツールの代用品ではないということです。しかし、これらのツールは主に専用の単体テスト、要求仕様の検証、コンプライアンス準拠を確認するシナリオで使用され、私たちは低レベルでの検証に焦点を合わせています。C-RUNは、使いやすいインタフェースを通じて、コードの最初のイテレーションがテストさる時点で、貴重なフィードバックを提供します。また、IAR Embedded Workbenchとの密な統合により、C-RUNはあらゆる開発者の日常業務の一部となることができます。