IDOLY PRIDE 资源解析

大概是第一次真正意义上自己动手从零开始拆一个游戏。

这游戏从 release 前一年多就在推上看到广告被吸引了,还大手笔出了高质量的动画,感觉制作组很用心,而且游戏 live 质量也超高,唯一让人愤慨的就是除了泳装所有角色都穿了安全裤,甚至制服下面都是黑成一坨的不明物质,能不能给人一点梦想啊喂(

另外说到 QualiArts 这个开发组,以前大学的时候还玩过挺长一段时间的「オルタナティブガールズ2」,当时也觉得非常不错,所以其实我对这个会社挺有好感的,只是最近听说 AL2 要停服了,感觉挺失落的,毕竟第一次碰到深玩过的游戏停服。嘛,估计 QA 是准备停掉 AL2 全身心投入到 IP 的开发了吧。

其实游戏刚 release 时我就想拆了,但当时只会一些最基本的逆向操作,也没空去研究逆向知识(其实是懒),最近跳槽有了一大段 neet 时间,于是就重新捡起来开始研究了。

话说当时写 ShizuruNotes 的时候也是因为疫情闲在家里的时候写的,莫非我已经社畜到就算闲下来也会干私活的地步了?

正题

不废话了,进入正题。

首先还是拿到游戏丢 IDA 一套操作,这游戏还是挺贴心的,metadata 没有经过任何混淆加密,直接就能用,简直新人之友。大概是我个菜鸡查找方式不对,一开始我在 Solis 命名空间下翻了半天,只翻到个 Solis.Common.Crypto,向上跟踪半天断链了。然后又翻了一阵,还是经验太少了,不知该从何下手,于是就放着了。

然后晚上我躺床在想,能不能直接从游戏文件下手?于是打开 RE,进入游戏文件夹翻了一会,看到个 octocacheevai 很可疑,初步判定是 asset 的 manifest,打开一看全是乱码,肯定是加密过了。受 pcr 的影响一开始我以为这是个 sqlite 文件,于是我 google 半天 “octo” 和 “octo data” 之类的关键字,全找到些无关的东西。最后死马当活马医直接搜了个 “octocacheevai”,google 居然返回给我唯一的一条搜索结果:

image

?????

直接就惊了。还是个尼尔的资源解密工具。
点开这个 repo,研究了一下,发现 octocacheevai 其实是个加密过的 protobuf 文件,加密方法是常见的 AES-128-CBC,paddingMode = PKCS7。实际解密时需要先排除掉加密文件的第一字节,该字节必定是 0x01。解密后得到的文件也要排除掉前16字节,那是 MD5 可以不用管。key 是直接用的一个长度为16的字符串取 MD5,iv 是长度为10也取 MD5。

1
2
3
4
5
6
7
key = hashlib.md5(key).digest()
iv = hashlib.md5(iv).digest()
cipher = AES.new(key, AES.MODE_CBC, IV=iv)

data = encrypted_cache_path.read_bytes()
dec_bytes = unpad(padded_data=cipher.decrypt(data[1:]), block_size=16, style="pkcs7")
dec_bytes = dec_bytes[16:]

然后直接用 protobuf 反序列化就可以了。

看完之后我管他的先点个 star,把尼尔下下来,用 RE 进入游戏文件一看,整个结构和 IP 完全一模一样(后来搜了才知道 QualiArts 和 applibot 都是 CyberAgent 旗下的,估计两家公司内部有 py)。于是先用尼尔的加密文件试了下这个轮子,能用,那么假设 IP 的解密算法真的和尼尔完全一模一样,现在我只需要知道 IP 的 key 和 iv,剩下的直接用这位大佬造好的的轮子就够了。

问题是,key 和 iv 到哪里去找?回到 dump.cs,这下有目标了,直接找 Octo 相关的 dll(我怀疑 Octo 是个 CyberAgent 自己社内搞的一个库?)。其中 OctoManager 很可疑,在 Setup 时会传给他一个 IOctoSettings 的接口实例,这个接口里面包含有 key,可以从 Manager 中看到解密时是从这里面取的 key。

image

于是又向上跟踪,很奇怪的是,跟踪到一定程度之后,又断掉了。不知道这是不是 esterTion 大佬在博客中说的动态指针函数的原因。


詰んだ。


怎么办。第一想到的是动态调试,然而我个菜鸡不会啊。虽然这是迟早必须掌握的技能,但肯定需要研究一段时间,我想先用熟悉的方法搞出来再慢慢研究。

于是我想到能不能直接从内存取?能是肯定能,但内存那么大该到哪里去找呢?然后我又想到,能不能先在尼尔的内存里找已知的那个 key,然后观察内存附近的特征,再到 IP 内存里找到这个特征,key 就应该在附近了。

总之想到了就先试一下吧。打开 GG,到尼尔内存里一搜

image

bingo! 然后把内存 dump 出来看附近的特征

image

可以看到上面比较近的地方有个 Octo/Downloader。以此为特征,再把 IP 的整个内存 dump 出来,从里面去找:

image

只有一个匹配,点开看该地址附近

image

可以看到两个非常可疑的长度为16的字符串,总之先把两个复制出来。再用同样的方法找到 iv,最后把 key 和 iv 丢进上面那个 repo 去跑。
结果没报错,心里一喜,打开解密出来的文件一看

image

顺眼了,果然是成功了。
于是简单地改了下轮子,把整个 manifest 用 json 输出

image

搞定。

剩下就是处理 asset 了(顺带吐槽这游戏的动态卡面居然是 mp4 而不是 l2d,难怪每次点开卡看完一圈动态之后就直接停了)。虽然可以直接从游戏文件里把 asset 全部拷出来,但想了下以后每次更新都要拷一遍好麻烦,还是等有空写个轮子直接从服务器取好了。

最扯的是这游戏的 iv 和尼尔的 iv 完全一样(你们两个真的在搞 py 啊

剩下还有通信和数据库什么的,准备后续继续尝试一下,如果有进展再写一篇文章。话说这游戏抓包,只要我一使用 proxy 就必卡在 start 界面动不了,莫非是做了证书检测?

其实这次拆 IP 主要还是靠运气,正好有个拆尼尔的大佬做好了轮子,正好 IP 和尼尔的加密方式一样,才能让我这菜鸡舔到个边。虽然基本上都是在抱大腿,但实际解密成功还是有一种达成感的。总之第一次拆游戏发现,其实大多数时间都是在搜索资料。当然也可能是因为我什么都不懂,所以才什么都要查,别人大佬可能一看就知道了。

最后说一下存 key 和 iv 的地方。有了答案之后,我在 metadata 里面找到了他们,而 so 文件里没有存任何关于它们的信息,所以可以肯定游戏运行时从 metadata 里取了 key。但是什么时候取的,怎么取的,还没研究出来,等有空的时候我再去研究了,反正能用了就先用着再说。顺便我估计这游戏用到的 string 全都被塞在 metadata 里。

2021-10-03 更新

花了整整一周的时间泡 IDA、查资料,终于把资源搞定了,manifest 的加密部分也用正攻法找到了。这游戏反射调用真是达到变态的程度,追踪起来太恼火了。

先说之前为什么我没有在 IDA 里找到相关的密钥字符串。我去翻了一些关于 IL2CPP metadata 的 分析资料,配合之前看的 Katy 大佬写的一篇非常详尽的 从逆向的角度来看 metadata 初始化过程,当时就觉得如果要从 metadata 读数据的话肯定应该还是在初始化的地方,如果是这样的话 Il2CppDumper 理论上是应该把这些 string 都识别出来的,但为什么没有呢。于是我跑到 Il2CppDumper 的 repo,去翻了下,发现

image

W T F ?!
这游戏发行我拆的时候还只有 Il2CppDumper v6.6.2,然后 metadata 版本又正好是 v27……然后就懂了。下载最新的 Il2CppDumper,重新一套操作(又跑了我 6 个小时),进 IDA 一看,stringLiteral 全部都有了。

然后可以正式开始分析了。

因为知道了这游戏的 manifest 格式是 protobuf,所以一开始从 Octo.Data 入手。跟着找到 DataManagerOctoManager,其中就有个 OctoFullSettings,跟着分析了一下里面存了关于游戏 manifest 的一些设置,其中就包括解密 key,字符串是在 Solis.Common.Octo.SolisOctoSetupper.GetC 里(这名字根本想不到),iv 是在 Octo.Data.AESCrypt.Decrypt 内置。另外从枚举可以看出这游戏分了 8 个版本,生产版本(Prd = 0)和开发版本的 key 不同,这也解释了之前我在 dump 的内存附近看到了两个可疑字符串的原因

image

真正的解密方法是在 Octo.Data.SecureFile,可以分析出加密数据库的第一个字节必是 0x01 作为 cipher type 使用,所以需要抛弃掉

1
cipher.decrypt(encryptedBytes[1:])

然后解密完成抛弃作为 md5 的前 16 bytes

image

然后直接把剩下的 bytes 读出来就行了。

manifest 搞定了,于是我准备开始找资源解密,没想到这才是恶梦的开始。

Qua:你以为 manifest 搞定了就完了?对不起我们还有更抽象的(

我在 dummy dll 和 dump.cs 里翻了整整 3 天,把包括 LocalStorage 和 FileUtil 什么的可疑字样全找遍了,期间还顺便摸清了这游戏资源 url 的格式和构造方法、photo 的读取方法、通信解密处、还有个我曾经特别怀疑的叫 AtlasUtility 的类,然而关键的解密资源毫无踪影,一度找得让我怀疑人生。最后我莫名其妙的想到个方法,直接在 System.IO.File.ReadAllBytes 和 System.IO.File.OpenRead 上进行交叉链接,结果在后者找到个 AssetBundleLoadInterceptor,大概长这个样子

image

注意第 76 行有一个 256LL 的立即数,当时就让我觉得,我找对地方了。
为什么这样说呢,因为我观察过几个加密的资源文件,这些文件有一个共通的特征,前 0x100 bytes 明显和其他不一样,而且后面还有明显的可读信息出现,所以我估计这游戏资源只加密了前 0x100 bytes,而正好就是这里的 256

从现在完事之后来看,没错,确实这个就是混淆资源文件的关键位置,然而当时点进去之后看了半天没看懂,感觉不像是在加密,于是就丢掉了(喂)。结果后来又在 dll 里面绕过来绕过去,还是没找到相应的方法,感觉还是只有这里最像,于是又捡回来开干。

点开 MaskedHeaderStream 可以看到 CryptByStringStringToMaskBytes 这两个方法,还记得之前尼尔那个解密 repo,我在想会不会是一样的,于是点进去看了一下,果然尼尔那边也叫这个名字,于是就加深了我这就是解密方法的信心,开始老老实实研究算法。

简单来说,资源混淆分为两个部分,一个是 stringToMaskBytes。这个方法用于生成长度为 maskStringLength * 2 (也就是 maskStringLength << 1)的 maskBytes。方法很简单,先读一个 maskString,把 maskBytes 从 0 开始的所有偶数位索引全部依序替换为 maskString,同时把 maskBytes 从最后一位开始所有的奇数位索引全部依序替换为 maskString 从最后一位开始按位取反的值

1
2
3
4
5
6
7
8
9
10
i = 0;
j = 0LL;
k = bytesLength - 1;
do {
char_j = maskString[j++];
maskBytes[i] = char_j;
i += 2;
maskBytes[k] = ~char_j;
k -= 2;
} while (maskStringLength != j);

这样就得到了一组 maskBytes,然后再造一个值为 0x9B(= 10011011)的 32 位立即数,取这个数的最低位向左位移 7 bit(= 10000000),再与这个数本身向右位移 1 bit 的值(= 01001101)做位或运算,得到 11001101,再把之前的 maskBytes 的每一字节和这个值异或一次得到新的 maskBytes,最后用这个新的 maskBytes 每一字节与 0x9B 本身做一次异或,就得到了最终的 maskBytes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
l = (unsigned int)bytesLength;
v13 = 0x9B;
m = (unsigned int)bytesLength;
pMaskBytes = maskBytes;
do
{
v16 = *pMaskBytes++;
--m;
v13 = (((v13 & 1) << 7) | (v13 >> 1)) ^ v16;
} while (m);
b = 0;
do
{
--l;
maskBytes[b] ^= (uint8_t)v13; // 必须先强转为单字节
b++;
} while (l);

第二部分就是真正做混淆操作的地方了,非常简单,直接拿输入的每个字节和上面生成的 maskBytes 做异或

1
2
3
4
5
6
StringToMaskBytes((uint16_t*)maskString, maskStringlen, maskBytes, byteslen);
i = 0;
do {
buffer[offset + i] ^= maskBytes[streamPos + i - (streamPos + i) / byteslen * byteslen];
i++;
} while (streamPos + i < headerLength);

这就是混淆算法的全部,那么现在的问题就是这个 maskString 是哪来的。

我一开始看到传的 abName,第一反应就是嗯这肯定是文件名!于是就把文件名(MD5)拿来测试,试了半天发现出来还是一片乱码,搞得我还以为是算法我写错了,于是就又去把尼尔的 repo 翻出来看,发现尼尔的解混淆算法也大体上都差不多。我就想干脆先把别人写好的试一下看能不能找到什么头绪。于是从手机上把尼尔的资源文件随便复制了个出来,用 190nm 大佬写好的 c 文件直接拿出来解混淆,仍然传文件名作为 maskString,好,运行!


……喵?怎么还是一片乱码……?


难道是别人大佬写错了?不应该啊。于是我决定不单独拿 c 文件出来,按着大佬写的步骤一步一步构建运行。

结果跑出来发现是对的,我一阵懵逼 what??????

既然代码是对的,那问题出在哪里呢。看了一会之后发现,所谓的 maskString 其实应该是用 assetBundleName,而不是文件名……

现在想想那个 abName 不就是暗示了 assetBundleName 吗,我 TM 傻逼啊……

摸清楚之后,一切就顺利了。把解混淆算法用 python 重写了一遍,不得不说 python 对字节类型的处理操作太不友好了,踩了好多个坑才解决,比如 bytes 不能通过 index 取值,没有 unsigned int 等。

资源部分解决了,之后有空准备再摸一下游戏系统的部分,比如技能描述里的 x 段提高具体是多少,还有最关键的 VB 如何决定放技能优先顺序等。API 部分我还没看,但要更艰难一些,听群里 Parsee 大佬说这游戏证书检测似乎不依赖于系统(QualiArts 没想到挺 nb 啊),难怪我之前尝试抓包也根本进不去游戏。

2021-10-12 更新

好了,我觉得大概不用再浪费时间研究这个游戏了。

因为 QualiArts 的操作实在过于精辟。

这游戏 live 的所有的核心方法,包括分数计算、技能段数的具体值、还有最关键的 VB 敌我技能施放优先顺序判定等,都是在服务端执行的,客户端找不到半点代码(您们谁大佬能黑进 QA 服务器偷个代码回来吧),客户端所做的就只是组织好参数发给服务器,然后从服务器获得计算好的结果再展示出来而已。

一开始我得到这个结论的时候都还有点怀疑,QA 的服务器资源都是随便乱用的??直到我看了他们的官方技术博客,才发现还真是全部丢给服务端计算的,甚至还因为这样做导致每次 live 传输的数据量太大而去 customize 了 codec…你们直接交给客户端做要死啊…嘛不过这样也有好处,直接导致增加了使用科技的难度,难怪这游戏从开始以来排行榜都是如此正常,这是好事,但同时也使得内部逻辑无法被逆向了。反正我个菜鸡是摊手了。

References

190nm: rein-kuro
Katy: IL2CPP Tutorial: Finding loaders for obfuscated global-metadata.dat files
Katy: Practical IL2CPP Reverse Engineering: Extracting Protobuf definitions from applications using protobuf-net
esterTion: bang dream proto相关
esterTion: High School Fleet ハイフリ – 拆解相关
Google: Protocal Buffers Documentation

 Comments
Comment plugin failed to load
Loading comment plugin