Unityのil2cppバイナリを簡易静的解析して対策してみる
目的/手に入る知識
il2cppビルドのバイナリに簡易的な静的解析を行い、プロジェクトコードでできる対策を検討します。
前提
こちらの環境で検証します。
ビルド環境とターゲット
- windows 11 home 21H2
- unity2020.3.34f1
- adnorid 10 / Xperia XZ3
解析に使用するツール「il2cppInspector」の開発が止まっており、
2020.3までのバージョンしか解析できないため2020.3を使用しています。
検証app
下記の内容で検証していきます。
- 起動時にprivateIdが未発行なら発行し、発行済みであれば取得し表示
- 発行されたidはsqlcipherを経由してApplication.persistentDataPathに永続化
こんなコードを実行します
詳細はこちら
想定シナリオ
先述したappからDBのpasswordの解析を想定します。
そもそも割られて困るデータはサーバーにという話なのですが、
DBコストよりもリスクをとる判断もあるので、今回はそのケースを想定します。
解析概要
今回は下記ツールで解析していきます。
- Ghidra 10.1.3
- Il2CppInspector 2021.1
GhidraはNSA[1]が開発したソフトウェアリバースエンジニアリングツールで、
解析に必要な多くの機能がGUIベースで搭載されています。
今回は主に逆アセンブル・逆コンパイルする用途で使っていきます。
アプリ解析だけであればGhidraのみでも可能ですが、
il2cppビルドのバイナリだとil2cppの独自定義コードになっているので、元の定義を復元させるためにIl2CppInspectorを使用します。
解析環境準備
ツールと必要なruntimeをインストール
- Il2CppInspector
- Ghidra
- dotnet(Il2CppInspectorに必要)
- jdk11(Ghidraに必要)
プロジェクト準備
Il2CppInspectorはリリース版だと、自環境で解析エラーで動かなかったのでこちらを参考に自前でビルドします。
Ghidra用のデータを取得
Il2CppInspectorを使用してビルドしたバイナリからGhidra用のデータを構築します。
公式のドキュメントに従って進めていきます。
apkをアンパック
apkの拡張子をzipに変更し、解凍しておきます。
Il2CppInspectorでGhidra用のデータを作成
解凍したapkから
- {アプリ名}/lib/arm64-v8a/il2cpp.so
- {アプリ名}/assets/bin/Data/Managed/Metadata/global-metadata.dat
を取り出し、Il2CppInspectorにかけます。
(apkでもできますが複数アーキテクチャまとめられていた場合、出力が複数出てくるので選択しておきます)
./Il2CppInspector -i"libil2cpp.so" -m"global-metadata.dat" -t "Ghidra" --unity-version "2020.3.34f1"
注意点ですが、実行ファイルと同じディレクトリにIl2CppInspectorのpluginフォルダが存在する必要があります。
(pluginフォルダはリリースから取得できます)
吐き出されたデータは後ほどフォルダごとインポートするため、適当なフォルダにまとめておきます。
これでGhidra解析データ作成は完了です。
Ghidraで環境構築
バイナリとIl2CppInspectorで生成したデータを使ってGhidraのprojectを立ち上げていきます。
バイナリの読み込み
前の項目でも使用した下記バイナリをGhidraにインポートしていきます。
{アプリ名}/lib/arm64-v8a/il2cpp.so
この時、自動アナライズを行うと後でコンフリクトする可能性があるのでアナライズはせずimportだけ行います。
次に、
Il2CppInspectorの生成データ反映
公式ドキュメントに従って、データのインポート設定を二か所行います。
il2cpp-types.h
libil2cpp.soを開いたコードブラウザで[File ]->[ Parse C Source..]を選択します。
新しいプリセットを作成後、cpp/appdata/il2cpp-types.h又はil2cpp.hを登録し、
オプションに-D_GHIDRA_を設定します。
上記二つの設定の後、 Parse to Programを押してしばらく待ちます。
il2cpp.py
Il2CppInspectorの吐き出し結果をまとめたフォルダをインポートします。
Script Managerを開きディクショナリマークから、吐き出したフォルダを追加してリフレッシュします。
Script Managerにil2cpp.pyが認識されているので実行します。[2]
アナライズ
2か所の設定が終わった後に、import時に避けたアナライズをAnalysis->Auto Analizeから実行します。[3]
アナライズが完了すればC#時のシンボルで検索が使用可能になります。
これでプロジェクト準備は完了です。
解析
環境がそろったのでDBのpasswordの解析に入ります。
構成把握
まず、appのlibフォルダ内にlibsqlcipher.soがあるため、何かしらの情報をdbに保存してあることが推察できます。
λ pwd
/c/Users/PC_User/Workspace/Qiita/nonObfuscate/sample/lib/arm64-v8a
λ ls
libil2cpp.so libmain.so libsqlcipher.so libunity.so
シンボル検索
sqlcipherはsqlite3のオープンソース拡張のため、何かしらのシンボルに依存してるだろうという読みで、
libil2cpp.soのコードブラウザにて「sqlite」と検索します。
暗号化部分特定
sqlcipherはsqlite3_keyのAPIを呼ぶことで暗号化しているので、この引数のKeyをたどっていきます。
呼び出し元特定
関数に対して右クリ->Refarenses->show Refarenses to ...を選択することで参照されている箇所を確認できるので、
それを使って呼び出し元を特定していきます。
SQLiteHelper_KeyはPlayerCredentialDAO_Setupを経由しSaveSample_Startから呼び出されていることがわかります。
keyの特定
呼びだし元のSaveSample_StartのDecompileを見てみます。
void SaveSample_Start(SaveSample *this,MethodInfo *method)
{
// 中略
String *pSVar10;
// 中略
pSVar10 = this->_dbKey;
// 中略
logger = Debug_1__TypeInfo->static_fields->s_Logger;
pSVar5 = StringLiteral_test;
PlayerCredentialDAO_Setup(this_00,(String *)method_00,pSVar10,StringLiteral_test,logger,in_x5);
SaveSampleのthisポインタを参照して読み出されているので、Startよりも前に別の関数で初期化されていることがわかります。
他の関数を検索するためシンボル検索でSaveSampleを検索するとコンストラクタが見つかります。
このコンストラクタを見てみると、_dbKeyがsampleKeyで初期化されおり、dbのkeyを特定することができました。
解析結果検証
andoridにinstallしてidを発行します。
adbでdb抽出
% adb shell ls storage/emulated/0/Android/data/com.DefaultCompany.ObfuscateSample/files
sampleData.db
il2cpp
% adb pull "storage/emulated/0/Android/data/com.DefaultCompany.ObfuscateSample/files/sampleData.db"
storage/emulated/0/Android/data/com.DefaultCompany.ObfuscateSample/files/sampleData.db: 1 file pulled, 0 skipped. 2.7 MB/s (8192 bytes in 0.003s)
取得したdbにkeyを試してみます。
% ./sqlcipher sampleData.db
// 一応暗号化かかってるか確認しています
sqlite> .tables
Error: file is not a database
sqlite> PRAGMA KEY="sampleKey";
sqlite> .tables
test
sqlite> select * from test;
1|5f3feeea-d35c-4317-b396-8f71628f4f9d
DBの中身を閲覧することができました。
対策
先述の通り、プロジェクトコードで対応できる範囲での対策を行います。
基本的に鼬ごっこなので、どこまでのリスクを強要するか各環境での判断が必要ですが、
今回は三点の対策を行います。
定数
焼きこまれた定数は、解析ツールの定数検索などで簡単に探されてしまいます。
そのままコードに乗せるのではなく、runtimeで導出されるようにします。
対応前
このセキュアな情報焼きこみですが、先行事例としてUnityさんが公式で開発しているUnityIAPに、レシート検証用の情報焼きこみがあるの参考にして実装します。[4]
秘匿したい情報を実行時に導出するTangleクラスを、Editor拡張で作成するようにして、
定数で焼きこんでいた部分を生成されたTangleに置き換えます。
生成したTangleを使用するようにkeyを書き換えます。
public class SaveSample : MonoBehaviour
{
//一部抜粋
private string _dbKey = "sampleKey";
}
替えた後はこんな感じになります
public class SaveSample : MonoBehaviour
{
//一部抜粋
private string _dbKey => new UTF8Encoding().GetString(Tangle.Data());
}
コードシンボル
難読化を行うことで対応します。
UnityプロジェクトのC#の難読化にはMono.Cecilを使って自前で行う方法や、
アセットを用いる方法があります。
今回は「Obfuscator」というアセットを使用します。
主要な設定項目は下記です。
Assemblies
難読化対象のアセンブリを指定します。
SRDebuggerなど外部のコードの挙動保障が難しくなるので、今回はsample.dllのみに難読化をかけます。[5]
Add face code
クラス内に存在する関数を複製して、ダミーの関数シンボルを量産します。
コード内容に変更がないので注意が必要です。
関数ないしコード量が増えるので、解析等に時間がかかるようになります。[6]
Regenerate Hash Salt Every Build
ビルド毎に難読化の結果が変わります。
(解析にかかる時間が増えますが、クラッシュレポートがより一層読みにくくなるので注意が必要です)
Create name translation file
難読化後のシンボルとのマップファイルを出力します。
Obfuscate Literals in all Methods
文字列に対して難読化をかけます。
今回のappではsqlcipherを使用しているのがバイナリ構成からわかってしまうので、
クエリやエラーlog用の文言にシンボルを残さないようにすべての文字列を難読化します。
注意点として、パフォーマンスとのトレードオフになります。
メモリダンプ対策でキャッシュを持たないように毎回バイト配列から生成される形になるためです。
mono backgroundのスタンドアローンビルドでdllを吐いて、dotPeekなどで効果を確認します。
難読化が難しい箇所
MonoBehaviourのイベント
MonoBehaviourのイベント関数は、イベントのシグネチャを持っているかどうかで呼び出し如何を判断しているため、シグネチャに干渉することはできません。
任意の型のMonoBehaviourが初めてその基底のスクリプトにアクセスしたときに、スクリプティングランタイム(MonoもしくはIL2CPP)によって何かのマジックメソッドが定義されているかを調査され、この情報がキャッシュされます。もしMonoBehaviourが特定のメソッドを持っていたら所定のリストに追加されます。例えばスクリプトがUpdateメソッドを持っていたら「毎フレームUpdateを呼ぶべきスクリプトのリスト」に追加されるわけです。
それもありますが、今回はMonoBehaviourを継承したクラスが1つな上、ビジネスロジックを持っているので解析がかなり容易になっています。
実際の開発環境では多数のviewクラスがMonoBehaviourを継承しますし、ビジネスロジックとMonoBehaviourの切り離しが行われていることが多いので、ここまで容易にはならないかなと。
ネイティブライブラリのシンボル
プロジェクトコードの難読化を行ったとしても、ネイティブライブラリ側のシンボルは消すことができません。
これはネイティブライブラリの呼び出しが、ライブラリ名と関数名に依存しているためです。
今回はプロジェクトコードのみで行える対応を主題としているのでここでは扱いませんが、
sqlcipherはBSDスタイルのライセンスで提供されているため、
自前でソースコードを難読化してビルドを行えばここも回避できそうです。
保存先
こちらも解析されにくい箇所に移動させます。
ルート化やバックアップファイルからの抽出など抜け道はあるため、あくまで時間稼ぎになります。
現実装で使っているUnityのpersistentdatapathは"android.content.Context.getExternalFilesDir"にてファイルパスの取得を行います。
Android: Application.persistentDataPath points to /storage/emulated/0/Android/data/<packagename>/files on most devices (some older phones might point to location on SD card if present), the path is resolved using android.content.Context.getExternalFilesDir.
このAPIで取得できるのは外部ストレージのpathです。
この外部ストレージパスは、Andoridのドキュメントにある通りEXTERNAL_STORAGEの権限を持つアプリに干渉されてしまいます。
アプリケーションが所有する永続ファイルを配置できるプライマリ共有/外部ストレージデバイス上のディレクトリへの絶対パスを返します。これらのファイルはアプリケーションの内部にあり、通常はメディアとしてユーザーに表示されません。
これはgetFilesDir()、アプリケーションがアンインストールされるときにこれらのファイルが削除されるという点で似ていますが、いくつかの重要な違いがあります。
リムーバブルメディアはユーザーが取り出すことができるため、共有ストレージが常に利用できるとは限りません。メディアの状態は、を使用して確認できます Environment#getExternalStorageState(File)。
これらのファイルにはセキュリティが適用されていません。たとえば、保持しているすべてのアプリケーション Manifest.permission.WRITE_EXTERNAL_STORAGEがこれらのファイルに書き込むことができます。
そこを防ぐために、参考に干渉されにくい内部ストレージのパスを取得し、そこにdbを保存するように変更します。
具体的には
"/storage/emulated/0/Android/data/{packagename}/files"ではなく、
"/data/data/{packagename}/files"を使用します。
Andoirdのローカル保存の話は先行記事としてカヤックさんが書かれているので、そちらを参考にネイティブのコードをたたきます。
ここを
public class SaveSample : MonoBehaviour
{
//一部抜粋
private string _dbPath => Path.Combine(Application.persistentDataPath,"sampleData.db");
}
こうします
public class SaveSample : MonoBehaviour
{
//一部抜粋
private string _basePath
{
get
{
#if !UNITY_EDITOR && UNITY_ANDROID
using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
using var currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
using var getFilesDir = currentActivity.Call<AndroidJavaObject>("getFilesDir");
return getFilesDir.Call<string>("getCanonicalPath");
#endif
return Application.persistentDataPath;
}
}
private string _dbPath => Path.Combine(_basePath,"sampleData.db");
}
効果確認
対策を行ったバイナリを再度解析して効果を確認します。
定数
定数検索でてこないことを確認します。
対策前
対策後
コードシンボル検索
"Key"や"sqlite"から追えないようになってること確認します。
難読化前
難読化後
Create name translation fileの設定で吐き出されたシンボルマップを元に難読化を確認します。
LJJEKKIOHIL⇨System.Int32 ObfuscateSample.SQLiteHelper::_sqlite3_key(System.IntPtr,System.String,System.Int32)
難読化前
難読化後
保存箇所確認
バイナリ上でpathを確認して、adbでのアクセスができないことを確認します。
% adb shell ls "data/data/com.DefaultCompany.ObfuscateSample/files"
ls: data/data/com.DefaultCompany.ObfuscateSample/files: Permission denied
% adb pull "data/data/com.DefaultCompany.ObfuscateSample/files/sampleData.db"
adb: error: failed to stat remote object 'data/data/com.DefaultCompany.ObfuscateSample/files/sampleData.db': Permission denied
まとめ
-
il2cppだとしても解析はできるので対策は必要
Mono.Cecilやアセットによってプロジェクトコード側で難読化できる -
プロジェクトコードで対応できる範囲
定数は一覧検索で探されてしまう->実行時導出にする
コードのシンボルを残すと解析難易度が下がってしまう->シンボルを難読化する
保存先のデータが抜かれてしまう->秘匿領域に保存する -
プロジェクトコードで対応が難しい範囲
UnityのMonoBehaviourが要求するイベント名
自前コンパイルできない環境の外部DLLを呼び出す際のシンボル情報
今回解析に使ったツールはすべて無償ツールで、解析初心者の自分でも2~3日あれば本記事の解析はできてしまいました。
基本的にサーバにデータを持たせるのがベターだとは思いますが、
もしクライアントにデータを持たせる場合は、難読化等ある程度コストをかけてセキュリティ面も強化したほうがよさそうです。
おまけ:global-metadate.datの難読化
Unityプロジェクトの解析難易度を上げる方法としては、メタデータを保持しているglobal-metadate.datの難読化という方法もあります。
こちらを本記事で扱っていないのにはいくつかの理由があります。
特殊なライセンスが必要
global-metadate.datはil2cppによって吐き出されるため、
書き出しや読み込みの挙動に干渉するにはil2cppに手を加える必要があります。
この「セキュリティ向上用途でのil2cppの改変」に対して利用規約上の問題はあるかUnityさんに問い合わせたところ、ソースコードライセンスが必要になるとの回答をいただきました。[7]
このソースコードライセンスというのは、値段や内容含めNDAが必要なため記事にするのが難しいかなという判断です。[8]
鼬ごっこ
仮にライセンスを契約したとしても、難読化自体を解析は可能です。
Il2CppInspectorの作成者であるKaty氏のブログで、難読化が施されている下記アプリについてリバースエンジニアリング用のglobal-metadate.dat解析が解説されています。
- Riot Games: League of Legends Wild Rift
- miHoYo: Genshin Impact
- Kakao Games: Guardian Tales
とはいえツール開発者クラスでの解析事例で、ツールユーザーレベルであれば弾ける可能性は上がりそうです。
簡易的な解析対策レベルではなさそう
特殊なライセンスが必要で、リバースエンジニアリングのツール開発者レベルであれば解析可能。
難読化もその解析も専門性が高く、簡易的な解析に対する対応とは言えないかなと思い、本記事では扱っていません。
Discussion