コンテンツにスキップ

エキスパート

共有ライブラリがある場合のテスト

このレッスンでは、簡単にはファジングのために変更できないように見えるが、バイナリに対してソース C++ ドライバーをリンクすることで共有ライブラリをファジング可能な C++ アプリケーションをファジングする方法を、手順を追って説明します。


学習時間の目安: 10 分

このレッスンを終了すると、以下のことができるようになります。

  1. 共有ライブラリとは何かを説明する。
  2. ライブラリをドライバー テストすることとアプリケーションをドライバー テストすることの違いを明確にする。
  3. バイナリのみのドライバー テストの仕組みを説明する。
  4. 共有ライブラリのサンプル テスト ドライバーを順を追って理解する。

共有ライブラリ

共有ライブラリとは…

  • Linux では .so ファイル、Windows では .dll ファイルです。
  • コンパイル済みのコードと関連データを含みます。
  • さまざまなプログラムで共有または利用できます。

共有ライブラリの本来の目的は、コンパイル済みのコードを複数のバイナリ プログラムで共有し、ディスク領域を節約することです。ソフトウェア ライブラリを共有ライブラリ オブジェクトとしてコンパイルすると、プログラムは自分自身でライブラリ コードのコピーを持つ代わりに、ライブラリをロードすることができます。

Note

ライブラリをロードするプログラムは、ライブラリの仮想インメモリ コピーを作成します。そのため、複数のプログラムが同じ共有ライブラリを使用しても、互いに干渉することはありません。

共有ライブラリは、ソース全体を含めないですむよう、事前コンパイルされた形でソフトウェア ライブラリを提供する目的で使用される場合もあります。このユース ケースでは、ヘッダー ファイルも提供され、それによって、共有ライブラリとリンクする方法をコンパイラに指示します。共有ライブラリ自体の中にもリンク情報 (特に注目するべきは関数名シンボル) を含むプログラム メタデータがあります。

現代のアプリケーションは、1 つまたは複数のプログラム実行ファイルと、プログラムが依存する複数の共有ライブラリを 1 つのパッケージにした形でデリバリーされることがよくあります。このユースケースでは、共有ライブラリは、実際には複数のプログラムで共有することや、新しいプログラムをリンクすることは意図されていません。しかし、共有ライブラリ用のテスト ドライバーを作成することで、新しいプログラムからリンクすることは、技術的には依然として可能です。やり方を見てみましょう。

ライブラリのハーネスとアプリケーションのハーネスの違い

一般的に、アプリケーションのライブラリ的なコンポーネント (共有ライブラリだけでなく) をテストする際は、以下を考慮します。

  • ライブラリ用のテスト ドライバーは、本質的には、そのライブラリを使用して作成された代替アプリケーションです。代替アプリケーションは単純である場合もありますが、代替アプリケーションの作成は、既存のアプリケーションをファジングするより手間がかかることが多くあります。
  • アプリケーションがライブラリを使用 (あるいは誤用) する方法は、テスト ドライバーがライブラリを使用する方法とは異なる可能性があります。アプリケーションを適切に模倣していないテスト ドライバーは、バグを検出できなかったり、異なるバグに遭遇します。
  • ライブラリのテスト ドライバーには、ライブラリ以外のアプリケーション コードに存在するバグを発見することはできません。

アプリケーションではなくライブラリをテストする理由は主に次の 2 つです。

  • アプリケーションをドライバーでテストするより、ライブラリをドライバーでテストするほうが容易な場合がある。たとえば、HTML パーサーは比較的ドライバーによるテストが容易ですが、Web ブラウザーはそうではありません。
  • ファジングのスピードまたは品質を改善できる可能性がある。ライブラリのレベルでは、テスト ドライバーは、ソフトウェア ロジックの実行に時間がかかる部分やそれほどテストが必要ではない部分をよりきめ細かくスキップできるため、テスト ドライバーの実行がより速く、より多くのバグを見つけることができます。

一般的に言えば、まずアプリケーション全体をテストし、それに加えて、特にテストが容易で、特にバグが多いと考えられるライブラリ コンポーネント (たとえばパーサーや プロトコル処理コード) をテストするのが最良です。その他のライブラリ コンポーネントは、アプリケーション全体のテスト ドライバーでは、該当コンポーネントを適切にカバーできそうにない場合にだけテストするべきです。

バイナリのみのドライバー テスト

セキュリティの専門家は、ソースが提供されていない共有ライブラリをテストしなければならない場合がよくあります。このチュートリアルは、アプリケーションと共に提供されている、ソースがないカスタム共有ライブラリの探索をねらいとしています。

たとえば、アプリケーションがオープン ソースのライブラリを使用していることがわかっている場合は、ソース コード (できればアプリケーションが使用しているのと同じバージョン) を手に入れて、ソースを使用したドライバー テストを実施するほうがよいやり方です。別の例として、クローズドソースのライブラリが単独で開発者にリリースされる場合は、ヘッダー ファイルが付属しており、ソースベースのドライバー テストに近いアプローチがベストなやり方になるでしょう。

要件

  • スキル: ある程度のリバース エンジニアリングを含む C/C++ の基本的なプログラミング
  • ツール: g++ (および gnu binutils)

Note

このチュートリアルは C および C++ のライブラリ向けです。説明やテクニックの多くは C および C++ 固有です。このチュートリアルでは Linux を使用し、示されるコマンドも Linux でだけ動作しますが、原則やテクニックはすべて共有ライブラリをサポートする任意のプラットフォーム (Windows、Mac OS、iOS、Android など) に応用できます。

サンプル 1: 基本

example1 は、カスタム共有ライブラリを使用する簡単なプログラムであり、コマンド ラインの引数をそのまま標準出力に返します。

Note

example1 は 1 つの C++ ライブラリ MyCustomCxxLib.so および 1 つの C ライブラリ my_custom_c_lib.so を使用する C++ のプログラムです。演習に進む前に自由にソースを調べてください。

共有ライブラリ関数を特定する

まず、共有ライブラリにどのような関数があり、アプリケーションがどれを使用しているかを特定する必要があります。IDA などのリバース エンジニアリング プログラムを使うこともできますが、今回の例では binutils で十分です。

次のコマンドを実行します。

nm -D example1 MyCustomCxxLib.so my_custom_c_lib.so | c++filt

nm -D オプションを指定すると、ELF オブジェクトのインポートされたシンボルおよびエクスポートされたシンボルが表示されます。インポートされたシンボルの隣には U が表示され、エクスポートされた関数シンボルの隣には T が表示されます。

Note

普段は -D を指定せずに binutils nm を使用しているかもしれません。それでも動作する場合はありますが、厳密には、これはインポートされたシンボルおよびエクスポートされたシンボルではなく ELF オブジェクトのデバッグ シンボルをリストします。この場合のように、ELF には除去されたデバッグ シンボルがあったかもしれません。

c++filt は、発見したC++ 名のマングルを解除します。C++ 名のマングリングの最終的な効果として、共有ライブラリは C++ 関数の引数の型 (MyCustomCxxLib::process_data(char const*, char*) など) を公開しますが、C 関数の引数の型 (my_custom_c_lib_process_data など) は取得されません。

ハーネス関数呼び出しおよび順序を決定する

テスト ドライバーで呼び出す関数と呼び出しの順序を決定します。そのために、通常、アプリケーションまたはライブラリを多少リバースエンジニアリングするか、どのようにターゲット関数が呼び出されるかというサンプルの順序を見つけます。

Note

これはリバースエンジニアリングのチュートリアルではないため、まだリバース エンジニアリングに慣れていない場合は、単に main.cpp を開いてください。このプログラムでは、example1main() のループ内で行っているように、テスト ドライバーは MyCustomCxxLib::process_data() の出力を my_custom_c_lib_process_data() に渡す必要があります。

テスト ドライバーはアプリケーションがライブラリを使用する方法を正確にまねる必要はありませんが、あまりアプリケーションからかけ離れると、さまざまな問題に遭遇する可能性があります。この場合は、やみくもに my_custom_c_lib_process_data() だけをファジングしようとすると、ライブラリが "bad format!" を出力しますが、MyCustomCxxLib::process_data() の次に my_custom_c_lib_process_data() という順序で行うと、問題なく動作します。この例はわざと作ったものですが、現実のドライバーによるテスト作業でも同じような状況はよくあります。

共有ライブラリのテスト ドライバーを作成する

共有ライブラリにリンク可能な関数宣言を作成します。アプリケーションにはヘッダー ファイルが付属していませんが、nm を使用すると、ヘッダーを再現するのに必要な情報の大部分が得られます。

先ほど実行した nm の出力から、直接以下を推測できます。

1
2
3
4
5
6
7
8
9
// Return type is unknown, because C++ name mangling doesn't include return types.
owner MyCustomCxxLib {
    ? process_data(const char*, char*);
}

// Return type and argument types are unknown, because it's a C function.
// In C++, C functions must be declared with extern "C".
// This lets C++ know to look for the unmangled name when linking.
extern "C" ? my_custom_c_lib_process_data(???);

ここで足りない型は intvoidchar *, int です。試行錯誤するか、リバース エンジニアリングによってこれを突き止めることができます。

最後に、harness.cxx を参照するか、何をすればいいかわかっていると思う場合はまず自分自身でテスト ドライバーを作成してみましょう。次に進む前に、テスト ドライバーをコンパイルし、実行し、ファジングを実行できることを確認します。理解を確認するため、自力で harness.cxx を再作成します。

Tip

このやり方は、ターゲット ライブラリがビルドされたのと同じ C++ コンパイラおよびプラットフォームを使用している場合に最もうまくいきます。たとえば、ライブラリが clang++ でコンパイルされているのに、g++ を使用してテスト ドライバーをコンパイルしようとすると、うまくいかない可能性があります。特に g++ の libstdc++ は何年か前に std::string の実装を変更したため、古い (依然として利用されている) バージョンの g++ ツールチェーンは、最近のバージョンとバイナリ互換ではありません。g++ の場合、-D_GLIBCXX_USE_CXX11_ABI=0-D_GLIBCXX_USE_CXX11_ABI=1 を切り替えるだけでこれを簡単に解決できる場合もあります。

演習: llua_simple

ファイル: llua_simple.tgz

学んだことを実践するため、llua_simple バイナリによって提供されたサンプル セットを使用して、libllua.so をドライバー テストしてみましょう。特に、llual_newstate()llual_loadfilex()、そして lua_pcallk() という流れをテストします。libllua.so は C 言語のライブラリであるため、C++ だった場合と比較して、関数引数の型に関してより多くの推測を行う必要があるでしょう。

llua_simple.c ソース コードを参照することもできますが、リバース エンジニアリングの経験があるなら、最初はソース コードを参照せずに (llua_simple および libllua.so バイナリだけを見て) この演習を行ってみましょう。どちらにせよ、ネットで (あるいは /usr/include で) Lua のヘッダー ファイルを探すのはやめましょう。練習として、libllua.so がクローズドソースのライブラリで、ヘッダー ファイルやソース ファイルは利用できないものとして考えます (実際はそうではありませんが)。

この演習で特にテスト ドライバーが検出するべき特定の脆弱性は想定されていませんが、高いカバレッジを達成するべきです。

サンプル 2: C++ オブジェクト

ファイル: example2.tgz

example2 は、より複雑なターゲットですが、example1 の場合と同じ一般的なプロセスに従ってドライバー テストを行います。バイナリをリバース エンジニアリングして 有効なヘッダー ファイルを作成し、(判明したライブラリ使用方法と同じように) ライブラリを動かすテスト ドライバーを C++ で作成し、テスト ドライバーをコンパイルしてライブラリにリンクします。

C++ のオブジェクトを扱う場合、このプロセスにはより手間と細部への注意が必要です。ドライバー テストする関数はパラメーターとして C++ オブジェクトを受け取る可能性があるため、テスト ドライバーはあらかじめそれらの C++ オブジェクトを作成する必要があります。さらに、C++ オブジェクトを作成し、適切に初期化するには、オブジェクトに関してある程度正しいクラス定義が必要です。

C++ のクラスについて、簡単におさらいしておきます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class MyClass /* : public OptionalBaseClass */ {
public: // <- for harnessing purposes, just set everything public.
  int member1;
  int member2;
  // ^ the size of a class is the size of a struct holding all of its
  // non-static data members (plus a vtable pointer, if the class or a
  // parent class has any virtual member functions. But, that happens
  // automatically).

  // Constructors. From a reverse-engineering perspective, constructors
  // are just (non-virtual) member functions that get called to initialize
  // classes.
  MyClass(int, float);
  MyClass(char *);

  // Member functions. From a reverse-engineering perspective, they're just
  // functions that take an implicit first argument, known as "this", which
  // is a pointer to a struct containing the object's data members.
  int do_something(int);
  void do_something_else(float);
}

Tip

C++ クラス定義構文の詳細を確認できます。ただし、他にクラス定義で可能なことの多くは、ドライバー テストやリバース エンジニアリングとは関係ありません。

「ある程度正しい」クラス定義を作成するにあたっては、以下の 3 つが重要です。

  1. 呼び出そうとしているメンバー関数の関数宣言 (コンストラクターを含む)
  2. クラスのサイズ
  3. クラスに仮想メソッドがある場合 (デストラクターを含む)、あるいは仮想メソッドを持つ親クラスがある場合、それらをすべて含める必要がある (さらに継承階層を再現する必要がある場合もある)このチュートリアルでは、仮想メソッドには触れません。

プログラムが上記のサンプル クラスを使用する場合、テスト ドライバーで使用するためにリバースエンジニアリングされたクラス定義は次のようになります。

1
2
3
4
5
6
7
8
9
class MyClass {
public:
  char data[8]; // two ints.

  // No need to represent constructors and member functions that the test driver
  // doesn't care about using.
  MyClass(char *);
  int do_something(int);
}

このサンプルを理解するため、最初のサンプルと同じプロセスを繰り返します。harness.cxx を調べ、example2 および ex2lib.so のソース ファイルを調べます。理解度を確認するため、harness.cxx を閉じて、バイナリだけを使用してこのファイルを再現してください (もちろん、リバースエンジニアリングのプロセスを容易にするため、example2 および ex2lib.so のソース コードの一部をこっそり見るのは自由です)。

✏️ まとめと振り返り

このレッスンでは、多くの現実のアプリケーションに応用可能なドライバーによるバイナリ テストの方法を学びました。これがドライバーによってバイナリをテストする唯一の方法ではありません。ドライバー テストに対する「正しい」アプローチとして解釈するのではなく、手持ちのテクニックの 1 つとしてプレイブックに加えるべきです。


学習内容

1.共有ライブラリとは何かを説明する。
  • 共有ライブラリとは…
    • Linux では .so ファイル、Windows では .dll ファイルです。
    • コンパイル済みのコードと関連データを含みます。
    • さまざまなプログラムで共有または利用できます。
2.ドライバーを使用してライブラリをテストすることとアプリケーションをテストすることの違いを明確にする。
  • 一般的に、ドライバーを使用してアプリケーションのライブラリ的なコンポーネント (共有ライブラリだけでなく) をテストする際は、以下を考慮します。
    • ライブラリ用のテスト ドライバーは、本質的には、そのライブラリを使用して作成された代替アプリケーションです。代替アプリケーションは単純である場合もありますが、代替アプリケーションの作成は、既存のアプリケーションをファジングするより手間がかかることが多くあります。
    • アプリケーションがライブラリを使用 (あるいは誤用) する方法は、テスト ドライバーがライブラリを使用する方法とは異なる可能性があります。アプリケーションを適切に模倣していないテスト ドライバーは、バグを検出できなかったり、異なるバグに遭遇します。
    • ライブラリのテスト ドライバーには、ライブラリ以外のアプリケーション コードに存在するバグを発見することはできません。
    • アプリケーションではなくライブラリをテストする理由は主に次の 2 つです。
      • アプリケーションをドライバーでテストするより、ライブラリをドライバーでテストするほうが容易な場合がある。たとえば、HTML パーサーは比較的ドライバーによるテストが容易ですが、Web ブラウザーはそうではありません。
      • ファジングのスピードまたは品質を改善できる可能性がある。ライブラリのレベルでは、テスト ドライバーは、ソフトウェア ロジックの実行に時間がかかる部分やそれほどテストが必要ではない部分をよりきめ細かくスキップできるため、テスト ドライバーの実行がより速く、より多くのバグを見つけることができます。
3.バイナリのみのターゲットをドライバーによってテストする作業を説明する。
  • セキュリティの専門家は、ソースが提供されていない共有ライブラリをテストしなければならない場合がよくあります。このチュートリアルは、アプリケーションと共に提供されている、ソースがないカスタム共有ライブラリの探索をねらいとしています。
  • たとえば、アプリケーションがオープン ソースのライブラリを使用していることがわかっている場合は、ソース コード (できればアプリケーションが使用しているのと同じバージョン) を手に入れて、ソースを使用したドライバー テストを実施するほうがよいやり方です。別の例として、クローズドソースのライブラリが単独で開発者にリリースされる場合は、ヘッダー ファイルが付属しており、ソースベースのドライバー テストに近いアプローチがベストなやり方になるでしょう。
4.サンプル共有ライブラリ テスト ドライバーを手順を追って確認する。
  • 次の手順を行う必要があります。
  • 共有ライブラリ関数を特定する。
  • ドライバーによってテストする関数呼び出しおよびその順序を特定する。
  • 共有ライブラリのテスト ドライバーを作成する。