MASTG-KNOW-0028 アンチデバッグ (Anti-Debugging)

デバッグはアプリのランタイム動作を解析する非常に効果的な方法です。これによりリバースエンジニアがコードをステップ実行し、任意の箇所でアプリの実行を停止し、変数の状態を検査し、メモリを読み取りおよび変更し、さらに多くのことを可能にします。

アンチデバッグ機能には予防型と反応型があります。名前が示すように、予防型アンチデバッグはまず第一にデバッガがアタッチすることを防ぎます。反応型アンチデバッグはデバッガを検出し、何らかの方法でそれに反応します (アプリの終了や隠された動作のトリガなど) 。「多ければ多いほど良い」ルールが適用されます。効果を最大限にするため、防御側は、さまざまな API レイヤーで動作しアプリ全体に分散される、複数の予防と検出の手法を組み合わせます。

"リバースエンジニアリングと改竄" の章で述べたように、 Android では二つの異なるデバッグプロトコルを扱う必要があります。 JDWP を使用した Java レベルと、 ptrace ベースのデバッガを使用したネイティブレイヤーでデバッグが可能です。優れたアンチデバッグスキームでは両方のデバッグタイプに対して防御する必要があります。

JDWP アンチデバッグ

"リバースエンジニアリングと改竄" の章では、デバッガと Java 仮想マシンとの間の通信に使用されるプロトコルである JDWP について説明しました。マニフェストファイルにパッチを適用して任意のアプリを容易にデバッグ可能にできることや、 ro.debuggable システムプロパティを変更することであらゆるアプリをデバッグ可能にできることを示しました。開発者が JDWP デバッガを検出および無効にするために行ういくつかのことを見てみます。

ApplicationInfo のデバッグ可能フラグの確認

すでに android:debuggable 属性は出てきています。 Android Manifest のこのフラグは JDWP スレッドがアプリに対して起動されるかどうかを決定します。その値はアプリの ApplicationInfo オブジェクトを使用してプログラムで決定できます。このフラグが設定されている場合、これはマニフェストが改竄されてデバッグ可能になっています。

    public static boolean isDebuggable(Context context){

        return ((context.getApplicationContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0);

    }

isDebuggerConnected

これはリバースエンジニアにとって当たり前かもしれませんが、 android.os.Debug クラスの isDebuggerConnected を使用してデバッガが接続されているかどうかを確認できます。

    public static boolean detectDebugger() {
        return Debug.isDebuggerConnected();
    }

同じ API は DvmGlobals グローバル構造体にアクセスすることによりネイティブコードを介してコールすることができます。

タイマーチェック

Debug.threadCpuTimeNanos は現在のスレッドがコードの実行に費やした時間量を示します。デバッグはプロセスの実行を遅くするため、 実行時間の違いを使用して、デバッガがアタッチされているかどうかを推測することができます

JDWP 関連のデータ構造への干渉

Dalvik では、グローバル仮想マシンの状態は DvmGlobals 構造体を介してアクセス可能です。グローバル変数 gDvm はこの構造体へのポイントを保持しています。 DvmGlobals には JDWP デバッグに重要なさまざまな変数やポインタが含まれており、改竄可能です。

例えば、 gDvm.methDalvikDdmcServer_dispatch 関数ポインタに NULL を設定すると JDWP スレッドがクラッシュします

gDvm 変数が利用できない場合でも、 ART で同様の技法を使用してデバッグを無効にできます。 ART ランタイムは JDWP 関連のクラスの vtable の一部をグローバルシンボルとしてエクスポートします (C++ では、 vtable はクラスメソッドのポインタを保持するテーブルです) 。これには JdwpSocketState および JdwpAdbState クラスの vtable を含んでおり、これらはネットワークソケットと adb を介した JDWP 接続をそれぞれ処理します。デバッグランタイムの動作は 関連する vtable のメソッドポインタを上書きすることにより (archived) 操作できます。

メソッドポインタを上書きするための方法のひとつは jdwpAdbState::ProcessIncoming のアドレスを JdwpAdbState::Shutdown のアドレスで上書きすることです。これによりデバッガは直ちに切断されます。

従来のアンチデバッグ

Linux では、 ptrace システムコール を使用して、プロセス (tracee) の実行を監視および制御し、そのプロセスのメモリとレジスタを調べて変更します。 ptrace はネイティブコードでシステムコールトレースとブレークポイントデバッグを実装する主要な方法です。ほとんどの JDWP アンチデバッグトリック (タイマーベースのチェックには安全かもしれません) は ptrace をベースとする従来のデバッガをキャッチしないため、多くの Android アンチデバッグトリックには ptrace が含まれており、一つのプロセスにアタッチできるのは一度に一つのデバッガのみであるという事実を悪用することがよくあります。

TracerPid のチェック

アプリをデバッグしてネイティブコードにブレークポイントを設定する際、 Android Studio はターゲットデバイスに必要なファイルをコピーし、プロセスにアタッチするために ptrace を使用する lldb-server を起動します。この時点で、デバッグされるプロセスの ステータスファイル (/proc/<pid>/status または /proc/self/status) を検査すると、 "TracerPid" フィールドは 0 とは異なる値を持つことがわかります。これはデバッグの兆候です。

これはネイティブコードにのみ適用される ことに注意します。 Java/Kotlin のみのアプリをデバッグする場合には "TracerPid" フィールドの値は 0 になります。

この技法は通常 JNI ネイティブライブラリ内の C で適用されます。これは Google の gperftools (Google Performance Tools)) Heap Checker 実装の IsDebuggerAttached メソッドに示されています。ただし、このチェックを Java/Kotlin コードの一部として含める場合は、 Tim Strazzere の Anti-Emulator プロジェクト から hasTracerPid メソッドの Java 実装を参照します。

このようなメソッドを自分で実装しようとする場合は、 adb で TracerPid の値を手動で確認できます。以下のリストは Google の NDK サンプルアプリ hello-jni (com.example.hellojni) を使用して、 Android Studio のデバッガをアタッチした後にチェックを実行しています。

com.example.hellojni (PID=11657) のステータスファイルに 11839 の TracerPID がどのように含まれているかを確認できます。これは lldb-server プロセスとして識別できます。

fork と ptrace の使用

以下の簡単なコード例のようなコードを介して、子プロセスをフォークし、デバッガとして親プロセスにアタッチすることで、プロセスのデバッグを防止できます。

子プロセスがアタッチされていると、さらに親プロセスにアタッチしようとしても失敗します。これを検証するには、コードを JNI 関数にコンパイルし、デバイスで実行するアプリにパックします。

gdbserver で親プロセスにアタッチしようとすると以下のエラーで失敗します。

ただし、子プロセスを強制終了し、親プロセスがトレースから "解放" することで、この失敗を簡単にバイパスできます。したがって、複数のプロセスとスレッド、および改竄を阻止するための何らかの形の監視を含む、より緻密なスキームが通常見つかります。一般的な手法は以下のとおりです。

  • 互いにトレースする複数のプロセスをフォークします。

  • 実行中のプロセスを追跡して子プロセスが生存していることを確認します。

  • /proc/pid/status の TracerPID など、 /proc ファイルシステムの値を監視します。

上記の手法について簡単に改良してみましょう。最初の fork の後で、子プロセスのステータスを継続的に監視する追加のスレッドを親プロセスで起動します。アプリがデバッグモードまたはリリースモードのいずれでビルドされたか (マニフェストの android:debuggable フラグで示されます) に応じて、子プロセスは以下のいずれかを実行する必要があります。

  • リリースモードの場合: ptrace のコールが失敗し、子プロセスはセグメンテーションフォルト (終了コード 11) で直ちにクラッシュします。

  • デバッグモードの場合: ptrace のコールは機能し、子プロセスは無期限に実行されるはずです。したがって、 waitpid(child_pid) のコールは決して戻らないでしょう。もし戻るようであれば、何かが怪しいのでプロセスグループ全体を強制終了します。

以下は JNI 関数でこの改善を実装するための完全なコードです。

再び、これを Android アプリにパックして、機能するかどうかを確認します。以前と同様に、アプリのデバッグビルドを実行すると二つのプロセスが表示されます。

ただし、この時点で子プロセスを終了すると、親プロセスも終了します。

これをバイパスするには、アプリの動作を少し改変する必要があります (これを行う最も簡単な方法は _exit へのコールを NOP でパッチするか、 libc.so_exit 関数をフックすることです) 。この時点で、おなじみの "軍備拡張競争" に突入します。この防御をより複雑な形で実装することもそれをバイパスすることも常に可能です。

Last updated

Was this helpful?