Tiny Snow .nvldata 完整逆向分析笔记
说明:这份文档整理的是可复现的分析过程、证据链和结论。内部逐字思考链不会记录;这里用“调查日志/判断依据/排除项”的形式替代,方便学习和复盘。
0. 目标
分析以下文件如何被游戏读取,以及如何离线提取:
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/scripts.nvldata
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/textures.nvldata
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/audios.nvldata
最终产物:
tools/nvldata_extract.py
docs/nvldata.md
docs/nvldata_full_analysis.md
extracted/nvldata_decrypted/*.assetbundle
1. 最终结论
.nvldata 是 NVLUnity 引擎使用的加密 AssetBundle。
它不是 ZIP,不是普通 UnityFS,也不是压缩包套壳。真实结构是:
- 原始数据本质是 UnityFS AssetBundle。
- 文件前 0x20 字节的 UnityFS 头被抹掉/替换。
- 从第 0x20 字节之后的数据,用固定 12 字节 key 按文件绝对偏移循环 XOR。
- 解密后可被 UnityPy/AssetStudio/AssetRipper 正常识别。
Tiny Snow 参数如下:
修复用 UnityFS header, 32 bytes:
55 6E 69 74 79 46 53 00 00 00 00 06 35 2E 78 2E
78 00 32 30 31 38 2E 34 2E 32 36 66 31 00 00 00
ASCII:
UnityFS\0 .... 5.x.x\0 2018.4.26f1\0\0\0
XOR key, 12 bytes:
61 BB 79 A8 62 D0 7E 7D EA 6B 76 E4
伪代码:
for offset in file:
if offset < 0x20:
output[offset] = fixed_unityfs_header[offset]
else:
output[offset] = input[offset] ^ key[offset % 12]
2. 项目结构观察
初始目录:
GameAssembly.dll
TinySnow.exe
UnityPlayer.dll
TinySnow_Data/
Dump/
关键文件:
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/audios.nvldata
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/scripts.nvldata
TinySnow_Data/StreamingAssets/StandaloneWindows/TinySnow/textures.nvldata
TinySnow_Data/il2cpp_data/Metadata/global-metadata.dat
GameAssembly.dll
文件大小:
audios.nvldata 247,510,833 bytes
scripts.nvldata 1,304,712 bytes
textures.nvldata 287,715,185 bytes
这些大小符合 Unity AssetBundle 资源包的体量分布:脚本包较小,音频/图片包较大。
3. 第一轮源码线索
在 Dump/ExportedProject/Assets/Scripts/Assembly-CSharp/NVLMaker 里找到相关类名:
PackageManager.cs
LoadingManager.cs
Utils.cs
AutoPathResolver.cs
AutoPathResolverGeneral.cs
AutoPathResolverPrecache.cs
IResource.cs
ProjectHelper.cs
关键线索:
private AutoPathResolver _textures;
private AutoPathResolver _scripts;
private AutoPathResolver _audios;
public static AssetBundle LoadBundle(string filename)
public static AssetBundleCreateRequest LoadBundleAsync(string filename)
public static string BundlePath(string entry, string name, string target)
public static string LocalBundlePath(string entry, string name)
但 Dump/ExportedProject 里的 C# 方法体大多是空桩:
public static AssetBundle LoadBundle(string filename)
{
return null;
}
这说明这个 Dump 更像是类型/资源导出,不能直接作为真实逻辑依据。需要回到 IL2CPP 的 GameAssembly.dll + global-metadata.dat。
4. 文件头证据
直接读 .nvldata 前 0x80 字节。
scripts.nvldata:
4C 01 00 00 A4 28 00 00 4A EC 04 00 57 9D 98 00
FC 0D 7B 12 F4 B1 E6 3C FA 8C EF 5F A7 12 02 9E
EA 6B 76 F7 89 33 79 A8 62 31 7E 7D EB F0 76 E4
61 F8 67 A8 63 D0 4E 5C EA 69 71 E4 23 35 CE A8
textures.nvldata:
4D 01 00 00 C7 28 00 00 8D F0 04 00 87 21 99 00
59 0F 8B 12 C7 DB D6 3E 19 9D 04 9C 07 06 8F E4
EA 6B 67 C2 4E CA 79 A8 E8 6A 7E 7C 61 36 76 E4
audios.nvldata:
3A 01 00 00 75 26 00 00 A1 A8 04 00 F3 6B 90 00
CE 12 7D 11 61 47 25 1E 35 A5 83 A6 DF 01 F1 29
EA 6B 78 24 D6 8A 79 A8 64 A5 7E 7D A0 DC 76 E4
观察:
- 文件不是
UnityFS开头。 - 前 16 字节像小端整数。
- 从 0x20 附近开始反复出现与 key 很接近的字节序列:
61 BB 79 A8 62 D0 7E 7D EA 6B 76 E4
这个重复结构非常像循环 XOR key 泄漏:如果明文在某些位置是 00,密文就会直接等于 key。
5. IDA 与 IL2CPP 证据
5.1 当前 IDA 数据库状态
IDA MCP 当前打开的是 TinySnow.exe,而不是 GameAssembly.dll。
TinySnow.exe 只看到少量启动器/Unity 壳字符串:
C:\Workspace\Build\TinySnow\TinySnowRetail\build\bin\x86\Master\TinySnow_x86_Master_il2cpp.pdb
TinySnow.exe
判断:
TinySnow.exe 不是主要逻辑位置。
Unity 项目 IL2CPP 真实业务逻辑在 GameAssembly.dll。
因此主要静态分析目标应切到:
GameAssembly.dll
TinySnow_Data/il2cpp_data/Metadata/global-metadata.dat
5.2 Il2CppDumper 证据
使用 Il2CppDumper 对 GameAssembly.dll 和 global-metadata.dat 生成:
Dump/Il2CppDumper/dump.cs
Dump/Il2CppDumper/script.json
Dump/Il2CppDumper/il2cpp.h
Dump/Il2CppDumper/DummyDll/
Il2CppDumper 输出:
Metadata Version: 24.1
Il2Cpp Version: 24.1
CodeRegistration : 10b2c0d0
MetadataRegistration : 10b2c140
script.json 中关键方法地址:
NVLMaker.Utils$$LoadBundle RVA 0x18C8B0, VA 0x1018C8B0
NVLMaker.Utils$$LoadBundleAsync RVA 0x18C880, VA 0x1018C880
NVLMaker.Utils$$BundlePath RVA 0x18AD60, VA 0x1018AD60
NVLMaker.Utils$$LocalBundlePath RVA 0x18C8E0, VA 0x1018C8E0
NVLMaker.Utils$$StreammingFile RVA 0x18D440, VA 0x1018D440
NVLMaker.Utils$$StreammingProjectFile RVA 0x18D4E0, VA 0x1018D4E0
NVLMaker.Utils$$AssetBundleFolder RVA 0x18AC50, VA 0x1018AC50
NVLMaker.PackageManager$$InitResolver RVA 0x270CD0, VA 0x10270CD0
NVLMaker.LoadingManager$$LoadBytesFromBundle RVA 0x26C9B0, VA 0x1026C9B0
NVLMaker.LoadingManager$$ToBundlePath RVA 0x26D920, VA 0x1026D920
5.3 LoadBundle 反汇编证据
NVLMaker.Utils$$LoadBundle:
1018c8b0 push ebp
1018c8b1 mov ebp, esp
1018c8b3 push 0
1018c8b5 push dword ptr [ebp+8] ; filename
1018c8b8 call 10422200 ; 本地文件存在/可读取检查
1018c8bd add esp, 8
1018c8c0 test al, al
1018c8c2 jne 1018c8c8
1018c8c4 xor eax, eax ; 文件不可用则返回 null
1018c8c6 pop ebp
1018c8c7 ret
1018c8c8 mov dword ptr [ebp+0c], 0
1018c8cf pop ebp
1018c8d0 jmp 108e0bf0 ; 跳到 UnityEngine.AssetBundle.LoadFromFile_Internal 包装
GameAssembly.dll 字符串区存在 Unity 绑定名:
UnityEngine.AssetBundle::LoadFromFile_Internal(System.String,System.UInt32,System.UInt64)
UnityEngine.AssetBundle::LoadFromFileAsync_Internal(System.String,System.UInt32,System.UInt64)
UnityEngine.AssetBundle::Contains(System.String)
UnityEngine.AssetBundle::LoadAsset_Internal(System.String,System.Type)
UnityEngine.AssetBundle::LoadAssetAsync_Internal(System.String,System.Type)
UnityEngine.AssetBundle::GetAllAssetNames()
判断依据:
LoadBundle(filename) 检查 filename 后跳到 Unity AssetBundle.LoadFromFile_Internal。
所以游戏最终把 .nvldata 路径直接交给 Unity 的 AssetBundle 加载接口。
这也解释了为什么解密后必须变成标准 UnityFS AssetBundle。
5.4 LoadBundleAsync 反汇编证据
1018c880 push ebp
1018c881 mov ebp, esp
1018c883 push 0
1018c885 push dword ptr [ebp+8]
1018c888 call 10422200
1018c88d add esp, 8
1018c890 test al, al
1018c892 jne 1018c898
1018c894 xor eax, eax
1018c896 pop ebp
1018c897 ret
1018c898 mov dword ptr [ebp+0c], 0
1018c89f pop ebp
1018c8a0 jmp 108e0b50
这与同步读取相同,只是最终跳到 async 版本。
5.5 .nvldata 扩展名证据
script.json 的字符串表里存在:
Address: 0x00C04DB4
Value : .nvldata
该字符串附近还存在:
StandaloneWindows
/Resources/
Assets
Assets/Games
这说明 .nvldata 是游戏逻辑显式使用的 BundleExt,而不是随意扩展名。
5.6 资源路径推断
根据 Utils 方法名和字符串表:
AssetBundleFolder(RuntimePlatform.WindowsPlayer) -> StandaloneWindows
BundleExt -> .nvldata
ProjectName(entry) -> TinySnow
StreammingProjectFile(entry, filename)
StreammingFile(filename)
BundlePath(entry, name, target)
组合路径:
Application.streamingAssetsPath/StandaloneWindows/TinySnow/scripts.nvldata
Application.streamingAssetsPath/StandaloneWindows/TinySnow/textures.nvldata
Application.streamingAssetsPath/StandaloneWindows/TinySnow/audios.nvldata
实际磁盘路径与此一致。
6. 排除过程
6.1 排除 ZIP
.nvldata 文件头不是:
50 4B 03 04
也没有可直接识别的 ZIP central directory。
6.2 排除普通 UnityFS
普通 UnityFS 应该以:
55 6E 69 74 79 46 53 00
UnityFS\0
开头。
但原始 .nvldata 开头是:
4C 01 00 00 ...
4D 01 00 00 ...
3A 01 00 00 ...
因此不是未加密 AssetBundle。
6.3 排除单字节 XOR
在前几 MB 中搜索 UnityFS、UnityWeb、UnityRaw、CAB- 的单字节 XOR 变体,没有稳定命中。
这说明不是“整个文件统一 XOR 一个字节”。
6.4 指向周期 XOR
0x20 附近出现明显重复:
61 BB 79 A8 62 D0 7E 7D EA 6B 76 E4
如果明文有大量 0 字节,循环 XOR 密文就会泄露 key。这与 UnityFS 头部附近经常出现多个 0 字节吻合。
7. 外部工具线索验证
检索 .nvldata 与 NVLUnity 后找到 CNGALTools 的 NVLUnityDecryptor:
https://github.com/YeLikesss/CNGALTools
关键源码:
001.NVL/NVLUnity/NVLUnityDecryptor/NvlUnityDecrypt/NvlUnity/ArchiveCrypto.cs
001.NVL/NVLUnity/NVLUnityDecryptor/NvlUnityDecrypt/NvlUnity.V1/NVLFilterV1.cs
001.NVL/NVLUnity/NVLUnityDecryptor/NvlUnityDecrypt/NvlUnity.V1/GameDBV1.cs
NVLFilterV1.Decrypt 核心逻辑:
if (offset < headerLen)
{
int copyLen = (int)Math.Min(headerLen - offset, dataLen);
header[(int)offset..copyLen].CopyTo(data[(int)offset..copyLen]);
dataPos += copyLen;
offset += copyLen;
}
Span<byte> key = this.mFilterKey.XorKey;
int keyLen = key.Length;
int keyPos = (int)(offset % keyLen);
while (dataPos < dataLen)
{
data[dataPos] ^= key[keyPos];
++keyPos;
if (keyPos == keyLen)
{
keyPos = 0;
}
++dataPos;
++offset;
}
Tiny Snow 条目:
internal class TinySnow : NVLUnityV101
{
public override byte[] XorKey { get; } = new byte[]
{
0x61, 0xBB, 0x79, 0xA8, 0x62, 0xD0,
0x7E, 0x7D, 0xEA, 0x6B, 0x76, 0xE4
};
}
NVLUnityV101 header:
55 6E 69 74 79 46 53 00 00 00 00 06 35 2E 78 2E
78 00 32 30 31 38 2E 34 2E 32 36 66 31 00 00 00
这与本地文件泄露出的 key 特征、Unity 版本字符串 2018.4.26f1 完全匹配。
8. 本项目 Python 实现
脚本位置:
tools/nvldata_extract.py
默认解密命令:
python tools\nvldata_extract.py
输出:
extracted/nvldata_decrypted/audios.assetbundle
extracted/nvldata_decrypted/scripts.assetbundle
extracted/nvldata_decrypted/textures.assetbundle
继续提取 Unity 对象:
python -m pip install UnityPy
python tools\nvldata_extract.py --extract
输出:
extracted/nvldata_assets/scripts/
extracted/nvldata_assets/textures/
extracted/nvldata_assets/audios/
9. 解密验证
执行:
python tools\nvldata_extract.py
结果:
decrypted: audios.nvldata -> extracted/nvldata_decrypted/audios.assetbundle
decrypted: scripts.nvldata -> extracted/nvldata_decrypted/scripts.assetbundle
decrypted: textures.nvldata -> extracted/nvldata_decrypted/textures.assetbundle
解密后文件头:
audios.assetbundle b'UnityFS\x00\x00\x00\x00\x065.x.x\x002018.4.26f1\x00\x00\x00'
scripts.assetbundle b'UnityFS\x00\x00\x00\x00\x065.x.x\x002018.4.26f1\x00\x00\x00'
textures.assetbundle b'UnityFS\x00\x00\x00\x00\x065.x.x\x002018.4.26f1\x00\x00\x00'
UnityPy 验证:
scripts.assetbundle:
objects = 106
containers = 105
textures.assetbundle:
objects = 2811
containers = 2810
audios.assetbundle:
objects = 3058
containers = 3057
部分资源名:
assets/games/tinysnow/data/scenario/start.bkscr.compiled.bytes
assets/games/tinysnow/main.bkscr.compiled.bytes
assets/games/tinysnow/data/fgimage/scfigrong_a_nol_031.png
assets/games/tinysnow/data/voice/cvrong14005.ogg
assets/games/tinysnow/data/sound/shuidibreak.ogg
这证明 .nvldata 解密后确实是正常 AssetBundle。
10. scripts 包为什么不是明文脚本
scripts.assetbundle 里大多是:
*.bkscr.compiled.bytes
*.tjs.compiled.bytes
*.bkpsr.compiled.bytes
assets.bytes
macro.bkscr.compiled.bytes
main.bkscr.compiled.bytes
这些是 NVLMaker 编译后的脚本 bytecode,不是 .bkscr 明文。
所以提取层级分两步:
.nvldata-> UnityFS AssetBundle。- AssetBundle ->
*.compiled.bytes等 Unity TextAsset。 - 如果需要可读脚本,还要继续分析 NVLMaker bytecode 格式。
CNGALTools 里另有 NVLUnityScriptDumper,它的用途就是动态 dump/还原脚本。那是下一阶段目标。
11. IDA 复现建议
如果要在 IDA 中复现证据,建议打开:
GameAssembly.dll
然后加载 Il2CppDumper 生成的:
Dump/Il2CppDumper/ida_with_struct_py3.py
或至少使用 script.json 里的 RVA 跳转。
关键地址:
ImageBase: 通常为 0x10000000
LoadBundle:
VA 0x1018C8B0
RVA 0x0018C8B0
LoadBundleAsync:
VA 0x1018C880
RVA 0x0018C880
BundlePath:
VA 0x1018AD60
RVA 0x0018AD60
LocalBundlePath:
VA 0x1018C8E0
RVA 0x0018C8E0
PackageManager.InitResolver:
VA 0x10270CD0
RVA 0x00270CD0
LoadingManager.LoadBytesFromBundle:
VA 0x1026C9B0
RVA 0x0026C9B0
建议在 IDA 中标注:
0x1018C8B0 -> NVLMaker.Utils.LoadBundle
0x1018C880 -> NVLMaker.Utils.LoadBundleAsync
0x1018AD60 -> NVLMaker.Utils.BundlePath
0x1018C8E0 -> NVLMaker.Utils.LocalBundlePath
0x10270CD0 -> NVLMaker.PackageManager.InitResolver
0x1026C9B0 -> NVLMaker.LoadingManager.LoadBytesFromBundle
可查字符串:
.nvldata
StandaloneWindows
UnityEngine.AssetBundle::LoadFromFile_Internal
UnityEngine.AssetBundle::LoadFromFileAsync_Internal
UnityEngine.AssetBundle::GetAllAssetNames
12. 学习用调查流程总结
推荐以后遇到类似 Unity/IL2CPP 封包时按这个顺序走:
- 列文件:确认资源目录、扩展名、大小分布。
- 看文件头:先判定是不是 ZIP/UnityFS/UnityWeb/UnityRaw。
- 搜源码:在 Dump C# 中找
AssetBundle、StreamingAssets、扩展名、LoadFromFile。 - 如果 C# 是空桩,转 IL2CPP:用
GameAssembly.dll + global-metadata.dat。 - 用 Il2CppDumper/IDA 对齐方法名和地址。
- 找
LoadBundle、BundlePath、PackageManager、LoadingManager。 - 搜字符串表:扩展名、平台目录、Unity 内部 API 名。
- 观察密文重复:周期性泄漏通常指向 XOR key。
- 找外部同引擎工具验证:不要盲信,要和本地文件头/IDA证据交叉验证。
- 写最小解密器,然后用 UnityPy/AssetStudio 验证对象数量和资源名。
13. 参考
CNGALTools / NVLUnityDecryptor
https://github.com/YeLikesss/CNGALTools
本地关键文件:
Dump/CNGALTools/001.NVL/NVLUnity/NVLUnityDecryptor/NvlUnityDecrypt/NvlUnity.V1/NVLFilterV1.cs
Dump/CNGALTools/001.NVL/NVLUnity/NVLUnityDecryptor/NvlUnityDecrypt/NvlUnity.V1/GameDBV1.cs
Dump/Il2CppDumper/script.json
Dump/Il2CppDumper/dump.cs
tools/nvldata_extract.py