八、API破解 – UNI'S ON AIR资源提取逆向全记录

注意:文章内容仅供学习和交流逆向经验使用,严禁将本文成果用于贩售资源、制作外挂等侵权违法行为。

  利用之前文章的方法以及可以无损提取出游戏的Live视频了,不过还有两个问题。

  一是只有第一次打开歌曲的时候才会从网络上下载,之后再打开同样的歌曲就直接从本地缓存加载内容,没有网络传输了。因此要想抓到包只能第一次打开歌曲的时候抓取,忘记抓包就只能等下次游戏更新了。

  二是歌曲需要一定的等级才能解锁,而要想升级只能花时间玩游戏,我实在没那么多时间呀。游戏突然宣布要更新添加新的歌曲了,然而我之前的歌曲都还没有解锁完,为了抢资源的首发,我决定看看有没有什么简便的方法可以在不解锁视频的情况下也能把资源下载下来。

0x01 资源下载方式分析

  根据之前抓包的记录可以发现,资源的链接一般是这样,一个资源文件的路径后面跟着一个签名(Signature)参数以及一个过期时间(Expires),这是典型的对象存储的链接特征。

  所谓对象存储就是将静态资源,尤其是占用空间较大的资源,与动态的后台接口分离开放在不同的服务器上。这样的话更有利于服务的稳定以及带宽、计算等资源的分配,对于现今采用云服务的应用来说尤为重要。

  不过这样的话就会存在一个问题,原来静态资源和动态后台接口在一起的时候,应用很容易通过SessionID或者Token的方式控制静态资源的访问权限,那将静态资源单独抽出来做成通用的对象存储服务后要如何控制应用的用户对资源的访问权限呢?

  为了解决这个问题,大部分云服务商采用的是一种临时授权的方式。就是说,用户需要对资源进行读写操作时,先向应用自己的后台发送请求,后台判断用户是否有该权限。如果有权限的话,后台就向对象存储服务索要一个临时的操作链接,这个链接包含着操作签名,并且在指定的过期时间之前有效。然后将该链接返回给用户,用户就可以通过这个链接对资源进行操作了。

  根据UNI'S ON AIR资源链接的形式推测它应该也是用的临时授权,于是就想看看它的资源链接是怎么来的。

0x02 反汇编

  这个反汇编要从何下手呢?由于网络传输的是cpk格式的文件,我决定先跟踪".cpk"这个字符串来看看。不过没什么收获,只能看到拼接出资源的文件名,剩下就不知道在干什么了。

  既然资源参数里有"Signature",那直接搜这个字符串说不定能搜到什么,我试着在ILSpy中搜索了一下,排除掉Unity自带的内容,还真的有重大发现。我找到了的这个方法LoadProxy.CreateSignatureUrl(string url),再看一看LoadProxy的其他方法,我敢肯定这个方法就是用来生成签名的资源链接的。

  那接下来就一步步跟进去看看Signature是哪来的就可以了。这个SignatureAssetsMasterDTO的属性,而AssetsMasterDTO是通过LoadProxy.GetAssetsMaster(string path)来的,这个方法的返回值又来自于静态方法AssetsMasterDTO.Find(IMasterFinder finder, string code)。而它的返回值来自于接口IMasterFinder的方法T FindMaster<T, S>(string code)该接口唯一的实现是MasterProxy类,于是就去看看这个类的方法实现。

FindMasster当没有缓存时的过程分支,里面EnumerableExtensions.GetValueSafely方法的定义为public static TValue GetValueSafely<TValue, TKey>(Dictionary<TKey, TValue> self, TKey key),合理推测这应该是对字典取值的进一步封装

  然而看着看着发现不对啊,这好像是没有缓存的情况下直接跑到现成的字典(Dictionary)里面拿了,这难道是说签名不是需要获取资源的时候才请求后台API获取的而是提前签好放在本地的?我赶快又回头看了下之前资源链接的过期时间,转换了一下发现过期时间是三个月之后……

  那看来就是有一个类似数据库的东西,需要下载的时候跑去里面查询链接。看了下之前抓包保存的文件,发现有一个api/masters/assets_masters.json文件相比其他json文件特别大,有4MB,再根据文件名判断这就是要找的文件了。不过一开始说过,json文件都是加了密的,要想看到文件内容就要去破解加密。

0x03 API解密

  要破解加过密的json文件只需要找到对应的解密函数就可以了,怎么找呢?这种游戏开发者自己写的加密一般很容易逆向,它不像CRIWARE这些成熟的商业软件专门在加密以及防止逆向方面费了功夫。这些开发者一般都是直接采用公开的加密方法,并且通常来说解密用的函数就叫“解密”(decrypt)。

  因此先尝试下直接在ILSpy里面搜索"decrypt"字符串,在结果中我注意到了Game.Engine.HttpClient.ApiLoader这个类的这个方法public void CreateDecryptor(IAesCriptoKey cryptKey)根据传入参数名字判断应该是采用了AES加密。ApiLoader这个类还有个private string GetJsonText(HttpResponse response)方法,看来json的解密就在这里没错了。

  从这个方法进去跟踪一下发现解密调用了Core.Libs.AesCryptography类的这个方法private void Decrypt(Stream input, Stream output),最终其实是用到了System.Security.Cryptography.RijndaelManaged这个.NET框架实现。而传入的密钥及初始向量来自IAesCriptoKey这个接口,这个接口只有四个属性InitVector、Key、InitVectorLength以及KeyLength,只要找到具体的实现就可以解密了,实现也不难找,API的实现如下:

// Namespace: Game.Engine.HttpClient
public class ApiAesKey : IAesCriptoKey // TypeDefIndex: 6479
{
	// Fields
	private static readonly byte[] xorkey; // 0x0
	private static readonly byte[] key; // 0x4
	private static readonly byte[] iv; // 0x8

	// Properties
	public byte[] InitVector { get; }
	public byte[] Key { get; }
	public int InitVectorLength { get; }
	public int KeyLength { get; }

	// Methods
	public byte[] get_InitVector(); // RVA: 0xD72380 Offset: 0xD72380
	public byte[] get_Key(); // RVA: 0xD7244C Offset: 0xD7244C
	public int get_InitVectorLength(); // RVA: 0xD72514 Offset: 0xD72514
	public int get_KeyLength(); // RVA: 0xD725B4 Offset: 0xD725B4
	public void .ctor(); // RVA: 0xD72654 Offset: 0xD72654
	private static void .cctor(); // RVA: 0xD7265C Offset: 0xD7265C
}

  有三个静态常量,根据名字猜测应该是将静态常量异或了一下作为真正的密钥,即Key = xorkey ^ key, InitVector = xorkey ^ iv。那去找到这几个量就行了,看一下这个类的初始化代码:

ApiAesKey$$.cctor的汇编代码

  我一开始把后面的HEX字符串当成了这几个量的值,结果发现并不对。网上查了一下<PrivateImplementationDetails>发现是这样,如果数组初始化的时候长度过长比如像这样写byte[] key ={1,2,3,...,31,32},那C#编译器在编译的时候就会对其进行优化。具体怎么优化的不太清楚,不过后面那个HEX字符串相当于键值对的一个键,现在的初始化过程变成了将HEX字符串对应的值赋给要初始化的数组变量。所以在dump.cs文件中搜索一下,找到了这样的结果:

// Namespace: 
[CompilerGeneratedAttribute] // RVA: 0x48E0FC Offset: 0x48E0FC
internal sealed class <PrivateImplementationDetails> // TypeDefIndex: 10269
{
	// Fields
	......
	internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=16 CC850966293364C78ADB5575CC332865FBBA8EBF /*Default value offset 0x5005E8*/; // 0x364
	internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=20 CDA46F1C24B92FA8375164475268E0375C6A403D /*Default value offset 0x5005F8*/; // 0x374
	internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=12 DCE6EC80D02A89DB2DD5D978E63DDFEBA245538C /*Default value offset 0x50060C*/; // 0x388
	internal static readonly long E431776FFE08B8A20E12762ADA3532540F43BDED = -8730743755230686625; // 0x398
	internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=44 E9F275DF6A0292983684449AE9ACD695B9BB5AD0 /*Default value offset 0x500620*/; // 0x3A0
	internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=32 F004C5798E5643F6A8617111075EF71F07F828E7 /*Default value offset 0x50064C*/; // 0x3CC
	......

	// Methods
	internal static uint ComputeStringHash(string s); // RVA: 0xD9CCA0 Offset: 0xD9CCA0
}

  xorkey的值在这里直接能看到,只不过被转换为long了。而其他的只是给了一个注释/*Default value offset 0x50064C*/,猜测应该是长度太长没给显示在这里。那这个注释里的偏移地址到底是哪里的地址呢?看了下也不是libil2cpp.so里的,那我猜应该是global-metadata.dat里的地址。

  找一个HEX查看器打开它,这里我就直接用IDA打开了。这里看地址应该是前后连续存储的,那可以拿long那个数值检验一下我的猜测对不对。然而根据前后变量地址试了好几个long的情况,没有一个数值是一样的,但是这些地址上的值看着又很像数组的初始化值。

  原来这又是一个涉及到大小端的问题,我在手动转换成long的时候采用的是大端模式,而程序采用的是小端模式,当我把字节反过来后,结果立刻就一致了。这样也就找到了密钥了。不过keyiv都比xorkey长,异或是只异或前几个字节呢还是其他情况呢?这个看一下ApiAesKey的实现其实也很容易知道。

  现在知道密钥了,用python可以用5行代码很快速地写出解密脚本。然而将assets_masters.json解密后发现,怎么还是一堆不可读的字节码。不过结尾出现了这样的序列\x06\x06\x06\x06\x06\x06密钥错的话应该不会这么巧吧,而且这序列又是啥?

  网上查了一下原来这是PKCS #7填充方式,因为有些加密算法只能对固定长度的消息进行加密,所以长度不够的话需要填充,填充方式就是缺几个字节就在消息末尾补几个值为该数字的字节。那这个解密的时候也好处理,直接取最后一个字节的值,然后从末尾截去这么多字节就可以了。

  那为什么还是不可读呢?又回头看了下ApiLoader.GetJsonText的代码,发现里面有对BinaryCompressor.Decompress的调用。原来是压缩了呀,看了一下是采用的zlib的压缩算法,这个在python也有实现,直接调用即可。所以最终的解密代码是这样:

cipher = AES.new(key, AES.MODE_CBC, iv)
b = cipher.decrypt(input_bytes)
b = b[:-b[-1]]  # PKCS7 strip
b = zlib.decompress(b)

  解密完一看,我的天哪,assets_masters.json这个文件竟然包含了整个游戏用到的所有资源信息,包括了文件名、大小、版本和签名,这直接跑个脚本就能全下载下来了!真还就能够不解锁资源直接下载,这把资源全部提前签好放在一个大文件里的做法我也是没有想到。

assets_masters.json解密后内容

0x04 mitmproxy集成

  有了密钥之后,我想把解密集成到mitmproxy里,这样就可以更方便的分析了。然而写到mitmproxy脚本里后发现,居然报错了。原来不是所有的json都经过了压缩,是否压缩通过自定义的头字段告知,通过分析源码以及抓包分析得知,头字段的名称以及取值均来自于ApiLoader中私有子类的变量名,并且是将变量名FooBar这种形式映射成了x-foo-bar这种形式,以防止与通用的HTTP头字段冲突。

  因此我们同样加个头字段的判断就可以了。

发表评论

电子邮件地址不会被公开。 必填项已用*标注