筆者はチート対策の専門家ではなく、ゲーム開発者でもありません。趣味で齧っている程度でございます。
それに加え、このネタ記事は有用性のない雑な知識を垂れ流すだけの内容となっております。時間を無駄に浪費したくない方は、ブラウザバックをオススメします
はじめに
日々チート対策に追われ、決して終わる事のないイタチごっこを永遠にやらされているアプリ開発者の皆様、お疲れ様です。
既存のチート対策を施しても中々防げるものでなく
ハッカー達は、あらゆる手段を使ってそれを回避して来ます。殴りたくなりますよね。
そんな貴方にオススメ!
この記事では、一味違ったチート対策をご紹介させていただきます。
前提知識
・APKのデコンパイルに関するリバースエンジニアリングの知識
・IDA Pro、GhidraなどのDisassemblerに関する知識
・関数フックなどの低レベルの操作に関する知識
・SOインジェクションに関する知識
・Androidの内部動作に関する最低限の知識
・一般的なチート対策の知識
既存のチート対策の例
・Rootチェック
まずは恒例のRootチェック
private static boolean checkIsRooting() {
String[] paths = {"/system/app/Superuser.apk", "/system/bin/su"};
for (String path : paths) {
if (new File(path).exists()) return true;
}
return false;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (checkIsRooting()) //Root化を検知
Process.killProcess(Process.myPid()); //アプリを停止
}
Root化を検知して、メモリハックなどの被害を防ぐことが出来ます。
・ProGuardを使用する
ProGuardを使用してアプリの圧縮、最適化ついでに難読化をすることが出来ます。
これを適応することにより、APKをデコンパイルされても抽出されるソースコードは可読性が著しく下がります。
.class public Lxxx/xxxxx/ItemPurchase;
.super Ljava/lang/Object;
.source "ItemPurchase.java"
↓
.class public La0;
.super Ljava/lang/Object;
.source "SourceFile"
ソースファイルの情報を削除するので、ハッカーはこのクラスが何だったのかを知ることが出来なくなります
public void setPrice(int price) {
this.price = price;
}
↓
public void B(int val) {
this.f0 = val;
}
メゾッドも同様に難読化されます。
また、関数内のローカル変数の情報も削除します
public void test(){
Entity localPlayer = getLocalPlayer();
Camera camera = localPlayer.camera;
Matrix viewMatrix = camera.viewMatrix;
viewMatrix.rotate(1.2f, 1.2f, 1.2f);
}
test関数のsmaliはこんな感じ
.method public test()V
.locals 4
.line 21
invoke-virtual {p0}, Lxx/Client;->getLocalPlayer()Lxx/xx/Entity;
move-result-object v0
.line 22
.local v0, "localPlayer":Lxx/xx/Entity;
iget-object v1, v0, Lxx/xx/Entity;->camera:Lxx/xx/xx/Camera;
.line 23
.local v1, "camera":Lxx/xx/xx/Camera;
iget-object v2, v1, Lxx/xx/xx/Camera;->viewMatrix:Lxx/xx/Matrix;
.line 24
.local v2, "viewMatrix":Lxx/xx/Matrix;
const v3, 0x3f99999a # 1.2f
invoke-virtual {v2, v3, v3, v3}, Lxx/xx/Matrix;->rotate(FFF)V
.line 26
return-void
.end method
↓
.method public test()V
.locals 4
invoke-virtual {p0}, Lxx/s0;->S()Lc0;
move-result-object v0
iget-object v1, v0, Lc0;->p0:Lc0;
iget-object v2, v1, Lc0;->e0:Lm1;
const v3, 0x3f99999a # 1.2f
invoke-virtual {v2, v3, v3, v3}, Lm1;->N(FFF)V
return-void
.end method
lineの情報はsmaliからjavaへの変換であまり意味をなさないのですが、変数の名前を残さないのは大きな対策になります。
しかしながら、リフレクションを使用している場合は注意が必要です。
ClassNotFoundException, NoSuchMethodExceptionなどが発生する可能性がありますので、しっかりと気を付けて設定しましょう。
・シンボルを隠蔽する
C++でゲームなどを開発している方は、リリース時にシンボルを隠蔽しましょう。
externalNativeBuild {
cmake {
cppFlags '-std=c++17 -fvisibility=hidden'
}
}
シンボルが残っている場合、悪用される場合があります。
void LocalPlayer::tick(){
//実装
}
void LocalPlayer::setSpeed(float speed){
//実装
}
void (*LocalPlayer_setSpeed)(void *, float);
void hack(void *handle){
//シンボルから関数を取得
LocalPlayer_setSpeed = (void (*)(void *, float)) dlsym(handle, "_ZN11LocalPlayer8setSpeedEf");
}
//フックしたtick関数
void LocalPlayer_tick_hook(void *localPlayer){
LocalPlayer_setSpeed(localPlayer, 10000.f); //スピードハック!!
}
また、シンボルを隠してもJNIの関数のシンボルは残ります。
extern "C" JNIEXPORT jboolean JNICALL
Java_xxx_xxx_Crucial_method(JNIEnv *env, jclass cls) {
/*...*/
}
これはJNIEXPORTがこのような実装になっているためです
#define JNIEXPORT __attribute__ ((visibility ("default")))
シンボルはハッカーにとてつもないヒントを与えてしまうことになります。
じゃあJNIEXPORTを外せばいいじゃん!となりますが...
java.lang.UnsatisfiedLinkError: No implementation found for void xxx.xxx.Crucial.method()
見つからないと言われてしまうので、
jint JNIEnv::RegisterNatives(jclass clazz, const JNINativeMethod* methods, jint nMethods)
RegisterNatives関数を使用して動的にバインドしましょう
void bindMethod(JNIEnv *env){
jclass class_Crucial = env->FindClass("xxx/xxx/Crucial");
JNINativeMethod Crucial_methods[] = {"method", "()V", (void*)&method};
env->RegisterNatives(class_Crucial, Crucial_methods, sizeof(Crucial_methods) / sizeof(Crucial_methods[0]));
}
この方法でシンボルを隠すことが出来ます。面倒ですが、間違いなくハッカーからの被害を防ぐ有効な手口です
・i2cpp系
il2cppはあまり詳しくないのですが、
APKからil2cpp.soをlibフォルダー、global-metadata.datをassetsフォルダーから取り出され、Il2CppDumperでダンプしたdllをdnSpyなどに解析される...というのが一般的な流れだと思います。
解析されたデータには当然、関数やクラスの情報が載ってしまっています。
それがバレてしまえば悪用される可能性がありますので、難読化を施したり...
メモリ改ざんなど、様々なハックを防ぐAnti-Cheat Toolkitがあるようです。
また、global-metadata.datを難読化、ハッシュ値の計算も対策の一つとして挙げられます。
対策に対する回避
お次は、ハッカーがどのように対策を回避するのかを見ていきたいと思います
・Rootチェック回避
.method private static checkIsRooting()Z
.locals 6
.line 35
const-string v0, "/system/app/Superuser.apk"
const-string v1, "/system/bin/su"
filled-new-array {v0, v1}, [Ljava/lang/String;
move-result-object v0
.line 36
.local v0, "paths":[Ljava/lang/String;
array-length v1, v0
const/4 v2, 0x0
move v3, v2
:goto_0
if-ge v3, v1, :cond_1
aget-object v4, v0, v3
.line 37
.local v4, "path":Ljava/lang/String;
new-instance v5, Ljava/io/File;
invoke-direct {v5, v4}, Ljava/io/File;-><init>(Ljava/lang/String;)V
invoke-virtual {v5}, Ljava/io/File;->exists()Z
move-result v5
if-eqz v5, :cond_0
const/4 v1, 0x1
return v1
.line 36
.end local v4 # "path":Ljava/lang/String;
:cond_0
add-int/lit8 v3, v3, 0x1
goto :goto_0
.line 39
:cond_1
return v2
.end method
ハッカーに特定された場合、以下のように書き換えられます。
.method private static checkIsRooting()Z
.locals 1
const/4 v0, 0x0
return v0 #falseを返す
.end method
falseを返すように書き換えられ、回避されてしまいます
じゃあProGuard適応すればいいじゃん!となりますが...
const-string v1, "/system/app/Superuser.apk"
const-string v3, "/system/bin/su"
filled-new-array {v1, v3}, [Ljava/lang/String;
move-result-object v1
move v3, v2
:goto_2
const/4 v4, 0x2
if-ge v3, v4, :cond_4
aget-object v4, v1, v3
new-instance v5, Ljava/io/File;
invoke-direct {v5, v4}, Ljava/io/File;-><init>(Ljava/lang/String;)V
invoke-virtual {v5}, Ljava/io/File;->exists()Z
move-result v4
if-eqz v4, :cond_3
const/4 v2, 0x1
goto :goto_3
:cond_3
add-int/lit8 v3, v3, 0x1
goto :goto_2
よくよく見ると文字列が丸見えです。ハッカーはこれを検索してたどり着いてきます。文字列を書き換えてしまえば、検出は免れてしまいます。
const-string v1, "a" #書き換え
const-string v3, "b" #書き換え
Base64などを使ってもいいかもしれません
・ProGuard回避?
さて、手厚い対応で素晴らしいProGuardさんですが、
AndroidManifest.xmlに記載されているクラス、システム的に紐づけられているandroidパッケージ、javaパッケージ、MainActivityなどを含むパッケージ直下のクラスには難読化を施さない傾向があります。(設定すれば難読化します)
知識が豊富なハッカーは難読化されなかったクラス、リソースの紐づけ、文字列、スタックトレースの取得から処理を推測します。
やらないよりはマシ!
・シンボル隠蔽を回避?
シンボルを隠蔽しても、IDAやGhidraなどのDisassemblerを使用することによる文字列の解析で解析される場合があります。
void LocalPlayer::setPos(vec3 const& pos){
history.push("set Pos %f %f %f", pos.x, pos.y, pos.z); //情報の保存やログの出力、ログの保存
/*...*/
}
この文字列を参照している関数を探せば、簡単に見つけることが出来ます。
auto LocalPlayer_setPos = (void (*)(void *, vec3 *)) base_address + 0x0000000001ECD8
こうして悪用されてしまいます。
特にゲーム関係のクラスだとPlayerやEntityは継承関係にあり、仮想関数などを使っていると最悪です。(実装上仕方ないですが)
vtableが見つかってしまえば、更に解析が進められてしまいます。
ログや文字列を使用していない場合でも、初期リリースはシンボルを残してリリースしてしまった、という場合はバイナリ差分ツールであるBinDiffを使用して解析されます。
・i2cpp系
global-metadata.datが暗号化されている場合は、ランタイムでダンプを行います。
ターゲットのAPKにダンパーのライブラリを埋め込み、smailをダンパーのライブラリを読み込むように改変します。
const-string v0, "il2cppdumper"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
GameGurdianを使用して暗号化されたglobal-metadata.datをダンプする方法もあります。
・ならどうすればいいのか
丹精込めて施した対策ものらりくらり交わされてしまうようでは意味がありません。ならば、どうすればいいのでしょうか?
有効なハック対策は如何にハッカーを困らせ、いじめるかだと私は考えています。
もはや既存の方法は前菜代わりにもなりえません。
ならば、予想すらさせなければいいのです。その例をご紹介します
インストーラの検知
PackageManager::getInstallerPackageNameメゾッドを使用することにより、インストーラのパッケージ名を検知することが出来ます。
PlayStoreからインストールされたアプリのインストーラのパッケージは、com.android.vendingとなります。ですので
private boolean checkInstalledByValid() {
return !this.getPackageManager().getInstallerPackageName(ctx.getPackageName()).equals("com.android.vending");
}
このような関数で検知することが出来ます、が
この関数の存在がバレてしえば意味を成しません。隠蔽しましょう。
C++サイドに実装します
bool checkInstalledByValid(JNIEnv *env, jobject activity) {
jobject packageManager = env->CallObjectMethod(activity, mContext_getPackageManager);
const char *installerPackageName = env->GetStringUTFChars((jstring) env->CallObjectMethod(packageManager, mPackageManager_getInstallerPackageName), nullptr);
return !(strstr(installerPackageName, "com.android.vending"));
}
ただ、これでは文字列が解析されてしまう可能性があるので、
文字列をコンパイル時xorします
return !(strstr(installerPackageName, xorstr_("com.android.vending")));
初期化などの重要な処理に紛れ込ませます
void init(JNIEnv *env) {
/*...*/
jclass cMainActivity = env->FindClass(xorstr_("xxx/xxx/MainActivity"));
jfieldID instance = env->GetStaticFieldID(cMainActivity, xorstr_("mActivity"), xorstr_("Landroid/app/Activity;"));
//不正なインストーラの検知
if (!checkInstalledByValid(env, env->GetStaticObjectField(cMainActivity, instance))){
pthread_t ptid;
//クラッシュ(終了させる)
pthread_create(&ptid, nullptr, crash_thread, wnv);
}
/*...*/
}
void *crash_thread(void *_env){
JNIEnv *env = (decltype(env)) _env;
volatile uintptr_t* crash = (decltype(crash))(0xdeadbeef);
jmethodID mProcess_killProcess = env->GetStaticMethodID(cProcess, xorstr_("killProcess"), xorstr_("(I)V"));
jmethodID mProcess_myPid = env->GetStaticMethodID(cProcess, xorstr_("myPid"), xorstr_("()I"));
//ランダムに待つ
sleep(rand() % 5 + 1);
//アプリ終了
env->CallStaticVoidMethod(cProcess, mProcess_killProcess, env->CallStaticIntMethod(cProcess, mProcess_myPid));
//強制クラッシュSIGSEV
*crash = 0x0;
return nullptr;
}
・インストーラの検知で有効なこと
エミュレータでの起動を防ぐ(例外あり)
APKの改造を検知する
最後に意味わからないことをしているのはProcess::killProcessが無効化された時用に置いてます。
これを-fvisibility=hiddenオプションを付けてコンパイルします。
これなら関数名も見えませんし、文字列も100%とは言えませんが解析されないし、ハッカーはクラッシュしたタイミングもつかめません。
もはやこれだけでも防げるのでは?
え?killProcessを無効化された上にプログラムカウンタから位置バレした?
そもそもsoファイルを読み込ませない
ベースアドレスが取得されるのも関数を悪用されるのも
これらの全ては不正なライブラリが読み込まれることから始まります。
ほならね、読み込ませなきゃいいんですよ
共有ライブラリを読み込むに使う関数といえば
System.loadLibrary("<libname>")
こいつをたどっていくと、最終的にこのような関数にたどり着きます
bool art::JavaVMExt::LoadNativeLibrary(_JNIEnv *, std::string const&, _jobject *, _jclass *, std::string*)
こいつをフックして読み込まれるライブラリを監視しようってことです
bool (*LoadNativeLibrary_original)(void *, _JNIEnv *, std::string const&, void *, void *, void *, void *);
bool LoadNativeLibrary_real(void *java_ext, _JNIEnv *env, std::string const& path, void *a3, void *a4, void *a5, void *a6){
log_info("LoadNativeLibrary -> Hook : %s", path.c_str());
return LoadNativeLibrary_original(java_ext, env, path, a3, a4, a5, a6);
}
extern "C" JNIEXPORT jstring JNICALL xxx_xxx_something(JNIEnv* env, jobject /* this */) {
std::string path_to_libart{};
dl_iterate_phdr([](dl_phdr_info *info, size_t size, void *data) -> int {
if (strstr(info->dlpi_name, "libart.so") != nullptr){
path_to_libart = info->dlpi_name; //見つけた!
}
return 0;
}, nullptr);
void *art_handle = fdlopen(path_to_libart.c_str(), RTLD_LAZY);
uintptr_t fn_ptr = (uintptr_t) find_fdlsym(art_handle, "_ZN3art9JavaVMExt17LoadNativeLibrary");
fdlclose(art_handle);
A64HookFunction((void *) fn_ptr, (void *)&LoadNativeLibrary_real, (void **)&LoadNativeLibrary_original);
}
'A64HookFunction'はここからお借りしております。
さて、見慣れない関数がいくつかありますが、これは自前で用意したものです。
なぜ普通のdlopenを使わないのかですが、APIが24以上でlibart.soをdlopenしようとすると
permissionの関係でnamespaceにアクセス出来ないと怒られてしまうようなので、ELFヘッダからシンボルテーブルと文字列テーブルの位置を特定し、find_dlsymで_ZN3art9JavaVMExt17LoadNativeLibraryから始まるシンボルを持つ関数のアドレスを取得しています。
art::Java::LoadNativeLibraryの引数は第零引数から第二引数以外はコロコロ変わっているので、ちゃんとしたシンボルを指定していません。オリジナル/置き換え関数の引数も、第三引数から第六引数まで全てvoidポインタにしています。
第六引数は今のとこ無いのですが、これから追加されるかもしれないことを見越して余分に置いてます。
では実行してみましょう
...
System.loadLibrary("sub"); //subライブラリをロード
}
I LoadNativeLibrary -> Hook : /data/app/~~vipa8Zi7udXdkU1BXQRaZg==/rec.enuwbt.test_forarticle-BgWMuoxTWXXrRcFX_Esuag==/base.apk!/lib/arm64-v8a/libsub.so
うーん、素晴らしい!
ホワイトリストかなんか作って、不正なライブラリを見つけたら弾けばオッケー!
bool LoadNativeLibrary_real(void *java_ext, _JNIEnv *env, std::string const& path, void *a3, void *a4, void *a5, void *a6){
log_info("LoadNativeLibrary -> Hook : %s", path.c_str());
bool ok = checkWhitelist(path);
if (!ok)
return true; //そうはさせない
return LoadNativeLibrary_original(java_ext, env, path, a3, a4, a5, a6);
}
完璧やん!え?監視する前にライブラリを読み込まれた?????
もういいって!!!
読み込まれたsoファイルを検知する
これは簡単です。先ほど出てきたdl_iterate_phdr関数を使用すればロードされてる共有ライブラリのマップを読み込むことが出来ます。
System.loadLibrary("sub"); //お先!
dl_iterate_phdr([](dl_phdr_info *info, size_t size, void *data) -> int {
log_info("-> %s", info->dlpi_name); //出力すんで
return 0;
}, nullptr);
出力を確認しますと...
...
I -> /system/lib64/libwebviewchromium_loader.so
I -> /apex/com.android.art/lib64/libopenjdkjvmti.so
I -> /apex/com.android.art/lib64/libart-dexlayout.so
I -> /data/data/rec.enuwbt.test_forarticle/code_cache/startup_agents/08bc8eac-agent.so
I -> /data/app/~~w4-swPSYZ0xtlWa-F0u80g==/rec.enuwbt.test_forarticle-ntB0ZgRomfMCI3RHi_9t6w==/base.apk!/lib/arm64-v8a/libsub.so
...
しっかりと確認できました!
dl_iterate_phdr([](dl_phdr_info *info, size_t size, void *data) -> int {
if (strstr(info->dlpi_name, "/lib/arm64/") != nullptr){
bool ok = checkWhitelist(info->dlpi_name);
if (!ok)
//ドッカーン!!
}
return 0;
}, nullptr);
もうこれは完全勝利です!間違いない!
え????ptraceでdlopenをリモートコールされてsoインジェクション???????
も、もういやーー!!
もうずっと回してればいいか...
void *observe(void *) {
while (true) {
dl_iterate_phdr([](dl_phdr_info *info, size_t size, void *data) -> int {
if (strstr(info->dlpi_name, "/lib/arm64/") != nullptr){
bool ok = checkWhitelist(info->dlpi_name);
if (!ok)
//ドッカーン!!
}
return 0;
}, nullptr);
}
return nullptr;
}
美しくないですが、しょうがない。
どうせロードされれば共有ライブラリのマップに入るわけですし...
.got.pltフックによるdlopenの割り込みで呼び出しを禁止しようとも思ったのですが、アーキテクチャ依存がひどいのでやめました。今度別に書こうと思います。
まとめ
え?大口叩いた割には案外ショボかった??
そんなこと言わないでください。泣いてしまいます。これが限界です。手札はもうありません。
ProGuardを適応する、なるべくC++で実装する、シンボルを隠す、文字列を見せない、インストーラを検知する、共有ライブラリのマップを確認する
これだけでも防げるものはあると思います。まあこれでも突破されてしまうのでしょうが...
arm64に依存するなんか気にしない方は、読み込まれるsoを監視する仕組みを組み込んでもいいのではないでしょうか?今更armeabi-v7a向けにリリースする方なんていないでしょう?
少しでもお役に立てれば幸いです。既知の対策でしたらすみませんでした。
おまけで気になること
UnityゲームのAPKの中にあるassetsフォルダは消しても動くのでしょうか?
試してみたいけど自分で作って試すのは面倒ですね....環境もないし...
さて、assetsフォルダにあるファイル達を解析されるのは致命的ですよね?アセットの改ざんやglobal-metadata.datのダンパーへの利用もあるわけですし。
実は、HiddenAPIにこのようなメゾッドがあります。
AssetManager::addAssetPath
この関数は、APKのパスを渡してあげるとそのAPKに含まれているassetフォルダにアクセスできるようにしてくれるといったものです。つまりは、動的にassetsフォルダにファイルを追加してくれるわけですね。
そこで考えてみたのが
assetsフォルダにassetsフォルダに入っていたファイル達を圧縮したZipを置いておく(暗号化してみてもいいかも)
any.zip
┗ assets
┗ファイル達
↓
attachBaseContextなどの初期の処理のタイミングで内部ストレージにコピー & .apkにリネーム
↓
リフレクションでaddAssetPathを呼び出す (HiddenAPIなので)
↓
幸せ
とはなりませんかね?どなたか試してみて頂けると素敵だ
addAssetPathへ指定するapkが署名されている必要はありません。