Phigros 存档解密
样本信息:
文件名 Phigros_4.0.0.apks
样本来源 Google Play
包名 com.PigeonGames.Phigros
版本 4.0.0(155)
SHA-256 b62d3d1a6fe9b0a834b47222b730c13d250ebe421dfc72cee3a1a47281ca7f76
〇、我真的是穷怕了
Phigros,我从小玩到大的音游,也在最近迎来了主线最终章。
讲真,这么一款陪了自己好几年的游戏突然完结了,心里总是有点空空的。
依稀记得很久之前,Data 不够用之时,我都是直接去改游戏存档,来给自己拿足够解锁歌曲的 Data。
(现在我真的没有改了,没事打歌攒出来的 10 个多 GB 的 Data 够我用到天荒地老了)
时过境迁,现在的存档怎么样了呢?
也许该去探望一下这位老朋友了。
一、旧地重游,物是人非
Phigros 作为一款 Unity 开发的游戏,根据 Unity 官方文档 的介绍,Android 端默认位置是在 /data/data/<包名>/shared_prefs/<包名>.v2.playerprefs.xml。
回到这个熟悉的地方,果然这位老朋友还在那里静静的等着我。

/data/data/com.PigeonGames.Phigros/shared_prefs/com.PigeonGames.Phigros.v2.playerprefs.xml
满心欢喜地打开这位老朋友,眼前的景象却让我感到陌生:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="RAxss7MtEi68v2JOObh1mg%3D%3D">84nt4CDG41fhF5EXHkVpow%3D%3D</string>
<string name="xaHiFItVgoS6CBFNHTR2%2BA%3D%3D">84nt4CDG41fhF5EXHkVpow%3D%3D</string>
<string name="Cgw4SttKwRFIjb68TF8z5EC%2FLwVpK8KjKmjcm9T3M78%3D">%2Bpyf2R4Qt4zJE6UG3UUydn0ltWNFpXzVDKoi8cnw5gWJGqNfIK4bx8bwl8iZ4y%2Fe</string>
<string name="RF7IYRhPKOOGn7Qea60lKexh7u7Uqrzkqc1fw%2FiLFYE%3D">Nj0hx6%2B2z%2BpWeORt16R4VMepzdKiDJCP%2FQavEe2O%2BHTm2IFSUZugJjoTcLQL4XnT</string>
...
很明显,这是加密了。
二、见其心性,窥见其心
Phigros 是一款本地游戏,存档的加解密肯定是在本地进行。
但我之前只听说过这是一款 Unity 游戏,详细的技术细节我从未了解。
没办法,发挥一下老手艺吧——开拆!
分析包体特征与反编译
apks 文件内含多个 apk 分包,我决定先在 split_config.arm64_v8a.apk 里面找找线索。

Phigros_4.0.0.apks
等下,为什么不是 base.apk?你是不是开了?
通常 Unity IL2CPP 的 native 部分会以 .so 的形式存在。对于 Google Play 的 App Bundle 构建,ABI 对应的 native library 往往会被拆分到 ABI split APK 中;这份样本的 base.apk 没有 lib 目录,因此需要到 split_config.arm64_v8a.apk 中寻找。

可见 base.apk 内无 lib 目录
这就是老一辈艺术家的自信。
打开 /split_config.arm64_v8a.apk/lib/arm64-v8a/ 目录,我们能看到其中包含 libil2cpp.so 与 libunity.so 。

/split_config.arm64_v8a.apk/lib/arm64-v8a/
这是很典型的 Unity IL2CPP 特征。
这让我想起了一位老朋友—— Il2CppDumper 。
把在 /assets/bin/Data/Managed/Metadata/ 下的 global-metadata.dat 一起喂给 Il2CppDumper。

Il2CppDumper
我们能得到 script.json 和 il2cpp.h 这两个文件。
然后启动我们的另一位老朋友 IDA,打开 libil2cpp.so。
等待 IDA 分析完成后,我们在 IDA 内执行 Il2CppDumper 自带的 ida_with_struct_py3.py 脚本,选择 script.json 和 il2cpp.h 来恢复符号。

ida_with_struct_py3.py
寻找 SharedPreference 保存逻辑
既然是保存存档,那么可以试着搜索关键字 save。
很快,SaveManagement 进入了视野。

SaveManagement
先打开最容易理解的 SaveManagement$$SaveString,按 F5 看看。地址是 0x1CA8D70。把自动生成的类初始化代码略过去,真正做事的只有三句:
v5 = SaveManagement__Encrypt(keyName, ...);
v7 = SaveManagement__Encrypt(value, ...);
UnityEngine_PlayerPrefs__SetString(v5, v7, 0);
这佐证了我们打开存档时,看到的 key 和 value 都被加密的事实。
这里的 SetString 是 Unity 的 PlayerPrefs 接口;在 Android 上,相关数据会落到应用自己的 shared_prefs XML 中。整份 XML 的标签还是明文,并不是整个文件被 AES 一把梭。
接下来顺着 SaveManagement__Encrypt 点进去。
一路跟到 AES,但密钥在哪?
SaveManagement.Encrypt(string) 的地址是 0x1CA81B8。
IDA 生成的伪代码比较长,主要是 IL2CPP 的对象分配、虚函数调用和释放资源。节约时间,只看关键的这几句——它们在函数的不同位置,中间省掉了对象创建和资源清理:
aes = v4->static_fields->aes;
System_Security_Cryptography_CryptoStream___ctor(v8, (System_IO_Stream_o *)v3, v6, 1, 0);
v18 = System_Convert__ToBase64String(v17, 0);
其中 v6 是通过 aes 的 _20_CreateEncryptor 得到的。str 经 StreamWriter 写入 v8,密文字节从内存流取出成为 v17,最后转成 Base64。
原来 XML 里的那些长字符串并不是直接显示 AES 的二进制密文。游戏先把字符串写进 CryptoStream,再把得到的密文字节转成 Base64,最后才写入 PlayerPrefs。
知道了存档用了 AES,那当然要去找找 Key 和 IV 了。但是这里的 aes 已经存在于 SaveManagement_TypeInfo->static_fields,Encrypt 只是取出来使用。密钥不是在这个函数里生成的。
静态字段是什么时候赋值的?
看看 SaveManagement$$.cctor。这个名字里的 cctor 是静态构造方法,地址 0x1CAA238。这里先创建了两个 AES 对象:
SaveManagement_TypeInfo->static_fields->aes = System_Security_Cryptography_Aes__Create(0);
v1 = System_Security_Cryptography_Aes__Create(0);
第二个随后赋给 aes2。刚才 Encrypt(string) 取的是第一个 aes,所以只追它。
还有两句关键调用,分别位于 Key 和 IV 的赋值前:
ASCII = BinaryUtils__LoopReverseXor(v6, 0);
ASCII = BinaryUtils__LoopReverseXor(v8, 0);
在各自前面,v6 来自 StringLiteral_7107,v8 来自 StringLiteral_7291;变换后的 ASCII 分别被传入 aes->klass->vtable._12_set_Key.methodPtr 和 v7->klass->vtable._10_set_IV.methodPtr。
那么 StringLiteral_7107 和 StringLiteral_7291 又是什么?
查 Il2CppDumper 给出的 stringliteral.json,再与 APK 内 global-metadata.dat 对照,得到:
StringLiteral_7107 → Phigros.enc.j57vnvr8wlZssXM7eWpa
StringLiteral_7291 → Q4zHm5vUEMJJ3iS9
哦我的上帝,这不就是 Key 和 IV 吗!
我知道你很急,但是你先别急——
这两串还不能直接塞进 AES。
静态构造方法在 ASCII.GetBytes 之后又调用了 BinaryUtils__LoopReverseXor。
只在 metadata 中搜到字符串,最多能证明它存在;跟着 IDA 里的赋值走到 set_Key / set_IV,才能说明它到底怎么被使用。
LoopReverseXor 在干什么
在 IDA 中进入 BinaryUtils$$LoopReverseXor,按 F5 获取 Pseudocode。
函数里有一长串移位和掩码,看着有点绕。

BinaryUtils$$LoopReverseXor
先不管那些细节,盯住 F5 循环中的关键部分:
v6 = (2 * v2->m_Items[v5]) & 0xFFAA | (v2->m_Items[v5] >> 1) & 0x55;
v7 = (4 * v6) & 0xFFFFFFCF | (v6 >> 2) & 0x33;
data->m_Items[v5] = v2->m_Items[v5 + 1] ^ (((unsigned __int8)v7 >> 4) | (16 * v7));
++v5;
v8 = v2->max_length - 1;
v9 = (__int64)(max_length + 1) < v8;
LODWORD(max_length) = v2->max_length;
从 v2->m_Items[v5] 与 v2->m_Items[v5 + 1] 的读取,以及最后写入 data->m_Items[v5],可以对照当前字节与下一个字节的作用。函数末尾还有单独处理最后一个字节的分支。
它不是把整串字符倒过来,而是把每个字节内部的八个 bit 倒序,再和原料中的下一个字节 XOR。最后一个位置则绕回原料的第一个字节。
拿开头两个字节算一下:
P 是 0x50,二进制为 01010000;
bit 倒序得到 00001010,也就是 0x0a。
下一个字符 h 是 0x68。
所以最终 Key 的第一字节为 0x68 XOR 0x0a = 0x62。
对两段原料全部执行,就得到真正传给 AES 的参数:
Key
627ff1942185e011c815e81e639b9a00001c766b826c29bd96578589f19a6fd6
IV
be56167f83da3befeff81861a5c5f3cd
往回看读取路径
Key 和 IV 都有了,让我们回到 SaveManagement$$Decrypt(0x1CA9258)。
它先用 Convert.FromBase64String 把文本还原为密文字节,再用同一个静态 aes 创建解密器,经 CryptoStream 和 StreamReader 读回字符串。
SaveManagement$$LoadString(0x1CA9C5C)则把这一步和 PlayerPrefs 接起来:
v5 = SaveManagement__Encrypt(keyName, (const MethodInfo *)defaultValue);
if ( UnityEngine_PlayerPrefs__HasKey(v5, 0) )
{
String_60782072 = UnityEngine_PlayerPrefs__GetString_60782072(v5, 0);
if ( !SaveManagement_TypeInfo->_2.cctor_finished )
j_il2cpp_runtime_class_init_0(SaveManagement_TypeInfo);
result = SaveManagement__Decrypt(String_60782072, v7);
if ( !result )
return v3;
}
这里还有一个兼容旧数据的分支:找不到 AES 键时会试 EncryptDES(keyName),如果读到旧值,再 DecryptDES 并用当前 AES 写回。这应该就是旧 DES 格式的键值迁移为当前 AES 格式的逻辑。
实践才是检验真理的唯一标准
我们已经获得了完整解密流程。
从 XML 拿到一个字符串,解密顺序是:
URL 编码反转义
→ Base64 解码
→ AES-256-CBC 解密
→ PKCS#7 去填充
→ UTF-8 读回字符串
那么我们按刚才得到的内容,写个 Python 脚本,验证一下结果吧。
为了方便脚本对内容进行加密,我顺便保存了原内容。
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map phigros_research_count="1654">
<string name="1keyzhanshi2" phigros_research_v1="WyJlR0ZJYVVaSmRGWm5iMU0yUTBKR1RraFVVaklsTWtKQkpUTkVKVE5FIiwiT0RSdWREUkRSRWMwTVdab1JqVkZXRWhyVm5CdmR5VXpSQ1V6UkE9PSIsIjAzMTI1N2NhNTk3ZDk3MmIxNWU0MTNhODA3YmIyY2UwMmEwYzYwNTYyNmI4OTBjYjhhOTFiMTIzY2UyNTM3YTEiXQ==">1</string>
<string name="GetBack.CAP3.0.Record.IN" phigros_research_v1="WyJVa1kzU1ZsU2FGQkxUMDlIYmpkUlpXRTJNR3hMWlhob04zVTNWWEZ5ZW10eFl6Rm1keVV5Um1sTVJsbEZKVE5FIiwiVG1vd2FIZzJKVEpDTW5vbE1rSndWMlZQVW5ReE5sSTBWazFsY0hwa1MybEVTa05RSlRKR1VXRjJSV1V5VHlVeVFraFViVEpKUmxOVlduVm5TbXB2VkdOTVVVdzBXRzVVIiwiNWU2YWU1ZDdiYzMwNDIzOGU4M2RkY2NjM2Q1M2E4OTkxNDIzODcxNWE0ZjAxY2E3OTI0NzUyNzhiMDI1Y2M0MCJd">{"s":996886,"a":99.6539535522461,"c":1}</string> <!--这一行就是 Get Back IN-->
<string name="MARENOL.LeaF.0.Record.EZ" phigros_research_v1="WyJRMmQzTkZOMGRFdDNVa1pKYW1JMk9GUkdPSG8xUXpKb1NGSk1Ta2REYkNVeVFrTTBOV3BFTTFnNFYyMDBKVE5FIiwiVVRsQllsaHhiRlpqYzBaYVozUllNblpTU0ROU2JFdHpUMUpJZVc5ek5FRTRNRVpqY0dNNFJXdE9TVmxQWkVSdVFXaHZSRUZFY0dOdk5ISlVOWFp3ZVE9PSIsIjVlMzM4MzZmMzdlMWY0OGViM2Q5Y2JkODIyN2FhNmE4OTNiMzExNTc1ZjA3ZDczZTdkMWY3ODA1MGU0ZDkwNDciXQ==">{"s":996684,"a":99.63157653808594,"c":1}</string>
<string name="magicballballCollectionTextOpened" phigros_research_v1="WyJjbTlEYkUweWFIQm1WMWRVTUhSMWNVeFVNR1pUZVZoRVNFbDNOWFpxTUc5MU1HZFBUV0pCWVV4dVlYbGlVVnBSWTFWTU1FMWtNMGN4WVZOQlQwY3djZz09IiwiT0RSdWREUkRSRWMwTVdab1JqVkZXRWhyVm5CdmR5VXpSQ1V6UkE9PSIsImY1OTk3NGM2ZDY2MzI1ZTdlMjBkMTYwN2E4MjExOGU4NGYxMzZiMjM1Y2JkNDg1ZmRhNDBjYTg0NmZiODhiNzIiXQ==">1</string>
...
可以看到我 Get Back IN 的成绩是 996886 分,ACC 是 99.6539535522461。
这和我游戏内的成绩一致。

Get Back IN 的成绩与 ACC,可见为 996886 与 99.65%
让我们把 Get Back IN 的成绩改成 1000000 分,ACC 改成 100.0,并试着加密:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="xaHiFItVgoS6CBFNHTR2%2BA%3D%3D">84nt4CDG41fhF5EXHkVpow%3D%3D</string>
<string name="RF7IYRhPKOOGn7Qea60lKexh7u7Uqrzkqc1fw%2FiLFYE%3D">kqfwO1uMaucCSyOs3f6pHGjYkZEn9aTrYVErEemEy30%3D</string> <!--这一行就是 Get Back IN-->
<string name="Cgw4SttKwRFIjb68TF8z5C2hHRLJGCl%2BC45jD3X8Wm4%3D">Q9AbXqlVcsFZgtX2vRH3RlKsORHyos4A80Fcpc8EkNIYOdDnAhoDADpco4rT5vpy</string>
<string name="roClM2hpfWWT0tuqLT0fSyXDHIw5vj0ou0gOMbAaLnaybQZQcUL0Md3G1aSAOG0r">84nt4CDG41fhF5EXHkVpow%3D%3D</string>
...
嗯,好像没什么问题。
导入游戏看看(导入前记得停止游戏,并检查应用数据文件的所有者、权限和安全上下文):

Get Back IN 的成绩与 ACC 均已成功修改
三、有福同享,有难同当
Python 脚本我已开源至 GitHub,有需要的可以前往查看。
开源出来的目的是为了一起讨论,而不是满足 yyw 的一己私欲。
爱护圈内环境,你我有责。
7 年过去了,Phigros 也迎来了完结,当年对着破破烂烂的板子打歌的音游人现在又在哪呢。