HN 日本語サマリー

← 一覧へ戻る
プログラミング

AFL++でのファズテストをより効果的に行うためのヒント

Tips for more effective fuzz testing with AFL++ (nullprogram.com)

51 pointsby luu7 コメント

要約

この記事は、C/C++ターゲットに対してAFL++(American Fuzzy Lop plus plus)を使用したファズテストの効果を高めるための実践的なヒントを、チェックリスト形式で提供しています。サンタイザやアサーションの設定、永続モードの活用、テスト間の独立性の確保、バッファのコピー、メモリ解放の回避、メモリファイルディスクリプタの使用、ターゲットバッファサイズの縮小といった具体的な手法が解説されています。

全文翻訳

AFL++でのファズテストをより効果的に行うためのヒント

2025年2月5日

nullprogram.com/blog/2025/02/05/

ファズテストはソフトウェアの欠陥を機械的に発見する上で非常に効果的ですが、依然として過小評価され、無視されています。複雑な入力を、どんな言語で書かれていようとも、まだファズテストされていないプログラムを選んでみてください。ファズテストを行えば、通常は少なくとも1つのバグが見つかります。現在お使いのコンピュータにインストールされているプログラムの少なくとも1つは、おそらく該当するでしょう。ほとんどすべてが該当するかもしれません。低レベルのバグは至る所にあります。過去6年間で約1,000のプロジェクトをファズテストしてきた経験から、それらのバグを見つけるためのヒントを蓄積してきました。チェックリスト形式は過去にうまくいったので(1、2)、今回もそれを使用します。この記事では、ソースコードが入手可能なCおよびC++ターゲットで、glibcベースのLinuxディストリビューション上で実行されるAFL++について説明します。これは現在、CおよびC++にとって疑いの余地のない最高のファジングプラットフォームです。

私のヒントは公式のアップストリームドキュメントを補完するものですので、そちらも参照してください。

プログラムがすでにファズテストされている場合でも、この記事のテクニックを適用することで、以前のファズテストで見逃された欠陥を発見できる可能性があります。

(1) サンタイザとアサーションを設定する

アサーションが多いほどファジングは効果的になり、サンタイザは自動的に挿入されるアサーションの一種です。デフォルトでは、アドレスサンタイザ(ASan)と未定義動作サンタイザ(UBSan)の両方でファズテストを行ってください。

$ afl-gcc-fast -g3 -fsanitize=address,undefined ...

ASanのデフォルト設定は理想的ではなく、ASAN_OPTIONS環境変数で調整する必要があります。カスタマイズする場合、AFL++は少なくともこれらのオプションを必要とします。

export ASAN_OPTIONS="abort_on_error=1:halt_on_error=1:symbolize=0"

symbolize=0を除き、これはASanのデフォルトであるべきです。発見されたクラッシュをデバッグする際には、デバッガ下でも同様に動作するようにUBSanも同じように設定したいでしょう。ファジングを改善するために、リターン後の使用バグを検出することでASanを欠陥に対してさらに敏感にしてください。これはファジングをわずかに遅くしますが、そのコストに見合う価値があります。

ASAN_OPTIONS+=":detect_stack_use_after_return=1"

デフォルト設定では、ASanは新しい割り当ての最初の4KiBをパターンで埋め、use-after-freeバグの検出を助けます。これはファジングには全く十分ではありません。ほぼすべての割り当てをパターンで完全に埋めるように引き上げてください。

ASAN_OPTIONS+=":max_malloc_fill_size=$((1<<30))"

デフォルト設定では、プログラムがmallocで4KiBを超えるメモリを割り当て、例えば初期化されていないメモリに対してstrlenを使用した場合、バグは検出されません。4KiB以降にはほぼ確実にゼロがあります。それに気づくまで、4KiBの制限は私のファズテストから多くのバグを隠していました。(4)によると、永続モードを使用する際に、割り当てをパターンで完全に埋めることはテストをより効果的に分離します。

C++をファズテストし、GCCのlibstdc++をリンクする場合、-D_GLIBCXX_DEBUGを検討してください。ASanはコンテナの容量内の範囲外アクセスを「見ることが」できず、追加のアサーションがギャップを埋めます。ただし、これはABIを変更することに注意してください。ファズテストはこのような不一致を即座に明らかにします。

(2) 永続モードを優先する

AFL++は多くのプログラムをコードを一行も書かずにインプレースでファズテストできますが(afl-gcc、afl-clang)、AFL++の永続モード(afl-gcc-fast、afl-clang-fast)を優先してください。通常、これは10倍高速であり、その労力に見合う価値があります。ただし、落とし穴もあります((4)、(5)を参照)。私はfuzztmpl.cというファイルを常に手元に置いており、これが私のすべてのファズテスターの祖先です。

#include <unistd.h>

__AFL_FUZZ_INIT();

int main(void)
{
    __AFL_INIT();
    char *src = 0;
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
    while (__AFL_LOOP(10000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        src = realloc(src, len);
        memcpy(src, buf, len);
        // ... send src to target ...
    }
}

私はこれをVimバッファに:rで読み込み、必要に応じて変更します。これは公式テンプレートを簡略化・改善したものです。公式テンプレート自体にも深刻な欠陥があります((5)を参照)。bufとlenの位置には、明示されていない制約があるため、不明な場合は元のテンプレートを参照してください。

私たちは21世紀も半ばを過ぎています。もはや16ビットマシンでソフトウェアをコンパイルする人はいません。ファイルごとに1つの翻訳単位(TU)という考え方に固執しないでください。ファズテストでは、最大で2つのTUが必要です。1つはインストルメント化されたコード用、もう1つはインストルメント化されていないコード用です。ほとんどの場合、後者はライブラリ(libc、libstdc++など)の形式を取り、それについて考える必要はありません。

ファズテストは通常、プログラムのサブセットのみを必要とします。これらのソースコードだけをテンプレートに直接含めるのは、効果的かつシンプルです。私のテンプレートでは、ヘッダーがソースコードから見えるようにならないように、unistd.hの直上にインクルードを配置しています。

#include "src/utils.c"
#include "src/parser.c"
#include <unistd.h>

これを初めて見たときは奇妙に思えるかもしれませんが、信じてください。このシンプルなテクニックは、ビルド設定の何千行もの手間を省きます。そうしないと、ファズテスト時とそれ以外の時で異なるオブジェクトファイルを管理する必要があります。

おそらくさらに重要なのは、プログラムの任意の関数をファズテストできるようになることです。静的関数も含まれます!すべてが同じTU内にあります。公開インターフェースに限定される必要はありません。おそらく(7)をスキップして、より良い内部インターフェースに対してテストできるでしょう。また、静的変数にも直接アクセスできるため、(4)に従ってテスト間でそれらをクリア/リセットできます。

プログラムはしばしばファズテストや一般的なテストのために設計されておらず、密接に結合されたコンポーネントを分離するのが難しい場合があります。私がファズテストしたプログラムの多くはこのようになっています。このテクニックを使用すると、プログラムにノコギリを入れることができ、元のソースコードを変更することなく、ファズテストのためだけに問題のあるシンボルを置き換えることができます。例えば、テスト対象のソースコードにmain関数が含まれている場合、それを削除できます。

#define main oldmain
#  include "src/utils.c"
#  include "src/parser.c"
#undef main
#include <unistd.h>

確かに、このようなハックを不要にするようにプログラムを改善する方が良いですが、ほとんどの場合、私はオープンソースプロジェクトのドライブバイレビューの一環としてファズテストを行っています。これにより、元の変更されていないプログラムの欠陥を迅速に発見でき、「ASanでコンパイルし、この50バイトのファイルを開くと、プログラムがクラッシュします」のようなシンプルなバグレポートが生成されます。

(4) ファズテストを互いに分離する

テストは、前のテストの影響を受けないようにする必要があります。これは永続モードでは困難であり、時には非現実的です。つまり、内部のstrtokバッファのようなグローバル状態であっても、その関数が使用されている場合はすべてリセットする必要があります。グローバル変数を避ける理由のリストにファズテストを追加してください。

(1)によって緩和されますが、それ以外の場合、初期化されていないヒープメモリには前のテストの内容が含まれている可能性があり、分離が破られます。ファジングインストルメンテーションへの干渉に加えて、このようにして見つかったバグは再現が非常に困難です。

初期化されていないメモリをテストに渡さないでください。例えば、スタック上に割り当てられた出力パラメータなどです。ゼロ初期化するか、パターンで埋めてください。アリーナを受け入れる場合は、各テストの前にパターンで埋めてください。

通常、ヒープアドレスを制御することはほとんどありません。これはテスト間で異なり、前のテストの動作に依存する可能性が高いです。プログラムがアドレス値に依存している場合、これは結果に影響を与え、再現を困難にする可能性があるため、注意してください。

(5) ファズテストバッファで直接テストしない

特に設計の良いCプログラムをファズテストする場合、そして公式テンプレートがそれを推奨しているため、bufとlenを直接ターゲットに渡すことが最も一般的な間違いです。

    myprogram(buf, len);  // BAD!

プログラムがヌル終端に依存していないことは良い兆候ですが、これは微妙な落とし穴を生み出します。AFL++によって割り当てられた基盤となるバッファはlenよりも大きく、ASanは入力に対する読み取りオーバーフローを検出できません!代わりに、サイズに合わせてコピーを渡してください。これが私のテンプレートのsrcの目的です。

プログラムがヌル終端入力を期待する場合、ヌルバイトを追加するためにこれを行う必要があります。std::stringのような「所有型」を受け入れる場合は、これはすでに自動的に行われています。「非所有型」ビューであるstd::string_viewの場合でも、独自のサイズ合わせコピーが必要です。

プログラムのチェックインされたファズテストでbufが直接使用されているのを見た場合は、この変更を加えて、新しい発見がないか確認してください。これは私にとって何度かうまくいっています。

(6) メモリ解放は気にしない

一般的に、ファズテストに関連しない作業は避けてください。公式のヒントには、「よりシンプルなターゲットを使用する」や「必要なものだけをインストルメント化する」と書かれており、デストラクタをテストから除外することは、両方のケースで役立ちます。プログラムが特にメモリを大量に消費しない限り、AFL++がターゲットプロセスをリセットする前にメモリが不足することはありません。

(1)がなければ、これは分離(4)にも役立ちます。なぜなら、異なるテストが前のテストからの初期化されていないメモリによって汚染される可能性が低くなるからです。

例外として、デストラクタをファズテストに含めたい場合は、テストで使用してください。また、メモリ以外のリソース、特にファイルディスクリプタを使い果たすのは簡単であり、信頼性の高いファズテストを行うためには、それらをクリーンアップする必要があるかもしれません。

もちろん、ターゲットがアリーナ割り当てを使用している場合は、これらすべては関係ありません!また、アドレスさえテスト間で変化しないため、完璧な分離が得られます。

(7) 名前付きパスのバックエンドにメモリファイルディスクリプタを使用する

多くのインターフェースは、控えめに言っても、あまり設計が良くなく、名前付きファイルシステムパスからの入力のみを受け入れ、ファイル自体を開いて読み取ることを主張します。このようなインターフェースをテストすることは課題ですが、特に並列ファジングに関心がある場合はそうです。幸いなことに、通常は簡単な回避策があります。メモリファイルディスクリプタを作成し、その/proc名を使用します。

int fd = memfd_create("fuzz", 0);
assert(fd == 3);
while (...) {
    // ...
    ftruncate(fd, 0);
    pwrite(fd, buf, len, 0);
    myprogram("/proc/self/fd/3");
}

標準入力が0、出力が1、エラーが2であるため、メモリファイルディスクリプタが3になることを想定していますが、これによりテストコードが少し簡単になります。もし3でなければ、おそらく何かが間違っている可能性が高く、アボートするのが最善の選択肢です。仮定したくない場合は、snprintfなどを使用してfdからパス名を構築してください。

pwrite(writeの代わりに)を使用すると、ファイルディスクリプタのオフセットがファイルの先頭に残ります。

メモリファイルディスクリプタのおかげで、ファズテストデータは永続ストレージに保存されないため、SSDへの摩耗が軽減されます(時折のフラッシュによる)。/procのおかげで、ファイルは共通のパス名にもかかわらずプロセスごとに一意であるため、並列ファジングの問題はありません。クリーンアップも不要です。

プログラムがファイルディスクリプタを要求する場合(つまり、内部関数をファズテストしていてソケットを要求する場合)、ファイルディスクリプタを直接渡してください。

    myprogram(fd);

FILE *を受け入れる場合は、/procパスをfopenすることもできますが、オブジェクト上にFILE *を作成するためにfdmemopenを使用する方が良いでしょう。

    myprogram(fdmemopen(buf, len, "rb"));

(6)に従い、ファイルディスクリプタに関連付けられていないため、fcloseを気にする必要がないことに注意してください。

(8) より小さなバッファのためにターゲットを設定する

病的なプログラムでよく見られるのは、「寛大な」固定バッファサイズです。

#define MY_MAX_BUFFER_LENGTH 65536

void example(...)
{
    char path[PATH_MAX];  // typically 4,096
    char buf[MY_MAX_BUFFER_LENGTH];
    // ...
}

これらの巨大なバッファはバグを隠しがちです。それらをひっくり返してください!それらを最大化し、不幸なパス(または超不幸なパス、オーバーフロー)を活性化するには、多くのファジング時間が必要です。ファズテストが最悪の条件に素早く到達し、そこからの実行パスを探索できる方が良いです。

したがって、これらを見た場合は、それらを大幅に削減してください。おそらく(3)を使用します。65536を例えば16に変更して、何が起こるか見てみてください。ファジングが短いバッファでクラッシュを見つけた場合、通常は元のバッファサイズでクラッシュするように入力を拡張することは簡単です。例えば、バイトをすでに繰り返されている以上に繰り返します。

結論とサンプル

うまくいけば、ここに記載された何かが、そうでなければ見過ごされていたであろう欠陥を捉えるのに役立つでしょう。さらに良いことに、これらのファジングテクニックの認識が、そもそもバグを防ぐことになるかもしれません。私のテンプレート、いくつかの堅牢なツール、そしてこの記事のノウハウのおかげで、私は数分でファズテストを作成できます。しかし、その容易さゆえに、私はそれを同様にカジュアルに破棄するため、ほとんどのものをキャプチャしてカタログ化する時間を取りません。サンプルを見たい場合は、古い短いリストがあります。おそらく、さらに1000プロジェクトのファズテストの後、より多くのテクニックを習得するでしょう。