破解addressables.cn加密AssetBundle并提取游戏资源的一条可行方法

声明。

  本文仅用于学习交流,讨论游戏软件安全话题。禁止利用该文章进行一切盈利活动。

bundle加密,assetStudio反编译不了bundle。

  unity中正常通过AssetBundle打包的bundle文件是无后缀的。示例:
bundle以哈希值延长,远程下载时对比缓存
  当然,也可以自定义后缀,但unity方面不识别后缀,只要文件内容无误即可正常读取。
  这种方法打出的bundle通过AssetBundle即可直接反编译并提取:
未加密的bundle
  只要稍微对bundle进行一下处理,AssetBundle就失效了,而unity的addressables的中国版,即Addressables.cn,可以通过秘钥在出包时进行加密处理。通过addressables加密打出的包默认为.bundle后缀,示例:
后缀为.bundle且命名一般含addressableasset
  再通过AssetStudio进行反编译操作会提示Process Assets甚至是No Unity file can be loaded,如下所示:

  除此之外,通过Addressables打包后的assetBundle通常会在StreamingAssets下的aa文件夹内,aa即AddressableAsset,并包含有Addressables的资源路径等相关配置文件。如下:

总思路。

  那通过Addressables加密后的bundle是不是就无法逆向了呢?我们知道,对文件加密后,在读取文件之前还有一步操作,那就是对加密文件进行解密,解密后的文件才能被正常读取。常见的加密方式如下:Addressables.cn秘钥加密、转存二进制后自定义加密、文件头部添加混淆字节、DES算法加密、AES算法加密。后两种算法都是开源的。

原思路。

  在了解常见加密策略后我们知道,不管是哪种加密手段,理论上在解密后都可以还原成原文件,然后通过io流对内存进行读取,然后写入磁盘。
  思路有了,那么第一步就是要定位解密时机。
  通过对游戏反编译后分析得出,游戏是通过Addressables.cn进行加密。解密时机当然就就可以推测为AssetBundle的加载部分,很轻松地就可以找到addressable的异步函数AsyncOperationHandle。
  那么如果游戏反编译失败,或者源码进行了函数体提取、加壳、代码混淆等加密手段,导致源码无法正常获取的话呢?后文会说明。
  在定位到解密时机后,在加载bundle后,获得的资源文件,例如AudioClip,我们就可以直接通过io流进行读写了。作者简单封装了的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using UnityEngine;
using System;
using System.IO;

public static class SavWav
{
// 保存AudioClip为Ogg文件
public static void Save(string filename, AudioClip audioClip)
{
using (var fileStream = CreateEmpty(filename))
{
ConvertAndWrite(fileStream, audioClip);
WriteHeader(fileStream, audioClip);
}
}

static FileStream CreateEmpty(string filepath)
{
var fileStream = new FileStream(filepath, FileMode.Create);
return fileStream;
}

static void ConvertAndWrite(FileStream fileStream, AudioClip audioClip)
{
var samples = new float[audioClip.samples];
audioClip.GetData(samples, 0);
var samplesInt16 = new short[samples.Length];

for (var i = 0; i < samples.Length; i++)
{
samplesInt16[i] = (short)(samples[i] * short.MaxValue);
}

var byteArray = new byte[samplesInt16.Length * 2];
Buffer.BlockCopy(samplesInt16, 0, byteArray, 0, byteArray.Length);
fileStream.Write(byteArray, 0, byteArray.Length);
}

static void WriteHeader(FileStream fileStream, AudioClip audioClip)
{
var hz = audioClip.frequency;
var channels = audioClip.channels;
var samples = audioClip.samples;

fileStream.Seek(0, SeekOrigin.Begin);
var riff = new byte[] { 82, 73, 70, 70 };
var chunkSize = BitConverter.GetBytes(fileStream.Length - 8);
var wave = new byte[] { 87, 65, 86, 69 };
var fmt = new byte[] { 102, 109, 116, 32 };
var fmtSize = new byte[] { 16, 0, 0, 0 };
var audioFormat = new byte[] { 1, 0 };
var numChannels = BitConverter.GetBytes(channels);
var sampleRate = BitConverter.GetBytes(hz);
var byteRate = BitConverter.GetBytes(hz * channels * 2);
var blockAlign = BitConverter.GetBytes((ushort)(channels * 2));
var bitsPerSample = new byte[] { 16, 0 };
var data = new byte[] { 100, 97, 116, 97 };
var subChunk2Size = BitConverter.GetBytes(samples * channels * 2);

fileStream.Write(riff, 0, 4);
fileStream.Write(chunkSize, 0, 4);
fileStream.Write(wave, 0, 4);
fileStream.Write(fmt, 0, 4);
fileStream.Write(fmtSize, 0, 4);
fileStream.Write(audioFormat, 0, 2);
fileStream.Write(numChannels, 0, 2);
fileStream.Write(sampleRate, 0, 4);
fileStream.Write(byteRate, 0, 4);
fileStream.Write(blockAlign, 0, 2);
fileStream.Write(bitsPerSample, 0, 2);
fileStream.Write(data, 0, 4);
fileStream.Write(subChunk2Size, 0, 4);
}
}

  通过SavWav.Save(),传入自定义文件名,以及loadAsset得到的AudioClip,通过io流写入本地磁盘。

音效可以,但流式音频不行。

  该封装的代码有个很明显的问题就是,AudioClip必须要完整加载到内存中才可以对AudioClip进行GetData(),否则会报错Cannot get data from streamed samples for audio clip。例如如果音频文件导入时勾选的是流式传输时就不是完整加载带内存中,而是变播放变加载,流式传输通常在获取麦克风录音,或者当音频很长,文件很大时,通过流式传输可以优化加载时间过长的问题。
  对于音效这些较小的文件通过上述代码可以完美获取完。但是,对于我的需求,很显然,游戏作者的音频文件采用无损wav格式,且分类为bgm,一段循环大概在40mb上下,所以勾选的是流式传输。那么bgm就导不出来了,作者尝试过等待AudioClip播放完毕后即AudioClip已经完整加载到内存中后,再执行GetData();对GetData函数进行映射处理,尝试调用GetData内部函数强制读取;分段转存完整音频文件;边播放边记录内存数据;作者不才技术不精,最后都无法有效处理该问题。

换个思路。

  既然无法通过loadAsset得到AudioClip文件,以及上文提到的无法获得游戏项目源码。在这种情况下,我们可以换个思路,资源文件例如AudioClip,是通过加载加密后的Bundle,然后解密Bundle,再从解密后的Bundle或者bundle内容中加载出来。那么可不可以在解密Bundle后,直接把解密后的Bundle给提取并写入磁盘?
  当然可以,解密后的AssetBundle自然就可以被AssetStudio给识别并反编译了。其实这也是在解密时机之后的阶段,理论上可以在对AssetBundle解密后,或者对解密后的内容重新封装成AssetBundle,获取并写入磁盘。
  当然,这种方法和上面的方法做对比,反编译的难度提高了一个级别,首先,秘钥不知道,其次解密阶段如果不是游戏作者通过自定义的函数进行解密的话,通常都在其他sdk或者引擎源码或者第三方程序集dll中。这对我们注入io截获代码块有不小的难度。
  通过尝试,作者选择了一条可行的路线,我们万能的dnspy。常规的代码加密都不会随意混淆或者裁切unityEngine下的源码,就比如开发者的普遍升级版防线,也是我之前文章探讨过的il2cpp打包方式。即使对函数进行抽取函数体,或者提高unityEngine的代码裁切等级,也不会混淆unityEngine下的dll。
  在unity官方文档中我们定位到Addressables位于UnityEngine.ResourceManagement.dll,在vs中我们定位到异步加载bundle的函数入口为UnityEngine.ResourceManagement.ResourceProviders

  然后我们在dnspy中UnityEngine.ResourceManagement.ResourceProviders,找到加载AssetBundle的函数入口

找到三个类型的加载方式

  分析一下,好像三种方式获取StreamingAssets中的ab都是有可能的。但是细看一下,本地有两个,一个是Loacl,直接通过AssetBundle的LoadFromFile。一个是LocalDecrypt,即本地解密,下方还有根据本地缓存来加载ab。尝试一下从LocalDecrypt下手。
  接着,定位到函数LoadWithDataProc,通过数据流加载ab包并以类似回调的方式传出加载好的内存数据流memoryStream。

下一步思路。

  定位到数据流之后,我们分析一下,这个memoryStream已经可以直接通过AssetBundle.LoadFromStreamAsync来异步加载了,和上面的第一种方式Loacl的加载ab包一致,推测出这个memoryStream已经解密完成。
  我们从原先的stream中复制一条新的数据流memoryStreamExtract,然后写入同级目录下,并命名为.bundleextract后缀。如下所示:

也可直接替换该函数体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
Stream stream = m_dataProc.CreateReadStream(fileStream, m_ProvideHandle.Location.InternalId);

FileStream fileStreamExtract = new FileStream(path + "extract", FileMode.CreateNew, FileAccess.Write);
MemoryStream memoryStreamExtract = new MemoryStream();
stream.CopyTo(memoryStreamExtract);
memoryStreamExtract.Position = 0L;
memoryStreamExtract.CopyTo(fileStreamExtract);
memoryStreamExtract.Position = 0L;
stream = memoryStreamExtract;
fileStreamExtract.Flush();

if (stream.CanSeek)
{
this.m_RequestOperation = AssetBundle.LoadFromStreamAsync(stream, crc);
return;
}
MemoryStream memoryStream = new MemoryStream();
stream.CopyTo(memoryStream);
stream.Flush();
stream.Dispose();
fileStream.Dispose();
memoryStream.Position = 0L;
this.m_RequestOperation = AssetBundle.LoadFromStreamAsync(memoryStream, crc);

然后保存,重新编译dll。

运行,出包。

  接着我们运行游戏,在游戏运行中,加载加密的AssetBundlePro,进入到我们修改后的dll中,然后进入对其解密后,在加载解密后的ab包时,该数据流被我们截获,并保存为同级目录下的同名.bundleextract。成功提取出解密后的bundle,并证明确实是进入的LoadWithDataProc函数。

反编译,提取


自此,反编译流程结束。

起因。

  某天玩到一款游戏,被一首游戏内配乐吸引,于是直接从游戏文件夹中找音频资源,无果。从资源命名推测音频资源通过unity的addressables打包成.bundle文件。尝试简单通过AssetStudio反编译bundle,无果,推测通过addressables进行了加密处理,于是开始以上的破解之路。