これまで CTF で APK の解析が出ると静的解析で解けない場合には諦めてしまっていたのですが、いい加減 Android アプリの動的解析手法も学ばなければと思ったので、勉強を兼ねてこの記事を書いていきます。
Cryptoverse CTF 2023 で出題された「Java Not Interesting」をテーマに、Android アプリの初歩的な動的解析手法をまとめていきます。
もくじ
APK ファイルを展開して静的解析を行う
まずは APK ファイルを展開して、おおよその実装を把握します。
APK ファイルは Apktool や dex2jar などを使って展開することができます。
apktool d app-debug.apk
また、Android Studio を使用して [File]>[Profile or Debug APK] から APK ファイルをロードすることでも展開することが可能です。
解析対象の 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
関数の処理を特定することができます。
デコンパイル結果から、どうやら 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 ファイルとしてパッキングする必要があります。)
Android Studio でデバッグを開始すると、エミュレータで APK が起動した後、以下のようなデバッグウインドウが立ち上がります。
ここからネイティブライブラリにシンボルを設定することでデバッグを楽にすることができそうですが、残念ながら解析対象のシンボルは手元にないため使用できません。
Android Studio でネイティブアプリのデバッグを行う
Android Studio でネイティブアプリのデバッグを行うために、[Run]>[Edit Configurations] から設定ウインドウを起動します。
次に、[Debug Type] を [Native Only] に設定します。
これで Android Studio のデバッガからネイティブライブラリのデバックを行えるようになります。
この設定変更の後、もう一度デバッグボタンを押してアプリのデバッグを開始します。
アプリのアーキテクチャとイメージベースを特定する
Android Studio でネイティブライブラリのデバッグを有効化したので、いよいよ Flag の検証を行っている箇所にブレークポイントを設定して処理を追っていきます。
ここからの作業は、一般的な ELF バイナリの動的解析と大きく変わりません。
ブレークポイントの設定対象を特定するために、まずは解析対象のネイティブライブラリがロードされているイメージベースを特定します。
デバックを開始した後にアプリの処理を Break すると、以下のように停止箇所のディスアセンブル結果と LLDB のコンソールが起動します。
続いて、LLDB のコンソールで以下コマンドを実行してロードされているイメージを列挙します。
image list
このコマンドの実行結果から解析対象のlibhello-jni.so
が表示されている行を探し、イメージベースのアドレスを特定します。
今回は0xd4b0d000
がイメージベースアドレスになっていました。(アプリを起動する度に変わります。)
続いて、イメージベースアドレスのメモリ情報を参照し、ELF ヘッダからロードされているイメージのアーキテクチャを特定します。
同じみの Wikipedia の表を参照し、オフセット 0x12 のe_machine
が 0x3 であることから、今回のデバッグでロードされているイメージが x86 アーキテクチャであることを確認できます。
参考:Executable and Linkable Format - Wikipedia
そこで、先ほど Apktool で展開した x86 用のネイティブライブラリを Ghidra で解析し、checkValid
関数のオフセットを特定します。
Ghidra の解析結果から、checkValid
関数のオフセットは0x1a020
であることがわかるので、これにイメージベースアドレスを加算した0xd4b27020
をディスアセンブリウィンドウに入力します。
これで、checkValid
関数のアドレスを特定することができました。
ブレークポイントを設定し、Flag を特定する
ここまで来たらあとは簡単です。
先ほど静的解析で特定した通り、このネイティブライブラリ関数は 38(0x26) 文字の入力を受け付け、先頭 7 文字目から 37 文字目までをif (cVar1 != *pcVar2)
の行で 1 文字ずつ検証します。
そのため、イメージベースアドレスに検証行のオフセットを加算した0xd4b2720c
にブレークポイントを設定しました。
# ブレークポイントを設定する
b *0xd4b2720c
ここにブレークポイントを設定した後、エミュレータ上のアプリで適当な 38 文字を入力して [Check Flag] ボタンを押すと、設定したブレークポイントで処理が停止します。
この時の 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 を取得できました。
ちなみに 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 向けのインストーラをダウンロードします。
今回使用したバージョン 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 を取得できるようになりました。
ただし、今回はもう少し解析を楽にするために 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 文字を取得することができました。
スクリプトが使えるので、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
これで今回のライブラリをビルドした 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 検証を行う処理まで進めること自体はできました。
特にアプリからの引数を必要としないネイティブライブラリ関数などは、この方法で簡単にデバッグできるようになります。
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 をダウンロードし、解凍します。
今回はエミュレータ上で実行するので、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 を起動できます。
[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.attach
のonEnter:
を使用してアプリの処理が target_address に到達したタイミングで行う処理を定義します。(ブレークポイントのようなもの)
今までの解析で特定した通り、Flag を取得するために行う必要がある操作は以下でした。
- アプリから 38 文字のテキストを入力して [Check Flag] ボタンを押す。
- ネイティブライブラリのオフセット 0x1a20c の行で ecx に格納されている値から Flag の 1 文字を取得する。
- 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 を取得することができました。
おまけ:シンボリック実行で動的解析を行う
angr の公式ドキュメントを読むとネイティブライブラリを使用した Android アプリのシンボリック実行をサポートしているような記載がありました。
参考:experimental java, android, and jni support in angr
上記のページをみると、実際に CTF で出題された Android アプリの解析問題で angr を使って Flag を取得した例が記載されています。
今回の問題では angr を使用する方法は試していませんが、また後日気が向いたら追記しようと思います。
まとめ
今回は CTF で出題された Android アプリの動的解析のために 4 つ(+1)のアプローチを実践してみました。
特に、frida-hook はいつかやろうと思ってずっと放置してた手法でしたが、実際にやってみるとかなり手軽な上に非常に強力なツールだと感じました。
Android 以外の解析でも役に立ちそうなので、また色々と使っていこうと思います。