修改 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 类的定义代码抄过来。