逆向记录(代号ayaranbu)

前言

爬取某页游CG的脚本突然崩了,今天花了一些时间研究了改动并重写了脚本,简单记录一下过程。

正文

小背景

这厂商的上一个游戏(代号UT)让我第一次接触解包。UT美工优秀,但优化很差,打副本闪退可谓是家常便饭。试想想打高难度本团灭,使用了抽卡用货币进行复活,复活玩打着打着闪退了,货币还消耗了不给返还…
所以我肯定是不玩的,每次更新都在某站对应的画廊等大佬更新CG。但是好景不长,大佬终于弃坑跑路了,作为伸手党的我就傻眼了。
尝试按F12查看已有角色的CG,结果发现资源URL是全明文而且没有任何防爬的验证措施(比如session token cookie之类的)。
这样只要在指定范围按URL pattern去遍历发送请求,服务器返回200的话保存下来就能拉取到所有CG了。
听起来非常暴力,but it works. 当时正好学了一天的Python,就按照以上的思路写了上古版本的资源爬取代码。

下载下来的数据是Unity的assetbundle,但当时显然是不知道这个“打不开”的二进制文件是什么东西,丢进hex editor看到了UnityFS的字样,然后才了解到相关知识。这个游戏实质是把mp4作为textasset打包的。

穷举id可能会触发大量403/404,可能会导致厂商检查服务器log时注意到。

关服后此厂商推出了新的页游,资源存储方式与这个一模一样…一样到老的脚本直接改一下cdn链接就能继续上岗了,就这样继续用了一年半。顺便一提游戏优化还是一言难尽,和前作一样卡得要死。
不久后的一次更新后(就在前几天),脚本跑了根本就没动静,一看log返回的全是404,心想着可能还在维护服务器,就等到晚上再跑了一遍,还是404。
进游戏一看完全没有在维护,爬下manifest文件表一看文件确实还是那种格式。最后就直接开了个角色剧情抓包一看,是一长串的hash。

开始研究

看一下 url 格式:
https://***.******.com/AssetBundles/DmmR18Web/ui/73285980484e56353254c0089894d7c46b218a8f7d0fc87652eaf93cb16c7589.assetbundle
在manifest中的文件名是明文,在请求URL里却是一串巨长的字符(这个长度应该是sha256)
推测做了hash,至于如何做的,那就需要逆向分析了。

从安卓端掏出lib和metadata,处理一下丢进ida。趁着ida在跑打开dump.cs寻觅一下变换函数的位置。
这个时候一般是从发送下载请求的函数入手,从关键字可以检索到AssetBundleDownloadUtility类,简单扫一下结构,确实是我们要找的。

1
2
3
4
5
6
7
8
9
10
11
12
// Namespace: App
public static class AssetBundleDownloadUtility // TypeDefIndex: 8847
{
// Fields
public static readonly string HASH_SALT; // 0x0
public static readonly int HASH_COUNT; // 0x8

// Methods
public static void customizeWebRequest(UnityWebRequest webRequest); // RVA: 0x26B8EA4 Offset: 0x26B8EA4
public static string makeAssetBundleFileNameHash(string assetBundlePath); // RVA: 0x26B8FC8 Offset: 0x26B8FC8
private static void .cctor(); // RVA: 0x26B9040 Offset: 0x26B9040
}

成员变量暗示可能对文件名进行了加盐操作,以及循环计算了一定次数的hash,目标函数应该为makeAssetBundleFileNameHash

静态分析

打开AssetBundleDownloadUtility类,此类静态变量一般会在ctor或者cctor等constructor中进行值的初始化。

循环次数设为了14,根据StringLiteral_6415对应的字符串我们也拿到了salt。
然后分析目标函数:

1
2
3
4
5
6
// Namespace: GameFrame
public static class AssetBundleUtility // TypeDefIndex: 5240
{
// Methods
public static string makeHashAssetBundleFilePath(string assetBundlePath, string salt, optional int loopHashCount); // RVA: 0x183DC14 Offset: 0x183DC14
}


图中已经给临时变量进行更名操作方便分析,画圈处为关键代码。
功能是将URL中文件名主体(不含目录以及后缀)替换为makeSha256Hash返回的值。
其中该函数的输入为文件名主体、循环次数以及盐。

清晰的do-while循环。(_ts是 text 和 salt 拼接后的字符串,在上面没有截到。)

  • A 区域表示在第一次循环之后,对该字符串的每个 byte 与0x2C相异或。
  • B 区域对处理后的字符串计算 sha256。
  • 执行顺序显然是 sha256->异或->sha256…异或->sha256

    接下来就很简单了,StringLiteral_2016对应的字符串是x2,联想 url 的结构,以及 C#格式化字符串的参数,就是将最终的 bytearray 转为 Hex string。
    分析完毕,测试代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import hashlib

# App.AssetBundleDownloadUtility$$.cctor
HASH_SALT = '******************************'# private
HASH_COUNT = 14
XOR_VAL = b'\x2C'

# GameFrame.HashUtility$$makeSha256Hash
def makeSha256Hash(text, salt = HASH_SALT, loopHashCount = HASH_COUNT):
text_salt = f'{text}{salt}'
b_text_salt = text_salt.encode('utf-8')
for i in range(loopHashCount):
if i >= 1:
b_text_salt = baxor(b_text_salt, XOR_VAL * len(b_text_salt))
b_text_salt = hashlib.sha256(b_text_salt).digest()# not hexdigest()
hashRes = res = ''.join(format(x, '02x') for x in b_text_salt)
print(hashRes)

def baxor(ba1, ba2):
return bytes(a ^ b for a,b in zip(ba1, ba2))

if __name__ == '__main__':
makeSha256Hash('movie0188_scene01.mp4')
# output: fffefb316d21387f88fc4de22e164ec3bb461b8f1af2a0da9faff190f6d81cc3

与实际结果测试,结果无误,问题解决。

然后顺便写了一份根据文件表,仅下载更新档的脚本,自动化掉了之前繁琐的操作。

结语

总体感觉很适合新人静态分析入门)
可以frida插一下0x183DC14拉到几个参数,不过要弄清过程还是静态分析更为适合一些,后面我们可能会用到frida这个强大的工具。