ロレム・イプサム・ドル・シット・アメット、コンセクテトゥール・アディピスシング・エリート。マエケナス・ノン・ディクタム・オルキ、ノン・フェギット・エックス。ヴェスティブラム・アンテ・イプサム・プリミス・イン・ファウチブス・オルキ・ルクツ・エト・ウルトリセス・ポスーレ・キュビリア・キュラエ;モルビ・エゲスタス・クァム・イン・ルクツ・アリエット。インテジャー・オルキ・エロス、コンセクアット・ア・プレティウム・アット、スシピット・イン・ヌラ。エティアム・ウト・アウグエ・エウ・フェリス・フィニブス・テンポル。ドネック・ソリシチュディン・ア・フェリス・セド・オルナーレ。クラス・ヴェル・エスト・レクタス。プロイン・センペル・エウィスモード・ポスーレ。ドゥイス・ラキニア、イプサム・ネック・ファシリシス・クルスス、ラキュス・アルク・コンディメントゥム・リスス、アック・コンセクアット・ジュスト・リスス・アック・リグラ。セド・アック・トゥルピス・ネック・ドル・ヘンドレリット・ヘンドレリット。ドゥイス・イド・ヴォルタット・ディアム。オルキ・ヴァリウス・ナトケ・ペナティブス・エト・マグニス・ディス・パルトゥリエント・モンテス、ナスケトゥル・リディキュルス・ムス。ヴィヴァムス・アック・イプサム・ニスル。ファセラス・エウィスモード・エト・エリット・ノン・ロンカス。プロイン・エゲスタス・エレイフェンド・エニム・ア・ウルトリシーズ。
インテジャー・センペル・インペルディエント・トルトール、アット・ロンカス・ニシ・ヴルプテ・テンポル。モルビ・シット・アメット・レクタス・マルセラ、モレシテ・イプサム・イド、ロボルティス・セム。ナム・フィニブス・リスス・エト・クァム・コンセクアット、セド・ビベンダム・アンテ・ディグニッシム。ウト・スシピット・ヴァリウス・マルセラ。セド・ヴルプテ・プルビナール・マグナ・アット・プルビナール。マエケナス・ソリシチュディン・ファウチブス・ニスル、エウ・イアクリス・ロレム・ポスーレ・イド。モルビ・ポルティトール・ミ・ヴィタエ・インペルディエント・テンポ。フスケ・マルセラ、レオ・ヴィタエ・イアクリス・アクムサン、ウルナ・リベロ・トリスティック・ディアム、エウ・ヴァリウス・フェリス・アウグエ・アック・リグラ。ウト・ビベンダム・ロンカス・エスト・イン・ルトルム。イン・ネクエ・ジュスト、ファウチブス・アック・オルキ・イン、ファシリシス・ペレンテス・ヌンク。ヴェスティブラム・ウルトリセス、トゥルピス・イド・ファウチブス・ティンシク、ジュスト・ヌラ・センペル・ロレム、イド・ソリシチュディン・ラキュス・ドゥイ・アック・ニブ。アリカム・クィス・エニム・ウト・アンテ・プレティウム・フェギット。クラス・コンセクアット・ウルトリセス・ヌンク、アック・ヴィヴェラ・ウルナ・ペレンテス・セド。インテジャー・アウクター・クァム・アット・ニスル・ヴルプテ・アリカム。マエケナス・ヴィタエ・ラオレート・クァム。
零 - 前言#
一般的な音楽ゲームプレイヤーとして、さまざまな音楽ゲームの私服やゲームの改造を見て、少し技術を理解しているかもしれないと思い、逆向きのスキルを学ぼうと試みましたが、すべて失敗に終わりました!!!!もちろん、全くの無駄ではなく、少なくとも何かを学ぶことができました。
「世界計画」は、当初のオープンサーバーのベータテストからプレイしていたゲームで、オープンサーバーの時はあまり魅力を感じずに辞めてしまいましたが、後に偶然の機会で再びプレイし、今まで続けています。逆向き分析を行う動機がなかったのですが、昨年、キャラクターのために一度ランキングを上げた際に、API を使ってリアルタイムでスコアラインを確認するためのアイデアが生まれ、ゲームのネットワークパケットを分析することを考えました。最近、そのことを思い出し、試してみることにしました。
一 - 概覧#
読解難易度:簡単
タスク:global-metadata.dat
を解読し、今後の Il2CppDumper によるさらなる分析を容易にする。
対象:Project Sekai 日本版 3.4.0 apk
ツール:IDA Pro 7.7 ; 010 Editor
参考資料:https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/
二 - 初探#
Unity ゲームの逆向き分析は、基本的にlibil2cpp.so
の分析を中心に行います。これはゲーム本体のコアロジックです。このゲームは il2cpp でパッケージ化されているため、IDA に投入してもシンボルテーブルがありません。この時点でglobal-metadata.dat
が必要になります。global-metadata.dat
を通じて、Il2CppDumperを使用して、関数名や構造体など、逆向き分析を支援するための多くの有用な情報を生成できます。
\lib\arm64-v8a
でlibil2cpp.so
を見つけ、\assets\bin\Data\Managed\Metadata
でglobal-metadata.dat
を見つけ、Il2CppDumper にこの 2 つを投入してみましたが、予想通りエラーが出ました。ERROR: Metadata file not found or encrypted.
と表示され、010 Editor でglobal-metadata.dat
を開いてみると、ファイルヘッダーは特徴的なAF 1B B1 FA
ではなく、基本的にファイルが暗号化されていることを示しています。
他のゲームと比較してみても、特に規則は見つからず、排他的な XOR などの単純な混乱方法を除外しました。もう手動でソースコードを解析するしかありません。
三 - ソースコード分析#
この分野に完全にゼロからの方は、まず参考資料の中の記事を読んでから続けてください!
libil2cpp.so
を IDA に投入し、分析が完了するのを待ちます。参考資料の経験を基に、関連する文字列を検索してみます。
Shift + F12
を押して String View を開き、global-metadata
を検索すると、唯一の結果が表示されました:
それをクリックし、X
を押してすべての参照を検索すると、同様に一箇所だけが見つかりました:
この関数を見て、F5
を押して擬似コードを生成すると、文字列はこの行のコードに現れます。
__int64 __fastcall sub_1A5E8BC(_DWORD *a1, int *a2, int a3, int a4, int a5, int a6, int a7, int a8)
{
// ...
result = sub_1AEC2AC((int)"global-metadata.dat", (int)a2, a3, a4, a5, a6, a7, a8, v21, v22, v23);
qword_864DEB8 = result;
if ( result )
{
// ...
}
return result;
}
il2cpp のソースコードに基づくと、このコードはMetadataCache::Initialize()
である可能性が非常に高いです。
bool il2cpp::vm::MetadataCache::Initialize()
{
s_GlobalMetadata = vm::MetadataLoader::LoadMetadataFile("global-metadata.dat");
if (!s_GlobalMetadata)
return false;
// ...
}
照らし合わせてみると、sub_1AEC2AC()
はおそらくMetadataLoader::LoadMetadataFile()
であることがわかります。それを見てみましょう:
_BYTE *__fastcall sub_1AEC2AC(const char *a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, char a9, int a10, void *a11)
{
// ... 変数
sub_1AF0D80();
v29 = "Metadata";
v30 = 8LL;
v12 = (unsigned __int64)(unsigned __int8)v23 >> 1;
if ( (v23 & 1) != 0 )
v13 = (char *)p;
else
v13 = (char *)&v23 + 1;
if ( (v23 & 1) != 0 )
v12 = v24;
v27[0] = (__int64)v13;
v27[1] = v12;
sub_1A81EEC(v27, &v29);
if ( (v23 & 1) != 0 )
operator delete(p);
v14 = strlen(a1);
if ( (v31 & 1) != 0 )
v15 = v33;
else
v15 = v32;
if ( (v31 & 1) != 0 )
v16 = *(_QWORD *)&v32[7];
else
v16 = (unsigned __int64)v31 >> 1;
v29 = (char *)a1;
v30 = v14;
v23 = (__int64)v15;
v24 = v16;
sub_1A81EEC(&v23, &v29);
if ( (v27[0] & 1) != 0 )
v17 = v28;
else
v17 = (char *)v27 + 1;
v18 = open(v17, 0);
if ( v18 == -1 )
goto LABEL_25;
if ( fstat(v18, (struct stat *)&v23) == -1 )
{
close(v18);
goto LABEL_25;
}
v19 = len[0];
v20 = mmap(0LL, len[0], 3, 2, v18, 0LL);
close(v18);
if ( v20 == (_BYTE *)-1LL )
{
LABEL_25:
v20 = 0LL;
goto LABEL_26;
}
if ( v19 >= 1 )
{
v21 = 0LL;
do
{
v20[v21] ^= byte_67C9101[v21 & 0x7F];
++v21;
}
while ( v19 != (_DWORD)v21 );
}
LABEL_26:
if ( (v27[0] & 1) != 0 )
operator delete(v28);
if ( (v31 & 1) != 0 )
operator delete(v33);
return v20;
}
MetadataLoader::LoadMetadataFile()
のソースコードと照らし合わせると:
void* il2cpp::vm::MetadataLoader::LoadMetadataFile(const char* fileName)
{
std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));
std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));
int error = 0;
os::FileHandle* handle = os::File::Open(resourceFilePath, kFileModeOpen, kFileAccessRead, kFileShareRead, kFileOptionsNone, &error);
if (error != 0)
{
utils::Logging::Write("ERROR: Could not open %s", resourceFilePath.c_str());
return NULL;
}
void* fileBuffer = utils::MemoryMappedFile::Map(handle);
os::File::Close(handle, &error);
if (error != 0)
{
utils::MemoryMappedFile::Unmap(fileBuffer);
fileBuffer = NULL;
return NULL;
}
return fileBuffer;
}
多くの共通点が見られ、MetaData
という文字列とファイル操作が含まれており、これら 2 つが同じものであると確信できます。定義された多くのパラメータについては、関数呼び出しの関係を簡単に分析したところ、const char *a1
が実際に有用であり、ファイル名を表していることがわかりました。ここで渡されるのはglobal-metadata.dat
です。
ソースコードを照らし合わせて、生成された逆向き擬似コードの変数名をリネームして、最終的に以下のような少し読みやすいコードを得ることができました:
_BYTE *__fastcall sub_1AEC2AC(char *fileName, ...)
{
unsigned __int64 v12; // x8
char *v13; // x9
__int64 fileNameLength; // x0
_BYTE *v15; // x8
unsigned __int64 v16; // x9
const char *resourceFilePath; // x0
int fileHandle; // w21
int fileSize; // w20
_BYTE *fileBuffer; // x19
__int64 i; // x8
__int64 maybe_resourcesDirectory; // [xsp+0h] [xbp-E0h] BYREF
unsigned __int64 v24; // [xsp+8h] [xbp-D8h]
void *p; // [xsp+10h] [xbp-D0h]
size_t len[2]; // [xsp+30h] [xbp-B0h]
__int64 maybe_dataDictionary[2]; // [xsp+80h] [xbp-60h] BYREF
char *v28; // [xsp+90h] [xbp-50h]
char *resourcesFolder; // [xsp+98h] [xbp-48h] BYREF
__int64 resourcesFolderNameLength; // [xsp+A0h] [xbp-40h]
unsigned __int8 v31; // [xsp+A8h] [xbp-38h]
_BYTE v32[15]; // [xsp+A9h] [xbp-37h] BYREF
_BYTE *v33; // [xsp+B8h] [xbp-28h]
utils::Runtime::GetDataDir((__int64)&maybe_resourcesDirectory);
resourcesFolder = "Metadata";
resourcesFolderNameLength = 8LL;
v12 = (unsigned __int64)(unsigned __int8)maybe_resourcesDirectory >> 1;
if ( (maybe_resourcesDirectory & 1) != 0 )
v13 = (char *)p;
else
v13 = (char *)&maybe_resourcesDirectory + 1;
if ( (maybe_resourcesDirectory & 1) != 0 )
v12 = v24;
maybe_dataDictionary[0] = (__int64)v13;
maybe_dataDictionary[1] = v12;
utils::PathUtils::Combine(maybe_dataDictionary, &resourcesFolder);
if ( (maybe_resourcesDirectory & 1) != 0 )
operator delete(p);
fileNameLength = strlen(fileName);
if ( (v31 & 1) != 0 )
v15 = v33;
else
v15 = v32;
if ( (v31 & 1) != 0 )
v16 = *(_QWORD *)&v32[7];
else
v16 = (unsigned __int64)v31 >> 1;
resourcesFolder = fileName;
resourcesFolderNameLength = fileNameLength;
maybe_resourcesDirectory = (__int64)v15;
v24 = v16;
utils::PathUtils::Combine(&maybe_resourcesDirectory, &resourcesFolder);
if ( (maybe_dataDictionary[0] & 1) != 0 )
resourceFilePath = v28;
else
resourceFilePath = (char *)maybe_dataDictionary + 1;
fileHandle = open(resourceFilePath, 0);
if ( fileHandle == -1 )
goto fileHandleError;
if ( fstat(fileHandle, (struct stat *)&maybe_resourcesDirectory) == -1 )
{
close(fileHandle);
goto fileHandleError;
}
fileSize = len[0];
fileBuffer = mmap(0LL, len[0], 3, 2, fileHandle, 0LL);
close(fileHandle);
if ( fileBuffer == (_BYTE *)-1LL )
{
fileHandleError:
fileBuffer = 0LL;
goto LABEL_26;
}
if ( fileSize >= 1 )
{
i = 0LL;
do
{
fileBuffer[i] ^= byte_67C9101[i & 0x7F];
++i;
}
while ( fileSize != (_DWORD)i );
}
LABEL_26:
if ( (maybe_dataDictionary[0] & 1) != 0 )
operator delete(v28);
if ( (v31 & 1) != 0 )
operator delete(v33);
return fileBuffer;
}
四 - キャプチャ・ザ・フラッグ#
おそらく明らかになったのは、このコードの部分です:
if ( fileSize >= 1 )
{
i = 0LL;
do
{
fileBuffer[i] ^= byte_67C9101[i & 0x7F];
++i;
}
while ( fileSize != (_DWORD)i );
}
ここで、読み込まれたfileBuffer
に対して XOR 暗号化を行っていると推測されます。この0x7F
は、パスワードテーブルのサイズが 128 であることを示しており、インデックスi
と AND を取ることでループ使用を実現しています。byte_67C9101
をクリックすると、その内容が私たちの推測を確認しました。
次は、暗号解除スクリプトを書くことです。
XOR は対称的であるため、暗号化と復号化は同じコードを使用して得られます。右クリックして Convert - Convert to C/C++ Array を選択し、パスワードテーブルをエクスポートしてから、C++ スクリプトを書きます:
// Generated by New Bing
#include <iostream>
#include <fstream>
#include <vector>
unsigned char KeyTable[128] = {
0xF7, 0xA9, 0x40, 0x2F, 0x25, 0x46, 0xC2, 0xF3, 0x2C, 0xC3, 0x5C, 0x8B, 0x04, 0x15, 0xA9, 0x7E,
0x7A, 0xDE, 0xB6, 0x16, 0x05, 0xD7, 0xE8, 0x50, 0x44, 0x6E, 0x8B, 0x9F, 0xB7, 0xFD, 0x93, 0xEF,
0x5C, 0x7C, 0x2A, 0x3A, 0xD3, 0x07, 0x1B, 0x3E, 0x0E, 0xC3, 0x10, 0xB9, 0x0E, 0x93, 0x09, 0xEF,
0xD7, 0x7F, 0x56, 0x93, 0xFB, 0x44, 0x16, 0x1F, 0xC1, 0xF0, 0xEB, 0xB7, 0xBA, 0x22, 0x3B, 0x2E,
0x2F, 0x6C, 0x2E, 0x88, 0xEA, 0x6E, 0xAA, 0x72, 0xC2, 0x77, 0x08, 0x42, 0xC7, 0x2E, 0x42, 0xB0,
0x65, 0x9C, 0xC8, 0x38, 0xA7, 0xFF, 0xF4, 0x17, 0x65, 0x0B, 0x79, 0xE3, 0x1C, 0x63, 0x83, 0xEB,
0x88, 0xE1, 0x89, 0xA1, 0x65, 0x80, 0x04, 0x68, 0xD4, 0xE3, 0xD0, 0x83, 0x3C, 0xD4, 0x57, 0x92,
0xAE, 0x3A, 0x52, 0x1E, 0x5A, 0x02, 0x86, 0x30, 0x77, 0x16, 0x23, 0xDF, 0xF5, 0xC7, 0x55, 0x5F
};
int main()
{
std::ifstream inputFile("global-metadata.dat", std::ios::binary);
inputFile.seekg(0, std::ios::end);
size_t fileSize = inputFile.tellg();
inputFile.seekg(0, std::ios::beg);
std::vector<uint8_t> mmapPtr(fileSize);
inputFile.read(reinterpret_cast<char*>(mmapPtr.data()), fileSize);
inputFile.close();
for (size_t i = 0; i < fileSize; ++i)
{
mmapPtr[i] ^= KeyTable[i & 0x7F];
}
std::ofstream decryptedFile("DecryptedMetadata.dat", std::ios::binary);
decryptedFile.write(reinterpret_cast<char*>(mmapPtr.data()), fileSize);
decryptedFile.close();
std::cout << "Finish Decrypt" << std::endl;
return 0;
}
実行して、解読されたファイルを開くと:
ファイルヘッダーが私たちの知っているAF 1B B1 FA
に変わっていることがわかります。 MetaDataStringEditorを使っても、正常に読み取ることができます。
これで、global-metadata.dat
の解読が完了しました。
後記:#
実際には、その擬似コードをこれほど多くのリネームをする必要はありませんでした。最初にfileBuffer
を見つけた時点で、あまり気にせず進めました。このglobal-metadata.dat
の暗号化方法は比較的簡単で、初心者でも少し時間をかければ解ける練習問題に属します。
そういえば、3.3.1 バージョンを解読した翌日に 3.4.0 が更新されて、少し無言になりました...
新しいバージョンのパスワードテーブルは古いものとは異なり、幸いにもゲーム本体は頻繁に更新されないので、毎回更新のたびに IDA がバックグラウンドで 1 時間以上分析するのを待つ必要がなくて助かります(
時間があれば、IDA を使わずに直接libil2cpp.so
を読んでパスワードテーブルを取得できるかどうかを研究してみたいですが、私のようにアセンブリすら理解していない人にとっては、かなり苦痛かもしれません(
次の記事は、ネットワーク通信の暗号解読についてです。