All Articles

Android アプリの解析問題に負けないために学んだ 5 つのテクニック

これまで CTF で APK の解析が出ると静的解析で解けない場合には諦めてしまっていたのですが、いい加減 Android アプリの動的解析手法も学ばなければと思ったので、勉強を兼ねてこの記事を書いていきます。

Cryptoverse CTF 2023 で出題された「Java Not Interesting」をテーマに、Android アプリの初歩的な動的解析手法をまとめていきます。

もくじ

APK ファイルを展開して静的解析を行う

まずは APK ファイルを展開して、おおよその実装を把握します。

APK ファイルは Apktooldex2jar などを使って展開することができます。

apktool d app-debug.apk

また、Android Studio を使用して [File]>[Profile or Debug APK] から APK ファイルをロードすることでも展開することが可能です。

image-20230511195619986

解析対象の APK ファイルの概要を把握するために、まず初めにAndroidManifest.xmlを確認します。

AndroidManifest.xml を読む

AndroidManifest.xmlは、 Android アプリの開発に必須のマニフェストファイルです。

ここでは、APK の依存関係となる SDK のバージョンや、Android Studio によるデバッグの可否、アプリの Permission など、様々な要素の宣言が行われます。

例えば、今回の解析対象であるapp-debug.apkのマニフェストファイルからは、以下のような情報を読み取ることができます。

# 最小の SDK バージョンが 23
<uses-sdk
android:minSdkVersion="23"
android:targetSdkVersion="33" 
/>

# アプリがデバッグ可能な状態に構成されている
# Main Activity が com.example.hellojni.HelloJni である
<application
android:debuggable="true"

    <activity
        android:name="com.example.hellojni.HelloJni"
        android:exported="true">
    </activity>
/>

参考:アプリ マニフェストの概要 | Android デベロッパー | Android Developers

参考:AndroidManifest.xml - Monaca Docs

ちなみに、今回のように解析対象のdebuggableが True になっていない場合には、APK ファイルを改ざんして再パッケージ化する必要があります。

詳細については以下が参考になります。

参考:Android の改竄とリバースエンジニアリング - owasp-mastg-ja

Main Activity を調査する

引き続き解析対象のアプリの概要を把握するために調査を行います。

AndroidManifest.xmlの内容を把握したら、次に最初に呼び出される Main Activity を参照してみます。

今回のバイナリの場合はcom.example.hellojni.HelloJniが Main Activity として登録されていました。

Android Studio で APK を展開した場合、この情報はjava/com.example/hellojni/HelloJni.smaliから参照できます。

.smali形式は、Android の Java VM(dalvik) で使用される dex 形式のバイナリをディスアセンブルしたものです。

ちなみに.smali形式への変換は可逆的なものなので、編集した上で再度 APK 形式に戻すことも可能です。

.smali形式のファイルはそのまま読んでもある程度理解できますが、smali2java などを使用して Java 形式にデコンパイルするとさらに読みやすくなります。

smali2java は以下のコマンドで使用できます。

smali2java_windows_amd64.exe  -path_to_smali=./HelloJini.smali

参考:AlexeySoshin/smali2java: Recreate Java code from Smali

今回取得したjava/com.example/hellojni/HelloJni.smaliのデコンパイル結果は以下のようになりました。

// {{ 省略 }}

private native final Boolean checkValid ( java.lang.String p0 ) {
} // .end method

private native final java.lang.String getFIaggg ( ) {
} // .end method

private static final void onCreate$lambda$0 ( com.example.hellojni.HelloJni p0, com.example.hellojni.databinding.ActivityHelloJniBinding p1, android.view.View p2 ) {
/* .registers 5 */
/* .param p0, "this$0" # Lcom/example/hellojni/HelloJni; */
/* .param p1, "$binding" # Lcom/example/hellojni/databinding/ActivityHelloJniBinding; */
/* .param p2, "it" # Landroid/view/View; */
final String v0 = "this$0"; // const-string v0, "this$0"
kotlin.jvm.internal.Intrinsics .checkNotNullParameter ( p0,v0 );
final String v0 = "$binding"; // const-string v0, "$binding"
kotlin.jvm.internal.Intrinsics .checkNotNullParameter ( p1,v0 );
/* .line 30 */
v0 = this.flag;
(( android.widget.EditText ) v0 ).getText ( ); // invoke-virtual {v0}, Landroid/widget/EditText;->getText()Landroid/text/Editable;
(( java.lang.Object ) v0 ).toString ( ); // invoke-virtual {v0}, Ljava/lang/Object;->toString()Ljava/lang/String;
v0 = /* invoke-direct {p0, v0}, Lcom/example/hellojni/HelloJni;->checkValid(Ljava/lang/String;)Z */
if ( v0 != null) { // if-eqz v0, :cond_24
	 /* .line 31 */
	 v0 = this.valid;
	 final String v1 = "Correct!"; // const-string v1, "Correct!"
	 /* check-cast v1, Ljava/lang/CharSequence; */
	 (( android.widget.TextView ) v0 ).setText ( v1 ); // invoke-virtual {v0, v1}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V
	 /* .line 33 */
} // :cond_24
v0 = this.valid;
v1 = this.flag;
(( android.widget.EditText ) v1 ).getText ( ); // invoke-virtual {v1}, Landroid/widget/EditText;->getText()Landroid/text/Editable;
(( java.lang.Object ) v1 ).toString ( ); // invoke-virtual {v1}, Ljava/lang/Object;->toString()Ljava/lang/String;
/* check-cast v1, Ljava/lang/CharSequence; */
(( android.widget.TextView ) v0 ).setText ( v1 ); // invoke-virtual {v0, v1}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V
/* .line 35 */
} // :goto_35
return;
} // .end method

/* # virtual methods */
protected void onCreate ( android.os.Bundle p0 ) {
/* .registers 5 */
/* .param p1, "savedInstanceState" # Landroid/os/Bundle; */
/* .line 25 */
/* invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V */
/* .line 27 */
(( com.example.hellojni.HelloJni ) p0 ).getLayoutInflater ( ); // invoke-virtual {p0}, Lcom/example/hellojni/HelloJni;->getLayoutInflater()Landroid/view/LayoutInflater;
com.example.hellojni.databinding.ActivityHelloJniBinding .inflate ( v0 );
final String v1 = "inflate(layoutInflater)"; // const-string v1, "inflate(layoutInflater)"
kotlin.jvm.internal.Intrinsics .checkNotNullExpressionValue ( v0,v1 );
/* .line 28 */
/* .local v0, "binding":Lcom/example/hellojni/databinding/ActivityHelloJniBinding; */
(( com.example.hellojni.databinding.ActivityHelloJniBinding ) v0 ).getRoot ( ); // invoke-virtual {v0}, Lcom/example/hellojni/databinding/ActivityHelloJniBinding;->getRoot()Landroidx/constraintlayout/widget/ConstraintLayout;
/* check-cast v1, Landroid/view/View; */
(( com.example.hellojni.HelloJni ) p0 ).setContentView ( v1 ); // invoke-virtual {p0, v1}, Lcom/example/hellojni/HelloJni;->setContentView(Landroid/view/View;)V
/* .line 29 */
v1 = this.getflag;
/* new-instance v2, Lcom/example/hellojni/HelloJni$$ExternalSyntheticLambda0; */
/* invoke-direct {v2, p0, v0}, Lcom/example/hellojni/HelloJni$$ExternalSyntheticLambda0;-><init>(Lcom/example/hellojni/HelloJni;Lcom/example/hellojni/databinding/ActivityHelloJniBinding;)V */
(( android.widget.Button ) v1 ).setOnClickListener ( v2 ); // invoke-virtual {v1, v2}, Landroid/widget/Button;->setOnClickListener(Landroid/view/View$OnClickListener;)V
/* .line 36 */
return;
} // .end method

上記のコードを読むと、onCreateで作成されたボタンをタップすると、フォームに入力されている文字列とともにonCreate$lambda$0 が呼び出されることがわかります。

ここで与えられた文字列は以下の箇所でinvoke-direct {p0, v0}によって呼び出されるcheckValid関数に与えられ、検証されることがわかります。

/* .line 30 */
v0 = this.flag;
(( android.widget.EditText ) v0 ).getText ( ); // invoke-virtual {v0}, Landroid/widget/EditText;->getText()Landroid/text/Editable;
(( java.lang.Object ) v0 ).toString ( ); // invoke-virtual {v0}, Ljava/lang/Object;->toString()Ljava/lang/String;
v0 = /* invoke-direct {p0, v0}, Lcom/example/hellojni/HelloJni;->checkValid(Ljava/lang/String;)Z */
if ( v0 != null) { // if-eqz v0, :cond_24
	 /* .line 31 */
	 v0 = this.valid;
	 final String v1 = "Correct!"; // const-string v1, "Correct!"
	 /* check-cast v1, Ljava/lang/CharSequence; */
	 (( android.widget.TextView ) v0 ).setText ( v1 ); // invoke-virtual {v0, v1}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V
	 /* .line 33 */
} // :cond_24

そして、checkValid関数による検証結果が True の場合には、画面にCorrect!を表示するようです。

つまり、ここで正しい Flag が入力値として与えられたかどうかは、checkValid関数によって判定されると推察できます。

ここから Flag を取得するためにcheckValid関数の実装を調べようと思いましたが、checkValid関数を宣言している以下の行を見ると、この関数はネイティブライブラリとして定義されているもののようでした。

private native final Boolean checkValid ( java.lang.String p0 ) {} // .end method

そのため、ここからさらにネイティブライブラリの解析を進めていきます。

ネイティブライブラリをデコンパイルする

先ほど記載した以下のコマンドで APK ファイルを展開すると、app-debug/lib/arm64-v8a配下にlibhello-jni.soが存在していることがわかります。

apktool d app-debug.apk

checkValid関数はこの中で実装されているため、このファイルを Ghidra で解析します。

なお、 app-debug/lib/配下には x86 などの他のディレクトリも存在しますが、現在の Android 環境では基本的に arm64-v8a に対応するアプリを作成することが要求されるため、arm64-v8a を一旦解析対象とします。

後述するように x86_64 アーキテクチャの環境で実行するエミュレータ上でデバッグを行う場合などには、必要に応じて x86 などのバイナリをデコンパイルします。

参考:64 ビット アーキテクチャのサポート | Android デベロッパー | Android Developers

Ghidra でデコンパイルすると、checkValid関数の処理を特定することができます。

image-20230511222152913

デコンパイル結果から、どうやら Flag は 0x26 文字であり、7 文字目から 0x25 文字目まではcVar1 != *pcVar5の行で 1 文字ずつ検証されているようです。

(最初の 6 文字と最後の 1 文字が無視されているのは、恐らく Flag のフォーマットがcvctf{}だからです。)

lVar3 = FUN_0011f5ac(auStack_38);
if (lVar3 == 0x26) {
  _ZNSt6__ndk16vectorIiNS_9allocatorIiEEEC2Em(auStack_50,0x1f);
  for (j = 0; j < 0x1f; j = j + 1) {
    dVar6 = (double)FUN_0011f654(2,j % 0x10);
    piVar4 = (int *)FUN_0011f698(auStack_50,(long)j);
    *piVar4 = ((int)dVar6 % 0x25) % 0x1f;
  }
  _ZNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC2IDnEEPKc
            (auStack_68,&UNK_001141ef);
  for (i = 6; i < 0x25; i = i + 1) {
    pcVar5 = (char *)FUN_0011f6bc(auStack_38,(long)i);
    cVar1 = *pcVar5;
    piVar4 = (int *)FUN_0011f698(auStack_50,(long)((i * 7) % 0x1f));
    pcVar5 = (char *)FUN_0011f6bc(auStack_68,(long)*piVar4);
    if (cVar1 != *pcVar5) {
      uStack_6c = 0;
      goto LAB_0011f508;
    }
  }
  uStack_6c = 1;
LAB_0011f508:
  _ZNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEED2Ev(auStack_68);
  FUN_0011f6ec(auStack_50);
}

ここで、pcVar5を取得している(char *)FUN_0011f6bc(auStack_68,(long)*piVar4);の関数を静的解析することでも恐らく Flag は取得できますが、この関数の処理はそれなりに複雑であり、静的解析で出力結果を求めるのはかなり時間を要するように思われます。

このような場合、動的解析を使用して(char *)FUN_0011f6bc(auStack_68,(long)*piVar4);の戻り値を 1 つずつ取得していくことで、より簡単に Flag を取得できます。

そこで、ここからは Android アプリのネイティブライブラリの動的解析にフォーカスして解析を進めていきます。

Android Studio のデバッガで動的解析を行う

AndroidManifest.xml を参照するとわかる通り、今回の APK ファイルはデバッグが初めから有効化されていました。

そのため、Android Studio を使用して [File]>[Profile or Debug APK] から APK ファイルをロードした後に、上部のツールバー上にある虫のボタンをクリックすることで、自動的にエミュレータ上でデバッグを開始することができます。

(デバッグが有効化されていない場合は、AndroidManifest.xml を編集した後に再度 APK ファイルとしてパッキングする必要があります。)

image-20230511223051867

Android Studio でデバッグを開始すると、エミュレータで APK が起動した後、以下のようなデバッグウインドウが立ち上がります。

image-20230511223323738

ここからネイティブライブラリにシンボルを設定することでデバッグを楽にすることができそうですが、残念ながら解析対象のシンボルは手元にないため使用できません。

image-20230511224051351

Android Studio でネイティブアプリのデバッグを行う

Android Studio でネイティブアプリのデバッグを行うために、[Run]>[Edit Configurations] から設定ウインドウを起動します。

image-20230512215555817

次に、[Debug Type] を [Native Only] に設定します。

image-20230512215655342

これで Android Studio のデバッガからネイティブライブラリのデバックを行えるようになります。

この設定変更の後、もう一度デバッグボタンを押してアプリのデバッグを開始します。

アプリのアーキテクチャとイメージベースを特定する

Android Studio でネイティブライブラリのデバッグを有効化したので、いよいよ Flag の検証を行っている箇所にブレークポイントを設定して処理を追っていきます。

ここからの作業は、一般的な ELF バイナリの動的解析と大きく変わりません。

ブレークポイントの設定対象を特定するために、まずは解析対象のネイティブライブラリがロードされているイメージベースを特定します。

デバックを開始した後にアプリの処理を Break すると、以下のように停止箇所のディスアセンブル結果と LLDB のコンソールが起動します。

image-20230512234021749

続いて、LLDB のコンソールで以下コマンドを実行してロードされているイメージを列挙します。

image list

このコマンドの実行結果から解析対象のlibhello-jni.soが表示されている行を探し、イメージベースのアドレスを特定します。

image-20230512234257109

今回は0xd4b0d000がイメージベースアドレスになっていました。(アプリを起動する度に変わります。)

続いて、イメージベースアドレスのメモリ情報を参照し、ELF ヘッダからロードされているイメージのアーキテクチャを特定します。

image-20230512234532190

同じみの Wikipedia の表を参照し、オフセット 0x12 のe_machineが 0x3 であることから、今回のデバッグでロードされているイメージが x86 アーキテクチャであることを確認できます。

参考:Executable and Linkable Format - Wikipedia

そこで、先ほど Apktool で展開した x86 用のネイティブライブラリを Ghidra で解析し、checkValid関数のオフセットを特定します。

Ghidra の解析結果から、checkValid関数のオフセットは0x1a020であることがわかるので、これにイメージベースアドレスを加算した0xd4b27020をディスアセンブリウィンドウに入力します。

これで、checkValid関数のアドレスを特定することができました。

image-20230512235123740

ブレークポイントを設定し、Flag を特定する

ここまで来たらあとは簡単です。

先ほど静的解析で特定した通り、このネイティブライブラリ関数は 38(0x26) 文字の入力を受け付け、先頭 7 文字目から 37 文字目までをif (cVar1 != *pcVar2)の行で 1 文字ずつ検証します。

そのため、イメージベースアドレスに検証行のオフセットを加算した0xd4b2720cにブレークポイントを設定しました。

# ブレークポイントを設定する
b *0xd4b2720c

ここにブレークポイントを設定した後、エミュレータ上のアプリで適当な 38 文字を入力して [Check Flag] ボタンを押すと、設定したブレークポイントで処理が停止します。

image-20230512235908953

この時の ECX には正解の Flag 文字が格納されており、入力値である EAX と比較を行います。

そのため、以下のように ECX の値を出力した後に ECX の値を EAX に書き込みしてから処理を継続すると、正解の Flag を一文字ずつ取得できます。

本来は LLDB 組込みの Python を使って自動化したかったのですが、デフォルトの Android Studio のデバッガだと source コマンドが動作しなかったので、ソースファイルによる自動化を行いました。

# cmd.txt に以下を記載
p/c $ecx
register write eax `$ecx`
continue

# ソースファイルの実行
command source C:\Users\Tadpole01\Downloads\cmd.txt

これで、以下のように 1 文字ずつ特定することで Flag を取得できました。

image-20230513000202912

ちなみに LLDB のコマンドについては以下が参考になりました。

参考:GDB to LLDB command map — The LLDB Debugger

エミュレータにデバッガをアタッチして動的解析を行う

エミュレータに APK をインストールする

今度は Android Studio のデバッグ機能を使用するのではなく、システムで稼働するエミュレータ内で起動したアプリケーションにデバッガをアタッチしていきます。

まずは、エミュレータを起動した状態で以下のコマンドを実行して解析対象の APK ファイルをインストールします。

adb install app-debug.apk

adb.exeは Android SDK に含まれており、以下からダウンロードできます。

参考:Download Android Studio & App Tools - Android Developers

APK のインストールが完了すると、起動中のエミュレータでアプリを起動できるようになります。

続いて、エミュレータにデバッガをアタッチするために事前に Android NDK ツールもダウンロードしておきます。

NDK ツールは以下からダウンロードできます。

参考:NDK Downloads | Android NDK | Android Developers

ちなみに、エミュレータにデバッガをアタッチするために NDK ツールを使用しますが、現在最新の r25 では r23 までの NDK ツールに存在していた gdbserver が同梱されなくなっているようです。

できれば使い慣れた gdb を使いたいと思っていましたが、今後のことも考え LLDB を使用することにします。

ホストマシンに LLDB をインストールする

次に、ホストマシンにエミュレータにアタッチするための LLDB をインストールします。

LLDB をインストールするには、以下の Github ページのリリースページから Windows 向けのインストーラをダウンロードします。

参考:GitHub - llvm/llvm-project: The LLVM Project is a collection of modular and reusable compiler and toolchain technologies. Note: the repository does not accept github pull requests at this moment. Please submit your patches at http://reviews.llvm.org.

今回使用したバージョン 16.0.3 は Python10 を依存関係としているため、Python10 がインストールされていて PATH が通っていない環境の場合は、別途インストールしておく必要があります。

Android エミュレータに lldb-server を展開し、アプリを起動する

いよいよ Android エミュレータで起動したアプリにホストマシンの LLDB をアタッチしてデバックを行っていきます。

まずは、エミュレータ側に lldb-server を展開します。

ビルド済みの lldb-server は、先ほど取得した NDK の toolchains 内に配置されています。

今回は OS のプラットフォームに合わせて$NDK_PATH\toolchains\llvm\prebuilt\windows-x86_64\lib64\clang\14.0.7\lib\linux\i386\lldb-serverのパスに存在していた lldb-server を使用しました。

# APK ファイルをエミュレータにインストールする
adb install app-debug.apk

# lldb-server をエミュレータにプッシュ
adb push lldb-server /data/local/tmp

# lldb-server に実行権限を付与
adb shell chmod +x /data/local/tmp/lldb-server

# 解析対象のパッケージのパスに lldb-server をコピー
adb shell run-as com.example.hellojni cp /data/local/tmp/lldb-server /data/data/com.example.hellojni/

上記のコマンドでは、最終的にプッシュした lldb-server に実行権限を付与して、解析対象のパッケージのパスにコピーしています。

解析対象のパッケージ名については、先ほど解析した AndroidManifest.xml から特定できます。

デバッガをアタッチする

いよいよデバッグの準備が整ったので、デバッグを開始します。

以下のコマンドを順に実行していきますが、先に解析対象のパッケージの情報を参照しておきます。

# パッケージ情報のダンプ
adb shell pm dump com.example.hellojni

>
DUMP OF SERVICE package:
Activity Resolver Table:
  Non-Data Actions:
      android.intent.action.MAIN:
        6525ec5 com.example.hellojni/.HelloJni filter 3d4e3e9
          Action: "android.intent.action.MAIN"
          Category: "android.intent.category.LAUNCHER"
{{ 中略 }}

dump コマンドでは非常に多くの情報を参照できますが、特にパッケージの ActivityName を参照しておきます。

上記の出力結果のcom.example.hellojni/.HelloJniの箇所をメモしておきます。

ここまでですべての準備が整ったので、以下のコマンドでデバッグを開始します。

ちなみに、com.example.hellojniには解析対象のパッケージ名が入ります。

# lldb-server を停止しておく(初回は不要)
adb shell run-as com.example.hellojni killall -9 lldb-server

# adb shell am start -a <Main Activity> -D -n "com.package.name/com.package.name.ActivityName" でアプリを起動
adb shell am start -a android.intent.action.MAIN -n "com.example.hellojni/.HelloJni"

# アプリが起動したら、lldb-server の待ち受けを開始
adb shell run-as com.example.hellojni sh -c '/data/data/com.example.hellojni/lldb-server platform --server --listen unix-abstract:///data/data/com.example.hellojni/debug.socket'

# 別のターミナルを開き、アプリの PID を確認
adb shell run-as com.example.hellojni ps

# lldb を起動
lldb.exe

# (lldb) のプロンプト上で順に実行
platform select remote-android
platform connect unix-abstract-connect:///data/data/com.example.hellojni/debug.socket

# 先ほど確認したアプリの PID にアタッチ
attach 17728

参考:How to find out activity names in a package? android. ADB shell - Stack Overflow

参考:How to start an application using Android ADB tools - Stack Overflow

これで、エミュレータで起動したアプリにホストの LLDB からアタッチできます。

後は先ほどと同じく以下のコマンドで Flag を特定できます。

# libhello-jni.so のベースアドレスを特定
image list
>
[202] 2720F64E-E4CD-4F44-2073-43B9B03816BB-CE41285A 0xd86b5000 libhello-jni.so

# 0xd86b5000+0x1a20c のアドレスにブレークポイントを設定してアプリの実行を再開
b *0xd86cf20c
continue

これでエミュレータで起動したアプリで Flag の検証箇所にブレークポイントを設定できるようになったので、先ほどと同じ方法で Flag を取得できるようになりました。

image-20230514112929403

ただし、今回はもう少し解析を楽にするために LLDB のスクリプトを使用したいと思います。

Python で LLDB を操作する

LLDB は、Python を使用したインターフェースがかなり手厚くサポートされています。

LLDB の Python インタプリタが利用できるか確認するために、アタッチしたデバッガで以下のコマンドを入力してみます。

Android Studio から起動したデバッガではなぜか使用できませんでしたが、今回ホストからアタッチした環境ではインタプリタを起動することができるようになっていました。

# LLDB 埋め込みの Python インタプリタを起動
script

参考:Python Reference — The LLDB Debugger

これで LLDB で Python スクリプトを使用できることがわかったので、以下の手順で Flag の取得を自動化します。

今回は、ブレークポイント到達時に処理する Python スクリプトを登録することで Flag を取得することにしました。

# 設定したブレークポイントの ID を特定する
br list
>
1: address = libhello-jni.so[0x0001a20c], locations = 1, resolved = 1, hit count = 0

# 特定したブレークポイント ID を使用して、ブレークポイント到達時の処理を定義
script counter=0
breakpoint command add --script-type python 1

# 以下を入力
import time

eax = frame.FindRegister("eax")
ecx = frame.FindRegister("ecx").value
eax.SetValueFromCString(ecx)
print(chr(int(ecx,16)),end="")

time.sleep(1)
thread = frame.GetThread()
process = thread.GetProcess()
process.Continue()
DONE

実際に実行してみると、以下のように Flag 文字を取得することができました。

image-20230514131809863

スクリプトが使えるので、Android Studio 上でデバッグするよりもリモートアタッチする方が楽な気がしますね。

ライブラリ関数を dlopen して ELF バイナリとして動的解析を行う

今回の問題の場合は、残念ながら C で実装したプログラムから与えた引数の文字列をネイティブライブラリ側のGetStringUTFChars関数に上手く連結することができず Flag を取得するところまではいけませんでしたが、解析対象によってはネイティブライブラリの関数を自作のプログラムから dlopen することで簡単に Flag の取得やデバッグをできるようになる場合があります。

ネイティブライブラリの NDK バージョンを特定する

ネイティブライブラリを dlopen して実行するにあたり、ネイティブライブラリの依存関係を満たすライブラリを取得する必要があります。

ビルド済み APK に含まれるネイティブライブラリのバージョンは Android エミュレータに配置したネイティブライブラリファイルを file コマンドで調査することで特定できます。

そのため、まずは以下のコマンドでエミュレータに libhello-jni.so を配置して file コマンドでバージョンを調査してみました。

# solver.out をエミュレータにプッシュ
adb push libhello-jni.so /data/local/tmp

# ファイルコマンドでバージョンを特定
adb shell file /data/local/tmp/libhello-jni.so

image-20230514193110857

これで今回のライブラリをビルドした NDK のバージョンは r25b であることがわかったので、https://dl.google.com/android/repository/android-ndk-r25b-windows.zip から NDK r25b のファイルをダウンロードします。

ダウンロードしたファイルを展開し、ndk-build.cmd を使用して展開すると、android-ndk-r25b\toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib配下から各プラットフォーム用のビルド済みライブラリファイルを取得できます。

今回は x86 用のバイナリを使用するため、i686-linux-android内にビルドされたライブラリファイルをコピーしておきます。

実行ファイルの作成

依存関係となるライブラリファイルの用意ができたので、続いてネイティブライブラリを呼び出すバイナリを作成します。

今回は以下のコードを使用しました。

checkValid関数のシンボルは Ghidra で特定したものをそのまま使用できました。

#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main()
{
    void *handle;
    char *error;

    handle = dlopen("./libhello-jni.so", RTLD_LAZY);
    if (!handle)
    {
        fprintf(stderr, "%s\n", dlerror());
        exit(EXIT_FAILURE);
    }

    dlerror();
    int (*checkValid)(wchar_t *) = dlsym(handle, "Java_com_example_hellojni_HelloJni_checkValid");
    error = dlerror();
    if (error != NULL)
    {
        fprintf(stderr, "%s\n", error);
        exit(EXIT_FAILURE);
    }
    printf("pid : %d\n", getpid());
    puts("dlopen success");
    printf("handle addr : %p\n", handle);

    wchar_t text = L"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    int i = checkValid(text);
    printf("Result : %d\n", i);
    dlclose(handle);
    return 0;
}

このコードを、以下のコマンドでビルドした後、gdb を使用して起動します。

実行時には、コンパイルしたファイルと libhello-jni.so、そして libandroid.so などを含む先ほどコピーしたライブラリファイルをすべて同じディレクトリに配置しておきます。

# x86 バイナリとしてビルド
gcc solver.c -m32 -o solver.out

# カレントディレクトリをライブラリパスに追加
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH

# gdb でプログラムを実行
gdb ./solver.out

これを実行すると、ネイティブライブラリで実装されていたcheckValid関数を呼び出せることがわかります。

今回は残念ながら C で実装したプログラムから与えた引数の文字列をネイティブライブラリ側のGetStringUTFChars関数に上手く連結することができず、Flag を取得するところまでは到達できませんでしたが、以下のように gdb を使って Flag 検証を行う処理まで進めること自体はできました。

image-20230514185015218

特にアプリからの引数を必要としないネイティブライブラリ関数などは、この方法で簡単にデバッグできるようになります。

Frida-hook で動的解析を行う

Frida は、Javascript のコードを Windows、macOS、Linux、iOS、Android など様々なプラットフォームのネイティブアプリにインジェクションして解析を行うことができるツールです。

ドキュメントを見ると、Frida のコアは C で実装されていて、QuickJS Javascript Engine をターゲットのブラックボックスなプロセスにインジェクションすることで、関数をフックしたり、プロセス内の任意の関数を呼び出したりすることができるようです。

参考:Welcome | Frida • A world-class dynamic instrumentation toolkit

Frida のセットアップ

さっそく Frida を使って Android アプリを解析するためのセットアップを行います。

まずはホストマシンに pip を使って frida-tools をインストールします。

# Windows マシンに frida-tools をインストール
python3.exe -m pip install frida-tools

手元の環境で Frida をインストールしたところ、Frida の実行バイナリは

%USERPROFILE%\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\LocalCache\local-packages\Python310\Scriptsの配下に存在していました。

そのため、ダウンロードした一連のファイルを適当なフォルダに配置して PATH を通しておきます。

# Frida の存在確認
frida.exe --version
>
16.0.19

これでホストマシンへの Frida のインストールが完了したので、次にエミュレータ上の Android マシンに frida-server を構成します。

以下の、Frida のリリースページから、先ほど確認したホストマシンの Frida と同じバージョンの frida-server をダウンロードし、解凍します。

参考:Releases · frida/frida

今回はエミュレータ上で実行するので、frida-server-16.0.19-android-x86.xzをダウンロードしました。

もし Android の実機に導入する場合は ARM バージョンを選択します。

APK ファイルと frida-server をエミュレータに展開する方法は、前述の lldb-server と同じ要領で行うことが可能ですが、今回は root 化が必要になる点に注意が必要です。

エミュレータにadb rootコマンドで root アクセスする場合、adbd cannot run as root in production buildsというエラーが出力される場合があります。

このような問題を発生させないためには、作成するエミュレータの OS で [Play Store] の欄が空欄のものを選択しておく必要があります。

また、Android 12 以降が既定で構成されている場合、OS のプラットフォームバージョンが x86_64 になるので注意が必要です。

その場合は、frida-server-16.0.19-android-x86_64 を使用することで frida-server を起動できます。

image-20230514222221793

[Play Store] の欄が空欄のエミュレータを使用している場合には、adb rootコマンドが有効に動作するため、以下のコマンドで frida-server を起動できるようになります。

# adb の root 化
adb root

# APK をエミュレータにインストールする
adb install app-debug.apk

# 解凍したファイルをリネーム
# x86_64 プラットフォームの場合は frida-server-16.0.19-android-x86_64 を使用
ren frida-server-16.0.19-android-x86 frida-server

# frida-server をエミュレータにプッシュして実行権限を割り当て
adb push frida-server /data/local/tmp
adb shell chmod +x /data/local/tmp/frida-server

# frida-server を起動
adb shell /data/local/tmp/frida-server

frida-hook によるデバッグを行う

エミュレータ側で frida-server を起動したら、いよいよアプリの解析に入ります。

frida-hook で解析を行う操作自体は非常に簡単です。

基本的には、Python スクリプトでエミュレータ側の frida-server にアタッチし、スクリプト内に埋め込んだ Javascript コードに処理をさせるようなイメージです。

まずは前半部分を見ていきます。

ここまでで、Python スクリプトからアタッチする frida-server の特定を行っています。

DEVICE_NAME は、adb devicesコマンドで特定が可能です。

import frida
import sys

def on_message(message, data):
    if message['type'] == 'send':
        print("[*] {0}".format(message['payload']))
    else:
        print(message)

# エミュレータに接続
DEVICE_NAME = "emulator-5554"
APK_PROCESS_NAME = "HelloJni"
process = frida.get_device_manager().get_device(DEVICE_NAME).attach(APK_PROCESS_NAME)

次に、今回のメインとなる Javascript 部分です。基本的には、今までのデバッガを使った動的解析と同じ発想で Flag を取得します。

まず、ネイティブライブラリのファイル名からModule.findBaseAddress(module_name)でイメージベースのアドレスを取得させています。

そして、Ghidra で解析済みの Flag を検証するコードのオフセットを加算して target_address としています。

var module_name = "libhello-jni.so";
// var sym_name = "Java_com_example_hellojni_HelloJni_checkValid";

var base_addr = Module.findBaseAddress(module_name);
var target_offset = 0x1a20c
var target_address = base_addr.add(target_offset);

var flag = "";

次に、Interceptor.attachonEnter:を使用してアプリの処理が target_address に到達したタイミングで行う処理を定義します。(ブレークポイントのようなもの)

今までの解析で特定した通り、Flag を取得するために行う必要がある操作は以下でした。

  1. アプリから 38 文字のテキストを入力して [Check Flag] ボタンを押す。
  2. ネイティブライブラリのオフセット 0x1a20c の行で ecx に格納されている値から Flag の 1 文字を取得する。
  3. ecx の値を eax にコピーして処理を継続する。

以下のコードでは、this.context.ecxを使用して target_address に到達した際に ecx レジスタの値の取得と eax レジスタの改ざんを行っています。

Interceptor.attach(target_address, {
  onEnter: function(args) {
    // console.log("eax: " + this.context.eax);
    // console.log("ecx: " + this.context.ecx);

    // Flag 文字の取得
    flag = flag + String.fromCharCode(this.context.ecx);
    console.log(flag);

    // レジスタの値の書き換え
    this.context.eax = this.context.ecx;
  },
  onLeave: function(retval) {
    // Do nothing
  }
});

最終的に実装したコードは以下のようになりました。

import frida
import sys

def on_message(message, data):
    if message['type'] == 'send':
        print("[*] {0}".format(message['payload']))
    else:
        print(message)

# エミュレータに接続
DEVICE_NAME = "emulator-5554"
APK_PROCESS_NAME = "HelloJni"
process = frida.get_device_manager().get_device(DEVICE_NAME).attach(APK_PROCESS_NAME)

jscode = """
var module_name = "libhello-jni.so";
var sym_name = "Java_com_example_hellojni_HelloJni_checkValid";

var base_addr = Module.findBaseAddress(module_name);
var target_offset = 0x1a20c
var target_address = base_addr.add(target_offset);

var flag = "";

// console.log(base_addr);
// console.log(target_address);

Interceptor.attach(target_address, {
  onEnter: function(args) {
    // console.log("eax: " + this.context.eax);
    // console.log("ecx: " + this.context.ecx);

    // Flag 文字の取得
    flag = flag + String.fromCharCode(this.context.ecx);
    console.log(flag);

    // レジスタの値の書き換え
    this.context.eax = this.context.ecx;
  },
  onLeave: function(retval) {
    // Do nothing
  }
});
"""

script = process.create_script(jscode)
script.on("message", on_message)
print("[*] Running APK")

script.load()
sys.stdin.read()

これをコマンドプロンプトから実行することで、以下のように Flag を取得することができました。

image-20230514234120616

おまけ:シンボリック実行で動的解析を行う

angr の公式ドキュメントを読むとネイティブライブラリを使用した Android アプリのシンボリック実行をサポートしているような記載がありました。

参考:experimental java, android, and jni support in angr

上記のページをみると、実際に CTF で出題された Android アプリの解析問題で angr を使って Flag を取得した例が記載されています。

今回の問題では angr を使用する方法は試していませんが、また後日気が向いたら追記しようと思います。

まとめ

今回は CTF で出題された Android アプリの動的解析のために 4 つ(+1)のアプローチを実践してみました。

特に、frida-hook はいつかやろうと思ってずっと放置してた手法でしたが、実際にやってみるとかなり手軽な上に非常に強力なツールだと感じました。

Android 以外の解析でも役に立ちそうなので、また色々と使っていこうと思います。