逆向记录(代号aria)

前言

: 是新游戏喔 残念,旧的,开服半年了…
某高级计算机体系结构测试deadline中,摸鱼效率MAX,简单看看。

正文

小背景

开服时有扫过一眼,感觉麻烦就没动,今天正好有朋友问,就试试好了。
亮点是剧情全程3D演出,游玩方式印象中是回合制。
我的祖传MI6开最高品质卡得不行了,不好证明是优化问题还是硬件问题。

初探——manifest与URL格式

manifest的包抓了一下,名字是

1
https://cdn.app.*********-aria.com/v11401_14c67a0bd0d3776b19350*********ca49292a2d43127663a3a2427951f731f8/assetbundle/WebGL_Hash/bf892ddb3aa0f7a9e7597e*********f6fe215dc6861328821b763325953c0

(打个电子码)
不得不说有点长,可以猜测v11401那一段是版本号+某个东西的sha256,assetbundle名字也被混淆为某个东西的sha256。格式总结一下就是
https://{cdn地址}/{资源版本号相关}/assetbundle/WebGL_hash/{assetbundle的hash}

其中资源版本号相关的可以每次拉取manifest的时候记录一下作为常量,不需要也不太可能去搞清楚如何计算的了。
打开manifest看了一眼,里面记录是正常的asset名字,推测与之前某次研究类似,即本地有一个自定义的sha256计算过程,需要走一下逆向。

初探——角色剧情

打开角色剧情看一下相关的文件,加载剧情期间下载了2个文件,猜测是剧情scripts、资源文件(包含语音、图片等)。用AssetStudio(后续简称AS)打开报错了——资源可能加密了。AS报错蕴含的信息量很大:因为如果是AES等能破坏掉header的加密方法,AS不会将之视为有效的Unity assets,也就不会走读取流程;说明是使用了Padding或者局部字符替换等不伤害header的操作。(其实还有可能是Unity版本问题,这里不是)

拖进hex editor一眼看出端倪——存在多个assetbundle头。

删掉第二个header前的部分试了下可以正常读取——是简单的Padding法。
但padding size不是定值,测试只下几个不同的assetbundle具有不同的size。
总之经过读取可以得知上述2个文件分别是图片CG包、语音包。

剧情scripts不在上述文件内,很有可能是在服务器返回等请求里,一般而言它包含各种控制、剧情文本、剧情资源文件url。因此
这个scripts对我们编写脚本作用最大。而服务器下发意味着经历过通信加密部分,想要构造请求意味着要把header里所有需要计算的部分逆向搞清楚,参考以前写过的alicerecode的代码,先看看别的。

推进剧情到动态部分时追加下载了两个文件,URL格式为https://{cdn地址}/{资源版本号相关}/Video/{Video的hash}
在额外的Video路径下,说明没有把mp4打包成assetbundle,可能是raw asset。
看了下hex里有ftypisom isomiso2avc1mp41等字样,印证猜想。

幕间——整理一下

目标是提取CG,那么逆向需要弄清楚这些

  • 静态png:文件类型是assetbundle,如何正确进行读取、如何计算assetbundle的hash
  • 动态mp4:文件类型是mp4,如何计算video的hash

正式研究——dumper & IDA

首先寻找hash部分,在dump.cs里查找hash关键词,翻到最后可以看到

1
2
3
4
5
6
7
8
9
10
11
12
// Namespace: GeePlus.GPUL.AssetBundles
public class AssetBundleUtils // TypeDefIndex: 13587
{
// Properties
...
// Methods
...
public static byte[] ComputeHash(byte[] buffer) { } // RVA: 0xDCBED8 Offset: 0xDCBED8 VA: 0xDCBED8
public static string GetHashName(string assetBundleName, Hash128 assetBundleHash) { } // RVA: 0xDC8630 Offset: 0xDC8630 VA: 0xDC8630
public static int GetPaddingSize(string assetBundleName, Hash128 assetBundleHash) { } // RVA: 0xDC87FC Offset: 0xDC87FC VA: 0xDC87FC
public void .ctor() { } // RVA: 0xDCC044 Offset: 0xDCC044 VA: 0xDCC044
}

结合命名空间,GetHashName应该是assetbundle的hash计算过程,GetPaddingSize就是开头填充的长度了。

video的话应该是这里

1
2
3
4
5
6
7
8
9
10
// Namespace: 
public static class ResourceHashMapper // TypeDefIndex: 8882
{
...
// Properties
public static string VideoHashFormat { get; set; }
...
public static string GetVideoHashName(string videoPath) { } // RVA: 0x1046DBC Offset: 0x1046DBC VA: 0x1046DBC
public static string GetHashName(string hashFormat, string srcPath) { } // RVA: 0x1046E68 Offset: 0x1046E68 VA: 0x1046E68
}

接下来就是静态分析了:

  • GetHashName(string assetBundleName, Hash128 assetBundleHash):
    在manifest里取得assetbundleName、assetbundleHash,将之按照{assetBundleName}+{assetBundleHash}+gpl_ab的格式构造一个字符串(加号也属于字符串而不是拼接),对该字符串计算sha256即为结果。assetbundleHash保存格式是bytes[16],需要按照02x格式化并拼接为hex string。
  • GetPaddingSize(string assetBundleName, Hash128 assetBundleHash):
    似乎是直接对hash128字符串进行循环求值,结果与一个常数相减。但最后看麻了就用了偷懒的方法:读取整个文件,rfind到最后一个匹配的UnityFS头的位置,seek过去进行读取。可行性与开销分析:文件数量未超过1000、平均大小在1MB,存在可行性。
  • GetVideoHashName(string videoPath):
    难点来了。流程是按照hashFormat去把videoPath格式化为一个字符串,计算sha256即为结果。问题在于,hashFormat疑似app初始化时服务器下发、而videoPath在剧情scripts中,也是服务器下发。

此时遗留问题为,如何简单获取hashFormat与videoPath?猜测的话,由于app初始化了hashFormat作为全局变量,因此所有videoPath都需遵循此格式参与后续计算。考虑到CDN开销,视频文件应当唯一,因此hashFormat应当也是唯一且至少在一个资源版本期间不会发生变动。videoPath也有可能是遵循一定的格式,比如{unit_id}_{scene_id}.mp4,混淆前具有易读性更符合开发者的习惯。

总结一下:静态分析没找到Format string,那直接动态地从内存中扒出这两个变量即可。

压轴——frida

秒了

可以假定videoPath符合这样的格式:ADV/movie_{unit_id}_{scene_id}.mp4,视频有两个就是01与02,角色id根据静态资源来就好了。
hashFormat为bless_turn_into_curse_{videoPath}

编写脚本进行测试,结论正确。懒得发gist了,贴一下核心部分。

1
2
3
4
5
6
7
8
9
10
11
# asset & video path to hash
INPUT_FORMAT = '{0}+{1}+gpl_ab'
MP4_INPUT_FORMAT = 'bless_turn_into_curse_{0}'
MP4_1_FORMAT = 'ADV/movie_{0}_01.mp4'
MP4_2_FORMAT = 'ADV/movie_{0}_02.mp4'

def GetHashName(abName, abHash128=''):
inputStr = INPUT_FORMAT.format(abName, abHash128) if abHash128 else MP4_INPUT_FORMAT.format(abName)
hashRes = hashlib.sha256(inputStr.encode('utf-8')).digest()
hashResHexStr = ''.join(format(x, '02x') for x in hashRes)
return hashResHexStr
1
2
3
4
5
# let UnityPy read unpadded part
index = f.read().rfind(b'UnityFS\x00\x00\x00\x00')
if index != -1:
f.seek(index)
env = UnityPy.load(f.read())

写在最后

如果hashFormat发生变动了,再走一遍frida即可解决。
嗯,就这样。

更新:现在早已关服,官方上架了离线版的archive。不算贵,已经买了。