前回は、暗号化の方法について紹介しました。
しかし、元に戻せる方法で保存する以上、何かしらの共通鍵を保持しておく必要があります。
単純にアプリ内の定数として保持しておくと、リバースエンジニアリングに非常に弱いので、多少の工夫が欲しいところです。
ところで、タイトルには反しますが、アプリ内で元に戻せる形でパスワードを保存する以上、本当に安全な方法は存在しません。
簡易的な対策を組み合わせることで、ちょっとした用途なら必要十分なセキュリティを確保するのが目的です。
先に、APKのリバースエンジニアリング方法を簡単にご紹介しておきましょう。
リソース、マニフェスト
apktoolが便利です。
インストールしたら、適当なAPKファイルのあるところで
apktool d myapp.apk
すれば、フォルダに展開されます。リソースやマニフェストは完全に見放題。
ソースコード
まず、apkの拡張子を.zipに変更して、ZIP解凍します。
次に、その中にあるclasses.dexをdex2jarでjarに変換します。
あとは、JD-GUIに読ませれば、かなり可読性高くソースが読めます。
コメントは当然消えるほか、一部の制御構造・ジェネリクスがたまに違う表現になりますが、ほぼ完全にソースコードが再現されます。機械語の逆アセンブルとは違います。
上記のようなリバースエンジニアリングでも共通鍵を盗まれにくくするには、
- ソースコードを見られてもわかりにくくする
- Javaソースコードの中に鍵を置かない
といった方針が考えられます。
前者は、難読化で対処します。
デフォルトで搭載されているProGuardなどのツールを使い、変数名を意味不明にすることで、わかりにくくなります。
(private String KEY
よりも、private String A1
のほうが、ぱっと見パスワードだと分からない)
更に、ダミーの文字列をたくさん入れたり、ビット反転させた文字列を定数にして使用時に判定させたり、長い文字列の45~60バイト目といった途中を使うなど、一手間加工すると、多少読むのがめんどくさくなります。
また、初回起動時にランダムで生成した鍵をSharedPreferenceに置くことで、ソースコードだけを見ても分からないようにする方法もあります。これだけだと無意味ですが、他の方法と組み合わせることで、クラックの手間を増加させることができます。
後者は、端末固有の情報を使う方法、ネットワーク経由で取得する方法,.soなどのCソースコードの中に鍵を置く方法などが考えられます。
端末固有の情報のうち、そのアプリだけが安全に取得できる適切なものは思いつかないので、今回はこの方法は使えません(たとえばBlu-rayなどの光メディアでは、多少仕組みは違いますが、PCからは読めない範囲に鍵データを置いています。マザーボードのTPMも似た発想です)
ネットワーク経由で取得するには、発行するサーバ側が何かしらの方法でアプリを認証する必要があり(任意のリクエストに鍵を返してしまったら意味がない)、アプリは自身を証明するために秘密鍵を利用したり自身の実行ファイルのハッシュ値を送るなどしますが、本質的な安全性が上がるわけではありません。
(自身のハッシュ値を検証する方法はよく用いられますが、ソースコードを読み放題の場合は容易に偽装できるので無力です)
実装が複雑になるため、今回は割愛します。クラックの手間が多少上がるのと、あとからサーバ側に対策を追加しやすいため、某大手などではこの方法を採用しているようです。
.soファイルの中に鍵を置くのは、よく使われる手法です。
Cで書かれたソースコードは機械語にコンパイルされるため、Javaで書かれたコードに比べて大幅に読みにくくなります。難読化を施せば、多くの人は読むのが嫌になると思われます。
今回はこの方法を採用します。
JNIを使うので、Android NDKはインストールしておきます。
Java側のソースコードイメージはこのようになります。
package com.example.key; import android.content.Context; public class TestKey { static { System.loadLibrary("TestKey"); } public static native byte[] getApplicationKey(Context context); }
自動生成されるCのコードはこのような感じです。
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_example_key_TestKey */ #ifndef _Included_com_example_key_TestKey #define _Included_com_example_key_TestKey #ifdef __cplusplus extern "C" { #endif /* * Class: com_example_key_TestKey * Method: getApplicationKey * Signature: (Landroid/content/Context;)[B */ JNIEXPORT jbyteArray JNICALL Java_com_example_key_TestKey_getApplicationKey (JNIEnv *, jclass, jobject); #ifdef __cplusplus } #endif #endif
これに対応するソースを書きます。
JNIEXPORT jbyteArray JNICALL Java_com_example_key_TestKey_getApplicationKey (JNIEnv *env, jclass clazz, jobject context) { if (!verifyApplication(env, context)) // TODO-A: ここは独自実装 { jclass jcEx = (*env)->FindClass(env, "/java/lang/SecurityException"); (*env)->ThrowNew(env, jcEx, "verify error"); return NULL; } const jbyte *src = "keykeykeykeykeyk"; // TODO-B: ここは難読化 const int size = strlen(src); jbyteArray arr = (*env)->NewByteArray(env, size); (*env)->SetByteArrayRegion(env, arr, 0, size, src); free(src); return arr; }
※鍵取得ではなく、暗号化・復号自体を丸ごとCで書くのも良い方法です。こちらの方が、よりネイティブコードの割合が増えて、読む気が削がれます。
2カ所、TODOがありますね。ここが、安全性確保のポイントになります。
Bは、定数を直接書いたら、いくら機械語でもすぐに見つかってしまいます。アセンブラが読めない人は解読できない程度に、難読化しましょう。固定パターンのXORなど単純なものを組み合わせるだけで、大きな効果があります。
ゲームのパッキングで使われているように、プログラム本体を圧縮・暗号化しておいて、プログラム先頭に解凍・復号・実行するミニプログラムを配置する方法を採用すれば、相当強固になります。
Aは、生成された.soファイルを他のアプリから読み込まれたときに、馬鹿正直に鍵を渡さないためのガードです。
ここのロジックを公開してしまうと、クラックの答えを渡しているようなものなので、公開しないようにします。内容としては、自身のpidを確認する、getApplicationSignatureでAPK署名鍵を取得する、パッケージ名を確認する、クラスのstaticフィールドを確認する、などがありそうです。組み合わせることが大事です。
このサンプルでは、contextを渡していますので、これを使ってapplicationSignatureやパッケージ名の検証ができるはずです。
以上、少し長くなってしまいましたが、これらの対策をカスタマイズすることで、鍵を盗むまでにかなりの技術と時間をかけさせることができます。
ちょっとしたアプリのパスワード保存や、ゲームのチート防止のためのアプリ検証などには、必要十分なバランスを実現できると思います。