Phigros 存档解密

2 小时前(已编辑)
/
22
1
本文仅为学术探讨,侵权请联系删除。

AI 参与声明:本文创作过程中使用了 AI 辅助。

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

/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

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 目录

可见 base.apk 内无 lib 目录

这就是老一辈艺术家的自信。

打开 /split_config.arm64_v8a.apk/lib/arm64-v8a/ 目录,我们能看到其中包含 libil2cpp.so 与 libunity.so 。

/split_config.arm64_v8a.apk/lib/arm64-v8a/

/split_config.arm64_v8a.apk/lib/arm64-v8a/

这是很典型的 Unity IL2CPP 特征。

这让我想起了一位老朋友—— Il2CppDumper 。

把在 /assets/bin/Data/Managed/Metadata/ 下的 global-metadata.dat 一起喂给 Il2CppDumper。

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

ida_with_struct_py3.py

寻找 SharedPreference 保存逻辑

既然是保存存档,那么可以试着搜索关键字 save。

很快,SaveManagement 进入了视野。

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

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 的成绩与 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 均已成功修改

Get Back IN 的成绩与 ACC 均已成功修改

三、有福同享,有难同当

Python 脚本我已开源至 GitHub,有需要的可以前往查看。

开源出来的目的是为了一起讨论,而不是满足 yyw 的一己私欲。

爱护圈内环境,你我有责。

7 年过去了,Phigros 也迎来了完结,当年对着破破烂烂的板子打歌的音游人现在又在哪呢。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...