mykter/afl-training には、初心者が afl++ を使用してオープンソースソフトウェアのファジングを迅速に習得するための一連の資料が含まれています。
セットアップ#
構築済みの ghcr.io/mykter/fuzz-training
を直接 pull するのにかかる時間が長くなる可能性があるため、私はローカルで Dockerfile を使用して最初からイメージをビルドし、その後コンテナを実行することを選択しました。
- ビルド:Dockerfile に
ARG http_proxy
を追加し、docker build コマンドに--build-arg "http_proxy=http://127.0.0.1:7890"
を追加し、パラメータ--network=host
を設定します(docker run の--net=host
とは異なります!)。これにより、ビルドプロセスがネットワークプロキシに接続できるようになります。Dockerfile の適切な位置に git プロキシ(git config --global http-proxy $http_proxy
)を追加して、git pull の速度を向上させます。 - 実行:外部ネットワークへのアクセスと科学的なインターネット接続のプロキシプログラムがホスト上で実行されているため、私のコンテナは実行中にホストの IP(おそらく 172.27.0.1)とプロキシポートに接続できません。そのため、コンテナをホストモード(
--net=host
)で実行することを選択し、ポートマッピングやホスト IP などの面倒を省きます。
libxml2#
libxml2 は人気のある XML 処理ライブラリで、C 言語で実装されています。以下の特徴により、ファジングの練習に非常に適しています:
- ステートレス
- ネットワーク通信やファイルの読み書きがない
- 文書が詳細で豊富、API が明示的で、内部のファジングインターフェースを追加分析する必要がない
- 機能が集中していてシンプル(XML の処理)、実行が速い
ここで考慮されている CVE(共通脆弱性と露出)は CVE-2015-8317:parser.c の xmlParseXMLDecl 関数が 不完全なエンコーディング と 不完全な XML 宣言 を処理する際に 境界外ヒープ読み取り を引き起こします。
/**
* xmlParseXMLDecl:
* @ctxt: an XML parser context
*
* parse an XML declaration header
*
* [23] XMLDecl ::= '<?xml' VersionInfo EncodingDecl? SDDecl? S? '?>'
*/
void xmlParseXMLDecl(xmlParserCtxtPtr ctxt) { ... }
parser.c の xmlParseXMLDecl 関数は XML 宣言部分を解析する役割を担っており、XML 宣言とは一般的に XML ドキュメントの最初の行に置かれる内容を指します:
<?xml version="1.0" encoding="UTF-8"?>
思考#
問題はこの関数の内部機能にありますが、この関数だけをテストする必要はありません:
- 不完全な文字列から値を取得することによって引き起こされる XML 宣言解析操作の脆弱性は、ファジングプロセス中に容易に発見できるはずです。宣言のみを含む XML ドキュメントと完全な XML ドキュメントを生成し、それぞれのファジング効率の違いはそれほど大きくないはずです。
xmlParseXMLDecl
関数の引数は XML 解析プロセスのコンテキスト(xmlParserCtxtPtr
)であり、このコンテキストを直接生成および変異させることは非常に面倒で非効率的です。このコンテキストはより高レベルの API によって生成されるべきです。
この 2 つの点を考慮して、私は xmlParserCtxtPtr
に関連するサンプルプログラムを見つけ、ファジングのハーネスとして使用しました:
#include <stdio.h>
#include <libxml/parser.h>
#include <libxml/tree.h>
/**
* exampleFunc:
* @filename: a filename or an URL
*
* Parse and validate the resource and free the resulting tree
*/
static void exampleFunc(const char *filename) {
xmlParserCtxtPtr ctxt; /* the parser context */
xmlDocPtr doc; /* the resulting document tree */
/* create a parser context */
ctxt = xmlNewParserCtxt();
if (ctxt == NULL) {
fprintf(stderr, "Failed to allocate parser context\n");
return;
}
/* parse the file, activating the DTD validation option */
doc = xmlCtxtReadFile(ctxt, filename, NULL, XML_PARSE_DTDVALID);
/* check if parsing succeeded */
if (doc == NULL) {
fprintf(stderr, "Failed to parse %s\n", filename);
} else {
/* check if validation succeeded */
if (ctxt->valid == 0)
fprintf(stderr, "Failed to validate %s\n", filename);
/* free up the resulting document */
xmlFreeDoc(doc);
}
/* free up the parser context */
xmlFreeParserCtxt(ctxt);
}
int main(int argc, char **argv) {
if (argc != 2)
return(1);
/*
* this initialize the library and check potential ABI mismatches
* between the version it was compiled for and the actual shared
* library used.
*/
LIBXML_TEST_VERSION
exampleFunc(argv[1]);
/*
* Cleanup function for the XML library.
*/
xmlCleanupParser();
/*
* this is to debug memory for regression tests
*/
xmlMemoryDump();
return(0);
}
プログラムのロジックは非常にシンプルで、最初にコマンドライン引数からファイル名を読み取り、新しい xmlParserCtxtPtr
インスタンスを構築し、ファイル名からテキストを取得して解析します。解析プロセスは xmlParserCtxtPtr
インスタンスによって追跡されます。
次に、libxml2 のライブラリとハーネスの実行可能プログラムをコンパイルします:
cd libxml2
CC=afl-clang-fast ./autogen.sh
AFL_USE_ASAN=1 make -j 4
cd ..
AFL_USE_ASAN=1 afl-clang-fast ./harness.c -I libxml2/include libxml2/.libs/libxml2.a -lz -lm -o fuzzer
そしてファジングを開始します:
AFL_SKIP_CPUFREQ=1 afl-fuzz -i inputs/ -o outputs/ ./fuzzer @@
ファジング結果#
永続モード#
参考答案で示されたハーネスは AFL-LLVM の Persistent Mode を使用しています。
基本的な AFL では、ファジングプログラムは子プロセスをフォークして fork_server として実行します。ファジングプロセスではターゲット(target)プログラムを何度も実行することが避けられません。ターゲットプログラムのロードに伴うシステムオーバーヘッドを避けるために、AFL は fork_server に fork
コマンドを実行させて新しいターゲットを生成します。
さらに fork によるオーバーヘッドを減らすために、永続モードではハーネスコード内で被測定 API を明示的にループ呼び出しすることを許可します。つまり、ハーネスの main 関数内で次のように行います:
while (__AFL_LOOP(1000)) {
/* Setup function call, e.g. struct target *tmp = libtarget_init() */
/* Call function to be fuzzed, e.g.: */
target_function(buf, len);
/* Reset state. e.g. libtarget_free(tmp) */
}
コンパイルされた XML ドキュメントはメモリ内で生成され、ファイルへの書き込み操作を省略し、直接ファジングプログラムに入力できます。AFL では、テストケースがファイルリダイレクトを介して STDIN に送信され、ターゲットプログラムがそれを読み取ります。AFL++ は共有メモリチャネルを提供し、ファジングプログラムとターゲット間のテストケースの受け渡しを実現します。この機能を利用するには、単に __AFL_FUZZ_INIT();
というマクロを宣言するだけです。
さらに、永続モードを使用することで、毎回ターゲットの初期化コードを実行する必要がなくなりますが、これらの初期化コードは fork_server がターゲットプロセスをフォークする際に依然として実行されます。プログラムがこれらの初期化コードを実行した後の状態を再利用するために、AFL++ の方法は fork server の初期化(元のプログラムの先頭から)をユーザー指定の位置まで遅延させることです。
/* This one can be called from user code when deferred forkserver mode
is enabled. */
void __afl_manual_init(void) {
static u8 init_done;
if (...) {...}
if (!init_done) {
__afl_start_forkserver();
init_done = 1;
}
}
上記の 3 つの特徴:永続モード、共有メモリファジング、および遅延初期化を組み合わせることで、最終的に libxml2 のハーネスが構成されます。__AFL_FUZZ_TESTCASE_BUF
と __AFL_FUZZ_TESTCASE_LEN
という 2 つのマクロは、それぞれ共有メモリアドレスとテストケースが占めるメモリサイズを示します。
#include "libxml/parser.h"
#include "libxml/tree.h"
#include <unistd.h>
__AFL_FUZZ_INIT();
int main(int argc, char **argv) {
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; // must be after __AFL_INIT
xmlInitParser();
while (__AFL_LOOP(1000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
xmlDocPtr doc = xmlReadMemory((char *)buf, len, "https://mykter.com", NULL, 0);
if (doc != NULL) {
xmlFreeDoc(doc);
}
}
xmlCleanupParser();
return(0);
}
AFL に付属の XML 辞書を利用して、一定の構文を知っている状態で入力文書の変異を行うことで、効率をさらに向上させることができます:
AFL_SKIP_CPUFREQ=1 afl-fuzz -i inputs/ -o outputs/ -x ~/AFLplusplus/dictionaries/xml.dict ./fuzzer
最後に見つかったクラッシュケースは実際にはかなりの数があり、その中には CVE に記載されている 終了していないエンコーディング値 も含まれています。
Heartbleed#
CVE-2014-0160 はかつて非常に有名な「ハートブリード」脆弱性で、この脆弱性は OpenSSL 1.0.1g より前の 1.0.1 バージョンに存在しました。
ファジングの進捗を加速させるために、私は handshake.cc の改造の過程で永続モード、共有メモリ、および遅延初期化を取り入れました。
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <assert.h>
#include <stdint.h>
#include <stddef.h>
#include <unistd.h>
#ifndef CERT_PATH
# define CERT_PATH
#endif
__AFL_FUZZ_INIT();
SSL_CTX *Init() {
SSL_library_init();
SSL_load_error_strings();
ERR_load_BIO_strings();
OpenSSL_add_all_algorithms();
SSL_CTX *sctx;
assert (sctx = SSL_CTX_new(TLSv1_method()));
/* これらの2つのファイルは次のコマンドで作成されました:
openssl req -x509 -newkey rsa:512 -keyout server.key \
-out server.pem -days 9999 -nodes -subj /CN=a/
*/
assert(SSL_CTX_use_certificate_file(sctx, "server.pem",
SSL_FILETYPE_PEM));
assert(SSL_CTX_use_PrivateKey_file(sctx, "server.key",
SSL_FILETYPE_PEM));
return sctx;
}
int main() {
SSL_CTX *sctx = Init();
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(1000))
{
SSL *server = SSL_new(sctx);
BIO *sinbio = BIO_new(BIO_s_mem());
BIO *soutbio = BIO_new(BIO_s_mem());
SSL_set_bio(server, sinbio, soutbio);
SSL_set_accept_state(server);
/* TODO: ハンドシェイクの一方を偽装するには、ここで sinbio にデータを書き込む必要があります */
int len = __AFL_FUZZ_TESTCASE_LEN;
BIO_write(sinbio, buf, len);
SSL_do_handshake(server);
SSL_free(server);
}
SSL_CTX_free(sctx);
return 0;
}
ファジングの結果は次の通りです。安定性が低いのは SSL コンテキストの再利用に関連している可能性があります。
脆弱性#
xxd
コマンドを使用すると、ファイルデータを 16 進数形式で出力できます(xxd のより強力な機能は、修正されたデータをファイルにダンプすることをサポートしていると言われています)。
- バイト 0:SSL データパケットタイプ、ここでは 18 は TLS1_RT_HEARTBEAT に対応します。
- バイト 1-2:SSL バージョン、ここでは 0301 は TLS1.0、0302 は 1.1、0303 は 1.2 を表します。ここでの 03f5 はファジングプログラムが自動生成したバージョン番号で、実際のバージョンと対応していない可能性がありますが、TLS サーバーは一部のフォールバックロジックにより、このようなバージョン番号を受け入れることができます。
- バイト 3-4:TLS 完全データパケット(ヘッダーを含む)の長さ、ここでの 0005 もファジングプログラムが生成したもので、不合理ですが、この脆弱性の核心問題はここにはありません。
- バイト 5:TLS ハートビートタイプ、1 はリクエスト、2 はレスポンスに対応します。ここはクライアントからサーバーに送信されるため、1 です。
- バイト 6:ハートビートペイロードの長さ。
- その他:ハートペイロードデータ、データの長さはバイト 6 に一致する必要があります。
通常の処理ロジックに従うと、サーバーはリクエストのペイロード長とデータをエコー(echo)します。簡略化されたコードロジックは次のようになります:
int
tls1_process_heartbeat(SSL *s) {
unsigned char *p = &s->s3->rrec.data[0], *pl;
/* 最初にタイプとペイロード長を読み取ります */
hbtype = *p++;
n2s(p, payload);
pl = p;
if (hbtype == TLS1_HB_REQUEST) {
/* レスポンスのためのメモリを割り当てます。サイズは1バイトの
* メッセージタイプ、2バイトのペイロード長、ペイロード、
* パディングを含みます */
buffer = OPENSSL_malloc(1 + 2 + payload + padding);
bp = buffer;
/* レスポンスタイプ、長さを入力し、ペイロードをコピーします */
*bp++ = TLS1_HB_RESPONSE;
s2n(payload, bp);
memcpy(bp, pl, payload);
}
}
攻撃者が大きなペイロード長を指定し、十分なペイロードデータを提供しない場合、サーバーはペイロード長に基づいてレスポンスのバッファを割り当て、ペイロードデータバッファからペイロード長のデータをレスポンスバッファにコピーします。その結果、ペイロード長の直後のメモリ内のデータがレスポンスに漏洩し、攻撃者に送信されます。
sendmail#1301#
テストする脆弱性は CVE-1999-0206 です。コードリポジトリにはすでに main.c
がハーネスコードとして提供されており、テスト対象の関数は mime7to8
です。この関数の役割は base64 エンコードまたは quoted-printable エンコードされたテキストを実際のデータに解析することです。
- base64:64 文字(6 ビット)を使用してバイナリデータを表現します。つまり、3 バイトのデータは 4 文字でエンコードする必要があります。
- quoted-printable:印刷不可能な ASCII 文字に対して、その 16 進数の文字表現の前に等号を付けて、3 文字でエンコードします。
AFL-training では、ファジング時に永続モードとマルチコア並行を使用することが推奨されています。ENVELOPE
構造体には文字列を直接格納できるデータメンバーがないように見えるため、一時的なファイル名を指定する必要があるため、ここでは共有メモリを直接使用してテストケースを渡すことはできません。
共有メモリの他に、遅延初期化もあまり効果がないようです。初期化部分のコードは非常にシンプルに見え、あまり時間がかからないため、私は単に __AFL_LOOP
を使用しました。
#include "my-sendmail.h"
#include <assert.h>
int main(int argc, char **argv)
{
HDR *header;
register ENVELOPE *e;
FILE *temp;
while (__AFL_LOOP(1000))
{
temp = fopen(argv[1], "r");
assert(temp != NULL);
header = (HDR *)malloc(sizeof(struct header));
header->h_field = "Content-Transfer-Encoding";
header->h_value = "quoted-printable";
header->h_link = NULL;
header->h_flags = 0;
e = (ENVELOPE *)malloc(sizeof(struct envelope));
e->e_id = "First Entry";
e->e_dfp = temp;
mime7to8(header, e);
fclose(temp);
free(e);
free(header);
}
return 0;
}
クラッシュを引き起こすテストケースを見つけた後、tmin を使用して簡略化しました:
0000000000000000000000=
0000000000000000000000000000000000000000=
00000000=
000000
脆弱性#
実際にクラッシュを引き起こす問題は非常に単純で、mime7to8
関数内で、buf と obuf はそれぞれ読み取りと書き込みのバッファであり、この関数は obuf に書き込む際にポインタ位置を検証していないため、次の書き込み位置を指す obp が「範囲外」に出てしまいます。
mime7to8
内では、canary は obp が範囲外に出ることによってその内容が上書きされます。canary がなければ、obp は最終的に buf の範囲内に移動し、本来読み取るべきデータが「汚染」されることになります。
sendmail#1305#
リポジトリにはハーネスが提供されており、私は永続モードを利用して改写しました。
// ...
__AFL_FUZZ_INIT();
int main(){
const int MAX_MESSAGE_SIZE = 1000;
char special_char = '\377'; /* 0xff と同じ文字。この文字は NOCHAR として解釈されます */
int delim = '\0';
static char **delimptr = NULL;
char *addr;
OperatorChars = NULL;
ConfigLevel = 5;
addr = (char *) malloc(sizeof(char) * MAX_MESSAGE_SIZE);
CurEnv = (ENVELOPE *) malloc(sizeof(struct envelope));
CurEnv->e_to = (char *) malloc(MAX_MESSAGE_SIZE * sizeof(char) + 1);
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; // must be after __AFL_INIT
while (__AFL_LOOP(1000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
strncpy(addr, buf, len);
addr[len] = '\0';
memcpy(CurEnv->e_to, addr, len);
CurEnv->e_to[len] = '\0';
parseaddr(addr, delim, delimptr);
}
return 0;
}
ANSWERS.md では、ハーネスのコードにいくつかのグローバル変数が使用されており、これらの変数がプログラムの状態となり、永続モードに影響を与える可能性があると著者は指摘していますが、私の観察によれば、実際に用途がありプログラムの文脈に密接に関連している変数は CurEnv
のみであり、parseaddr
関数もこの変数の唯一のフィールド e_to
に対する読み取り操作のみを含んでいるため、私が改写した永続モードは機能するはずです。
しかし、今回はクラッシュテストケースを見つけることができないようです。。。
date#
date.c
の main 関数にグローバル変数が存在するかどうかわからないため、私は自由に __AFL_LOOP
を改写することができませんでした。後のデバッグ時に STDIN を使用する方が便利であり、STDIN からテストケースを渡すことと共有メモリを使用することの効率差はそれほど大きくない(効率に大きく影響するのはフォークの部分です)ため、ANSWERS.md に従って date.c
をハーネスに改写し、getenv
の前に env
を設定するコードを追加しました。
static char val[1024 * 16];
read(0, val, sizeof(val) - 1);
setenv("TZ", val, 1);
char const *tzstring = getenv ("TZ");
timezone_t tz = tzalloc (tzstring);
脆弱性#
Savannah Git Hosting - gnulib.git/commit による説明は、環境変数 TZ の長さが ABBR_SIZE_MIN (119)
を超えたため、extend_abbr
関数内でヒープメモリのオーバーフローが発生することです。
私は GDB を使用してトレースしましたが、確かに extend_abbr
関数内で、tzalloc
によって割り当てられた tz->abbrs
メモリの後に新しい文字列を追加しようとしてポインタが越境しました。
ntpq#
脆弱性は主に cookedprint(datatype, length, data, status, stdout)
関数に存在します。C/S モードのプログラムに対して、私は正常なプログラムの相互作用をシミュレートする必要があると思っていました。つまり、ntpd を起動してから ntpq 側でファジングを行う必要があると考えましたが、cookedprint
のプログラムロジックを考慮すると、ANSWERS.md
では引数に対して意味のないファジングを行い、大きな文字列を STDIN として変異させ、各パラメータに分割することが直接示されています。
datatype=0;
status=0;
memset(data,0,1024*16);
read(0, &datatype, 1);
read(0, &status, 1);
length = read(0, data, 1024 * 16);
cookedprint(datatype, length, data, status, stdout);
コードカバレッジの観点から見ると、4.2.2 バージョンでは新しいパスをトリガーするテストケースが cookedprint
関数をカバーしていますが、4.2.8p10 バージョンのコードはカバーされていません(##### はカバーされていないことを示します)。
まとめ#
AFL およびその関連ツールは、ファジングテストをほぼ「箱から出してすぐに」実現することができるようにしました。オープンソースプログラムをファジングするには、コンパイル時にインストゥルメンテーションを行い、適切なハーネスプログラムを作成するだけで、残りは完全に「手放して」AFL に任せることができます。
しかし、ファジング中にいくつかの難題にも直面しました:
- ファジングの効率をあらゆる手段で向上させる方法。テストケースの変異、選択、スケジューリング、実行の過程には、まだ大きな効率改善の余地があります。AFL-training の各チャレンジを試みた際、10 時間以上経過してもクラッシュを発見できなかったチャレンジ(sendmail#1305)が 1 つありましたが、他のプログラムは比較的短時間で多かれ少なかれクラッシュを発見できました。ただし、これは脆弱性の位置が既知であるため、脆弱性のある関数を個別に取り出してハーネスを作成できたからです。脆弱性が未知の実際の状況では、効率をさらに向上させる必要があります。
- ファジングプロセス中に発見されたクラッシュを分析する方法。AFL-training のほとんどの CVE はメモリオーバーフローに関連しています。オーバーフローの最も明白な理解は、ポインタがその指すべき領域を超えたことを意味しますが、「越境」が発生する理由を理解するには、プログラム自身のメモリ割り当てやアクセスパターンを考慮する必要があります。知らないプログラムに対しては、GDB などのツールを使用してステップデバッグを行う必要があります。