概要
敢えて脆弱に作られたモバイルアプリでモバイルアプリセキュリティテストの演習を行うことができるUncrackAble1を用いて、OWASPのドキュメントであるMASTGに基づいたセキュリティテストのやり方をまとめていく。
この記事は以下の記事で触れた内容を省略しているので、こちらを先に読んでいただくことを推奨します。
検証環境:Android11エミュレータ(Pixel4a)
使用ツール:adb, jadx, Frida(16.6)
問題
以下のような、Secretを入れる入力欄があるシンプルなアプリケーションで、秘密の文字列を入手することが最終目標です。
注意すべきは、見て分かる通りルート化検出が行われていることです。
ルート化検出に関する詳しいことは、前回の記事を参考にしてください。
よって次のようなステップで解説していきます。
1.ルート化検知手法の調査
2.ルート化検知のバイパス
3.SecretのVerify部分の調査
4.Secretの入手
解説
1.ルート化検知手法の調査
まずはルート化検知を行っている部分を調査します。
jadxで見ていきます。
jadx-gui
まずはMainActivityを調べます。
AndoroGoatと比べてとてもシンプルな構造になっているので、比較的探しやすいと思います。

Root化検知部分を探していきます。
探し方のコツとしては、Rootを検知した際に出ている文言をさがすことです。
今回は"Root Detected!"になります。
すぐonCreate関数に見つかると思います。
protected void onCreate(Bundle bundle) {
if (c.a() || c.b() || c.c()) {
a("Root detected!");
}
if (b.a(getApplicationContext())) {
a("App is debuggable!");
}
super.onCreate(bundle);
setContentView(R.layout.activity_main);
}
c.a()、c.b()、c.c()の3つで検知しているとわかります。
さっそくこの3つの関数を見ていきましょう。
jadxのguiでは、ダブルクリックするだけでそのクラスや関数に飛ぶことができます。
cクラスはこのようになっています。
public class c {
public static boolean a() {
for (String str : System.getenv("PATH").split(":")) {
if (new File(str, "su").exists()) {
return true;
}
}
return false;
}
public static boolean b() {
String str = Build.TAGS;
return str != null && str.contains("test-keys");
}
public static boolean c() {
for (String str : new String[]{"/system/app/Superuser.apk", "/system/xbin/daemonsu", "/system/etc/init.d/99SuperSUDaemon", "/system/bin/.ext/.su", "/system/etc/.has_su_daemon", "/system/etc/.installed_su_daemon", "/dev/com.koushikdutta.superuser.daemon/"}) {
if (new File(str).exists()) {
return true;
}
}
return false;
}
- c()では前回同様一般的なルート化アプリのパッケージファイル等が存在するかを確認しています。
- a()ではスーパーユーザー(root)権限への切り替えに使われるsu実行ファイルが環境変数に存在するかを確認しています。
- b()ではビルドタグという「unsigned,debug」のように、ビルドを説明するコンマ区切りのタグがtest-keyを含むかをかくにんしています。
ビルド番号にtest-keysがつくのはAOSPなどで独自にOSビルドをし、署名をしていない状態のアプリケーションのことであるので、改変された可能性のある環境であるからです。
これでルート化の判別方法がわかりました。
これらの判別方法は前回同様MASTGの0x5jに記載されています。
またビルドタグについては以下が参考になります。
2.ルート化検知のバイパス
2-1 JSコード解説
では実際に先ほどのルート化判別を回避していきましょう。
今回もFridaを使っていきます。
Fridaセットアップは前回の記事で触れているのでそちらを参考にしてください。
さっそくルート化をバイパスするスクリプトを書いていきますが、内容も大まかには前回と同様です。
以下JSコード
Java.perform(function() {
send("Start Scrpt");
var rootDetectionActivity = Java.use("sg.vantagepoint.a.c");
rootDetectionActivity.a.implementation = function (){
return false;
}
rootDetectionActivity.b.implementation = function (){
return false;
}
rootDetectionActivity.c.implementation = function (){
return false;
}
})
ほとんど変わらないのでざっくりと説明します。
Java.use()にパスとクラス名を入れます。
そしてFridaのimplementationメソッドを使って、それぞれの関数の処理を無名関数内の処理に上書きしています。
これで全てのチェックが常にfalseを返すようになり、ルート化検出をバイパスできます。
2-2 Pythonコード解説
以下は送信用のPythonコード
import frida
import sys
# ターゲットのAPKパッケージ名を記入
APK_PROCESS_NAME = "owasp.mstg.uncrackable1"
# Hookするデバイス名を記入
DEVICE_NAME = "emulator-5554"
def on_message(message, data):
if message["type"] == "send":
print(f"[*] {message['payload']}")
else:
print(message)
jscode = """
Java.perform(function() {
send("Start Scrpt");
var c = Java.use("sg.vantagepoint.a.c");
c.a.implementation = function (){
send("Hooking is Rooted a method");
return false;
}
c.b.implementation = function (){
send("Hooking is Rooted b method");
return false;
}
c.c.implementation = function (){
return false;
}
})
"""
# USBデバッグ時用
# process = frida.get_usb_device().attach("owasp.mstg.uncrackable1")
# AVD用
device = frida.get_usb_device()
pid = device.spawn([APK_PROCESS_NAME])
process = device.attach(pid)
script = process.create_script(jscode)
script.on("message", on_message)
print("[*] Running Uncrackable1")
script.load()
device.resume(pid)
sys.stdin.read()
注目するべきはこちらのコードです。
まず前回ではプロセス名だったのが、パッケージ名に変わっています。
そもそもパッケージ名とプロセス名の違いは何かというと
パッケージ名はAndroidシステムがアプリを識別するために一意なものであり、プロセス名はOSがメモリ上で実行中のプロセスを管理している識別子です。
そして今回パッケージ名を使う理由はパッケージ名を実際には使っている、spawn()によるものです。
この関数はその名の通り、プロセスを生成することの他にそのidを返すことも行う関数です。
なぜ前回は使われなかったこの関数が使われているかというと
それはRoot化検知の関数が発火するタイミングにあります。
前のアプリではボタンを押したタイミングで関数が実行されました。
しかし今回は、なんのユーザー操作もなく、onCreate内で自動的に実行されています。
attachを使うにはすでにプロセスが起動している必要があるため、そのまま使うとonCreateの実行がさきに行われるのでルート化検知のバイパスが間に合いません。
そのため新たにプロセスを生成して、onCreateが実行される前にhookする必要があります。
よって今回はspawnを使う必要がありました。
結局なぜパッケージ名を使うかというと、先ほど言ったようにプロセス名は実行中のプロセスに割り振られます。
よって新たにプロセスを作成するときに使えるはずがありません。使えるのアプリを一意に識別できるパッケージ名となるわけです。
参考
残る操作は前回と同じなので説明は飛ばします。
ターゲットデバイス側でfridaを立ててからスクリプトを実行することを忘れないでください。
これで入力欄に文字列を入力できるようになりました。

3.SecretのVerify部分の調査
3-1 シークレット入手のための調査
最初と同様jadxから該当部分を探します。
もうメインと該当部分しかありあせんが、手がかりを探すためにアプリを動かします。
まず、先ほど書けるようになった入力欄に適当な文字を入力してVERIFYボタンを押すと、以下のようなモーダルがでます。

この文言を頼りに該当部分を探すと、verify関数が見つかります。
なかでも重要なのが以下の部分です
String obj = ((EditText) findViewById(R.id.edit_text)).getText().toString();
AlertDialog create = new AlertDialog.Builder(this).create();
if (a.a(obj)) {
create.setTitle("Success!");
str = "This is the correct secret.";
} else {
create.setTitle("Nope...");
str = "That's not it. Try again.";
}
objは単純に入力された文字列です。それを比較しているのがa.a()になります。
文字列比較部分に飛んでコードを確認します。
public class a {
public static boolean a(String str) {
byte[] bArr;
byte[] bArr2 = new byte[0];
try {
bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
} catch (Exception e) {
Log.d("CodeCheck", "AES error:" + e.getMessage());
bArr = bArr2;
}
return str.equals(new String(bArr));
}
public static byte[] b(String str) {
int length = str.length();
byte[] bArr = new byte[length / 2];
for (int i = 0; i < length; i += 2) {
bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16));
}
return bArr;
}
}
aの戻り値に注目します。
return str.equals(new String(bArr));
aの引数が入力文字列なので、入力された文字列とbArrを比較した結果が一致しているかを見ています。
つまり比較対象のbArrが今回の目標であるシークレットとなります。
Fridaでhookするにはこれだけ分かれば充分です。
しかしながらこのコードを正しく理解するためにbArrについてももう少し見ていきたいと思います。
3-2 コード理解のための調査
もう一度bArrがなにか抜き出しておきます。
bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
b()は単純にStringから2文字ずつをバイト値に変更して配列に入れています。Base64.decodeもそのままBase64からデコードしているだけです。また使われている値はハードコードされているのに簡単に復元できそうです。
Base64知らない人向け:https://developer.mozilla.org/ja/docs/Glossary/Base64
しかしながらbArrがなにか確認するには、sg.vantagepoint.a.a.a()の処理内容も先に知っておく必要があります。
ややこしいですが、今見ていたのはsg.vantagepoint.uncrackable1パッケージのaクラスであり、次に見るのがsg.vantagepoint.aパッケージ内のaクラスです。
先ほどからクラス名やメソッド名が何をしているかわかりませんが、これらはjadxでデコンパイル時に一部情報が消えてしまっているためと思われます。ですのできちんと処理の流れを追ってコードを見ることが重要です。
sg.vantagepoint.aのaメソッドは以下のようになっています。
public static byte[] a(byte[] bArr, byte[] bArr2) {
SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES/ECB/PKCS7Padding");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(2, secretKeySpec);
return cipher.doFinal(bArr2);
}
上から説明していきます。
SecretKeySpecのコンストラクタは第1引数にバイト配列、第2引数にアルゴリズムを指定して秘密鍵を生成しています。
Cipher.getInstanceで引数に入れた暗号化アルゴリズムで暗号化または復元を行うオブジェクトを取得しいます。
そしてinitではopmodeとkeyを渡して暗号を初期化しているのですが、このopemodeとはENCRYPT_MODE、DECRYPT_MODE、WRAP_MODE、またはUNWRAP_MODEのどれかで数値で表すこともできます。
左から1~4となっています。(参照:https://docs.oracle.com/javase/jp/8/docs/api/constant-values.html#javax.crypto.Cipher.DECRYPT_MODE)
doFinalでは初期化したモードでそれを実行しています。
ここで重要なのは、2はDECRYPT_MODEつまり復号の操作ということです。
つまりこの関数は第1引数をシークレットキーとして第2引数を復号する関数でした。
ただし返るのは戻り値の型から分かるようにStringではなくバイトの配列です。
4.Secretの入手
4-1 Fridaを使った入手方法
今回はFridaを使うやり方の他にもう一つ、pythonで復号処理を各やり方を説明したいと思います。
そのためにもbArrの詳しい説明を行いました。
まずはいつも通りFridaを使ってhookしたいと思います。
以下がJSのコード
Java.perform(function () {
// sg.vantagepoint.a.a クラスの a メソッドをフック
var AESHelper = Java.use("sg.vantagepoint.a.a");
AESHelper.a.implementation = function (key, data) {
var result = this.a(key, data);
console.log("[+] ===== AES Decryption Result =====");
console.log("[+] bArr (string): " + bytesToString(result));
console.log("[+] ===================================");
return result;
};
// ヘルパー関数:バイト配列を文字列に変換
function bytesToString(bytes) {
var str = "";
for (var i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
}
});
特徴として、ルート化検出バイパスでは無名関数に引数がありませんでしたが、今回sg.vantagepoint.a.aクラスのa関数は普通にsg.vantagepoint.uncrackable1.aクラスのa関数内でbArrとして呼ばれるので、引数が2つないとエラーでアプリが落ちます。
また当然Keyと復号するdataの両方がないと復号できないので、keyとdataを受け取っています。
その次の行でresultに入れているthis.a()ですが、thisにはsg.vantagepoint.a.aクラスのオブジェクトが入っているので元のa関数が呼び出されています。
今回はシークレットの文字列をターミナルに出力するので、console.logで結果が見やすいようにしておきます。
最後のbytesToStringは名前の通り、バイト配列から文字列に変換しています。
これでresultがそのまま文字列として出力されます。
以下のようにpythonにいれて実行します。
import frida
import sys
# ターゲットのプロセス名を記入
APK_PROCESS_NAME = "Uncrackable1"
# Hookするデバイス名を記入
DEVICE_NAME = "emulator-5554"
def on_message(message, data):
if message["type"] == "send":
print(f"[*] {message['payload']}")
else:
print(message)
jscode = """
Java.perform(function() {
var AESHelper = Java.use("sg.vantagepoint.a.a");
AESHelper.a.implementation = function(key, data) {
var result = this.a(key, data);
console.log("[+] ===== AES Decryption Result =====");
console.log("[+] bArr (string): " + bytesToString(result));
console.log("[+] ===================================");
return result;
};
function bytesToString(bytes) {
var str = "";
for (var i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
}
});
"""
# USBデバッグ時用
# process = frida.get_usb_device().attach("owasp.mstg.uncrackable1")
# AVD用
process = frida.get_device_manager().get_device(DEVICE_NAME).attach(APK_PROCESS_NAME)
script = process.create_script(jscode)
script.on("message", on_message)
print("[*] Running Uncrackable1")
script.load()
sys.stdin.read()
今回はプロセス名であることに注意してください。
そしてpythonコードを実行した後、sg.vantagepoint.uncrackable1.aクラスのaが呼び出されるには文字列が入力されてボタンが押される必要があるので適当に画面にaとか入れてVERIFYを押します。
すると画面には最初と同じようにNope...と出ますがターミナルを見るとシークレットがこのように出力されます。
[*] Running Uncrackable1
[+] ===== AES Decryption Result =====
[+] bArr (string): I want to believe
[+] ===================================
実際に「I want to believe」といれてみるとSucces!とでます。
(小ネタですが、エミュレータではクリックを長押しするとペーストできます。)
これで抜き出すことに成功しました。
4-1 Pythonのみを使った入手方法
続いてFridaを使わずpythonだけでシークレットを出力する方法を説明します。
bArrを詳しく見て分かる通り、秘密鍵と暗号データ、暗号化アルゴリズムまでもが判明しています。
よって復号処理を自力で実装すればよいだけです。
ほぼ元のコードをコピペでJavaでも実装できますが、コードの簡単さと理解のためpythonを用います。
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
def hex_to_bytes(hex_string):
"""16進文字列をバイト配列に変換"""
return bytes.fromhex(hex_string)
def aes_decrypt(key_bytes, encrypted_data):
"""AES/ECB/PKCS7Padding復号化"""
cipher = AES.new(key_bytes, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_data)
# PKCS7パディングを除去
return unpad(decrypted, AES.block_size)
def main():
# Uncrackable1から取得した値
key_hex = "8d127684cbc37c17616d806cf50473cc"
encrypted_base64 = "5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc="
# キーをバイト配列に変換
key_bytes = hex_to_bytes(key_hex)
# Base64デコード
encrypted_data = base64.b64decode(encrypted_base64)
# AES復号化
try:
decrypted = aes_decrypt(key_bytes, encrypted_data)
print(f"復号化結果 (hex): {decrypted.hex()}")
print(f"復号化結果 (文字列): {decrypted.decode('utf-8')}")
except Exception as e:
print(f"復号化エラー: {e}")
if __name__ == "__main__":
main()
内容はbArrの解説とコメントを見て分かる通りです。
このようにちゃんと出力できました。
復号化結果 (hex): 492077616e7420746f2062656c69657665
復号化結果 (文字列): I want to believe
まとめ
今回は、以下のことを学びました。
- ルート化検出からアプリ起動時に発火するメソッドのhookの仕方
- spawnの有無におけるパッケージ名とプロセス名の使い分け
- 丸々書きかるのではなく元の関数を使ったhook
- AESでの簡単な復号処理
セキュリティ的な問題点と解決方法の考察
- セキュリティ対策がルート化検出しか実装していないので簡単にルート化検出バイパスやシークレットのhookができた
→Frida自体の検出であったり、アンチデバッグの機能を追加する - 暗号データとシークレットキーがハードコードされていたのでFridaが使えなくてもシークレットを抜き出せた。
→秘密鍵をenvなどで安全に管理して、暗号化ロジックに難読化をかける
参考文献
https://github.com/OWASP/owasp-mastg/blob/master/Document/0x05j-Testing-Resiliency-Against-Reverse-Engineering.md
https://frida.re/docs/gadget/
https://docs.oracle.com/javase/jp/8/docs/api/javax/crypto/Cipher.html
https://docs.oracle.com/javase/jp/8/docs/api/javax/crypto/spec/SecretKeySpec.html