感谢 @MalitsPlus 和 @Francesco149 在之前进行的出色工作,使我得以将资源挖掘进行下去。
APK
class.dex 使用了类似 jiagu.py 中的方法进行隐藏。 算法未破解,但通过运行游戏可以从 data 目录下获取解密后的 TargetApk.zip 文件,但需要 root 才能读取,同时游戏有针对 root 的检测。
资源下载
游戏资源在 https://prod-lapis-pack.you-zhe.com 路径下获取,没有身份认证,但需要设置 X-Unity-Version 请求头:
curl -X HEAD -H "X-Unity-Version: 2019.4.26f1c1" --location "https://prod-lapis-pack.you-zhe.com/<path>"
curl -X HEAD -H "X-Unity-Version: 2019.4.26f1c1" --location "https://prod-lapis-pack.you-zhe.com/assets/products/resources"
资源清单
manifest.xml 是游戏资源清单文件,从 /<manifestVersion>/Android/JP/manifest.xml 路径下下载。
manifestVersion 可以使用游戏主界面显示的版本,比如 202209301251-1-a9c916bdcd,或者资源清单内部的资源版本,比如 202209301249-1-298166。
如 @MalitsPlus 的Lapis Re:LiGHTs 资源解析 中所述,资源清单存在加密,解密步骤为:
-
使用
AES/CBC/PKCS5Padding解密, 密钥9ec473ae662f4e2a592a502b903c9ec473ae662f4e2a8d857ea7592750bb903c(HEX), 初始化向量f184dfb4912bce95dbdb7d975397723f(HEX)。 -
GZIP 解压缩
-
C# BinaryFormatter 反序列化,这一步
最后一步本来需要知道原始类结构,但是经过一番搜寻,找到了 nrbf.py 这个脚本,可以将序列化数据导出为JSON,从而绕过了对原始类结构的需要。
public static final String SECRET_KEY_HEX = "9ec473ae662f4e2a592a502b903c9ec473ae662f4e2a8d857ea7592750bb903c";
public static final byte[] IV_HEX = "f184dfb4912bce95dbdb7d975397723f";
public static final byte[] SECRET_KEY = HexFormat.of().parseHex(SECRET_KEY_HEX);
public static final byte[] IV = HexFormat.of().parseHex(IV_HEX);
public static byte[] manifestDecrypt(byte[] bytes) throws Exception {
var cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(SECRET_KEY, "AES"), new IvParameterSpec(IV));
try (var is = new GZIPInputStream(new CipherInputStream(new ByteArrayInputStream(bytes), cipher))) {
var temp = Files.createTempFile("lapis", null);
Files.write(temp, is.readAllBytes());
var pb = new ProcessBuilder();
pb.command("python", "nbfr.py", temp.toAbsolutePath().toString());
var p = pb.start();
p.waitFor(10, TimeUnit.SECONDS);
return p.getInputStream().readAllBytes();
}
}
Unity AssetBundle
资源清单中 Oz.GameKit.Version.AssetBundleInfo 类型的对象对应 Unity AssetBundle,从 /<version>/Android/JP/AssetBundles/<name>.bdl 路径下载。其参数 key 非 0 表示文件被加密,需要解密。解密后可使用 AssetStudio 等工具查看内容。
public static void assetBundleDecrypt(byte[] bytes, int key) {
var step = Math.abs(key % 10) + 1;
var align = Math.abs(key / 10 % 10) + 1;
var length = bytes.length;
for (int index = 0, start = 0; start < length; ++index, start += step) {
var x = 1103515245 * index + 12345;
if (x < 0) {
x = 1103515245 * index + 77880;
}
var h = x >> 16;
bytes[start] ^= key >> ((h & 0x00FFL) - (h & 0x7FFFL) / align * align);
}
}
数据库
资源清单中 Oz.GameKit.Version.MasterDataInfo 类型的对象对应 SQLite 数据库。
实际具有只有两种文件:MASTERDATA 和 MASTERDATA_LANG,对应参数数据库和文本数据库,分别从 /<version>/MasterData/master_data.db 和 /<version>/Android/JP/master_data_lang.db 下载。
具有三个参数 key1 key2 key3 为初始密钥,固定为 2000、1500、1000。
游戏下载数据库后会先进行解密,再用随机生成的密钥加密。
实际初始解密时使用的参数为 1972=2000^100, 1354=1500^150, 800=1000^200。
@Francesco149 的 reversing-sifas 给出了数据库的加解密算法,感谢他的工作。
public static byte[] databaseDecrypt(byte[] ciphertext, int k1, int k2, int k3) {
var keys = databaseKeys(ciphertext.length, k1, k2, k3);
var plaintext = new byte[ciphertext.length];
for (var i = 0; i < plaintext.length; ++i) {
plaintext[i] = (byte) (ciphertext[i] ^ keys[i]);
}
return plaintext;
}
private static final long LCG_A = 0x000343FDL;
private static final long LCG_C = 0x00269EC3L;
private static final long LCG_MASK = 0xFFFFFFFFL;
private static byte[] databaseKeys(int length, int k1, int k2, int k3) {
var keys = new byte[length];
for (var i = 0; i < length; ++i) {
keys[i] = (byte) (((k1 >> 24) & 0xFF) ^ ((k2 >> 24) & 0xFF) ^ ((k3 >> 24) & 0xFF));
k1 = (int) ((((k1 * LCG_A) & LCG_MASK) + LCG_C) & LCG_MASK);
k2 = (int) ((((k2 * LCG_A) & LCG_MASK) + LCG_C) & LCG_MASK);
k3 = (int) ((((k3 * LCG_A) & LCG_MASK) + LCG_C) & LCG_MASK);
}
return keys;
}
Wwise SoundBank
最后一类 Oz.GameKit.Version.SoundBankInfo 对象是 Wwise SoundBank 格式的音频数据,从 /<version>/Android/SoundBanks/<name>.bnk 路径下载。
文件没有加密,但 Wwise 引擎本身的工作机制将音频事件名进行了哈希处理,需要通过事件名模式、已有的事件名进行猜测和逆向。 参见 REVERSING WWISE NUMBERS。
利用 wwiser-utils 逆向工具,参考剧情脚本、参数数据库的语音播放事件名,可以大致总结出语音事件的命名模式:
-
Play_<group>_<speaker>_<no><suffix>, -
<group>一般是 SoundBank 文件名,但也有存在差异的情况 -
<speaker>是说话人编号,推测前三位用于标识声优,第四位用于区分所配音的人物 -
<no>是时间线编号(或单纯的序号),一般从0001开始按顺序递增,但存在疑似废案的数据会使用1001开始的值 -
<suffix>是可选的,可能为b、c、_b、_c、_1等,猜测也是用于废案。 -
e.g.
Play_VOX_EVT_0001_01_01_0010_0001-
groug = VOX_EVT_0001_01_01,0001号活动语音第1话 -
speaker = 0010,ティアラ(安齋由香里) -
no = 0001,时间线顺序0001
-
卡图
使用 Spine 的 2D 动画。 贴图文件未经过 premultiplied alpha 处理。
Lua 脚本
头部 20 字节为 SHA1 签名,由脚本主体直接生成。 尾部 32 字节似乎也是签名,生成方式不明。 其余部分为脚本主体,存在加密或修改了解释器字节码,未破解。