-
-
[原创]phigros游戏存档解密方法
-
发表于: 4天前 815
-
设备:多巴胺越狱的ipad
游戏版本:3.19.3
闲来无事,想打开紧张刺激的音乐游戏来锻炼一下手指。

突然发现我太久不更新导致新曲子更新了一大堆,导致我的存款告急。

每首曲子烧16mb,不算上新曲子的折扣,只能解不到3首。

与其坐以待毙,不如用现有的知识主动出击
话不多说,直接动手。
首先,我们需要找到游戏的存档文件。

存档肯定是要被持久化存储的。所以可以在Library/Preferences找到所有的存储文件。

这个games.Pigeon.Phigros.plist的文件有点意思啊,122kb的大小呢。
来,让我看看!

好消息!找到了。
坏消息!加密了。
事已至此,我们需要更深入一些!
使用appsdump3来砸壳提取出解密的ipa文件
我们来观赏一下里面的内容。

看到了Frameworks和Data文件夹了,具有unity游戏的特征。

看到有UnityFramework文件,确认就是unity搓的游戏了!
根据unity的文档,引擎可以打两种包,mono和il2cpp。
由于ios不支持mono,所以一定是il2cpp ,因此我们可以用il2cppdumper工具来解包它。
这个工具需要两个文件才能工作。UnityFramework和global-metadata.dat
UnityFramework在

global-metadata.dat在


dump的很顺利,看来这两个文件没有被加密。
顺利的拿到script.json和il2cpp.h
剩下的工作就要请出ida了!

使用ida打开UnityFramework

等待分析完成后执行ida_with_struct_py3.py这个脚本。选择script.json和il2cpp.h来恢复符号。

跟存档有关,肯定相关函数符号有save这个单词。

竟然有458个相关函数!!!
我没有什么好的办法,慢慢找吧。
突然!出现了一个函数为我指明了方向!

这不就是存钱的地方吗!进去看看。

if ( (byte_46A5098 & 1) == 0 )
{
sub_1278B58(a1: &Method_System_Collections_Generic_List_MoneyUnit__get_Item__);
sub_1278B58(a1: &SaveManagement_TypeInfo);
sub_1278B58(a1: &StringLiteral_NumOfMoney);
byte_46A5098 = 1;
}这里是确保函数初始化的地方。
for ( i = 0; i < 5; ++i )
{
v3 = System::Int32::ToString(this: (System::Int32 *)&i);
v4 = System::String::Concat(a1: aNumofmoney, a2: v3, a3: 0);
Item = *((_QWORD *)this + 2);
if ( Item == 0
|| (Item = System::Collections::Generic::List<System::Object>::get_Item(
a1: Item,
a2: (unsigned int)i,
a3: Method_System_Collections_Generic_List_MoneyUnit__get_Item__)) == 0 )
{
sub_1278DD4(a1: Item);
}
v6 = *(unsigned int *)(Item + 16);
if ( *((_DWORD *)SaveManagement_TypeInfo + 56) == 0 )
j__il2cpp_runtime_class_init_0(a1: SaveManagement_TypeInfo);
result = SaveManagement::SaveInt(a1: v4, a2: v6, a3: 0);
}这里就是存钱的地方了!
让我们看看它做了什么?
循环了5次?
把变量i转为了string
再把连个字符串拼在一起?为什么要这么做?
拼接的结果是:
NumOfMoney0 NumOfMoney1 NumOfMoney2 NumOfMoney3 NumOfMoney4
拼接好的字符串变量v4最后被塞进了
result = SaveManagement::SaveInt(a1: v4, a2: v6, a3: 0);
先不管为什么拼接,先看看是怎么存的吧。

我好像大概明白了!
v4进入了SaveInt函数是a1,被加密后变成了v3
v6进入了SaveInt函数是a2,被加密后变成了v5
v3和v5被PlayerPrefs的SetString存储了。
PlayerPrefs是什么???
让我们查一下。这是一个unity提供给开发者的一个存档系统,由key:value构成。
所以说:v3是key,v5是value!
这就更不对了。这就说明这个游戏有5个货币的和!难道有5种货币?
算了,不管那么多。知道怎么加密就行了。
看样子,加密由SaveManagent这个保存管理器负责!

细看一下加密的逻辑:


我并没看到密钥相关的内容。说明密钥不在这里!
好像每个函数都要和密钥打交道,或许SaveManagement的构造方法里面应该有。
__int64 __fastcall SaveManagement::cctor(SaveManagement *this)
{
__int64 v1; // x0
__int64 *v2; // x8
__int64 v3; // x19
__int64 ASCII; // x0
__int64 v5; // x0
__int64 v6; // x19
__int64 v7; // x0
__int64 v8; // x19
__int64 v9; // x19
if ( (byte_46A516D & 1) == 0 )
{
sub_1278B58(a1: &System_Security_Cryptography_Aes_TypeInfo);
sub_1278B58(a1: &System_Convert_TypeInfo);
sub_1278B58(a1: &SaveManagement_TypeInfo);
sub_1278B58(a1: &StringLiteral_PGRS);
sub_1278B58(a1: &StringLiteral_Q4zHm5vUEMJJ3iS9);
sub_1278B58(a1: &StringLiteral_Kk_wisgNYwcAV8WVGMgyUw__);
sub_1278B58(a1: &StringLiteral_Phigros_enc_j57vnvr8wlZssXM7eWpa);
sub_1278B58(a1: &StringLiteral_6Jaa0qVAJZuXkZCLiOa_Ax5tIZVu_taK);
byte_46A516D = 1;
}
if ( *((_DWORD *)System_Security_Cryptography_Aes_TypeInfo + 56) == 0 )
j__il2cpp_runtime_class_init_0(a1: System_Security_Cryptography_Aes_TypeInfo);
**(_QWORD **)(SaveManagement_TypeInfo + 184) = System::Security::Cryptography::Aes::Create(this: nullptr);
v1 = System::Security::Cryptography::Aes::Create(this: nullptr);
v2 = *(__int64 **)(SaveManagement_TypeInfo + 184);
v2[1] = v1;
v2[2] = (__int64)aPgrs;
v3 = *v2;
ASCII = System::Text::Encoding::get_ASCII(this: nullptr);
if ( ASCII != 0 )
{
v5 = (*(__int64 (__fastcall **)(__int64, char *, _QWORD))(*(_QWORD *)ASCII + 600LL))(
a1: ASCII,
a2: aPhigrosEncJ57v,
a3: *(_QWORD *)(*(_QWORD *)ASCII + 608LL));
ASCII = BinaryUtils::LoopReverseXor(a1: v5, a2: 0);
if ( v3 != 0 )
{
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v3 + 504LL))(
a1: v3,
a2: ASCII,
a3: *(_QWORD *)(*(_QWORD *)v3 + 512LL));
v6 = **(_QWORD **)(SaveManagement_TypeInfo + 184);
ASCII = System::Text::Encoding::get_ASCII(this: nullptr);
if ( ASCII != 0 )
{
v7 = (*(__int64 (__fastcall **)(__int64, char *, _QWORD))(*(_QWORD *)ASCII + 600LL))(
a1: ASCII,
a2: aQ4zhm5vuemjj3i,
a3: *(_QWORD *)(*(_QWORD *)ASCII + 608LL));
ASCII = BinaryUtils::LoopReverseXor(a1: v7, a2: 0);
if ( v6 != 0 )
{
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v6 + 472LL))(
a1: v6,
a2: ASCII,
a3: *(_QWORD *)(*(_QWORD *)v6 + 480LL));
v8 = *(_QWORD *)(*(_QWORD *)(SaveManagement_TypeInfo + 184) + 8LL);
if ( *((_DWORD *)System_Convert_TypeInfo + 56) == 0 )
j__il2cpp_runtime_class_init_0(a1: System_Convert_TypeInfo);
ASCII = System::Convert::FromBase64String(a1: a6jaa0qvajzuxkz, a2: 0);
if ( v8 != 0 )
{
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v8 + 504LL))(
a1: v8,
a2: ASCII,
a3: *(_QWORD *)(*(_QWORD *)v8 + 512LL));
v9 = *(_QWORD *)(*(_QWORD *)(SaveManagement_TypeInfo + 184) + 8LL);
ASCII = System::Convert::FromBase64String(a1: aKkWisgnywcav8w, a2: 0);
if ( v9 != 0 )
return (*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v9 + 472LL))(
a1: v9,
a2: ASCII,
a3: *(_QWORD *)(*(_QWORD *)v9 + 480LL));
}
}
}
}
}
sub_1278DD4(a1: ASCII);
}看样子摸到头奖了!
if ( *((_DWORD *)System_Security_Cryptography_Aes_TypeInfo + 56) == 0 ) j__il2cpp_runtime_class_init_0(a1: System_Security_Cryptography_Aes_TypeInfo); **(_QWORD **)(SaveManagement_TypeInfo + 184) = System::Security::Cryptography::Aes::Create(this: nullptr); v1 = System::Security::Cryptography::Aes::Create(this: nullptr);
都写脸上了,是aes加密算法。
这个不是重点!不知道密钥和偏移都是白扯!
下面这一段才是重点!
v5 = (*(__int64 (__fastcall **)(__int64, char *, _QWORD))(*(_QWORD *)ASCII + 600LL))( a1: ASCII, a2: aPhigrosEncJ57v, a3: *(_QWORD *)(*(_QWORD *)ASCII + 608LL)); ASCII = BinaryUtils::LoopReverseXor(a1: v5, a2: 0);
看样子,应该是读取了
aPhigrosEncJ57v
这个字符串不是很完整。

完整字符串是
Phigros.enc.j57vnvr8wlZssXM7eWpa
这个字符串经过LoopReverseXor处理得到了变量ASCII
__int64 __fastcall BinaryUtils::LoopReverseXor(__int64 result)
{
__int64 v1; // x19
__int64 v2; // x11
unsigned int v3; // w10
unsigned __int64 v4; // x12
unsigned int v5; // w13
_BYTE *v6; // x8
char *v7; // x9
char v8; // x10^3
char v9; // t1
v1 = result;
if ( (byte_46A50EC & 1) == 0 )
{
result = sub_1278B58(a1: &qword_43C4148);
byte_46A50EC = 1;
}
if ( v1 == 0 )
goto LABEL_16;
result = sub_1278C00(a1: qword_43C4148, a2: *(unsigned int *)(v1 + 24));
v2 = *(_QWORD *)(v1 + 24);
v3 = v2 - 1;
if ( (int)v2 - 1 >= 1 )
{
if ( result != 0 )
{
v4 = 0;
v5 = *(_QWORD *)(v1 + 24);
v6 = (_BYTE *)(result + 32);
v7 = (char *)(v1 + 33);
while ( v4 < v5 && v4 + 1 < v5 && v4 < *(unsigned int *)(result + 24) )
{
v8 = __rbit32((unsigned __int8)*(v7 - 1)) >> 24;
v9 = *v7++;
*v6++ = v9 ^ v8;
v2 = *(_QWORD *)(v1 + 24);
v5 = v2;
v3 = v2 - 1;
if ( (__int64)++v4 >= (int)v2 - 1 )
goto LABEL_11;
}
LABEL_15:
sub_1278DE0(a1: result);
}
LABEL_16:
sub_1278DD4(a1: result);
}
LABEL_11:
if ( (_DWORD)v2 == 0 )
goto LABEL_15;
if ( result == 0 )
goto LABEL_16;
if ( v3 >= *(_DWORD *)(result + 24) )
goto LABEL_15;
*(_BYTE *)(result + (int)v3 + 32) = *(_BYTE *)(v1 + 32) ^ (__rbit32(*(unsigned __int8 *)(v1 + 32 + (int)v3)) >> 24);
return result;
}工作方式:
以 "Phigros.enc.j57vnvr8wlZssXM7eWpa" 为例的字符处理流程
该字符串一共37个字符,函数将其作为37个字节的数组处理。
循环处理阶段
处理第0个字符:
取出第0个字符 'P' 的ASCII码 0x50
将二进制 01010000 位反转得到 00001010,即十进制10
取出第1个字符 'h' 的ASCII码 0x68
用10异或0x68,得到0x62,对应字符'b'
将'b'存入输出数组的第0个位置
处理第1个字符:
取出第1个字符 'h' 的ASCII码 0x68
将二进制 01101000 位反转得到 00010110,即十进制22
取出第2个字符 'i' 的ASCII码 0x69
用22异或0x69,得到0x7F
将0x7F存入输出数组的第1个位置
处理第2个字符:
取出第2个字符 'i' 的ASCII码 0x69
将二进制 01101001 位反转得到 10010110,即十进制150
取出第3个字符 'g' 的ASCII码 0x67
用150异或0x67,得到0xF1
将0xF1存入输出数组的第2个位置
继续循环:
按照同样方式,依次处理第3个和第4个字符、第4个和第5个字符……直到处理完第35个和第36个字符
每次都用当前字符的位反转结果去异或下一个字符
最后一个字符处理
循环结束后,剩余最后一个字符(索引36)需要特殊处理:
取出第0个字符 'P' 的ASCII码 0x50
取出最后一个字符 'a' 的ASCII码 0x61
将 'a' 的二进制位反转
取位反转后的最低位
用这个值异或0x50
将结果存入输出数组的最后一个位置
下面还有一个同理
v7 = (*(__int64 (__fastcall **)(__int64, char *, _QWORD))(*(_QWORD *)ASCII + 600LL))( a1: ASCII, a2: aQ4zhm5vuemjj3i, a3: *(_QWORD *)(*(_QWORD *)ASCII + 608LL)); ASCII = BinaryUtils::LoopReverseXor(a1: v7, a2: 0);
处理流程如下
输入
Phigros.enc.j57vnvr8wlZssXM7eWpa
转为bytes的形式。
50 68 69 67 72 6f 73 2e 65 6e 63 2e 6a 35 37 76 6e 76 72 38 77 6c 5a 73 73 58 4d 37 65 57 70 61
再经过LoopReverseXor转换成下面这样
b'b\x7f\xf1\x94!\x85\xe0\x11\xc8\x15\xe8\x1ec\x9b\x9a\x00\x00\x1cvk\x82l)\xbd\x96W\x85\x89\xf1\x9ao\xd6'
输入
Q4zHm5vUEMJJ3iS9
转为bytes的形式。
51 34 7a 48 6d 35 76 55 45 4d 4a 4a 33 69 53 39
经过LoopReverseXor转换
b'\xbeV\x16\x7f\x83\xda;\xef\xef\xf8\x18a\xa5\xc5\xf3\xcd'
到了这里,两个奇怪的字符处理完了,变成更奇怪的字符了。
所以这有什么用呢?
已知数据经过LoopReverseXor处理后到的变量ASCII,类型是byte[]
我们可以看看是谁使用了变量ASCII
v5 = (*(__int64 (__fastcall **)(__int64, char *, _QWORD))(*(_QWORD *)ASCII + 600LL))(
a1: ASCII,
a2: aPhigrosEncJ57v,
a3: *(_QWORD *)(*(_QWORD *)ASCII + 608LL));
ASCII = BinaryUtils::LoopReverseXor(a1: v5, a2: 0);
if ( v3 != 0 )
{
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v3 + 504LL))(
a1: v3,
a2: ASCII,
a3: *(_QWORD *)(*(_QWORD *)v3 + 512LL));
v6 = **(_QWORD **)(SaveManagement_TypeInfo + 184);这里有一个不知名的解引用函数
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v3 + 504LL))
是v3这个对象的+504偏移的这个函数。
v3是什么来路?

v3是v2地址的值
v2是SaveManagement_TypeInfo + 184偏移的值

SaveManagement_TypeInfo + 184是
System::Security::Cryptography::Aes::Create(this: nullptr);
这就好办了!!!
打开dump.cs文件去翻一下这个函数。

Create函数返回的是Aes类。
我不就是Aes类吗!我返回我自己?
这是一个抽象类,也就是说这个类必须被继承才能被使用!
再次搜索,可以得到两个继承类。


竟能如此相像?
到底是哪个呢?504偏移到底是谁的函数?
我们可以去ida那个看看这两个类的详细定义!


这也太多了吧!
这个时候,我一细想,为什么aes要使用抽象类设计?又为什么设计两套aes实现?
打开精彩的互联网一查!
突然得知
AesCryptoServiceProvider 是只能在windows平台上工作的!
AesManaged 可以跨平台工作!
因为我是在ios平台上运行的,只能是AesManaged实现!
直接减少一半的查找范围!
因为传入的是byte[]数组形式。查找范围可以再次缩小!
搜完,好像只剩下面两个了
// RVA: 0x2E886C8 Offset: 0x2E886C8 VA: 0x2E886C8 Slot: 10
public override void set_IV(byte[] value) { }
// RVA: 0x2E88708 Offset: 0x2E88708 VA: 0x2E88708 Slot: 12
public override void set_Key(byte[] value) { }成功找到设置偏移和密钥的地方!
下面就是加解密的脚本了
import base64
import re
import argparse
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.Padding import pad
def reverse_bits(x):
return int(f"{x:08b}"[::-1], 2)
def loop_reverse_xor(data):
n = len(data)
out = bytearray(n)
for i in range(n - 1):
out[i] = data[i + 1] ^ reverse_bits(data[i])
out[n - 1] = data[0] ^ reverse_bits(data[n - 1])
return bytes(out)
AES_KEY = loop_reverse_xor(
b"Phigros.enc.j57vnvr8wlZssXM7eWpa"
)
AES_IV = loop_reverse_xor(
b"Q4zHm5vUEMJJ3iS9"
)
def is_base64(text):
if not isinstance(text, str):
return False
if len(text) % 4 != 0:
return False
if not re.match(r'^[A-Za-z0-9+/]+=*$', text):
return False
return True
def decrypt_string(text):
if not is_base64(text):
return text
try:
raw = base64.b64decode(text)
if len(raw) % 16 != 0:
return text
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
decrypted = cipher.decrypt(raw)
try:
unpadded = unpad(decrypted, 16)
return unpadded.decode("utf-8")
except:
return decrypted.decode("utf-8", errors="ignore")
except:
return text
def encrypt_string(text):
if not isinstance(text, str):
text = str(text)
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
padded = pad(text.encode("utf-8"), 16)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted).decode("utf-8")
def main():
parser = argparse.ArgumentParser(description='AES加解密工具')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-e', '--encrypt', metavar='字符串', help='加密字符串')
group.add_argument('-d', '--decrypt', metavar='字符串', help='解密字符串')
args = parser.parse_args()
if args.encrypt:
result = encrypt_string(args.encrypt)
print(result)
elif args.decrypt:
result = decrypt_string(args.decrypt)
print(result)
if __name__ == "__main__":
main()解密成果:

解密成果了,去找找money相关的字段
可以找到下面5个
"NumOfMoney0": "641",
"NumOfMoney1": "46",
"NumOfMoney2": "0",
"NumOfMoney3": "0",
"NumOfMoney4": "0",
发现特征了吗?

看来并不是5种货币,是5个单位!
从上到下看应该是
KB MB (止步于此) GB (在b站看到有肝帝达到过) TB (真的有人能慢慢玩到这吗?) PB (开发者通道)
我也不是很贪,拿一丢丢就好。
NumOfMoney4加密一下是

拿i7Ou3ia/bynTMr9FMXIDnw==搜索一下得到2Zy6gnckhcnltlh66eM6FA==

我小改一下

填写完成后再重新导入设备后替换文件。
打开游戏,看到惊喜!

完活!
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。