修改 Overcooked! 2 存档
这两天玩 Overcooked! 2 ,有的关卡因为没有攒够星星玩不了。我花钱买了游戏却不让我往后玩,那我自己改存档吧!
Overcooked! 2 存档位于 C:\Users\{username}\AppData\LocalLow\Team17\Overcooked2\{key}\
文件夹下。username 是自己的用户名;key 是 Steam 账号的 17 位 SteamID,我的账号的 key 是 76561198849752742。
不难看出 Overcooked! 2 的存档都是以 .save
作为后缀。诸多游戏厂商都会把自家游戏存档存为 .save
格式。不过这并不是一种通用格式,不同厂商间存储的方式还不一样,有些使用文本文档或者 .json
格式明文存储(例如 Insurgency),有些使用序列化方式存储(例如 Arma3)。Overcooked! 2 的存储方式比较高级:
它加密了……
Overcooked! 2 是使用 Unity 引擎开发的游戏,这件事你一打开游戏就能知道。那我不妨把这游戏逆掉。
dnSpy,启动!
这样一款游戏,里边出现的类可以说是相当之多了。不过我只需要找到加密和解密存档的代码,因此我选择搜索 "save" 关键词来查找代码。不知道什么原因,开发人员没有使用 Unity 的 Mono Security 来保护代码,这也方便了我逆向。最终我在 GlobalSave
类里找到了极其可疑的代码:
相关加密解密代码如下:
1private byte[] Obfuscate(byte[] deobfuscatedText, int size, int start = 0, string salt = "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", string hashFunction = "SHA1", int keySize = 256)
2{
3 if (deobfuscatedText == null || deobfuscatedText.Length == 0 || start + size > deobfuscatedText.Length)
4 {
5 return null;
6 }
7 byte[] array = new byte[16];
8 System.Random random = new System.Random();
9 random.NextBytes(array);
10 byte[] bytes = new PasswordDeriveBytes(this.GetUniqueId(), Encoding.ASCII.GetBytes(salt), hashFunction, 2).GetBytes(keySize / 8);
11 RijndaelManaged rijndaelManaged = new RijndaelManaged();
12 rijndaelManaged.Mode = CipherMode.CBC;
13 byte[] array2 = null;
14 try
15 {
16 using (ICryptoTransform cryptoTransform = rijndaelManaged.CreateEncryptor(bytes, array))
17 {
18 using (MemoryStream memoryStream = new MemoryStream())
19 {
20 using (CryptoStream cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write))
21 {
22 cryptoStream.Write(deobfuscatedText, start, size);
23 cryptoStream.FlushFinalBlock();
24 array2 = memoryStream.ToArray();
25 memoryStream.Close();
26 cryptoStream.Close();
27 }
28 }
29 }
30 }
31 catch (Exception ex)
32 {
33 Debug.LogError("GlobalSave Obfuscate exception=" + ex.ToString());
34 return null;
35 }
36 finally
37 {
38 rijndaelManaged.Clear();
39 }
40 byte[] array3 = new byte[16 + array2.Length];
41 Array.Copy(array, array3, 16);
42 Array.Copy(array2, 0, array3, 16, array2.Length);
43 return array3;
44}
45// 加密部分
46
47private byte[] Deobfuscate(byte[] obfuscatedText, int size, int start = 0, string salt = "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", string hashFunction = "SHA1", int keySize = 256)
48{
49 if (obfuscatedText == null || obfuscatedText.Length == 0 || obfuscatedText.Length <= start + size || obfuscatedText.Length <= 16)
50 {
51 return null;
52 }
53 byte[] array = new byte[16];
54 Array.Copy(obfuscatedText, start, array, 0, 16);
55 byte[] array2 = new byte[size - 16 - start];
56 Array.Copy(obfuscatedText, 16, array2, 0, array2.Length);
57 byte[] bytes = new PasswordDeriveBytes(this.GetUniqueId(), Encoding.ASCII.GetBytes(salt), hashFunction, 2).GetBytes(keySize / 8);
58 RijndaelManaged rijndaelManaged = new RijndaelManaged();
59 rijndaelManaged.Mode = CipherMode.CBC;
60 byte[] array3 = new byte[array2.Length];
61 try
62 {
63 using (ICryptoTransform cryptoTransform = rijndaelManaged.CreateDecryptor(bytes, array))
64 {
65 using (MemoryStream memoryStream = new MemoryStream(array2))
66 {
67 using (CryptoStream cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Read))
68 {
69 cryptoStream.Read(array3, 0, array3.Length);
70 memoryStream.Close();
71 cryptoStream.Close();
72 }
73 }
74 }
75 }
76 catch (Exception ex)
77 {
78 Debug.LogError("GlobalSave Deobfuscate exception=" + ex.ToString());
79 return null;
80 }
81 finally
82 {
83 rijndaelManaged.Clear();
84 }
85 return array3;
86}
87// 解密部分
88
89public byte[] ByteSave()
90{
91 string text = this.ConvertDataToSave();
92 if (string.IsNullOrEmpty(text))
93 {
94 return null;
95 }
96 byte[] bytes = Encoding.UTF8.GetBytes(text);
97 byte[] array = this.Obfuscate(bytes, bytes.Length, 0, "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", "SHA1", 256);
98 if (array == null)
99 {
100 return null;
101 }
102 byte[] bytes2 = BitConverter.GetBytes(CRC32.Calculate(array));
103 byte[] array2 = new byte[array.Length + bytes2.Length];
104 Array.Copy(array, array2, array.Length);
105 Array.Copy(bytes2, 0, array2, array.Length, bytes2.Length);
106 return array2;
107}
108// 存储时进行校验
109
110public bool ByteLoad(byte[] _data)
111{
112 if (_data == null || (long)_data.Length <= 4L)
113 {
114 return false;
115 }
116 int size = _data.Length - 4;
117 if (!CRC32.Validate(_data, (uint)size))
118 {
119 return false;
120 }
121 byte[] array = this.Deobfuscate(_data, size, 0, "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", "SHA1", 256);
122 if (array == null)
123 {
124 return false;
125 }
126 string @string = Encoding.UTF8.GetString(array);
127 return this.ConvertDataFromSave(@string);
128}
129// 读取时进行校验
对于加密函数,经过分析我得到,程序会使用我一开始提到的 key 与密码盐 "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==" 使用 PasswordDeriveBytes()
类共同生成密钥,并且随机生成长度为 16 字节的初始化向量 IV。其中,在生成密钥时,程序采用 PBKDF1 算法与 SHA1 算法对密码迭代两次。随后程序依据已知数据,使用 CBC 模式对明文进行 AES 加密(Rijndael 算法)。由于初始化向量是随机生成的,程序将 CRC32 校验码以及初始化向量的 16 个字节写在了存档文件的最前端,方便解密时读取。后面写入被加密的存档文件。此外根据反编译出的 LitJson
命名空间推测明文存档是以 .json
格式读取的。
程序解读毕。将上述代码复制粘贴后略加修改,即可得到用于加密解密存档的脚本了。修改过的脚本如下:
1using System;
2using System.Diagnostics;
3using System.IO;
4using System.Net.Security;
5using System.Security.Cryptography;
6using System.Text;
7
8namespace overcooked2
9{
10 public class CRC32
11 {
12 public const uint c_HashSize = 4u;
13
14 private const uint poly = 1491524015u;
15
16 private const uint seed = 3605721660u;
17
18 private static uint[] s_table;
19
20 private CRC32()
21 {
22 if (s_table == null)
23 {
24 MakeTable();
25 }
26 }
27
28 protected void MakeTable()
29 {
30 s_table = new uint[256];
31 for (uint num = 0u; num < 256; num++)
32 {
33 uint num2 = num;
34 for (uint num3 = 0u; num3 < 8; num3++)
35 {
36 num2 = (((num2 & 1) != 1) ? (num2 >> 1) : (num2 ^ 0x58E6D9AF));
37 }
38 s_table[num] = num2;
39 }
40 }
41
42 public uint CalculateHash(byte[] _data, uint _start, uint _size)
43 {
44 uint num = 3605721660u;
45 for (uint num2 = _start; num2 < _start + _size; num2++)
46 {
47 num = ((num >> 8) ^ s_table[_data[num2] ^ (num & 0xFF)]);
48 }
49 return num;
50 }
51
52 public static void Append(ref byte[] _buffer)
53 {
54 new CRC32().AppendHash(ref _buffer);
55 }
56
57 public void AppendHash(ref byte[] _buffer)
58 {
59 AppendHash(ref _buffer, 0u, (uint)_buffer.Length);
60 }
61
62 public void AppendHash(ref byte[] _buffer, uint _start, uint _size)
63 {
64 AppendHash(ref _buffer, 0u, _size, CalculateHash(_buffer, _start, _size));
65 }
66
67 public void AppendHash(ref byte[] _buffer, uint _start, uint _size, uint _hash)
68 {
69 byte[] bytes = BitConverter.GetBytes(_hash);
70 if (_buffer.Length < (int)(_start + _size + 4))
71 {
72 byte[] new_buffer = new byte[_start + _size + 4];
73 Array.Copy(_buffer, new_buffer, _buffer.Length);
74 _buffer = new_buffer;
75 }
76 int num2 = (int)_size;
77 for (int i = 0; i < bytes.Length; i++)
78 {
79 _buffer[i + num2] = bytes[i];
80 }
81 }
82 }
83
84 class OC2
85 {
86 private static byte[] Deobfuscate(byte[] obfuscatedText, int size, string key, int start = 0, string salt = "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", string hashFunction = "SHA1", int keySize = 256)
87 {
88 if (obfuscatedText == null || obfuscatedText.Length == 0 || obfuscatedText.Length <= start + size || obfuscatedText.Length <= 16)
89 {
90 return null;
91 }
92 byte[] array = new byte[16];
93 Array.Copy(obfuscatedText, start, array, 0, 16);
94 byte[] array2 = new byte[size - 16 - start];
95 Array.Copy(obfuscatedText, 16, array2, 0, array2.Length);
96 byte[] bytes = new PasswordDeriveBytes(key, Encoding.ASCII.GetBytes(salt), hashFunction, 2).GetBytes(keySize / 8);
97 RijndaelManaged rijndaelManaged = new RijndaelManaged();
98 rijndaelManaged.Mode = CipherMode.CBC;
99 byte[] array3 = new byte[array2.Length];
100 try
101 {
102 using (ICryptoTransform cryptoTransform = rijndaelManaged.CreateDecryptor(bytes, array))
103 {
104 using (MemoryStream memoryStream = new MemoryStream(array2))
105 {
106 using (CryptoStream cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Read))
107 {
108 cryptoStream.Read(array3, 0, array3.Length);
109 memoryStream.Close();
110 cryptoStream.Close();
111 }
112 }
113 }
114 }
115 catch (Exception ex)
116 {
117 return null;
118 }
119 finally
120 {
121 rijndaelManaged.Clear();
122 }
123 return array3;
124 }
125
126 private static byte[] Obfuscate(byte[] deobfuscatedText, int size, string key, int start = 0, string salt = "jjo+Ffqil5bdpo5VG82kLj8Ng1sK7L/rCqFTa39Zkom2/baqf5j9HMmsuCr0ipjYsPrsaNIOESWy7bDDGYWx1eA==", string hashFunction = "SHA1", int keySize = 256)
127 {
128 if (deobfuscatedText == null || deobfuscatedText.Length == 0 || start + size > deobfuscatedText.Length)
129 {
130 return null;
131 }
132 byte[] array = new byte[16];
133 System.Random random = new System.Random();
134 random.NextBytes(array);
135 byte[] bytes = new PasswordDeriveBytes(key, Encoding.ASCII.GetBytes(salt), hashFunction, 2).GetBytes(keySize / 8);
136 RijndaelManaged rijndaelManaged = new RijndaelManaged();
137 rijndaelManaged.Mode = CipherMode.CBC;
138 byte[] array2 = null;
139 try
140 {
141 using (ICryptoTransform cryptoTransform = rijndaelManaged.CreateEncryptor(bytes, array))
142 {
143 using (MemoryStream memoryStream = new MemoryStream())
144 {
145 using (CryptoStream cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write))
146 {
147 cryptoStream.Write(deobfuscatedText, start, size);
148 cryptoStream.FlushFinalBlock();
149 array2 = memoryStream.ToArray();
150 memoryStream.Close();
151 cryptoStream.Close();
152 }
153 }
154 }
155 }
156 catch (Exception ex)
157 {
158 return null;
159 }
160 finally
161 {
162 rijndaelManaged.Clear();
163 }
164 byte[] array3 = new byte[16 + array2.Length];
165 Array.Copy(array, array3, 16);
166 Array.Copy(array2, 0, array3, 16, array2.Length);
167 return array3;
168 }
169
170 static void Main(string[] args)
171 {
172 string inputfile = args[0];
173 string steamid = args[1];
174 byte[] init_data = File.ReadAllBytes(inputfile);
175 if (Path.GetExtension(inputfile) == ".save")
176 {
177 byte[] final_data = Deobfuscate(init_data, init_data.Length - 4, steamid);
178 string outputfile = Path.ChangeExtension(inputfile, ".json");
179 File.WriteAllBytes(outputfile, final_data);
180 }
181 else if (Path.GetExtension(inputfile) == ".json")
182 {
183 byte[] final_data = Obfuscate(init_data, init_data.Length, steamid);
184 string outputfile = Path.ChangeExtension(inputfile, ".save");
185 CRC32.Append(ref final_data);
186 File.WriteAllBytes(outputfile, final_data);
187 }
188 }
189 }
190}
写好代码后可以尝试管不管用,我用只有 5 颗星的存档 03 做测试。存档 03 这五颗星分别是来自王座室的三颗星和关卡 1-1 的两颗星,其中关卡 1-1 的历史最高得分是 136。
下面是存放存档的文件夹。不难猜测,CoopSlot_SaveFile_2.save 是我要修改的存档。
下面两张图依次是解密后格式化前与格式化后的存档文件。如我的猜测,解密后的存档文件果真是 .json
格式:
看起来还是很奇怪。似乎需要把所有的反斜线都去掉,并且把上下花括号外的引号都去掉才像是正常的 .json
文件。
确实,现在参数名称和参数值一一对应。除了 NGPEnabled,其他参数都很好理解。那我现在就要把关卡 1-1(LevelID 为 1)的最高分数设为 514,并且三星通关。现在加密并把它重新塞进游戏,看看效果如何。
特别需要注意的是,最开始我使用 .NET 8.0 运行这个程序,但是在解密时并不能成功;而相同的代码在 .NET 3.1(原程序使用了 .NET 3.5)可以完美运行,不过程序中使用的 Crc32
类(出现于 .NET 6.0)在 .NET 3.1 中不受支持,于是只好再将 Crc32
类的定义代码抄过来。