更新日志#

  • 2026-06-24 OurNotes 内测初探
  • ???????? 忘了
  • 20261003 解密一把梭

内测#

初探#

很常规的两个路径:

apk\lib\arm64-v8a\libil2cpp.so

.apk\assets\bin\Data\Managed\Metadata\global-metadata.dat 但有加密。

魔数头被抹掉,可以看到大量的 0x66,有明显的统计特征,0x00 占比 20.477%20.477\%,0x66 占比 7.175%7.175\%,0x99 占比 1.868%1.868\%

image-20260625005456472
image-20260625005456472

直接怀疑是固定密钥 xor 或 sub,进一步猜测是纯 xor,但部分区段是不符合明显 0x66 的特征,猜测是按区块的异或解密。

此外,0x1000-0x4FFF 区间的统计熵显著变大。

image-20260625010430806
image-20260625010430806

推测这段有单独的 Key 或 AES 等加密算法。

安装#

需指定安装来源安装:

Terminal window
adb install -r -d -g -i com.android.vending "アワーノーツ.apk"

否则会被 Google Play 劫持。

安装完毕:

image-20260625005002429
image-20260625005002429

metadata dump#

朋友搞的,尚未复现,

il2cppdumper#

把 IDA 配好让 opus 4.8 自己去迭代进行,得到一个尚有缺陷的魔改版 il2cpp dumper

https://github.com/MetaMikuAI/Il2CppDumper-0x66

能导出大概 47.4 MB 的 dump.cs 和 3.35 MB 的字符串表,剩有几万个方法导出失败,大多是泛型之类,等需要的时候再细分吧。

导入 IDA 很快,想要什么上 il2cpp-to-csharp-skill 让 AI 自己来分析就行了。

毕竟个人对这个游戏不是很感冒,主要是研究了一些 il2cpp dumper 的魔改,剩下的等有需要的时候再进行研究了。

抓取日志#

Terminal window
adb logcat -s Unity

SF#

内测已经过了,忘记预先抓包一下初始个人数据模板,要做的话也基本是盲测了,因此此处只做一次尝试。

时间校验#

由于内测时间结束,且游戏本身有时间校验,虽然可以通过修改系统时间绕过,但还是最好解决一下时间校验的问题。

image-20260707140420257
image-20260707140420257

定位时间校验相关逻辑

// 0x73A64AC
void *App_Display_Presenter_TitleDisplayPresenterBase_object___ValidateCbt(
App_Display_Presenter_TitleDisplayPresenterBase__CreateUI_d__14_T__o *this,
const MethodInfo_73A64AC *method)
{
__int64 v2; // x1
__int64 v3; // x1
__int64 v4; // x1
void *JstNow; // x19
App_Master_MasterLoader_o *Instance; // x0
void *Parameter; // x0
System_String_o *v8; // x20
System_DateTime_o v9; // x1
System_DateTime_o v10; // x0
App_Display_Presenter_TitleDisplayPresenterBase__OnBack_d__13_T__o *v12; // x0
const MethodInfo_73A6594 *v13; // x1
if ( (byte_A3414E3 & 1) == 0 )
{
sub_4451420(&Fwk_Generic_AppTime_TypeInfo, method);
sub_4451420(&System_DateTime_TypeInfo, v2);
sub_4451420(&Method_Fwk_Generic_SingletonBase_MasterLoader__get_Instance___167494936, v3);
sub_4451420(&StringLiteral_34379, v4);
byte_A3414E3 = 1;
}
if ( !*(&Fwk_Generic_AppTime_TypeInfo->_2.cctor_finished + 1) )
((void (*)(void))sub_44515A0)();
JstNow = Fwk_Generic_AppTime___c__get_JstNow(0LL);
Instance = (App_Master_MasterLoader_o *)Fwk_Generic_SingletonBase_object___get_Instance((const MethodInfo_7199538 *)Method_Fwk_Generic_SingletonBase_MasterLoader__get_Instance___167494936);
if ( Instance
&& (Parameter = App_Master_MasterLoader__GetParameter(Instance, (System_String_o *)StringLiteral_34379, 0LL)) != 0LL )
{
v8 = (System_String_o *)*((_QWORD *)Parameter + 5);
if ( !*(&System_DateTime_TypeInfo->_2.cctor_finished + 1) )
sub_44515A0(System_DateTime_TypeInfo);
v9.fields._dateData = (uint64_t)System_DateTime__Parse(v8, 0LL);
v10.fields._dateData = (uint64_t)JstNow;
return System_DateTime__op_GreaterThanOrEqual(v10, v9, 0LL);
}
else
{
v12 = (App_Display_Presenter_TitleDisplayPresenterBase__OnBack_d__13_T__o *)sub_44516CC();
return App_Display_Presenter_TitleDisplayPresenterBase_object___OnKill(v12, v13);
}
}

整个函数直接干掉

il2cpp:00000000073A64AC 00 00 80 52 MOV W0, #0
il2cpp:00000000073A64B0 C0 03 5F D6 RET

重新打包,签名并安装即可

api 地址#

一共两个主要的地址

https://api-cbt.bang-dream-on.jp
https://static-cbt.bang-dream-on.jp

前者是业务 API,后者应该是资源站的地址。这里只改前者。

xor 0x66 后填回 global-metadata.dat,这里 metamiku 直接配了公网服务器、SSL 证书和等长的域名,因此也不涉及什么 padding。

这里应该没人不会吧(?)不会可以看 metamiku 另一篇博客。

可行性验证#

根据 PrivateSekai 总结的 skill,让 AI 分析并搓了一个小 demo(gpt 编造了好些资源,导致进度有点拖延了)

稍后会将代码整理发一下。

image-20260707183200714
image-20260707183200714

持续开发#

基本交给 Sol 干了,纯 vibe 真的很爽。仓库地址不方便贴,就麻烦自己翻吧。

正式研究#

1.0.1-25 metadata#

20260922 记,astra 十分钟秒了。

恢复流程#

整体恢复顺序如下,后文再展开各处的逆向分析:

  1. 获取用于解密 libil2cpp.so 的 seed。
  2. 使用 seed,按区域配置解密 libil2cpp.so。
  3. 分析解密后的 libil2cpp.so,结合 libanort.so 追踪 global-metadata.dat 的加载与解密逻辑,并解密 metadata。
  4. 将解密后的 libil2cpp.so 和 global-metadata.dat 一起交给适配后的 Il2CppDumper 导出。

seed#

本版本用于解密 libil2cpp.so 的 seed 为 0xba981955,计算过程暂不展开。它与后文 metadata 解密使用的 0xD96603C0 是两个不同的值。

观察#

首先对齐一下版本:

MD5(libil2cpp.so) = 909329A83B267FF5583BF9EB693CD485
MD5(libanogs.so) = 4D9AE055F02945A4E4EDF4D82A08D665
MD5(libanort.so) = 91958F8DF1C00A00804103CF278EC1CD
MD5(global-metadata.dat) = C3154D03AB0746592E88BE9D5ACBF498

先观察 global-metadata.dat,和内测时很像,0x00、0x66 有明显的统计特征,部分区域的统计熵较高。直觉上仍然考虑分区 xor 0x66,高熵部分可能是 AES,后面再验证。

单看分布只能给出猜测,具体算法还是要找到加载和解密的位置。这次尝试静态提取,不依赖运行时 dump。

Java 层#

安装包里除了常见的 libil2cpp.so,还有两个神秘文件:libanogs.so 和 libanort.so。

在 Java 层找到对应的加载入口:

classes4.dex
package com.ano.gshell;
public class AnoApplication extends Application {
static {
System.loadLibrary("anogs");
System.loadLibrary("anort");
memoryLoaders = new ArrayList<>();
}
}

继续看 attachBaseContext,会调用 native initialize(...),传入包名、应用目录、APK 路径等参数。这里暂时没看到直接解密字节的逻辑,先记住这两个库,转到 native 层。

native 层与跨库追踪#

用 IDA 打开 libil2cpp.so,找到文件名:

.rodata:0000000002001A8E aGlobalMetadata DCB "global-metadata.dat",0

但是找不到引用它的位置。先换个入口,从库的初始化开始看。

ELF 动态表中的 DT_INIT 指向 0x54C8AA4,也就是加载时执行的初始化函数:

.text:00000000054C8AA4 .init_proc
.text:00000000054C8AA4 STP X30, XZR, [SP,#-0x10]!
.text:00000000054C8AA8 ADRL X16, xx0 ; 这个神秘 xx0 是什么?
.text:00000000054C8AB0 ADR X30, unk_54C8AB8
.text:00000000054C8AB4 BR X16

跟进 xx0 会到 0xBDEE460,这里通过导入项跳往外部函数。在当前窗口继续点 __imp_xx0 看不到实现,要去其他 .so 的导出表里找。

在 libanort.so 中打开 View → Open subviews → Exports,果然能找到同名的 xx0:

image-20260922021703135
image-20260922021703135

双击进去,地址是 0xB117C。先是一大堆保存寄存器的指令,往下到 0xB1210:

.text:00000000000B1210 BL sub_B0BFC

继续跟进,找到实际传参的位置:

; libanort.so
.text:00000000000B0F90 ADRP X0, 0x1CB000
.text:00000000000B0F94 ADD X0, X0, #0xC2C
.text:00000000000B0F98 MOV W2, WZR
.text:00000000000B0F9C SUB X1, X8, #0x14
; 中间保存基址、恢复栈
.text:00000000000B0FB0 B sub_AF8C0

传入的是配置地址 0x1CBC2C、libil2cpp 的加载基址和 0。这里 F5 可能没显示全参数,以汇编为准。

到这里,从 libil2cpp 的初始化入口追到了 libanort,接下来看它具体处理什么。

代码段解密#

查看 0x1CBC2C,往后 0x10 就是 libil2cpp.so,后面还有 .rodata、.text 等区域记录。拿 .text 这条举例:

0x1CBCF4 起点:0x4D83330
0x1CBCFC 长度:0x74A37C
0x1CBD14 模式:1

sub_AF8C0 读取配置,在 0xB0820 调用 sub_13FEE0,模式 1 继续到 sub_13E57C。

这条记录的处理方式是:

  1. 跳过 0x2000 - (起点 & 0xFFF) 字节,得到 0x4D85000。
  2. 从这里开始,前 0x4000 字节分为八个 0x800 字节块,进行 AES 处理。
  3. 从 0x4D95000 开始调用 sub_156870,每个完整 0x10000 字节块的前 0x4000 字节做 xor,最后不足一块的尾段全部 xor。

因此需要处理的范围是配置给出的,不能直接把整个 .so 异或一遍。

xor 的字节来自传入的 seed。0x156904 右移 16 位,0x156930 广播字节,0x156954 执行向量异或。简化后的取值规则如下,旁边的连续 xor 函数 sub_156844 也是同样的规则:

key = (seed >> 16) & 0xFF;
if (key == 0)
key = 0x87;

画一下 seed 和区域处理的关系。下面只画当前记录使用的大区段、模式 1 分支:配置决定处理哪里,seed 决定用什么密钥;AES 和 xor 作用于不同的子区间。

flowchart TD S["32 位 seed:0xba981955<br/>计算过程暂不展开"] C["区域配置 + 模块基址<br/>起点、长度、模式"] --> F["sub_13FEE0<br/>大区段,模式 1"] S -->|W2 参数| F F --> R["调整起点 P<br/>P = 起点 + 0x2000 - 起点低 12 位"] S --> K["按 %08x%08x 将 seed 拼接两次<br/>得到 16 字节 ASCII AES 密钥"] S --> X["k = seed 的第 16–23 位<br/>若为 0,改用 0x87"] R --> A["sub_13E57C:AES-CBC<br/>处理 P 到 P+0x4000<br/>每 0x800 字节独立重置 IV"] K --> A V["固定 IV"] --> A A --> Q["sub_156870:周期 xor<br/>从 P+0x10000 开始<br/>每完整 0x10000 块处理前 0x4000<br/>不足一块的尾段全部处理"] X --> Q Q --> O["完成该区域的这轮解码<br/>其余子区间保持原样"]

这里的箭头表示 AES 处理完后继续处理后面的 xor 区间,不是把同一段数据先 AES 再 xor。代入 seed 0xba981955,xor 字节为 0x98,AES 密钥为 16 字节 ASCII 字符串 ba981955ba981955。

下面这段代码用 0x98 异或后可以恢复:

; libil2cpp.so,xor 0x98 后
.text:0000000005427C44 ADRP X0, 0x2001000
.text:0000000005427C48 ADD X0, X0, #0xA8E
.text:0000000005427C4C MOV X20, X1
.text:0000000005427C50 BL 0x54389BC
.text:0000000005427C54 MOV X19, X0

0x2001000 + 0xA8E = 0x2001A8E,正好是 global-metadata.dat。文件名引用出现了,后面的 0x54389BC 是加载函数。

再核对位置:

(0x5427C44 - 0x4D95000) % 0x10000 = 0x2C44 < 0x4000

确实位于配置指定的 xor 区间内。这里既有处理范围,也有恢复后的文件名和调用关系,不只是“看起来像汇编”。

metadata 解密#

解密 libil2cpp.so 后,继续分析 metadata 的加载路径,并结合 libanort.so 确定文件的解密方式。代码段的 0x98 和 metadata 的 0x66 要分开看。

0x54389BC 会经过文件映射路径读取数据。另一边,libanort 中的 sub_152594 参数形式与 mmap 一致,它先检查文件头,匹配 0x12724394 后解密,否则回到普通 mmap。

我们的 metadata 前四字节是 94 43 72 12,按小端序读取就是这个标记。不过,两边具体如何通过 hook 接上,安装过程还没完全追完,先保留这个缺口。

metadata 的 seed 倒是很直接:在 0x15271C 附近准备 0xD96603C0,于 0x15272C 调用 sub_156638。

(0xD96603C0 >> 16) & 0xFF = 0x66

最开始对 xor 0x66 的猜测得到验证。AES 密钥也来自同一个参数,在 0x1567A4 用 %08x%08x 拼接两次,得到 16 字节 ASCII:

key = d96603c0d96603c0
IV = 02030405060708090a0b0c0d0e0f1011

IV 位于 libanort.so:0x190060。0x13E448 将 0x4000 字节拆成八块,后续经 0x13E37C、0x13F25C 做 AES-128-CBC,每块重新设置密钥和 IV,无填充。

整理得到文件解密规则,下面均为文件偏移:

  1. [8, 0x1000):xor 0x66。
  2. [0x1000, 0x5000):AES-128-CBC,使用上述密钥和 IV,每 0x800 字节重置一次。
  3. 从 0x11000 开始,每个完整 0x10000 字节块的前 0x4000 字节 xor 0x66。
  4. 最后不足 0x10000 字节的尾段全部 xor 0x66,这里不要漏掉。
  5. 按加载器行为清零 [0,4) 和 [5,8),保留偏移 4 的 0x18。

魔术头暂时不补,下面记录的是未补头版本的 MD5:

MD5(global-metadata.dat.decrypt) = 1189B97CA265D647FFAB1B0C2266FBCC

文件解密和恢复标准 metadata 布局是两步,后面再处理 dumper 的适配。

Il2cppDumper#

将上述解密后的 libil2cpp.so 和 global-metadata.dat 作为输入,使用适配后的 Il2CppDumper 导出。

MetaSekaiLab/Il2CppDumper-0x66

BepInEx#

虽然 libil2cpp.so 还没造好,但是 BepInEx 版本已经装配好了,这个暂时就不发了,参考 Project Sekai 逆向笔记 或者 email 我。

nnnotes#

MetaSekaiLab/nnnotes 是用于处理 OurNotes 游戏文件的工具包,支持浏览 Addressables catalog、下载并解密资源包,以及下载和解码 masterdata。还可以导出剧情、Live2D 模型、据点场景、CRI 音频和谱面,并为 ournotes-player 生成网页展示所需的数据。

使用时需自行配置游戏文件、密钥和服务器地址,具体安装与使用方式见仓库 README。

下面是 nnnotes 的 demo 版

import argparse
import hashlib
import struct
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit
from urllib.request import urlopen
from Crypto.Cipher import AES
VERSION = '0.0.2'
SERVERS = {
'tw': ('TW/HK/MO', 'https://l14-prod-hk-patch-sirius.gamerfusiontech.com/prod/hk_27f3c91e8b62d6056c7a19f2e83b6d10'),
'en': ('EN REGION', 'https://l14-prod-sg-patch-sirius.bilibiligame.net/prod/en_3e8a72c5f1d9066b9a37c2e85f619db0'),
'kr': ('KOREA', 'https://l14-prod-sg-patch-sirius.bilibiligame.net/prod/kr_461b4e9a7c2385f0e2d966a1b73c8f52'),
}
LANGUAGES = {'zh-Hans': '简体中文', 'zh-Hant': '繁体中文', 'en': 'English', 'ko': '한국어', 'ja': '日本語'}
CATALOGS = Path(__file__).parent / 'downloads/catalog'
def decrypt(data, filename):
if data.startswith(b'UnityFS\0'):
return data
key = bytes.fromhex('7372a4ee777db361ad896c99e408a182')
seed = bytes.fromhex('ee24a70238e2a0e5')
nonce = hashlib.sha256(seed + filename.encode('utf-8')).digest()[:8]
aes = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)
return aes.decrypt(data[:16384]) + data[16384:]
def parse(data):
def u32(offset):
return struct.unpack_from('<I', data, offset)[0]
def array(offset):
if offset == 0xFFFFFFFF:
return []
return struct.unpack_from(f'<{u32(offset - 4) // 4}I', data, offset)
def text(offset):
if offset == 0xFFFFFFFF:
return ''
if offset & 0x40000000:
parts = []
while offset != 0xFFFFFFFF:
part, offset = struct.unpack_from('<II', data, offset & 0x3FFFFFFF)
parts.append(text(part))
return '/'.join(reversed(parts))
pos = offset & 0x3FFFFFFF
return data[pos:pos + u32(pos - 4)].decode('utf-16-le' if offset & 0x80000000 else 'ascii')
magic, version, keys = struct.unpack_from('<III', data)
if magic != 0x0DE38942 or version != 2:
raise ValueError('不支持的 Catalog 格式')
locations = set()
for pos in range(keys, keys + u32(keys - 4), 8):
locations.update(array(u32(pos + 4)))
result = []
for pos in sorted(locations):
primary, internal, _, deps = struct.unpack_from('<4I', data, pos)
result.append({'offset': pos, 'primary_key': text(primary),
'internal_id': text(internal), 'dependencies': array(deps)})
return result
def browse(entries):
bundles = {e['offset']: e for e in entries
if e['internal_id'].startswith('https://dummy.net/')
and e['internal_id'].endswith('.bundle')}
files = {}
for entry in entries:
if entry['internal_id'].startswith('Assets/'):
ids = [i for i in entry['dependencies'] if i in bundles]
if ids:
files.setdefault(entry['primary_key'], set()).update(ids)
for ident, entry in bundles.items():
files['bundles/' + entry['primary_key']] = {ident}
return bundles, files
class Handler(BaseHTTPRequestHandler):
def listing(self, label, rows):
title = f'nnnotes v{VERSION} by MetaMiku /' + label
data = ('<!doctype html><meta charset="utf-8"><title>' + escape(title)
+ '</title><h1>' + escape(title) + '</h1><hr><ul>'
+ '\n'.join(sorted(rows)) + '</ul><hr>').encode()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = unquote(urlsplit(self.path).path)
parts = path.strip('/').split('/') if path.strip('/') else []
if not parts:
self.listing('', [f'<li><a href="/{key}/">{name}/</a></li>' for key, (name, _) in SERVERS.items()])
return
region = parts[0]
if region not in SERVERS:
self.send_error(404)
return
if len(parts) == 1:
self.listing(SERVERS[region][0] + '/', ['<li><a href="/">../</a></li>'] +
[f'<li><a href="/{region}/{key}/">{name}/</a></li>' for key, name in LANGUAGES.items()])
return
language = parts[1]
if language not in LANGUAGES:
self.send_error(404)
return
cdn = SERVERS[region][1]
key = (region, language)
if key not in self.server.catalogs:
catalog = CATALOGS / region / f'catalog_main_{language}.bin'
if not catalog.exists():
with urlopen(cdn + f'/asset/Android/{catalog.name}', timeout=60) as response:
raw = response.read()
catalog.parent.mkdir(parents=True, exist_ok=True)
catalog.write_bytes(raw)
self.server.catalogs[key] = browse(parse(catalog.read_bytes()))
bundles, files = self.server.catalogs[key]
base = f'/{region}/{language}/'
if len(parts) > 2 and parts[2] == 'download':
ident = parts[3] if len(parts) == 4 else ''
if not ident.isdecimal() or int(ident) not in bundles:
self.send_error(404)
return
entry = bundles[int(ident)]
name = entry['internal_id'].rsplit('/', 1)[1]
url = cdn + entry['internal_id'].removeprefix('https://dummy.net')
with urlopen(url, timeout=60) as response:
data = decrypt(response.read(), name)
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + quote(name))
else:
prefix = '/'.join(parts[2:])
prefix += '/' if prefix else ''
parent = base + prefix.rstrip('/').rsplit('/', 1)[0] + '/' if '/' in prefix.rstrip('/') else base
if not prefix:
parent = f'/{region}/'
rows = {f'<li><a href="{quote(parent)}">../</a></li>'}
for name, ids in files.items():
if not name.startswith(prefix):
continue
tail = name[len(prefix):]
if '/' in tail:
directory = prefix + tail.split('/')[0] + '/'
rows.add(f'<li><a href="{base}{quote(directory)}">{escape(tail.split("/")[0])}/</a></li>')
else:
for ident in sorted(ids):
label = tail if len(ids) == 1 else tail + ' — ' + bundles[ident]['primary_key']
rows.add(f'<li><a href="{base}download/{ident}">{escape(label)}</a></li>')
self.listing(SERVERS[region][0] + '/' + LANGUAGES[language] + '/' + prefix, rows)
return
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', action='version', version=f'nnnotes {VERSION}')
parser.add_argument('port', nargs='?', type=int, default=8000)
args = parser.parse_args()
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
server.catalogs = {}
print(f'nnnotes: http://127.0.0.1:{args.port}', flush=True)
server.serve_forever()
if __name__ == '__main__':
main()

解密一把梭#

20261003

还是同构的,一把梭。

适用版本,没列出来的不一定不适用,只是没测试。

bdon-b-1.0.1-25
bdon-b-1.0.2
bdon-jp-1.0.4
#!/usr/bin/env python3
"""OurNotes 同构 APK 一把梭:python decrypt_apk.py input.apk [-o output]。 by MetaMiku"""
import argparse
import hashlib
import io
import struct
import zipfile
import zlib
from pathlib import Path
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN
from Crypto.Cipher import AES
from elftools.elf.elffile import ELFFile
IV_BYTES = bytes(range(2, 18))
META_MAGIC = 0x12724394
TABLE_STRIDE = 80
SELECT = {'libanort': (0, 2), 'libanogs': (0, 2, 3, 4)}
def u32(data, offset=0):
return struct.unpack_from('<I', data, offset)[0]
def find_all(data, needle):
out, start = [], 0
while True:
pos = data.find(needle, start)
if pos < 0:
return out
out.append(pos)
start = pos + 1
class Elf:
def __init__(self, data):
self.data = data
self.elf = ELFFile(io.BytesIO(data))
assert self.elf.elfclass == 64 and self.elf.little_endian
assert self.elf['e_machine'] == 'EM_AARCH64'
self.loads = [p for p in self.elf.iter_segments() if p['p_type'] == 'PT_LOAD']
text = self.elf.get_section_by_name('.text')
self.text_base = text['sh_addr']
off = self.try_offset(text['sh_addr'], text['sh_size'])
self.words = struct.unpack(f"<{text['sh_size'] // 4}I",
data[off:off + text['sh_size']])
def try_offset(self, va, size=1):
hits = [p['p_offset'] + va - p['p_vaddr'] for p in self.loads
if p['p_vaddr'] <= va and va + size <= p['p_vaddr'] + p['p_filesz']]
return hits[0] if len(hits) == 1 else None
def offset(self, va, size=1):
off = self.try_offset(va, size)
assert off is not None, f'ELF 地址越界:0x{va:x}+0x{size:x}'
return off
def read(self, va, size):
off = self.offset(va, size)
return self.data[off:off + size]
def va_of(self, offset):
for section in self.elf.iter_sections():
if section['sh_type'] == 'SHT_NOBITS' or not section['sh_addr']:
continue
if section['sh_offset'] <= offset < section['sh_offset'] + section['sh_size']:
return section['sh_addr'] + offset - section['sh_offset']
return None
def movz_movk(elf):
"""把 32 位 MOVZ LSL#0 + MOVK LSL#16 还原成 (高半地址, 常量)。"""
pairs = []
for i, word in enumerate(elf.words):
if (word & 0x7F800000) != 0x72800000 or ((word >> 21) & 3) != 1:
continue
reg, high = word & 31, (word >> 5) & 0xFFFF
for j in range(i - 1, max(-1, i - 8), -1):
prev = elf.words[j]
if (prev & 0x7F800000) == 0x52800000 and ((prev >> 21) & 3) == 0 \
and (prev & 31) == reg:
pairs.append((elf.text_base + i * 4,
((prev >> 5) & 0xFFFF) | (high << 16)))
break
return pairs
def crypt(data, seed, iv, aes_start, xor_start, encrypt=False):
result = bytearray(data)
table = bytes(i ^ (((seed >> 16) & 255) or 0x87) for i in range(256))
if aes_start is None:
return result.translate(table)
assert 0 <= aes_start and aes_start + 0x4000 <= xor_start <= len(data)
for pos in range(aes_start, aes_start + 0x4000, 0x800):
aes = AES.new(f'{seed:08x}{seed:08x}'.encode(), AES.MODE_CBC, iv)
transform = aes.encrypt if encrypt else aes.decrypt
result[pos:pos + 0x800] = transform(data[pos:pos + 0x800])
for pos in range(xor_start, len(data), 0x10000):
end = pos + 0x4000 if len(data) - pos >= 0x10000 else len(data)
result[pos:end] = result[pos:end].translate(table)
return result
def check_code(data, address):
instructions = list(Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN).disasm_lite(data, address))
assert sum(i[1] for i in instructions) == len(data), f'AES 代码校验失败:0x{address:x}'
assert any(i[2] == 'ret' for i in instructions), '未找到函数返回'
def check_metadata(data):
assert data[:8] == b'\0\0\0\0\x18\0\0\0'
regions = [struct.unpack_from('<III', data, 8 + i * 12) for i in range(31)]
end = 0x17C
for offset, size, count in regions:
assert end <= offset <= offset + size <= len(data), 'metadata 分区越界'
end = offset + size
assert regions[0][0] == 0x17C and end == len(data)
offset, size, count = regions[0]
assert count > 1 and size == count * 4
indices = struct.unpack_from(f'<{count}I', data, offset)
base, size, _ = regions[1]
assert indices[0] == 0 and indices[-1] == size
for start, end in zip(indices, indices[1:]):
assert 0 <= start <= end <= size
data[base + start:base + end].decode('utf-8')
base, size, _ = regions[2]
names = data[base:base + size]
assert b'mscorlib.dll\0' in names
for region, stride, fields in [(3, 24, (0,)), (4, 20, (0,)), (5, 32, (0,)),
(10, 12, (0,)), (11, 12, (0,)),
(19, 82, (0, 4)), (20, 36, (0,))]:
offset, size, count = regions[region]
assert size == stride * count, f'metadata 表 {region} 布局改变'
for pos in range(offset, offset + size, stride):
for field in fields:
index = u32(data, pos + field)
end = names.find(b'\0', index)
assert 0 <= index <= end < len(names)
names[index:end].decode('utf-8')
offset, size, count = regions[19]
for field, region in [(0x3A, 5), (0x3C, 4), (0x3E, 11), (0x40, 3),
(0x42, 15), (0x44, 17), (0x46, 16), (0x48, 18)]:
total = sum(struct.unpack_from('<H', data, pos + field)[0]
for pos in range(offset, offset + size, 82))
assert total == regions[region][2], f'metadata 表 {region} 成员总数不符'
print(f'metadata:{regions[19][2]} 类型,{regions[5][2]} 方法,{regions[20][2]} 程序集')
def row_shape(table_data, target, row):
"""80 字节行结构:va/off/size 在 +0x10,MD5 十六进制在 +0x20;先只看形状。"""
if row < 0 or row + 64 > len(table_data):
return None
va, off, size, _ = struct.unpack_from('<4I', table_data, row + 16)
if not (0x1000 < va < 0x08000000 and 0 < size < 0x08000000):
return None
if target.try_offset(va, size) != off:
return None
md5 = table_data[row + 32:row + 64]
if not all(c in b'0123456789abcdef' for c in md5):
return None
return va, off, size, md5
def table_row(table_data, target, row):
"""形状对了还不够,MD5 必须和实际段字节一致。"""
shape = row_shape(table_data, target, row)
if shape is None:
return None
va, off, size, md5 = shape
if hashlib.md5(target.data[off:off + size]).hexdigest().encode() != md5:
return None
return va, off, size
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('apk', type=Path)
parser.add_argument('-o', '--output', type=Path)
args = parser.parse_args()
if not __debug__:
raise SystemExit('请勿用 python -O 禁用校验')
output = args.output or args.apk.with_name(args.apk.stem + '-decrypted')
paths = [f'lib/arm64-v8a/{name}.so' for name in ('libanort', 'libanogs', 'libil2cpp')]
paths += ['assets/bin/Data/Managed/Metadata/global-metadata.dat']
with zipfile.ZipFile(args.apk) as apk:
assert all(apk.namelist().count(p) == 1 for p in paths), 'APK 文件缺失或重复'
anort, anogs, il2cpp = [Elf(apk.read(p)) for p in paths[:3]]
metadata = apk.read(paths[3])
# 1. 配置表:名字地址往前 16 字节
needle = b'libil2cpp.so'.ljust(32, b'\0')
config = None
for pos in find_all(anort.data, needle):
va = anort.va_of(pos)
if va is None or va < 16 or anort.try_offset(va - 16, 0x224) is None:
continue
candidate = anort.read(va - 16, 0x224)
init, saved = struct.unpack_from('<II', candidate, 80)
if candidate[16:48] == needle and saved == 32 and il2cpp.try_offset(init, saved) is not None:
config = candidate
break
assert config, '未找到 libil2cpp 配置表'
print(f'配置表:init=0x{struct.unpack_from("<I", config, 80)[0]:x}')
# 区域参数,后面解密和基值校验都要用
plan = []
previous_end = 0
for i in range(8):
entry = config[132 + i * 52:184 + i * 52]
if not any(entry):
continue
name = entry[:16].split(b'\0')[0].decode('ascii')
va, off, size = struct.unpack_from('<III', entry, 16)
assert u32(entry, 48) == 1, f'{name} 解密模式改变'
assert size and il2cpp.offset(va, size) == off and previous_end <= va
previous_end = va + size
plan.append((name, va, off, size, 0x2000 - (va & 0xFFF) if size >= 0x100000 else None))
text = [item for item in plan if item[0] == '.text'][0]
_, text_va, text_off, text_size, text_skip = text
assert text_skip is not None and text_size >= text_skip + 0x4000
cipher = il2cpp.data[text_off + text_skip:text_off + text_skip + 0x4000]
# 2. metadata seed:锚定头部标记,同函数窗口内取 seed,用分区表验
pairs = movz_movk(anort)
magic = [address for address, value in pairs if value == META_MAGIC]
assert magic, '未找到 metadata 头部标记 0x12724394'
near = [value for address, value in pairs
if magic[0] - 0x60 <= address <= magic[0] + 0x180 and value != META_MAGIC]
metadata_seed = None
for seed in dict.fromkeys(near):
xor = ((seed >> 16) & 255) or 0x87
head = bytes(byte ^ xor for byte in metadata[8:0x17C])
regions = [struct.unpack_from('<III', head, i * 12) for i in range(31)]
end = 0x17C
if regions[0][0] != 0x17C:
continue
for offset, size, count in regions:
if not (end <= offset <= offset + size <= len(metadata)):
break
end = offset + size
else:
if end == len(metadata):
metadata_seed = seed
break
assert metadata_seed is not None, '未找到可用的 metadata seed'
print(f'metadata seed:0x{metadata_seed:08x}')
# 3. IV:唯一常量
assert anort.data.count(IV_BYTES) == 1, 'IV 常量不唯一或缺失'
iv = IV_BYTES
print(f'IV:{iv.hex()}')
# 4. 摘要表:按行结构扫描,实际字节验算后分两张表
tables = {}
for section in anort.elf.iter_sections():
if section['sh_type'] == 'SHT_NOBITS' or not section['sh_flags'] & 0x2 \
or section['sh_flags'] & 0x4:
continue
start, stop = section['sh_offset'], section['sh_offset'] + section['sh_size']
for base in range(start, stop - TABLE_STRIDE * 5, 4):
if base >= TABLE_STRIDE and any(
row_shape(anort.data, elf, base - TABLE_STRIDE) for elf in (anort, anogs)):
continue
for name, elf in (('libanort', anort), ('libanogs', anogs)):
if all(table_row(anort.data, elf, base + i * TABLE_STRIDE)
for i in SELECT[name]):
tables.setdefault(name, set()).add(anort.va_of(base))
for name in SELECT:
assert len(tables.get(name, ())) == 1, f'{name} 摘要表定位失败:{tables.get(name)}'
tables = {name: tables[name].copy().pop() for name in SELECT}
print('摘要表:' + ','.join(f'{name}=0x{value:x}' for name, value in tables.items()))
# 5. 分量:按算法常量取行,CRC32(MD5) 异或
component = 0
for name, elf in (('libanort', anort), ('libanogs', anogs)):
base = anort.offset(tables[name], TABLE_STRIDE * 5)
for index in SELECT[name]:
row = base + index * TABLE_STRIDE
va, off, size = struct.unpack_from('<III', anort.data, row + 16)
component ^= zlib.crc32(hashlib.md5(elf.data[off:off + size]).hexdigest().encode())
print(f'摘要分量:0x{component:08x}')
# 6. 状态基值:写栈槽指令 + 函数窗口内唯一的 MOVZ/MOVK
candidates = {}
for i, word in enumerate(anort.words):
if word & 0xFFF00000 != 0xB9000000 or (word >> 10) & 0xFFF != 1 \
or (word >> 5) & 31 != 31:
continue
reg = word & 31
low = high = None
low_n = high_n = 0
for j in range(max(0, i - 0x600 // 4), i + 1):
prev = anort.words[j]
if (prev & 0x7F800000) == 0x52800000 and ((prev >> 21) & 3) == 0 \
and (prev & 31) == reg:
low_n, low = low_n + 1, prev
elif (prev & 0x7F800000) == 0x72800000 and ((prev >> 21) & 3) == 1 \
and (prev & 31) == reg:
high_n, high = high_n + 1, prev
if low_n == 1 and high_n == 1:
candidates[anort.text_base + i * 4] = \
((low >> 5) & 0xFFFF) | (((high >> 5) & 0xFFFF) << 16)
def text_ok(seed):
plain = b''.join(
AES.new(f'{seed:08x}{seed:08x}'.encode(), AES.MODE_CBC, iv).decrypt(
cipher[pos:pos + 0x800])
for pos in range(0, 0x4000, 0x800))
try:
check_code(plain, text_va + text_skip)
return True
except AssertionError:
return False
seeds = [(site, value ^ component) for site, value in candidates.items()
if text_ok(value ^ component)]
assert len(seeds) == 1, f'状态基值候选定位失败:{[hex(s) for s, _ in seeds]}'
site, seed = seeds[0]
print(f'状态基值:0x{candidates[site]:08x}(写栈槽 0x{site:x});code seed:0x{seed:08x}')
# 7. 解密 libil2cpp:恢复初始化入口,再逐区域 AES + XOR
init, saved_size = struct.unpack_from('<II', config, 80)
offset = il2cpp.offset(init, saved_size)
result = bytearray(il2cpp.data)
result[offset:offset + saved_size] = config[48:80]
for name, va, off, size, skip in plan:
source = result[off:off + size]
plain = crypt(source, seed, iv, skip, skip + 0x10000 if skip is not None else 0)
assert crypt(plain, seed, iv, skip, skip + 0x10000 if skip is not None else 0,
True) == source
if name in ('.text', 'il2cpp'):
check_code(plain[skip:skip + 0x4000], va + skip)
result[off:off + size] = plain
print(f'解密区域:{name} RVA=0x{va:x} size=0x{size:x}')
assert b'global-metadata.dat\0' in result
entry = il2cpp.offset(init, 0x100)
check_code(result[entry:entry + 0x100], init)
# 8. 解密 metadata:XOR + AES + 周期 XOR,再清头部
decoded = crypt(metadata, metadata_seed, iv, 0x1000, 0x11000)
decoded[8:0x1000] = crypt(decoded[8:0x1000], metadata_seed, iv, None, 0)
restored = crypt(decoded, metadata_seed, iv, 0x1000, 0x11000, True)
restored[8:0x1000] = crypt(restored[8:0x1000], metadata_seed, iv, None, 0)
assert restored == metadata
decoded[:4], decoded[5:8] = bytes(4), bytes(3)
check_metadata(decoded)
output.mkdir(parents=True, exist_ok=True)
for name, data in [('libil2cpp.so', result), ('global-metadata.dat', decoded)]:
(output / name).write_bytes(data)
print(f'{output / name}\n MD5={hashlib.md5(data).hexdigest()}')
if __name__ == '__main__':
main()
$ python tools/decrypt_apk.py ./bdon-b-1.0.1-25.apk
libanort 摘要分量:0xa31d8a46
libanogs 摘要分量:0xea8ac46d
状态基值:0xf30f577e;IV:02030405060708090a0b0c0d0e0f1011
代码 seed:0xba981955;XOR:0x98;AES ASCII:ba981955ba981955
metadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0
解密区域:.rodata RVA=0x1ffa260 size=0xd37574
解密区域:.text RVA=0x4d83330 size=0x74a37c
解密区域:il2cpp RVA=0x54cd6ac size=0x6920d88
metadata:34693 类型,234093 方法,201 程序集
bdon-b-1.0.1-25-decrypted\libil2cpp.so
MD5=fa0b42f2f6484a0078f6a7dfd9091550
bdon-b-1.0.1-25-decrypted\global-metadata.dat
MD5=1189b97ca265d647ffab1b0c2266fbcc
$ python tools/decrypt_apk.py ./bdon-b-1.0.2.apk
libanort 摘要分量:0xe4e19465
libanogs 摘要分量:0xea8ac46d
状态基值:0x24801923;IV:02030405060708090a0b0c0d0e0f1011
代码 seed:0x2aeb492b;XOR:0xeb;AES ASCII:2aeb492b2aeb492b
metadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0
解密区域:.rodata RVA=0x2066530 size=0xd6d79c
解密区域:.text RVA=0x4ebaed0 size=0x773d7c
解密区域:il2cpp RVA=0x562ec4c size=0x6b09670
metadata:34887 类型,236400 方法,201 程序集
bdon-b-1.0.2-decrypted\libil2cpp.so
MD5=64a5d985b423a39c185be9fa3a950862
bdon-b-1.0.2-decrypted\global-metadata.dat
MD5=981bfc954570853a81c5ade3552ef095
$ python tools/decrypt_apk.py ./bdon-jp-1.0.4.apk
libanort 摘要分量:0x6a3ccc44
libanogs 摘要分量:0xea8ac46d
状态基值:0x24801923;IV:02030405060708090a0b0c0d0e0f1011
代码 seed:0xa436110a;XOR:0x36;AES ASCII:a436110aa436110a
metadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0
解密区域:.rodata RVA=0x230ea70 size=0xd1433c
解密区域:.text RVA=0x4cc6430 size=0x7194cc
解密区域:il2cpp RVA=0x53df8fc size=0x62b6218
metadata:34645 类型,220237 方法,218 程序集
bdon-jp-1.0.4-decrypted\libil2cpp.so
MD5=6ee47629173c3a080e4a79f96d07c3f0
bdon-jp-1.0.4-decrypted\global-metadata.dat
MD5=96acbd8bea3fdca064314b0c5313a251