“我万物归一者泡泡今个心情好,拿逆向工程知识随机塞爆一个幸运调查员也很正常吧?”
“能不能塞塞我的,再不教我我可要开始念了!“
”犹格·索托斯知晓门。犹格·索托斯即是门。犹格·索托斯是门的钥匙和看门人……“
——San值不是很高的作者的胡言乱语
本文基于V1.07版本分析,请注意时效性。
Q: 今天是哪个游戏倒霉?
A: 犹格索托斯的庭〇。模拟经营加可爱纸片人,就好这口!
Q: 原因?
A: 感觉很好玩,好玩到想要剖开肚子亲自察看。我开始逐渐理解病娇了。
Q: 想得到什么?
A: 一开始想得到各种ID表用来写CheatTable,不过现在看来最大的收获可能是美术素材。
桃子可爱滴捏
初步分析
游戏的Unity版本为2020.3.33f1c2,Mono方式,单走一个Assembly-CSharp。
游戏资源方面,AssetStudio把全部资源解出来备用。大概看了一圈,各种图片纹理都直接能看。比较特别的是声音资源用了FMOD Studio的方案,存在一堆.bank文件里。有bank api可参考,外加我也对声音资源没什么兴趣,就不碰了。
如此一来,资源方面几乎可以堂堂完结了。
逻辑方面,直接用老朋友dnspy加载Assembly-CSharp.dll。大致扫几眼,似乎都被自动还原了,并且没有什么动态加载行为,看起来难度并不高。那么接下来就是找到跟物品ID相关的部分了。
Item数据加载点
搜索包含“Item”的类,非常不错,看到了名叫ItemManager的家伙。Load方法如下:
// ItemManager
// Token: 0x0600056F RID: 1391
public static void Load()
{
if (ItemManager.mItemArray != null)
{
return;
}
ItemManager.mItemArray = ItemArray.Deserialize(ResourcesManager.Instance.LoadTableByte("Item"));
List<long> keys = ItemManager.mItemArray.Keys;
int count = keys.Count;
ItemManager.mKeyIndexMap = new Dictionary<long, int>(count);
for (int i = 0; i < count; i++)
{
ItemManager.mKeyIndexMap[keys[i]] = i;
}
看起来没找错地方。但是LoadTableByte方法里面涉及到解密流程,先加两行Dump一份解密完的文件作为参考。
byte[] array = ResourcesManager.Instance.LoadTableByte("Item");
using (FileStream fileStream = new FileStream("./Item.decrypted", FileMode.OpenOrCreate, FileAccess.Write))
{
fileStream.Write(array, 0, array.Length);
}
编译,保存模块,运行一下游戏。游戏根目录出现了一份解密完成的Item二进制。
Table解密逻辑
LoadTableByte方法如下:
// ResourcesManager
// Token: 0x06000169 RID: 361 RVA: 0x00021DA4 File Offset: 0x0001FFA4
public byte[] LoadTableByte(string table)
{
TextAsset textAsset = this.Load<TextAsset>(string.Format("Table/{0}", table));
if (textAsset != null && textAsset.bytes != null)
{
byte[] bytes = textAsset.bytes;
this.ConvertDataBytes(ref bytes);
return bytes;
}
return null;
}
从这里可以确定加载的文件是AssetBundle解包出来的TextAsset,路径是Table/Item。
ConvertDataBytes是解密部分,方法如下:
// ResourcesManager
// Token: 0x0600016A RID: 362 RVA: 0x00021DE8 File Offset: 0x0001FFE8
private void ConvertDataBytes(ref byte[] bytes)
{
bytes = RSAHelper.DecryptByPublicKey(RSAHelper.GetPublicEngine(this.s_RSAKey.PublicKey), bytes);
int i = 1;
int num = 0;
while (i < bytes.Length)
{
byte b = (byte)(i - num);
bytes[i] ^= b;
i <<= 1;
num++;
}
int j = bytes.Length - 1;
int num2 = 0;
while (j > 0)
{
byte b2 = (byte)(j - num2);
bytes[j] ^= b2;
j >>= 1;
num2++;
}
}
// ResourcesManager
// Token: 0x040001CC RID: 460
private RSAHelper.RSAKey s_RSAKey = new RSAHelper.RSAKey
{
PublicKey = "MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQCyJiTzABL2wironv9+4wnZTg7JXr1ekiMA3RdL2e+W8kEtyZgghb5KBBAASKuiGNxhadrnSgC8+h1r7B/JLudatvdlzwyy1gAs/mbVYHd7x1WoBfzDpWkZX8bhDO/uX4GnBhWAmtapbbjVGOAVIuaIV8lBzNXJ30mJPDI4wKc7/QIBAw==",
PrivateKey = ""
};
可以看到解密流程分为两部分,先rsa再xor。
RSA解密
首先分析rsa部分。在这里制作组使用私钥签名资源,运行时使用公钥解密。把公钥补上头尾丢进openssl解析一下,确认是PEM格式。
❯ cat pk.pem
-----BEGIN PUBLIC KEY-----
MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQCyJiTzABL2wironv9+4wnZTg7JXr1ekiMA3RdL2e+W8kEtyZgghb5KBBAASKuiGNxhadrnSgC8+h1r7B/JLudatvdlzwyy1gAs/mbVYHd7x1WoBfzDpWkZX8bhDO/uX4GnBhWAmtapbbjVGOAVIuaIV8lBzNXJ30mJPDI4wKc7/QIBAw==
-----END PUBLIC KEY-----
❯ openssl rsa -pubin -in pk.pem -text -noout
RSA Public-Key: (1024 bit)
Modulus:
00:b2:26:24:f3:00:12:f6:c2:2a:e8:9e:ff:7e:e3:
09:d9:4e:0e:c9:5e:bd:5e:92:23:00:dd:17:4b:d9:
ef:96:f2:41:2d:c9:98:20:85:be:4a:04:10:00:48:
ab:a2:18:dc:61:69:da:e7:4a:00:bc:fa:1d:6b:ec:
1f:c9:2e:e7:5a:b6:f7:65:cf:0c:b2:d6:00:2c:fe:
66:d5:60:77:7b:c7:55:a8:05:fc:c3:a5:69:19:5f:
c6:e1:0c:ef:ee:5f:81:a7:06:15:80:9a:d6:a9:6d:
b8:d5:18:e0:15:22:e6:88:57:c9:41:cc:d5:c9:df:
49:89:3c:32:38:c0:a7:3b:fd
Exponent: 3 (0x3)
至于BouncyCastle的DecryptByPublicKey,它只会解密头128byte,再和剩下的拼到一起。方法如下:
// Org.BouncyCastle.RSATools.RSAHelper
// Token: 0x060004B8 RID: 1208 RVA: 0x00013E7C File Offset: 0x00012E7C
public static byte[] DecryptByPublicKey(IAsymmetricBlockCipher publicEngine, byte[] enBytes)
{
byte[] result;
try
{
byte[] array = new byte[128];
Buffer.BlockCopy(enBytes, 0, array, 0, array.Length);
byte[] array2 = publicEngine.ProcessBlock(array, 0, array.Length);
byte[] array3 = new byte[enBytes.Length + (array2.Length - array.Length)];
Buffer.BlockCopy(array2, 0, array3, 0, array2.Length);
if (enBytes.Length > array.Length)
{
Buffer.BlockCopy(enBytes, array.Length, array3, array2.Length, array3.Length - array2.Length);
}
result = array3;
}
catch (Exception ex)
{
throw ex;
}
return result;
}
直接github搜DecryptByPublicKey,限定语言python,偷一个别人写好的再小改一下就好了。
XOR解密
int i = 1;
int num = 0;
while (i < bytes.Length)
{
byte b = (byte)(i - num);
bytes[i] ^= b;
i <<= 1;
num++;
}
int j = bytes.Length - 1;
int num2 = 0;
while (j > 0)
{
byte b2 = (byte)(j - num2);
bytes[j] ^= b2;
j >>= 1;
num2++;
}
没什么好说的,直接照着转写成python即可。唯一需要注意的就是需要加上取模模拟overflow。
解密脚本
把以上两块拼起来就可以得到Table里所有的文件的解密脚本了。
Table解密脚本
import base64
import rsa
import six
from Crypto.PublicKey import RSA
class DecryptByPublicKey(object):
def __init__(self, publicKey):
public_key = RSA.import_key(base64.urlsafe_b64decode(publicKey))
self._modulus = public_key.n # the modulus
self._exponent = public_key.e # factor
try:
rsa_pubkey = rsa.PublicKey(self._modulus, self._exponent)
self._pub_rsa_key = (
rsa_pubkey.save_pkcs1()
) # using publickey ( modulus, factor ) calculate a public key
except Exception as e:
raise TypeError(e)
def decrypt(self, b64decoded_encrypt_text) -> str:
public_key = rsa.PublicKey.load_pkcs1(self._pub_rsa_key)
encrypted = rsa.transform.bytes2int(b64decoded_encrypt_text[:128])
decrypted = rsa.core.decrypt_int(encrypted, public_key.e, public_key.n)
decrypted_bytes = rsa.transform.int2bytes(decrypted)
if len(decrypted_bytes) > 0 and list(six.iterbytes(decrypted_bytes))[0] == 1:
try:
raw_info = decrypted_bytes[decrypted_bytes.find(b"\x00") + 1 :]
except Exception as e:
raise TypeError(e)
else:
raw_info = decrypted_bytes
return raw_info + b64decoded_encrypt_text[128:]
def load_binary(path):
with open(path, "rb") as file:
f = file.read()
return f
def xor_decrypt(data):
data_arr = bytearray(data)
num = 0
i = 1
while i < len(data_arr):
b = (i - num) % 256
data_arr[i] ^= b
i <<= 1
num += 1
num2 = 0
j = len(data_arr) - 1
while j > 0:
b2 = (j - num2) % 256
data_arr[j] ^= b2
j >>= 1
num2 += 1
return bytes(data_arr)
def decrypt_table(table_name):
pub_key = "MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQCyJiTzABL2wironv9+4wnZTg7JXr1ekiMA3RdL2e+W8kEtyZgghb5KBBAASKuiGNxhadrnSgC8+h1r7B/JLudatvdlzwyy1gAs/mbVYHd7x1WoBfzDpWkZX8bhDO/uX4GnBhWAmtapbbjVGOAVIuaIV8lBzNXJ30mJPDI4wKc7/QIBAw=="
path = table_name
result = DecryptByPublicKey(pub_key).decrypt(load_binary(path))
result = xor_decrypt(result)
return result
至此,我们终于和ItemManager.Load站在同一条起跑线上了。
Item反序列化
本部分内容仅为记录被折磨的过程而生,不建议任何人复刻。
真想要解析建议直接调用,手写模板纯属没事找事。
本部分将比较简略,没什么技术含量,全是无差别的人类劳动。
ItemArray的元素分Key和Value。(此处的key非ProtocolParser.ReadKey,而是instance.Keys.Add。本部分不需要也不会涉及ProtocolParser.ReadKey)
Example.ItemArray.Deserialize
// Example.ItemArray
// Token: 0x06000DF1 RID: 3569 RVA: 0x00063930 File Offset: 0x00061B30
public static ItemArray Deserialize(Stream stream, ItemArray instance)
{
if (instance.Keys == null)
{
instance.Keys = new List<long>();
}
if (instance.Items == null)
{
instance.Items = new List<Item>();
}
for (;;)
{
int num = stream.ReadByte();
if (num == -1)
{
return instance;
}
if (num != 8)
{
if (num != 18)
{
Key key = ProtocolParser.ReadKey((byte)num, stream);
if (key.Field == 0U)
{
break;
}
ProtocolParser.SkipKey(stream, key);
}
else
{
instance.Items.Add(Item.DeserializeLengthDelimited(stream));
}
}
else
{
instance.Keys.Add((long)ProtocolParser.ReadUInt64(stream));
}
}
throw new ProtocolBufferException("Invalid field id: 0, something went wrong in the stream");
}
key的id=8,值为一个uint64。ReadUInt64方法如下,此后的ReadUInt32等方法仅是长度不同。
ProtocolParser.ReadUInt64
// SilentOrbit.ProtocolBuffers.ProtocolParser
// Token: 0x0600162A RID: 5674 RVA: 0x00097574 File Offset: 0x00095774
public static ulong ReadUInt64(Stream stream)
{
ulong num = 0UL;
for (int i = 0; i < 10; i++)
{
int num2 = stream.ReadByte();
if (num2 < 0)
{
throw new IOException("Stream ended too early");
}
if (i == 9 && (num2 & 254) != 0)
{
throw new ProtocolBufferException("Got larger VarInt than 64 bit unsigned");
}
if ((num2 & 128) == 0)
{
return num | (ulong)((ulong)((long)num2) << 7 * i);
}
num |= (ulong)((ulong)((long)(num2 & 127)) << 7 * i);
}
throw new ProtocolBufferException("Got larger VarInt than 64 bit unsigned");
}
value的id=18,值实际上是一堆((byte)Attribute_type, (uint64||string)Attribute_value)元组,通过以下逻辑解析。
Example.Item.DeserializeLengthDelimited
// Example.Item
// Token: 0x06000DE9 RID: 3561 RVA: 0x00062F74 File Offset: 0x00061174
public static Item DeserializeLengthDelimited(Stream stream, Item instance)
{
instance.ItemID = 0;
instance.NameID = 0;
instance.DescID = 0;
instance.ItemType = ItemEItemType.E_Null;
instance.PileCount = 0;
instance.ItemShow = ItemEItemShow.E_Auto;
instance.Rarity = 0;
if (instance.Purchased == null)
{
instance.Purchased = new List<int>();
}
instance.Composed = 0;
if (instance.Recover == null)
{
instance.Recover = new List<int>();
}
instance.SanDown = 0;
instance.FunctionEff = ItemEFunctionEff.E_Null;
if (instance.EffData == null)
{
instance.EffData = new List<int>();
}
instance.Sort = 0;
if (instance.GetItem == null)
{
instance.GetItem = new List<int>();
}
long num = (long)((ulong)ProtocolParser.ReadUInt32(stream));
num += stream.Position;
while (stream.Position < num)
{
int num2 = stream.ReadByte();
if (num2 == -1)
{
throw new EndOfStreamException();
}
if (num2 <= 56)
{
if (num2 <= 24)
{
if (num2 == 8)
{
instance.ItemID = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
if (num2 == 16)
{
instance.NameID = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
if (num2 == 24)
{
instance.DescID = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
else if (num2 <= 40)
{
if (num2 == 32)
{
instance.ItemType = (ItemEItemType)ProtocolParser.ReadUInt64(stream);
continue;
}
if (num2 == 40)
{
instance.PileCount = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
else
{
if (num2 == 48)
{
instance.ItemShow = (ItemEItemShow)ProtocolParser.ReadUInt64(stream);
continue;
}
if (num2 == 56)
{
instance.Rarity = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
}
else if (num2 <= 88)
{
if (num2 <= 72)
{
if (num2 == 64)
{
instance.Purchased.Add((int)ProtocolParser.ReadUInt64(stream));
continue;
}
if (num2 == 72)
{
instance.Composed = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
else
{
if (num2 == 80)
{
instance.Recover.Add((int)ProtocolParser.ReadUInt64(stream));
continue;
}
if (num2 == 88)
{
instance.SanDown = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
}
else if (num2 <= 104)
{
if (num2 == 96)
{
instance.FunctionEff = (ItemEFunctionEff)ProtocolParser.ReadUInt64(stream);
continue;
}
if (num2 == 104)
{
instance.EffData.Add((int)ProtocolParser.ReadUInt64(stream));
continue;
}
}
else
{
if (num2 == 114)
{
instance.Icon = ProtocolParser.ReadString(stream);
continue;
}
if (num2 == 120)
{
instance.Sort = (int)ProtocolParser.ReadUInt64(stream);
continue;
}
}
Key key = ProtocolParser.ReadKey((byte)num2, stream);
uint field = key.Field;
if (field == 0U)
{
throw new ProtocolBufferException("Invalid field id: 0, something went wrong in the stream");
}
if (field != 16U)
{
ProtocolParser.SkipKey(stream, key);
}
else if (key.WireType == Wire.Varint)
{
instance.GetItem.Add((int)ProtocolParser.ReadUInt64(stream));
}
}
if (stream.Position != num)
{
throw new ProtocolBufferException("Read past max limit");
}
return instance;
}
逻辑清晰,实现简单,但是模板很难写。这一个模板花了博主几个小时,不仅写得很丑陋还没什么用。
ItemTable.bt
//------------------------------------------------
//--- 010 Editor v12.0.1 Binary Template
//
// File:
// Authors:
// Version:
// Purpose:
// Category:
// File Mask:
// ID Bytes:
// History:
//------------------------------------------------
uint64 read_uint(int64 pos) {
local uint64 value = 0;
local int uint64_size;
local ubyte ui64_byte;
for (uint64_size = 0; uint64_size < 10; uint64_size++) {
ui64_byte = ReadByte(pos + uint64_size);
value = value | ((ui64_byte & 127) << 7 * uint64_size);
if ((ui64_byte & 128) == 0) {
break;
}
}
return value;
}
uint64 read_uint_length(int64 pos) {
local int uint64_size;
local ubyte ui64_byte;
for (uint64_size = 0; uint64_size < 10; uint64_size++) {
ui64_byte = ReadByte(pos + uint64_size);
if ((ui64_byte & 128) == 0) {
break;
}
}
return uint64_size + 1;
}
typedef struct Uint_N {
local uint64 length = read_uint_length(FTell());
local uint64 value = read_uint(FTell());
};
typedef struct Key {
local uint32 key_id = read_uint(FTell());
};
uint64 read_attr_length(int64 pos) {
local int tmp = ReadUByte(pos);
if (tmp == 114) {
return 2 + ReadUByte(pos + 1);
}
return 1 + read_uint_length(pos + 1);
}
typedef struct Item_attr {
local uint64 length = 1;
local string repr;
enum<ubyte> AttrType { ItemID = 8,
NameID = 16,
DescID = 24,
ItemType = 32,
PileCount = 40,
ItemShow = 48,
Rarity = 56,
Purchased = 64,
Composed = 72,
Recover = 80,
SanDown = 88,
FunctionEff = 96,
EffData = 104,
Icon = 114,
Sort = 120 } attr_type;
if (attr_type == Icon) {
ubyte str_length;
char value[str_length];
length += 1 + str_length;
repr = value;
} else {
Uint_N attr_value<size = read_uint_length(startof(this)), read = Str("(UInt64)%Lu", this.value)>;
length += attr_value.length;
SPrintf(repr, "%d", attr_value.value);
}
};
typedef struct Item {
Uint_N length<size = read_uint_length(startof(this)), read = Str("(UInt64)%Lu", this.value)>;
local int64 base = FTell();
local int64 offset = 0;
local int attr_count = 0;
local int tmp = 0;
while (offset < length.value) {
offset += read_attr_length(base + offset);
attr_count += 1;
}
Item_attr attrs[attr_count]<optimize = false,
size = read_attr_length(startof(this)),
read = Str("%s: %s", EnumToString(this.attr_type), this.repr)>;
};
typedef struct Element {
enum<ubyte> ElementType { KEY = 8,
VALUE = 18 } type;
if (type == KEY) {
Uint_N key<size = read_uint_length(startof(this)), read = Str("(UInt64)%Lu", this.value)>;
} else if (type == VALUE) {
Item item<size = (read_uint(startof(this)) + read_uint_length(startof(this)))>;
} else {
}
};
typedef struct ItemArray {
int capacity;
Element elements[capacity * 2]<optimize = false>;
};
ItemArray items;
解析效果:
这里解析出的各种ID应该是需要去字符串数据库查的,所以说即使解析成功也无法得知物品与ID对应关系,除非从Icon看图标人脑辨认。而这个解析模板只能解析Item,这也是为什么说不建议复刻此类操作。
成功了,但是和没成功没什么区别。受不了了,一拳把地球打爆.jpg
后记
已经小半年没写博客了,这篇又是水货。很惭愧,但是拒不改正。不水的倒是有两篇,但是都在草稿箱睡了很久了,大抵是夭折了罢。
这次逆向说白了没什么目的性,真的需要做什么操作不如直接改C#轻松。
该去改点真正有用的东西了,比如说一次炼金一百个1之类的,然后继续闷头挣钱不管好感。
正好凑够四个章节,四个女主一人一张图,欸嘿~
你问桃子?那个算送的。
能解密fmod加密的bank文件吗?
从可行性上说,解肯定是可以解。但是我没有亲自实践,可以搜搜有没有其他用这个的游戏的解包。
FMOD Studio是个音频组件,一般来说应该不会在资源加密上下太多功夫?