Note
感谢 @MalitsPlus@Francesco149 在之前进行的出色工作,使我得以将资源挖掘进行下去。

程序

so 解密

以下库存在自加密

  • libtolua.so

  • libLapis.so

  • libklabniceway

在加载时通过 .init_array 运行解密函数,读取 elf.e_entry 指向的地址,获取 addresslengthkey 三个参数。

 --------------------
|  ...               |
|  0x18 e_entry      |
|  ...               |
|  ...               |
|  address (4B)      | <- e_entry
|  length  (4B)      |
|  key     (4B)      |
|  ...               |
|  ...               |
|  加密段             | <- address
|  ...               |
|  ...               |
 --------------------

然后解密 address 开始的 length 个字节,每第 key % 9 个字节取反(为 0 时全部取反)。

DEX 脱壳

class.dex 使用了 jiagu.py 中的方法进行隐藏。 相关处理使用 libklabniceway.so

 --------------------
|  脱壳用 classes.dex |
 --------------------
|  加密源 APK         |
 --------------------
|  源 APK 大小        |
 --------------------

解密算法如下,简单来说就是:逆序+取反+异或密钥。

private static final int[] KEY = {0x5e, 0x2f, 0x97, 0xcb, 0x65, 0xb2};
public byte[] decrypt(byte[] cipher) {
  var plain = new byte[cipher.length];
  for (var i = 0; i < plain.length; ++i) {
      var k = KEY[i % 6];
      var b = cipher[length - i - 1];
      plain[i] = (byte) (k ^ ~b);
  }
  return plain;
}

root 检测

游戏资源

资源下载

Caution
游戏资源已无法下载

游戏资源在 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 资源解析 中所述,资源清单存在加密,解密步骤为:

  1. 使用 AES/CBC/PKCS5Padding 解密, 密钥 9ec473ae662f4e2a592a502b903c9ec473ae662f4e2a8d857ea7592750bb903c (HEX), 初始化向量 f184dfb4912bce95dbdb7d975397723f (HEX)。

  2. GZIP 解压缩

  3. 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 数据库。 实际具有只有两种文件:MASTERDATAMASTERDATA_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> 是可选的,可能为 bc_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 脚本

Lua 脚本运行使用基于 luajittolua 框架。

Lua 脚本由三部分组成:

  1. 20字节二进制格式的 SHA1 签名,由第二部分计算得出

  2. 主体,脚本字节码

  3. 32字节HEX格式 MD5 签名,生成方式不明

Lua字节码进行了以下修改:

  • 加密:plaintext[i] = (byte) (0xabcdef & 0xFF) ^ ciphertext[i])

  • 指令变更:MOVNOTUNMLEN 编号移至 ISEQV 前,即 MOV=4 NOT=5 UNM=6 LEN=7 ISEQV=8 …​

  • 格式变更:

    • 修改文件头魔数,header = ESC 'P' 'O' '0x82' flagsU [namelenU nameB*]

    • bcinsWuvdataH 顺序对换,即 pdata = phead uvdataH* bcinsW* kgc* knum* [debugB*]

此外,libtolua.so 文件部分区域被加密,处理逻辑位于 kcDecodeFunc 中。 ELF 格式 e_entry 部分指向的地址为三个 UInt32 参数,用于:

  1. 加密区域起始地址

  2. 加密区域长度

  3. 密钥 key

算法大致为 magic = key % 9; plaintext[i] = (magic == 0 || i / magic * magic == i) ~ciphertext[i] : ciphertext[i]

References