更新日志
2026-06-24OurNotes 内测初探????????忘了20261003解密一把梭
内测
初探
很常规的两个路径:
apk\lib\arm64-v8a\libil2cpp.so
.apk\assets\bin\Data\Managed\Metadata\global-metadata.dat 但有加密。
魔数头被抹掉,可以看到大量的 0x66,有明显的统计特征,0x00 占比 ,0x66 占比 ,0x99 占比

直接怀疑是固定密钥 xor 或 sub,进一步猜测是纯 xor,但部分区段是不符合明显 0x66 的特征,猜测是按区块的异或解密。
此外,0x1000-0x4FFF 区间的统计熵显著变大。

推测这段有单独的 Key 或 AES 等加密算法。
安装
需指定安装来源安装:
adb install -r -d -g -i com.android.vending "アワーノーツ.apk"否则会被 Google Play 劫持。
安装完毕:

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 的魔改,剩下的等有需要的时候再进行研究了。
抓取日志
adb logcat -s UnitySF
内测已经过了,忘记预先抓包一下初始个人数据模板,要做的话也基本是盲测了,因此此处只做一次尝试。
时间校验
由于内测时间结束,且游戏本身有时间校验,虽然可以通过修改系统时间绕过,但还是最好解决一下时间校验的问题。

定位时间校验相关逻辑
// 0x73A64ACvoid *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, #0il2cpp:00000000073A64B0 C0 03 5F D6 RET重新打包,签名并安装即可
api 地址
一共两个主要的地址
https://api-cbt.bang-dream-on.jphttps://static-cbt.bang-dream-on.jp前者是业务 API,后者应该是资源站的地址。这里只改前者。
xor 0x66 后填回 global-metadata.dat,这里 metamiku 直接配了公网服务器、SSL 证书和等长的域名,因此也不涉及什么 padding。
这里应该没人不会吧(?)不会可以看 metamiku 另一篇博客。
可行性验证
根据 PrivateSekai 总结的 skill,让 AI 分析并搓了一个小 demo(gpt 编造了好些资源,导致进度有点拖延了)
稍后会将代码整理发一下。

持续开发
基本交给 Sol 干了,纯 vibe 真的很爽。仓库地址不方便贴,就麻烦自己翻吧。
正式研究
1.0.1-25 metadata
20260922 记,astra 十分钟秒了。
恢复流程
整体恢复顺序如下,后文再展开各处的逆向分析:
- 获取用于解密
libil2cpp.so的 seed。 - 使用 seed,按区域配置解密
libil2cpp.so。 - 分析解密后的
libil2cpp.so,结合libanort.so追踪global-metadata.dat的加载与解密逻辑,并解密 metadata。 - 将解密后的
libil2cpp.so和global-metadata.dat一起交给适配后的 Il2CppDumper 导出。
seed
本版本用于解密 libil2cpp.so 的 seed 为 0xba981955,计算过程暂不展开。它与后文 metadata 解密使用的 0xD96603C0 是两个不同的值。
观察
首先对齐一下版本:
MD5(libil2cpp.so) = 909329A83B267FF5583BF9EB693CD485MD5(libanogs.so) = 4D9AE055F02945A4E4EDF4D82A08D665MD5(libanort.so) = 91958F8DF1C00A00804103CF278EC1CDMD5(global-metadata.dat) = C3154D03AB0746592E88BE9D5ACBF498先观察 global-metadata.dat,和内测时很像,0x00、0x66 有明显的统计特征,部分区域的统计熵较高。直觉上仍然考虑分区 xor 0x66,高熵部分可能是 AES,后面再验证。
单看分布只能给出猜测,具体算法还是要找到加载和解密的位置。这次尝试静态提取,不依赖运行时 dump。
Java 层
安装包里除了常见的 libil2cpp.so,还有两个神秘文件:libanogs.so 和 libanort.so。
在 Java 层找到对应的加载入口:
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:

双击进去,地址是 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 起点:0x4D833300x1CBCFC 长度:0x74A37C0x1CBD14 模式:1sub_AF8C0 读取配置,在 0xB0820 调用 sub_13FEE0,模式 1 继续到 sub_13E57C。
这条记录的处理方式是:
- 跳过
0x2000 - (起点 & 0xFFF)字节,得到0x4D85000。 - 从这里开始,前
0x4000字节分为八个0x800字节块,进行 AES 处理。 - 从
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 作用于不同的子区间。
这里的箭头表示 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, X00x2001000 + 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 = d96603c0d96603c0IV = 02030405060708090a0b0c0d0e0f1011IV 位于 libanort.so:0x190060。0x13E448 将 0x4000 字节拆成八块,后续经 0x13E37C、0x13F25C 做 AES-128-CBC,每块重新设置密钥和 IV,无填充。
整理得到文件解密规则,下面均为文件偏移:
[8, 0x1000):xor0x66。[0x1000, 0x5000):AES-128-CBC,使用上述密钥和 IV,每0x800字节重置一次。- 从
0x11000开始,每个完整0x10000字节块的前0x4000字节 xor0x66。 - 最后不足
0x10000字节的尾段全部 xor0x66,这里不要漏掉。 - 按加载器行为清零
[0,4)和[5,8),保留偏移 4 的0x18。
魔术头暂时不补,下面记录的是未补头版本的 MD5:
MD5(global-metadata.dat.decrypt) = 1189B97CA265D647FFAB1B0C2266FBCC文件解密和恢复标准 metadata 布局是两步,后面再处理 dumper 的适配。
Il2cppDumper
将上述解密后的 libil2cpp.so 和 global-metadata.dat 作为输入,使用适配后的 Il2CppDumper 导出。
BepInEx
虽然 libil2cpp.so 还没造好,但是 BepInEx 版本已经装配好了,这个暂时就不发了,参考 Project Sekai 逆向笔记 或者 email 我。
nnnotes
MetaSekaiLab/nnnotes 是用于处理 OurNotes 游戏文件的工具包,支持浏览 Addressables catalog、下载并解密资源包,以及下载和解码 masterdata。还可以导出剧情、Live2D 模型、据点场景、CRI 音频和谱面,并为 ournotes-player 生成网页展示所需的数据。
使用时需自行配置游戏文件、密钥和服务器地址,具体安装与使用方式见仓库 README。
下面是 nnnotes 的 demo 版
import argparseimport hashlibimport structfrom html import escapefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import Pathfrom urllib.parse import quote, unquote, urlsplitfrom 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-25bdon-b-1.0.2bdon-jp-1.0.4#!/usr/bin/env python3"""OurNotes 同构 APK 一把梭:python decrypt_apk.py input.apk [-o output]。 by MetaMiku"""import argparseimport hashlibimport ioimport structimport zipfileimport zlibfrom pathlib import Path
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIANfrom Crypto.Cipher import AESfrom elftools.elf.elffile import ELFFile
IV_BYTES = bytes(range(2, 18))META_MAGIC = 0x12724394TABLE_STRIDE = 80SELECT = {'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.apklibanort 摘要分量:0xa31d8a46libanogs 摘要分量:0xea8ac46d状态基值:0xf30f577e;IV:02030405060708090a0b0c0d0e0f1011代码 seed:0xba981955;XOR:0x98;AES ASCII:ba981955ba981955metadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0解密区域:.rodata RVA=0x1ffa260 size=0xd37574解密区域:.text RVA=0x4d83330 size=0x74a37c解密区域:il2cpp RVA=0x54cd6ac size=0x6920d88metadata:34693 类型,234093 方法,201 程序集bdon-b-1.0.1-25-decrypted\libil2cpp.so MD5=fa0b42f2f6484a0078f6a7dfd9091550bdon-b-1.0.1-25-decrypted\global-metadata.dat MD5=1189b97ca265d647ffab1b0c2266fbcc$ python tools/decrypt_apk.py ./bdon-b-1.0.2.apklibanort 摘要分量:0xe4e19465libanogs 摘要分量:0xea8ac46d状态基值:0x24801923;IV:02030405060708090a0b0c0d0e0f1011代码 seed:0x2aeb492b;XOR:0xeb;AES ASCII:2aeb492b2aeb492bmetadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0解密区域:.rodata RVA=0x2066530 size=0xd6d79c解密区域:.text RVA=0x4ebaed0 size=0x773d7c解密区域:il2cpp RVA=0x562ec4c size=0x6b09670metadata:34887 类型,236400 方法,201 程序集bdon-b-1.0.2-decrypted\libil2cpp.so MD5=64a5d985b423a39c185be9fa3a950862bdon-b-1.0.2-decrypted\global-metadata.dat MD5=981bfc954570853a81c5ade3552ef095$ python tools/decrypt_apk.py ./bdon-jp-1.0.4.apklibanort 摘要分量:0x6a3ccc44libanogs 摘要分量:0xea8ac46d状态基值:0x24801923;IV:02030405060708090a0b0c0d0e0f1011代码 seed:0xa436110a;XOR:0x36;AES ASCII:a436110aa436110ametadata seed:0xd96603c0;XOR:0x66;AES ASCII:d96603c0d96603c0解密区域:.rodata RVA=0x230ea70 size=0xd1433c解密区域:.text RVA=0x4cc6430 size=0x7194cc解密区域:il2cpp RVA=0x53df8fc size=0x62b6218metadata:34645 类型,220237 方法,218 程序集bdon-jp-1.0.4-decrypted\libil2cpp.so MD5=6ee47629173c3a080e4a79f96d07c3f0bdon-jp-1.0.4-decrypted\global-metadata.dat MD5=96acbd8bea3fdca064314b0c5313a251