Android-Flutter逆向原理及实战

文章原理部分参考:Android-Flutter逆向 | LLeaves Blog

最近实战逆向一些APP发现都有用到flutter,因此学习下flutter逆向,本文预计包括flutter介绍,flutter例题,flutter实战逆向几部分

Flutter介绍

Flutter 是由 Google 推出的一个跨平台 UI 开发框架,最早在 2017 年发布。它允许开发者使用一套代码同时构建Android、iOS、Web、Windows、macOS、Linux 等多个平台的应用。Flutter 的核心语言是 Dart

从逆向角度看,Flutter 与传统 Android 应用(Java/Kotlin + XML UI)在结构上差异非常大,因此分析方法也不同。

Flutter的基本架构

Flutter 的架构大致分为三层:

Framework(Dart 层):这是开发者主要编写代码的地方,全部使用 Dart

Flutter框架层,开发者可以通过 Flutter框架层 与 Flutter 交互,该框架提供了以 Dart 语言编写的现代响应式框架。Flutter框架相对较小,因为一些开发者可能会使用到的更高层级的功能已经被拆分到不同的软件包中,使用 Dart和 Flutter的核心库实现

主要包含:

  • Widget 层:Flutter 的 UI 构建方式
  • Rendering 层:布局与绘制逻辑
  • Animation / Gesture:动画与手势系统

Flutter 的 UI 不是 XML,而是通过 Widget Tree 构建,例如:

1
2
3
4
5
MaterialApp
└── Scaffold
└── Column
├── Text
└── Button

这些 Widget 最终会被转换为 RenderObject 并交给底层绘制。

Engine(C++ 层)

Flutter引擎是一个用于高质量跨平台应用的可移植运行时,由C/C++编写。它实现了Flutter的核心库,包括动画和图形、文件和网络I/O、辅助功能支持、插件架构,以及用于开发、编译和运行Flutter应用程序的Dart运行时和工具链。引擎将底层C++代码包装成 Dart代码,通过dart:ui暴露给 Flutter框架层。

主要组件:

  • Dart VM
  • Skia 图形引擎
  • 文本布局
  • GPU 渲染
  • 平台通道(Platform Channel)

Flutter 的 UI 不是使用 Android 原生控件,而是:

1
2
3
4
5
6
7
Dart Widget

RenderObject

Skia

GPU 绘制

因此 Flutter UI 全部是自己绘制的

这也是为什么:

  • uiautomator 很难识别控件
  • Android Layout Inspector 基本无效

Embedder

Flutter可以通过一套代码在多个平台使用依靠着嵌入层,嵌入层采用了适合当前平台的语言编写,例如 Android使用的是 Java和 C++, iOS 和 macOS 使用的是 Objective-C 和 Objective-C++,Windows 和 Linux 使用的是 C++。嵌入层提供一个程序入口,程序由此可以与底层操作系统进行协调。

mbedder 是平台适配层,例如 Android 上:

1
2
3
FlutterActivity
FlutterView
FlutterJNI

Flutter Engine 会通过 Embedder 与系统交互,例如:

  • Surface / OpenGL / Vulkan
  • 输入事件
  • 生命周期
  • Platform Channel

Flutter architectural overview

Flutter APK的典型结构

Flutter architectural overview

Flutter Android APK 通常包含几个关键文件:

1
2
3
4
5
6
7
8
9
lib/
└── arm64-v8a/
├── libflutter.so
└── libapp.so
assets/
└── flutter_assets/
├── AssetManifest.json
├── FontManifest.json
└── kernel_blob.bin (debug)

最关键的是libflutter.so是Flutter 引擎

包含:Dart VM,Skia,Runtime

一般 所有 Flutter APP 共用同一套 engine

libapp.so是 应用逻辑的 AOT 编译结果

release 模式下:

1
Dart → AOT → native code → libapp.so

因此:

  • 没有 Dart 源码
  • 没有 Java 逻辑
  • 业务代码在 libapp.so

Flutter 逆向的核心就是分析 libapp.so

flutter_assets有资源文件

Flutter编译模式

Dart AOT

Release Flutter APP 会把 Dart 编译成 native code

1
Dart → Kernel → AOT → ARM64

因此没有源码,函数名被去掉,类型信息消失

Flutter使用Dart作为应用程序开发编程语言,因此Flutter的编译模式与Dart的编译模式相关。下面这张表总结了Dart的编译模式。

image-20260305215018996

类型 含义
Script 最常见的JIT模式。就像Node.js一样,可以通过Dart VM命令行工具直接执行Dart源代码
Script Snapshot Dart 源码的运行时快照,JIT模式。与Script模式不同,Script Snapshot会将源代码打包成代码的Token形式,这可以节省了在编译时词法分析器所花费的时间。
Application Snapshot 完整应用快照,JIT模式。Dart的Application Snapshot有点像运行时的转储。它包含了从源代码解析的类和函数,所以运行时可以更快地进行加载和执行。但是这种快照与架构相关,在IA_32上生成的快照无法在X64平台上运行。
AOT 提前编译为机器码,在这种模式下,Dart源代码会被翻译成汇编文件,然后汇编文件由汇编器为不同架构编译成二进制代码。

对于Flutter,其在上述编译模式的基础上进行了调整

  • ScriptScript Snapshot:与Dart的模式一样,但Flutter从未使用过。

  • Kernel Snapshot:对应用代码进行中间字节码(Dart kernel格式)快照。通过避免Dart代码重新编译来实现移动端的快速启动,类似于Java字节码与JVM,核心快照是不依赖于体系架构的。

  • Core JITDart编译代码的一种二进制格式。程序数据和指令打包成特定的二进制格式,供 Dart运行时加载。实际上该模式 是一种 AOT 模式。

  • AOT Assembly:即Dart的AOT模式,完全AOT预编译的本地代码。

Dispatch Dynamically(是否支持动态调用)

这一列是逆向时很重要的一点

类型 是否支持动态分发
Script True
Snapshot True
AOT False

动态分发是什么意思

例如 Dart:

1
2
dynamic obj;
obj.foo();

如果是 JIT:运行时可以决定调用哪个函数。

但 AOT 编译后:

1
call 0x123456

函数地址已经固定。

因此:AOT 会失去很多动态信息。

Flutter release APK 使用 AOT 的原因:

  1. 启动更快
  2. 不需要 JIT 编译器
  3. 安全策略(iOS 不允许 JIT)

在开发阶段,开发Android App时,为了实现热重载技术加速UI的开发,Flutter在这个阶段使用Kernel Snapshot 模式,即核心快照模式。在编译生成的app-deug.apk 中的资源目录下存在isolate_snapshot_data vm_snapshot_data 以及kernel_blob.bin ,前两个文件分别用于加速isolate启动,加速dart_vm启动,最后一个文件为业务代码的字节码。在lib目录中还存在libflutter.so,即flutter动态链接库,与实际业务代码无关。

Flutter逆向

com from LLeaves

使用readelf -s命令读取保存快照信息的libapp.so将会输出下面的内容

1
2
3
4
5
6
7
8
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 000000000014c000 29728 OBJECT GLOBAL DEFAULT 7 _kDartVmSnapshotInstructi
2: 0000000000153440 0x22bd30 OBJECT GLOBAL DEFAULT 7 _kDartIsolateSnapshotInst
3: 0000000000000200 15248 OBJECT GLOBAL DEFAULT 2 _kDartVmSnapshotData
4: 0000000000003dc0 0x147af0 OBJECT GLOBAL DEFAULT 2 _kDartIsolateSnapshotData
5: 00000000000001c8 32 OBJECT GLOBAL DEFAULT 1 _kDartSnapshotBuildId

_kDartVmSnapshotData: 代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地启动 Dart isolate,但不包含任何 isolate 专属的信息。

Isolate 是 Dart 运行时中的 基本执行单元。可以把它理解为一个完全隔离的运行环境,类似轻量级进程,而不是传统意义上的线程。

在 Dart 设计中:

  • 每个 isolate 拥有自己的内存(heap)
  • 每个 isolate 有独立的全局变量
  • 每个 isolate 有自己的事件循环
  • isolate 之间 不能直接共享对象

因此 Dart 的并发模型不是传统的 共享内存 + 锁,而是 隔离 + 消息传递

Dart 为了避免这些问题,采用了 Actor Model 的思想。

也就是:

1
2
3
4
Isolate A        Isolate B
heap A heap B
| |
---- message ----

特点:

  • 没有共享内存
  • 只能 通过消息通信

Flutter 里为什么几乎只有一个 Isolate

在 Flutter 应用中通常结构是:

1
2
3
VM isolate

main isolate (Flutter UI / business logic)

VM isolate:Dart VM 内部使用的管理 isolate。

main isolate:Flutter 应用真正执行代码的 isolate。

因为 Flutter UI 需要:

  • 单线程渲染
  • 事件循环
  • 顺序执行

所以 Flutter 默认只使用一个业务 isolate

如果需要后台任务,才会手动创建 isolate。

_kDartVmSnapshotInstructions:包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。

_kDartIsolateSnapshotData:代表 Dart 堆的初始状态,并包含 isolate 专属的信息。

_kDartIsolateSnapshotInstructions:包含由 Dart isolate 执行的 AOT 代码。

其中_kDartIsolateSnapshotInstructions 是最为重要的,因为包含了所有要执行的AOT代码,即业务相关的代码。

Dart VM中所有的代码都运行在一些isolate内,isolate可以看作是一个隔离的Dart执行环境,有自己的全局状态和通常自己的执行线程(mutator线程)。isolate被组织成isolate group ,同一个组内的isolate共享同一个垃圾回收堆,用于存储该isolate组分配的对象。

在Flutter 中,不会使用多个isolate,除了始终存在的 VM isolate之外,只使用一个isolate

image-20260305220741159

Isolate中维护了堆栈变量,函数调用栈帧,用于GC、JIT等辅助任务的子线程等, 而这里的堆栈变量就是要被序列化到磁盘上的东西,即IsolateSnapshot。此外像dart预置的全局对象,比如null,true,false等等等是由VMIsolate管理的,这些东西需序列化后即VmSnapshot。

最初快照不包括机器代码,但是后来在开发AOT编译器时添加了此功能。开发 AOT 编译器和带代码的快照的动机是允许在由于平台级别限制而无法进行 JIT 的平台上使用 VM。带代码的快照的工作方式与普通快照几乎相同,但略有不同:它们包含一个代码部分,与快照的其余部分不同,它不需要反序列化。此代码段的铺设方式允许它在映射到内存后直接成为堆的一部分。dart源码中runtime/vm/app_snapshot.cc处理快照的序列化和反序列化

在 Dart VM 里,运行一个 Dart 程序至少需要两类东西:

A. 对象堆的初始状态也就是一堆 VM 需要的对象,类对象、字段描述、字符串、常量、ICData/metadata、以及应用里用到的各种对象图等。

  • VM 级共享:跨 isolate 共用的那部分 → VmSnapshotData
  • 某个 isolate 专属:应用相关的那部分 → IsolateSnapshotData

B. 代码的初始状态

最初快照确实主要是 data(对象堆),机器码不在快照里;后来为了 AOT,快照加入了代码段,对应:

  • VmSnapshotInstructions:VM 共享的 stub / 通用例程(一般很小)
  • IsolateSnapshotInstructions:应用 AOT 产生的机器码(业务逻辑的主体)

为什么带代码的快照可以不反序列化代码段

code section 与快照其余部分不同,它不需要反序列化;映射到内存后直接成为堆的一部分。

直观理解:

  • Data 部分:本质是对象图。里面有大量指针/引用关系,保存到磁盘时通常会变成 偏移/索引,加载时需要 重建对象、修复指针、补全引用 ——这就是反序列化的工作。
  • Code 部分(AOT 指令):本质是一段 已经可执行的机器码。只要它的布局设计成“加载地址无关”或“可重定位/可打补丁”,就可以通过 mmap 映射后直接执行,不必像对象那样重建。

data 需要反序列化;instructions 更像一个可执行的镜像段,加载后做少量修补即可

(逆向上就会看到:_kDartIsolateSnapshotInstructions 指向一块很大的 .text/ROX 类似区域,而 _kDartIsolateSnapshotData 指向更像 blob 的数据段。)

app_snapshot.cc 在做什么(序列化/反序列化的核心流程)

runtime/vm/app_snapshot.cc(以及它相关的几个文件)大体在做三件事:

1 生成快照(Serialize / Write)

目标:把启动一个 isolate 所需的对象堆 +(可选)AOT 代码打包成两个 blob(data + instructions)。

典型步骤可以抽象为:

  1. 遍历对象图从一组根出发(例如 isolate 的 object store、class table、常量池等),把所有可达对象标记/收集。
  2. 给对象分配快照内的编号/偏移因为写到磁盘不能直接写内存指针,所以会把引用改写成 指向某个已写对象的ID/offset
  3. 写出对象内容对不同类型对象有不同编码方式(小整数、字符串、数组、Class、Function、Field…),并记录必要的元信息。
  4. (AOT 情况)写出代码段把 AOT 产生的 Code/指令区域,按照 VM 约定的布局写入 instructions blob(或直接引用已生成的段)。

可以把它理解为:一次把堆拍扁成二进制的过程

2 加载快照(Deserialize / Read)

目标:把磁盘上的 blob 恢复成内存中可用的对象与代码,使 isolate 进入“可运行状态”。

典型步骤抽象为:

  1. 读取 header / 校验 BuildId: _kDartSnapshotBuildId 就是为了校验 快照与 VM 版本/编译参数匹配,不匹配直接拒绝加载。
  2. 反序列化 data(重建对象堆)
    • 分配内存空间
    • 按顺序创建对象实例
    • 把“对象引用ID/offset”修复成真实指针
    • 初始化 isolate 的 object store / class table 等
  3. 装载 instructions(代码段)
    • 这块通常不会“逐对象反序列化”
    • 更接近:把 code blob 映射到内存(mmap/拷贝)后,做必要的 重定位/patch
  4. 把 data 里的 Code 对象与 instructions 段关联起来:对象堆里会有 Code 对象,它需要指向实际的机器码入口地址,这个阶段会把地址填上。

最终效果是:

  • 反序列化后:Dart 的类、函数、字符串等都在堆里了
  • 代码段映射后:函数入口能跳到实际机器码了
  • VM 可以直接从 main / entrypoint 开始跑

3 VM Snapshot vs Isolate Snapshot的分工

app_snapshot.cc 处理的是应用快照相关(更偏 isolate/app),但它会遵循整体模型:

  • VM snapshot:负责共享区(基础对象、stub 等)
  • Isolate snapshot:负责应用区(业务类/函数/常量 + 大量 AOT code)

一般情况下要想获取更多关于业务代码相关的信息,可以:

静态解析ibapp.so,即写一个解析器,将libapp.so中的快照数据按照其既定格式进行解析,获取业务代码的类的各种信息,包括类的名称、其中方法的偏移等数据,从而辅助逆向工作。(Blutter)

动态编译修改过的ibflutter.so并且重新打包到APK中,在启动APP的过程中,由修改过的引擎动态链接库将快照数据获取并且保存。

Impact-I/reFlutter: Flutter Reverse Engineering Framework (github.com)

第二种方法详细请参考Android-Flutter逆向 | LLeaves Blog

现在主要静态可以使用Blutter比较方便,(注意我用的是kali 2022) 其他可能会出现对gcc版本的要求的问题,很难处理

WMCTF VNCTF 2023 BabyAnti

WMCTF2023 VNCTF2023 BabyAnti 分析 | Matriy’s blog

WMCTF2025 Want2BecomeMagicalGirl

WMCTF2025 Want2BecomeMagicalGirl复现 | Matriy’s blog

Flutter 抓包

这里选用reqable

关掉系统代理(只抓手机)

手机扫码连接,注意关闭电脑防火墙

image-20260316221823857

flutter抓包和普通APK抓包区别

如果只是安装了系统证书(system CA)就能解密大部分 Flutter 请求,其实这是完全正常的情况,也说明App并没有严格的 SSL pinning。关键点在于 Flutter 的证书信任模型。

Flutter App 可能混合使用:

网络来源 TLS来源
Flutter HttpClient BoringSSL
WebView Android TLS
第三方SDK OkHttp

很多 App 不是全局 pinning,而是只对关键接口 pinning

例如:

1
2
api.payment.com
api.login.com

其他接口:

1
2
3
cdn
analytics
config

不做 pinning。

Flutter App 抓包和普通 Android APK 抓包最大的区别在于TLS/证书校验实现的位置不同,因此会出现有些请求能看到响应、有些 SSL handshake 失败的情况。核心原因通常是Flutter 自带 TLS 栈 + 证书校验策略不同。下面我详细解释

普通 Android App(Java/Kotlin)通常使用:

  • HttpURLConnection
  • OkHttp
  • Volley
  • Retrofit

这些库底层调用 Android 系统 TLS (Conscrypt / BoringSSL)。

因此:

  1. 安装抓包证书(Reqable / Charles / Burp)
  2. 系统信任该 CA
  3. HTTPS MITM 成功
  4. 可以看到明文 HTTP
1
APP->Android TLS (系统) - > 信任用户CA-->代理工具MITM -->抓包成功

Flutter不是走 Android 系统 TLS。

Flutter 使用的是 Dart VM 内置的 HttpClient,底层是BoringSSL,并且很多版本:

  • 不信任系统用户证书
  • 或者使用自带证书验证逻辑

Flutter网络路径:

1
Flutter App -> Dart HttpClient --> Flutter BoringSSL  -> 证书验证

因此即使安装了抓包 CA Flutter也不信任,就会出现SSL Handshake failed

为什么会出现部分请求成功

可能结构是:

1
2
3
4
5
Flutter App
├─ Dart HttpClient ❌ handshake failed
├─ WebView ✔ 能抓
├─ OkHttp Plugin ✔ 能抓
└─ Analytics SDK ✔ 能抓

Flutter 抓包的常见解决方案

方法1:Frida Hook

Hook Flutter TLS 验证函数:

常见 hook 点:

1
2
3
ssl_verify_peer_cert
SSL_CTX_set_custom_verify
X509_verify_cert

或者 Dart 层:

1
badCertificateCallback

常用脚本:

1
flutter_ssl_bypass.js

方法2:patch libflutter.so

直接:

1
patch libflutter.so

修改:

1
ssl_verify_peer_cert

返回成功。

方法3:使用专门工具

例如:

  • objection
  • frida-flutter
  • reFlutter

Flutter逆向实战-某豆APP

分析的版本是5.5.3 当前已更新5.5.6 每个版本的包名什么的都不一样,因此分析还挺麻烦的,如果版本变了,大家看个思想就行了换汤不换药.

sign分析前言

为什么要分析signature?

一、判断 APK 是否被重新打包(防二次打包)

Android APK 在安装时必须经过 开发者私钥签名

如果别人反编译 → 修改 → 重新打包,那么:

  • 新 APK 必须重新签名
  • 签名 一定会变化

因此很多应用会在运行时做类似检查:

1
2
3
4
获取当前应用的 signature
计算 hash
与原始签名 hash 比较
不一致 → 退出 / 崩溃 / 功能失效

常见代码逻辑:

1
2
3
PackageManager pm = getPackageManager();
PackageInfo pi = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] sigs = pi.signatures;

然后:

1
2
SHA1(signature)
MD5(signature)

再和硬编码的值比较。

逆向者如果想修改 APK,就必须:

  • patch 掉 signature check
  • 或伪造 signature 返回值

二、用于服务端认证

很多 APP 会把 签名 hash 作为客户端身份的一部分。

例如请求接口时:

1
2
3
4
5
device_id
app_version
signature_hash
timestamp
sign

服务器会检查:

1
signature_hash == 官方签名

如果修改了 APK:

  • 重新签名
  • signature_hash 不一样

服务器就能识别出非官方客户端。

三、Android 的权限模型依赖 signature

Android 有一种权限叫:

1
signature permission

只有 相同签名的 APP 才能互相访问。

比如:

1
android:protectionLevel="signature"

一些系统 APP / SDK / 插件框架 会利用这一点。

如果想:

  • 注入模块
  • 调用隐藏 API
  • 访问内部服务

就必须伪造或绕过 signature 校验。

四、很多安全壳也依赖 signature

很多壳会做类似检查:

1
2
3
1. 检查 APK signature
2. 检查 classes.dex hash
3. 检查 so hash

signature 是最简单的一层

sign分析

可以看到被混淆了

image-20260317102850111

/user/info

image-20260317104248732

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
POST /api/app/user/info HTTP/1.1
user-agent: DevID%3D064c950bff95f53e%3BDevType%3Dmars%3A33%3BSysType%3Dandroid%3BVer%3D5.5.3%3BBuildID%3Dcom.qvBOX.mDXNd
x-api-key: timestamp=1773747931;sign=d2c71fec29273112e75d5efaee15eb0ca63b902b;nonce=745c3161-ac7c-4008-a890-ee0dd7511cba
accept-encoding: *
content-length: 2
device: android
host: d1qswax7phcx3d.cloudfront.net
authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzU4MjEwNjQsImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.v1iFUPrmexldfDO1sXbNMSy0EEO8NqAOySnCEa8Laj8
api_version: 1.0.0
content-type: application/json;charset=UTF-8

{}

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 17 Mar 2026 11:45:31 GMT
Access-Control-Expose-Headers: Content-Disposition,Refresh-Authorization
Cache-Control: no-store
Access-Control-Allow-Origin: *
X-Cache: Miss from cloudfront
Via: 1.1 823755aa003df89e20018474a1133e26.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: MNL51-P2
X-Amz-Cf-Id: zigSX1EclwXfwx-x3ayvydN3uIDUClnoGmX3Fj-EplG6DUbvxceYPg==

{"code":200,"data":"xA3WfqE/8NmcL6bNWhATTej6L/JLIwyO+JkbQSnkvPy+x4VLEPI0NUGbybW4gC2U/2RyjpWZNUayZZzSDWxmX+cuToq21OoPZmdE5V7M/fKRoIdB00kGt87m4boq3ALFYSR4sBJmQd3DSWPZsl5ilmANRl2TpgtkkEJNAKFNDRroVj3k5zmfor7atA91Ko/pgix++F2CovlfDqbCm1Uq21B4DKlaCdjInUskUgBYdSPkuMhS9/rCoIxkwHrSZ31cQP/2cER0D/AWWb7PRmrm6Eg0IgJPaFR81yio/2tIs3EPpoWSAwFjlrehQ09LRn7JijoGqYp3cscTQT/mBnbE+kRW6FN2o6EtYqld7eIrWXvwluRWKRoAAriqvW2oN3qNiG29YOmvqTdTiVOj33V9l2/jgxY3m/SYHlPCA6my0dAPmS0mwlBdN3YtZg5Ul34+rLeQyQx5ClP/yRiqFfJcMcaFZntPs2TQrkRj5zeyDVraFocA0li1vEzu5DyxJ9jzmlBq0mSR5IrzI6UlQecz4puRarZHHHRWLh2Ullse8dYu1LmaISNi7hNY9mc2SLvoc6Ai1BbWtWUFNp4P7Hg4AXZhw6yM6uCWwFhCFvDzDOSnLcsXamA0H/HFwn3lQBX8nVFAcKk2m+Y6yg37Ga3QIBSJgPd9Vr5im9QmYT0IkGRQO7Yx0sMMHVrnH6bNZ3gVHKgLDKcfowNVRWR4C2EAQzGWOu/baX90AKw25XR0nZ/ffS+Jn7lX25zOMpOocZ/+a4fnOEXf5OPryQipoReUvl4ayrRTtIKJ/mxFZ+oHa0YbPpUBeTGO6ByxzYsNw==","hash":true,"msg":"success","time":"2026-03-17T11:45:31.316Z","tip":"成功"}

编辑了部分data信息 不能直接复制

image-20260317104326495

1
2
3
4
5
6
7
D:\Matriy\Desktop\VN\output>findstr /s /i /n "timestamp=" *.*
ida_script\addNames.py:72832: ida_struct.set_member_cmt(ida_struct.get_member(struc, 252248), '''String: "timestamp="''', True)
pp.txt:44660:[pp+0x3d958] String: "timestamp="

D:\Matriy\Desktop\VN\output>findstr /s /i /n ";nonce=" *.*
ida_script\addNames.py:72830: ida_struct.set_member_cmt(ida_struct.get_member(struc, 252264), '''String: ";nonce="''', True)
pp.txt:44662:[pp+0x3d968] String: ";nonce="

可以看到这些字符串,有了 pp 偏移但没用,还没把它转换成加载这个 pp 槽位的指令模式(IDA中的数据是无法交叉引用的)

我是按抓包定输入 -> pp.txt 定对象池簇 -> 请求拦截器定调用点 -> 反汇编闭包定字段顺序 -> 本地回代验证这条链,把 sign 算法收出来的。

x-api-key是 timestamp=…;sign=…;nonce=…

我们按上面可以搜一下,在flutter里字符串不会直接在代码里裸引用,而是通过 PP,object pool取。所以交叉引用基本没用,要看 pp.txt。

image-20260317222744915

在pp.txt:44652到pp.txt:44668中

  • x-api-key
  • nonce
  • userAgent
  • kaFtkDJRcchRMTI9
  • timestamp=
  • ;sign=
  • ;nonce=
  • HMAC is closed

这组字符串全部贴在 [kel] mUa::Xrd (0x74ca14) 和 [kel] mUa::ln (0x74c7e4) 周围,把这两个函数当成签名核心,mUa::Xrd / mUa::ln 非常可疑

真正的判断,是靠代码里对 pp+offset 的实际加载确认的,Flutter AOT 里 x27 通常就是 PP。所以看到这种指令

1
2
add x17, x27, #0x3d, lsl #12
ldr x17, [x17, #0x958]

就等价于load [pp+0x3d958]

定位到kel.dart

image-20260318090250717

kel.dart 空,不代表 [kel] 这条链不存在。这份 blutter 导出对 kel 这个库的正文恢复失败了,只留下了一个库壳和几个类型壳

可以根据0x74c7e4看下native层

image-20260318092455473

kel_mUa::ln_74c7e4() 本身不是真正的签名算法体,它是一个 async 外壳。它在函数内部明确创建并调度了一个闭包,而这个闭包在 pp.txt:44655 里已经标成了:[pp+0x3d930] AnonymousClosure: static (0xe14cb4), in [kel] mUa::ln (0x74c7e4)

image-20260318092559305

1
2
3
74c88c  ADD X1, X27, #0x3D,LSL#12
74c890 LDR X1, [X1,#0x930]
74c894 BL AllocateClosureStub_f0111c

这说明 kel_mUa::ln_74c7e4 从对象池加载了 [pp+0x3d930],然后创建了一个 closure。而 pp.txt:44655 明确[pp+0x3d930] 就是 static (0xe14cb4)。

0x74c7e4主要是:

  • 分配 Context

  • 分配 Future

  • 填 async state

  • 创建 closure

  • 启动 async 调度

  • 0x74c7e4 外壳

  • 0xe14cb4 真正闭包体

伪代码里的 v4[n] 对应对象池 [pp+8*n]。所以例如:

  • v4[31527] = [pp+0x3d938] = “nonce”
  • v4[31528] = [pp+0x3d940] = “userAgent”
  • v4[31529] = [pp+0x3d948] = “kaFtkDJRcchRMTI9”
  • v4[31531] = [pp+0x3d958] = “timestamp=”
  • v4[31532] = [pp+0x3d960] = “;sign=”
  • v4[31533] = [pp+0x3d968] = “;nonce=”
  • v4[1148] = [pp+0x23e0] = “path”
  • v4[1353] = [pp+0x2a48] = “timestamp”
  • v4[15095] = [pp+0x1d7b8] = “token”

image-20260318092827041

image-20260318092858113

可以对应到token,然后看 0xe14cb4 的流程:

1. 0xe14d14 调 `dart_core_Map__factory_ctor__fromLiteral`说明它先建了一个 Map。
2. 0xe14e14 到 0xe14e24,加载 [pp+0x3d938] "nonce",然后把 sub_6BB9B8() 生成的值塞进去。这就是 nonce,uuid生成16 字节随机 -> 版本位/variant 位修正 -> 8-4-4-4-12 格式化。
3. 0xe14e44 到 0xe14e50,加载 [pp+0x23e0] "path",值来自调用上下文。这是请求路径。
4. 0xe14da0 到 0xe14dc8 先把时间做 /1000/1000,变成秒级时间戳;0xe14ea0 到 0xe14eac 再加载 [pp+0x2a48] "timestamp" 把它写进 Map。这是 timestamp。
5. 0xe14f08 到 0xe14f10 调 lel_nUa::Gmh_6bcdf8.这是 token getter。后面 0xe14f90 到 0xe14fa4 用 [pp+0x1d7b8] "token" 把它写进 Map。
6. 0xe15038 到 0xe15040 调 lel_nUa::Vmc_6bcb40,这是 user-agent getter。前面 0xe150a8 那段会用 [pp+0x3d940] "userAgent" 把它写进 Map。

后半段:

  1. 0xe150c8 到 0xe150d4,取 [pp+0x3d948] “kaFtkDJRcchRMTI9”,这是签名 key 常量。

  2. 0xe15144 到 0xe1514c 调 sub_74C948,这个函数里有:

    分配 Uint8Array,判断 key 长度是否 > 64,超过就先做一次压缩,这是典型的HMAC key预处理。

  3. 0xe15158 到 0xe15160,调 sub_B78D3C,这是把消息喂进去拿摘要的那一步。

  4. 0xe151a4 到 0xe151ac,调 sub_AF49D0。这一步输出的正是 digest 的 hex 字符串,用它对应到了最后 40 位 hex 的 sign。

  5. 0xe1517c 到 0xe15268,分配一个字符串数组,然后按顺序塞:

    [pp+0x3d958] “timestamp=”

    [pp+0x3d960] “;sign=”

    [pp+0x3d968] “;nonce=”

  6. 0xe15264 到 0xe15268,调 dart_core__StringBase___interpolate_cd7da0,把上面这些片段拼成最终 header 值,timestamp=<ts>;sign=<hex>;nonce=<uuid>

HMAC(Hash-based Message Authentication Code,基于哈希的消息认证码)是一种结合了哈希函数和密钥的加密算法,用于验证消息的完整性和真实性。它通过将消息和一个秘密密钥作为输入,生成一个唯一的哈希值(认证码)。这种方法广泛应用于安全通信协议(如HTTP、SSL、SSH)以及密码存储和会话管理等场景。

Iel Del Sel里都有User-Agent

image-20260318091323834

Iel中还有Api-version

image-20260318091426418

优先看Iel.dart

发现asm/Iel.dart:474 bl #0x74cb7c

0x74cb7c似乎是统一请求拦截器,发送前处理的流程,发包前的统一补头 + 进入签名链

由此可以写出解密代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import json
import uuid
import hmac
import hashlib
from collections import OrderedDict
from urllib.parse import quote

SIGN_KEY = b"kaFtkDJRcchRMTI9"

def build_user_agent(dev_id: str, dev_type: str, sys_type: str, ver: str, build_id: str) -> str:
raw = f"DevID={dev_id};DevType={dev_type};SysType={sys_type};Ver={ver};BuildID={build_id}"
return quote(raw, safe="")


def build_sign(path: str, timestamp: str, nonce: str, token: str, user_agent: str) -> str:
msg = OrderedDict([
("nonce", str(nonce)),
("path", str(path)),
("timestamp", str(timestamp)),
("token", str(token)),
("userAgent", str(user_agent)),
])
data = json.dumps(msg, separators=(",", ":"), ensure_ascii=True)
return hmac.new(SIGN_KEY, data.encode(), hashlib.sha1).hexdigest()


def build_x_api_key(path: str, token: str, user_agent: str, timestamp: str, nonce: str | None = None) -> str:
nonce = nonce or str(uuid.uuid4())
sign = build_sign(path, timestamp, nonce, token, user_agent)
return f"timestamp={timestamp};sign={sign};nonce={nonce}"

if __name__ == "__main__":
path = "/api/app/user/info"
timestamp = "1773747931"
nonce = "745c3161-ac7c-4008-a890-ee0dd7511cba"
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzU4MjEwNjQsImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.v1iFUPrmexldfDO1sXbNMSy0EEO8NqAOySnCEa8Laj8"

user_agent = build_user_agent(
dev_id="064c950bff95f53e",
dev_type="mars:33",
sys_type="android",
ver="5.5.3",
build_id="com.qvBOX.mDXNd",
)

sign = build_sign(path, timestamp, nonce, token, user_agent)
x_api_key = build_x_api_key(path, token, user_agent, timestamp, nonce)

print("user-agent =", user_agent)
print("sign =", sign)
print("x-api-key =", x_api_key)

发包代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import time
import uuid
import json
import hmac
import hashlib
import requests
from collections import OrderedDict
from urllib.parse import quote

BASE_URL = "https://xxx.cloudfront.net"
SIGN_KEY = b"kaFtkDJRcchRMTI9"

def build_user_agent(dev_id: str, dev_type: str, sys_type: str, ver: str, build_id: str) -> str:
raw = f"DevID={dev_id};DevType={dev_type};SysType={sys_type};Ver={ver};BuildID={build_id}"
return quote(raw, safe="")

def build_sign(path: str, timestamp: str, nonce: str, token: str, user_agent: str) -> str:
msg = OrderedDict([
("nonce", nonce),
("path", path),
("timestamp", timestamp),
("token", token),
("userAgent", user_agent),
])
data = json.dumps(msg, separators=(",", ":"), ensure_ascii=True)
return hmac.new(SIGN_KEY, data.encode(), hashlib.sha1).hexdigest()

def build_x_api_key(path: str, token: str, user_agent: str, timestamp: str, nonce: str) -> str:
sign = build_sign(path, timestamp, nonce, token, user_agent)
return f"timestamp={timestamp};sign={sign};nonce={nonce}"

def post_user_info(
token: str,
dev_id: str,
build_id: str,
dev_type: str = "mars:33",
sys_type: str = "android",
ver: str = "5.5.3",
):
path = "/api/app/user/info"
url = BASE_URL + path

timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
user_agent = build_user_agent(dev_id, dev_type, sys_type, ver, build_id)
x_api_key = build_x_api_key(path, token, user_agent, timestamp, nonce)

headers = {
"User-Agent": user_agent,
"x-api-key": x_api_key,
"device": "android",
"authorization": token,
"api_version": "1.0.0",
"content-type": "application/json;charset=UTF-8",
"accept-encoding": "*",
}

body = "{}"
print("[*] URL =", url)
print("[*] UA =", user_agent)
print("[*] x-api-key =", x_api_key)

resp = requests.post(
url,
headers=headers,
data=body,
timeout=20,
)

print("[*] status =", resp.status_code)
print("[*] text =", resp.text)
return resp


if __name__ == "__main__":
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzU4MjEwNjQsImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.v1iFUPrmexldfDO1sXbNMSy0EEO8NqAOySnCEa8Laj8"

post_user_info(
token=token,
dev_id="064c950bff95f53e",
build_id="com.qvBOX.mDXNd",
)

image-20260318103110844

data数据解密

逆了很久,流程是:

1. 外层响应先进入 jel_lUa::async_op_db5328
2. 成功码分支里检查 hash
3. hash == true 时,进入 sub_6F6FF0 -> sub_6F695C -> sub_EE0A50
4. sub_6F6FF0 产出固定 secret:vEukA&w15z4VAD3kAY#fkL#rBnU!WDhN
5. sub_6F695C 做真正的通用解密
6. sub_EE0A50 把明文 JSON 字符串转成 Dart Map
7. 后面才是各接口自己的业务 parser,比如 /user/info 的 parseUser

sub_6F695C 最终还原出的公式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
raw = base64.b64decode(data)
prefix = raw[:12]

seed = secret + prefix
h1 = sha256(seed).digest()
mid = h1[8:24]

h2 = sha256(mid + seed[:22]).digest()
h3 = sha256(seed[22:] + mid).digest()

key = h2[:8] + h3[8:24] + h2[24:]
iv = h3[:4] + h2[12:20] + h3[28:]

ciphertext = raw[12:]
plaintext = AES-256-CBC(key, iv, ciphertext, PKCS7)

hook脚本用的是blutter_frida.js,这个脚本现在只保留了解密分析相关点位:

因为dart和native层实在太屎了,要配合fridahook验证

  • envelope 分支:0xdb5a10 / 0xdb5a90
  • 通用解密:0x6f6ff0 / 0x6f695c
  • 三次 digest:0xb7881c
  • 最终材料:keyReady / cipherSliceReady / ivReady
  • JSON 入口:0xee0a50
  • 业务 sink:parseUser

一开始用的接口data测试是/user/info,一开始只根据这个接口无法单独验证因此我还去抓了其他接口的包

比较惊讶的是居然连frida都不检测,是对自己的产品太自信了嘛 0.0

下面的data其实很长我只截取了一部分,(想试试的后面frida hook部分会有一部分完整的加密数据可以拿去解一下)

catch1.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
POST /api/app/user/info HTTP/1.1
user-agent: DevID%3D064c950bff95f53e%3BDevType%3Dmars%3A33%3BSysType%3Dandroid%3BVer%3D5.5.3%3BBuildID%3Dcom.qvBOX.mDXNd
x-api-key: timestamp=1773747931;sign=d2c71fec29273112e75d5efaee15eb0ca63b902b;nonce=745c3161-ac7c-4008-a890-ee0dd7511cba
accept-encoding: *
content-length: 2
device: android
host: d1qswax7phcx3d.cloudfront.net
authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzU4MjEwNjQsImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.v1iFUPrmexldfDO1sXbNMSy0EEO8NqAOySnCEa8Laj8
api_version: 1.0.0
content-type: application/json;charset=UTF-8

{}

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 17 Mar 2026 11:45:31 GMT
Access-Control-Expose-Headers: Content-Disposition,Refresh-Authorization
Cache-Control: no-store
Access-Control-Allow-Origin: *
X-Cache: Miss from cloudfront
Via: 1.1 823755aa003df89e20018474a1133e26.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: MNL51-P2
X-Amz-Cf-Id: zigSX1EclwXfwx-x3ayvydN3uIDUClnoGmX3Fj-EplG6DUbvxceYPg==

{"code":200,"data":"xAzYsNw==","hash":true,"msg":"success","time":"2026-03-17T11:45:31.316Z","tip":"成功"}

catch2.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /api/app/media/play HTTP/1.1
user-agent: DevID%3D064c950bff95f53e%3BDevType%3Dmars%3A33%3BSysType%3Dandroid%3BVer%3D5.5.3%3BBuildID%3Dcom.qvBOX.mDXNd
x-api-key: timestamp=1773933137;sign=66288cb4c02c423e694bb3211190e728926a44d2;nonce=81a04b56-68fa-4f50-abcc-b3731fccdb77
accept-encoding: *
content-length: 13
device: android
host: nbamsdfkjag.yvacbeg4.com
authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzY1MTc2OTksImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.tptgFpZkSFAlmAjzeG1Q-Q9zqAydggosKaXsJSBdS6Q
api_version: 1.0.0
content-type: application/json;charset=UTF-8

{"id":200968}
{
"code": 200,
"data": "amKXywtTdkb3D/65iTx97gzQa2BqBSv+Wjdx780pXjmzu0sz3E1XFZFr/x0MYpXOi6QyBt8s=",
"hash": true,
"msg": "success",
"time": "2026-03-19T15:12:17.749Z",
"tip": "成功"
}

catch3.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /api/app/media/like HTTP/1.1
user-agent: DevID%3D064c950bff95f53e%3BDevType%3Dmars%3A33%3BSysType%3Dandroid%3BVer%3D5.5.3%3BBuildID%3Dcom.qvBOX.mDXNd
x-api-key: timestamp=1773933137;sign=1b4755fd36640953190fca83c427f0eea979bba1;nonce=eafcd56d-86b8-466c-9c29-cac03a56c665
accept-encoding: *
content-length: 39
device: android
host: nbamsdfkjag.yvacbeg4.com
authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwdWJsaWMiLCJleHAiOjE3NzY1MTc2OTksImlzc3VlciI6ImNvbS5idXR0ZXJmbHkiLCJzdWIiOiJhc2lnbiIsInVzZXJJZCI6MTcwNTM1Mzk4fQ.tptgFpZkSFAlmAjzeG1Q-Q9zqAydggosKaXsJSBdS6Q
api_version: 1.0.0
content-type: application/json;charset=UTF-8

{"id":200968,"pageNum":1,"pageSize":10}
{
"code": 200,
"data": "h4gJ1Sc9IWPinoMB40zZJ1OT/d4zYkMoT7+NBdd4KFk6MfccaxclHIafHga3hZXDFG/YIdHzo5J4XNCSbXBPsDBD3vuqs7lq+6WVYhg/1ODTVFpROTAzr+vlK+ZE6zj7sniZ==",
"hash": true,
"msg": "success",
"time": "2026-03-19T15:12:18.079Z",
"tip": "成功"
}

这种高熵数据base64解了下 好像是16的倍数,可能是AES相关的加密,也可以以此为线索去找

我下面讲一下我怎么分析的,我一开始先定了两个锚点:

外层响应入口:jel_lUa::async_op_db5328。这是因为响应envelope里有 code / data / hash / msg / time 这些字段

/user/info -> sub_67D774 -> UTa,这是为了找一个最终一定会消费解密结果的地方,不能只盯着高熵base64

当时我的思路不是先找 AES,而是响应谁先碰到 data,哪个业务函数最终消费了解密后的结果,中间缺失的那段,就是通用层(在这里搞了很久,静态分析不出来因此结合hook去分析验证)

/user/info最后会进 sub_67D774,Map -> UTa parser,一旦能确认 sub_67D774 的入参是 Map,就能证明在它之前一定存在一层通用 decode

所以我先顺着 eel__bUa::async_op_d71120 去确认/user/info的真实路径。

当时得到的链是:

1
2
3
4
5
6
7
d71120
-> sub_423264
-> loc_67CE9C
-> sub_67CD58
-> VCk__cB::Crd_421200
-> await
-> sub_67D774(field_b)

然后这里保险起见我进行hook了,在sub_67D774之前,有一条很明显的线:sub_423264 -> 67CE9C -> 67CD58 -> 421200 -> … -> field_b

先后hook了67CE9C/67CD58这两个,验证在它们前后,data 是不是已经从高熵字符串变成对象Map了

日志中decodeStageA.enter/leave 和 decodeStageB.enter/leave 里,caller分别落在:

  • /media/v5/home
  • /bulletscreen/list

而且打印出来的是请求配置对象、method、path、header 之类的内容,不是响应data,说明 67CE9C/67CD58 虽然在静态链上看着像通用解密,但动态上它们大量参与的是请求侧包装,不是data解密点。

67CE9C/67CD58 被证伪,可以去hook验证db5328,因为它是:

  • 最早拿到hash
  • 最早拿到raw data,又正好是 envelope success 分支的控制点

也就是说,不管解密发生在哪,db5328 都是最早能看到hash和raw data ,所以把 hook 缩到两个点:

  • 0xdb5a10,看 hash 和 data
  • 0xdb5a90,看最后塞回 wrapper 的东西是什么

日志中:

  • 0xdb5a10:x0=true,x4 是高熵 base64
  • 0xdb5a90:x4 已经是 Dart Map

后面去hook EE0A50,有了上一步,已经知道db5328 里某处raw base64 -> Map但还不知道中间是直接在 db5328 里解完,还是只是调别的 helper

静态上 db5328 里最可疑的三段就是sub_6F6FF0,sub_6F695C,sub_EE0A50

其中 EE0A50 很像字符串解析入口,所以去 hook EE0A50 的目的很明确,如果 EE0A50 的入参是明文 JSON 字符串,那它就是 json.loads,那么真正解密就一定发生在它前面的 6F695C

后面jsonParseFromDBh日志显示,x0 已经是完整明文 JSON。

  • 6F6FF0:产 secret
  • 6F695C:真解密
  • EE0A50:JSON parse

接下来去 hook 6F695C 内部,因为到这个阶段,问题已经从链在哪里收缩成:

  • key/iv 怎么来的
  • 真正密文是哪一段
  • 算法是 AES-CBC/PKCS7 还是别的

所以我在沿着 6F695C 内部结构专门打了这些点:

  • preDecode
  • digestHelper
  • keyReady
  • cipherSliceReady
  • ivReady
  • cipherProcess

顺序

  • 先拿固定 secret
  • 再拿三次 digest
  • 再拿最终 key
  • 再拿最终 iv
  • 再拿送进 cipher 的真实 bytes

后面给回的日志证明了:

  • preDecode.leave 固定返回 secret
  • keyReady 是 32 字节
  • ivReady 是 16 字节
  • cipherSliceReady 才是真正 AES 输入

中间注意dart的Smi 比如数据32,其实是16,忘记了绕了一大圈原理可以自己搜索或者在WMCTF2025里面看看

但在 Dart AOT 里,这些很多是 Smi,不是裸整数。24 实际是 12

这个修正一做,很多东西突然全部对上:

  • prefix 不是 24,而是 12
  • ciphertext 不是 raw[24:-4],而是 raw[12:]

三份包立刻都 block对齐了:

  • 4204 - 12 = 4192
  • 1628 - 12 = 1616
  • 31372 - 12 = 31360

然后我做的是:

  1. 用 live 日志里 data 的前几个base64字符,先还原出前12字节 prefix
  2. 用静态汇编推测digest输入结构
  3. 本地试算,看能不能命中hook出来的 key/iv

第一次试的时候,出现了一个很关键的现象:

  • h3 相关部分全对
  • h2 相关部分不对

这说明:

  • seed = secret + prefix
  • mid = h1[8:24]
  • h3 = sha256(seed[22:] + mid)

这些已经是对的,错的是第二次 digest 的输入。重新回到 6a80 附近看栈传参,才发现之前又少看了一步:

  • 不是直接 sha256(firstHalf(seed))
  • 而是先把 mid 加进 growable
  • 再把 firstHalf(seed) 加进去

所以正确的是h2 = sha256(mid + seed[:22])

hook代码,后面的不用动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
const ShowNullField = false;
const MaxDepth = 5;
var libapp = null;

const HookOffsets = {
parseUser: 0x67d774,
preDecode: 0x6f6ff0,
commonDecode: 0x6f695c,
digestHelper: 0xb7881c,
keyReady: 0x6f6d34,
cipherSliceReady: 0x6f6d9c,
ivReady: 0x6f6dc4,
cipherProcess: 0x499f64,
envelopeHashBranch: 0xdb5a10,
envelopeStore: 0xdb5a90,
jsonParse: 0xee0a50,
};
const LogCounts = Object.create(null);
const ActiveDecodeThreads = new Set();
const DecodeTraceDepth = 3;
const DecodeTraceStringLimit = 192;

function onLibappLoaded() {
logMsg(`libapp base = ${libapp}`);

Interceptor.attach(libapp.add(HookOffsets.envelopeHashBranch), {
onEnter: function () {
logTaggedRegs('envelopeHashBranch', 'envelopeHashBranch', this.context, ['x0', 'x1', 'x4'], 4, 24);
}
});

Interceptor.attach(libapp.add(HookOffsets.envelopeStore), {
onEnter: function () {
logTaggedRegs('envelopeStore', 'envelopeStore', this.context, ['x4'], 4, 24);
}
});

Interceptor.attach(libapp.add(HookOffsets.preDecode), {
onEnter: function () {
if (!callerMatches(this.returnAddress, ['0xdb5a50', '0xdb5c30']))
return;
this.fromEnvelope = true;
logTaggedRegs('preDecode.enter', `preDecode.enter caller=${getLibappOffset(this.returnAddress)}`, this.context, ['x0'], 4, 24);
},
onLeave: function (retval) {
if (!this.fromEnvelope)
return;
logDecodeRet('preDecode.leave', 'preDecode.leave', retval, 24, 4);
}
});

Interceptor.attach(libapp.add(HookOffsets.commonDecode), {
onEnter: function () {
if (!callerMatches(this.returnAddress, ['0xdb5a5c', '0xdb5c3c']))
return;
this.fromEnvelope = true;
this.threadId = getThreadId();
ActiveDecodeThreads.add(this.threadId);
logTaggedRegs('commonDecode.enter', `commonDecode.enter caller=${getLibappOffset(this.returnAddress)}`, this.context, ['x0'], 4, 24);
},
onLeave: function (retval) {
if (!this.fromEnvelope)
return;
if (this.threadId !== undefined)
ActiveDecodeThreads.delete(this.threadId);
logDecodeRet('commonDecode.leave', 'commonDecode.leave', retval, 24, 5);
}
});

Interceptor.attach(libapp.add(HookOffsets.jsonParse), {
onEnter: function () {
if (!callerMatches(this.returnAddress, ['0xdb5a70', '0xdb5c50']))
return;
logTaggedRegs('jsonParseFromDBh', `jsonParseFromDBh caller=${getLibappOffset(this.returnAddress)}`, this.context, ['x0'], 5, 24);
}
});

Interceptor.attach(libapp.add(HookOffsets.digestHelper), {
onEnter: function () {
if (!ActiveDecodeThreads.has(getThreadId()))
return;
const callerOff = getLibappOffset(this.returnAddress);
if (!['0x6f6a28', '0x6f6adc', '0x6f6b58'].includes(callerOff))
return;
this.digestCallerOff = callerOff;
logByteRegs(
`digest.enter:${callerOff}`,
`digest.enter caller=${callerOff}`,
this.context,
['x0'],
4,
12,
64,
);
},
onLeave: function (retval) {
if (this.digestCallerOff === undefined)
return;
logByteRet(
`digest.leave:${this.digestCallerOff}`,
`digest.leave caller=${this.digestCallerOff}`,
retval,
12,
4,
64,
);
}
});

Interceptor.attach(libapp.add(HookOffsets.keyReady), {
onEnter: function () {
if (!ActiveDecodeThreads.has(getThreadId()))
return;
logByteRegs('keyReady', 'keyReady', this.context, ['x0'], 4, 12, 64);
}
});

Interceptor.attach(libapp.add(HookOffsets.cipherSliceReady), {
onEnter: function () {
if (!ActiveDecodeThreads.has(getThreadId()))
return;
logByteRegs('cipherSliceReady', 'cipherSliceReady', this.context, ['x0'], 4, 12, 64);
}
});

Interceptor.attach(libapp.add(HookOffsets.ivReady), {
onEnter: function () {
if (!ActiveDecodeThreads.has(getThreadId()))
return;
logByteRegs('ivReady', 'ivReady', this.context, ['x0'], 4, 12, 64);
}
});

Interceptor.attach(libapp.add(HookOffsets.cipherProcess), {
onEnter: function () {
if (!ActiveDecodeThreads.has(getThreadId()))
return;
const callerOff = getLibappOffset(this.returnAddress);
if (callerOff !== '0x6f6de8')
return;
this.fromCommonDecode = true;
logByteRegs('cipherProcess.enter', `cipherProcess.enter caller=${callerOff}`, this.context, ['x0'], 4, 12, 64);
logDecodeStackArgs('cipherProcess.stack', 'cipherProcess.stack', this.context, this.returnAddress, 3, 3, 12);
},
onLeave: function (retval) {
if (!this.fromCommonDecode)
return;
logByteRet('cipherProcess.leave', 'cipherProcess.leave', retval, 12, 4, 96);
}
});

Interceptor.attach(libapp.add(HookOffsets.parseUser), {
onEnter: function () {
logDecodeStackArgs('parseUser.enter', 'parseUser.enter', this.context, this.returnAddress, 2, 4, 16);
},
onLeave: function (retval) {
logDecodeRet('parseUser.leave', 'parseUser.leave', retval, 16, 3);
}
});

logMsg('decode hooks installed');
}

function tryLoadLibapp() {
try {
libapp = Module.findBaseAddress('libapp.so');
} catch (e) {
if (e instanceof TypeError && e.message === "not a function") {
libapp = Process.findModuleByName('libapp.so');
if (libapp != null) {
libapp = libapp.base;
}
} else {
throw e;
}
}
if (libapp === null)
setTimeout(tryLoadLibapp, 500);
else
onLibappLoaded();
}
const PointerCompressedEnabled = true;
const CompressedWordSize = 4;
const HeapAddressReg = 'x28';
const NullReg = 'x22';
const StackReg = 'x15';

tryLoadLibapp();

告诉AI让它搓一份解密代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
from __future__ import annotations

import argparse
import base64
import json
import re
import sys
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

SECRET = b"vEukA&w15z4VAD3kAY#fkL#rBnU!WDhN"
DATA_RE = re.compile(r'"data"\s*:\s*"([A-Za-z0-9+/=]+)"')

@dataclass
class DecodeAttempt:
raw: bytes
prefix: bytes
ciphertext: bytes
key: bytes
iv: bytes
plaintext: bytes


def extract_data_b64(text: str) -> str:
match = DATA_RE.search(text)
if match is None:
raise ValueError("could not find JSON data field")
return match.group(1)


def load_data_b64(source: str) -> str:
path = Path(source)
if path.exists():
return extract_data_b64(path.read_text())
return extract_data_b64(source)


def aes_cbc_decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes:
decryptor = Cipher(
algorithms.AES(key),
modes.CBC(iv),
backend=default_backend(),
).decryptor()
return decryptor.update(ciphertext) + decryptor.finalize()


def pkcs7_unpad(data: bytes) -> bytes:
if not data:
raise ValueError("empty plaintext")
pad = data[-1]
if pad < 1 or pad > 16:
raise ValueError(f"invalid pkcs7 pad length: {pad}")
if data[-pad:] != bytes([pad]) * pad:
raise ValueError("invalid pkcs7 padding bytes")
return data[:-pad]


def derive_candidate_key_iv(prefix: bytes) -> tuple[bytes, bytes]:
seed = SECRET + prefix
half = len(seed) // 2
h1 = sha256(seed).digest()
mid = h1[8:24]
h2 = sha256(mid + seed[:half]).digest()
h3 = sha256(seed[half:] + mid).digest()
key = h2[:8] + h3[8:24] + h2[24:]
iv = h3[:4] + h2[12:20] + h3[28:]
return key, iv


def attempt_decode(
data_b64: str,
*,
prefix_len: int,
footer_len: int,
key_hex: str | None,
iv_hex: str | None,
) -> DecodeAttempt:
raw = base64.b64decode(data_b64)
prefix = raw[:prefix_len]
ciphertext = raw[prefix_len:] if footer_len == 0 else raw[prefix_len:-footer_len]
if len(ciphertext) % 16 != 0:
raise ValueError(
f"ciphertext length {len(ciphertext)} is not aligned to 16 bytes "
f"(raw={len(raw)} prefix={prefix_len} footer={footer_len})"
)

if key_hex is None or iv_hex is None:
key, iv = derive_candidate_key_iv(prefix)
else:
key = bytes.fromhex(key_hex)
iv = bytes.fromhex(iv_hex)

plaintext = aes_cbc_decrypt(ciphertext, key, iv)
return DecodeAttempt(
raw=raw,
prefix=prefix,
ciphertext=ciphertext,
key=key,
iv=iv,
plaintext=plaintext,
)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", help="catch file path or raw response text")
parser.add_argument("--prefix-len", type=int, default=12)
parser.add_argument("--footer-len", type=int, default=0)
parser.add_argument("--key-hex")
parser.add_argument("--iv-hex")
parser.add_argument("--no-unpad", action="store_true")
parser.add_argument("--raw", action="store_true", help="print plaintext bytes instead of UTF-8 text")
args = parser.parse_args()

try:
data_b64 = load_data_b64(args.source)
attempt = attempt_decode(
data_b64,
prefix_len=args.prefix_len,
footer_len=args.footer_len,
key_hex=args.key_hex,
iv_hex=args.iv_hex,
)
except Exception as exc:
print(f"[!] decode setup failed: {exc}", file=sys.stderr)
return 1

print(f"[*] raw_len={len(attempt.raw)} prefix_len={len(attempt.prefix)} ciphertext_len={len(attempt.ciphertext)}")
print(f"[*] prefix_hex={attempt.prefix.hex()}")
print(f"[*] key_hex={attempt.key.hex()}")
print(f"[*] iv_hex={attempt.iv.hex()}")

body = attempt.plaintext
if not args.no_unpad:
try:
body = pkcs7_unpad(body)
print("[*] pkcs7=ok")
except Exception as exc:
print(f"[!] pkcs7 failed: {exc}", file=sys.stderr)

if args.raw:
sys.stdout.buffer.write(body)
return 0

try:
text = body.decode("utf-8")
except Exception as exc:
print(f"[!] utf8 failed: {exc}", file=sys.stderr)
print(body.hex())
return 2

print(text)
try:
parsed = json.loads(text)
print(f"[*] json=ok keys={list(parsed)[:8]}")
except Exception as exc:
print(f"[!] json failed: {exc}", file=sys.stderr)
return 3

return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260321144209988

frida vip hook

这块比较困难,当时没给data解密(以为搞不出来了),先做的hook,发现单纯hook对象或者实际逻辑加大了逆向的难度,能去看响应体能够更好的辅助逆向

因为有了userinfo解密后的对象,这样hook起来就比较方便了,我起先是hook了vipright,viplevel,viptype,vipexpiretime等用户模型字段,但是发现hook这些虽然改变了用户等级(能够访问vip界面),但是vip视频仍旧无法观看

然后分析了下接口

image-20260321144610446

研究了下大概是这样的,当用户从大厅列表进入视频页后,会发一个post请求给服务端,然后我们这会去GET得到一个视频列表,流媒体格式,感兴趣的话可以去看看推特怎么传输视频的,以m3u8的形式返回一个视频列表,当然这里跟twitter的不同点是这里还进行了加密,虽然很简单

image-20260321145500019

key很简单也是明文的

image-20260321145535929

这里没必要去搞它,如果感兴趣的话也可以研究一下

上面m3u8对应的视频列表会发过来

image-20260321145649766

弄清楚这点后我重点关注之前的/media/play的post请求,到底post了啥,导致发回来这玩意,(这里我没有截断字节,想试试的朋友们)

1
2
3
4
5
6
7
8
{
"code": 200,
"data": "6vau/dEgCkkPlDQL93E/fj61TOAPMMc/DX1/R2o7jyoesGlM5VWdFZuGhUsXYRovtHBPdY3hpKiIMP2BBT/vjmQvIWA8GRY2EmAtkO5J7qaTXbUWW7qEy5X8QDjaUbUU7/13RkszTW/P5w7c3gJ2LLMz8J0rBQFgucyClQVCzStkHUspXKHPBTNVrpkiWAl2Eiq2aguaI6pNWNGLjZgfAGwN9+UjBxCH7Oez26z1S/oIr19nxI+ai/j0dsDYkUp8PtR1yb0Ay1Dofku6lMIeH/1QkdkjEETwYPDFHmUioTG2FCypB1mUlFeiAG/nruCxhTfEcNYyj8zxWe6S5moMopOb8H82zQ0L+eiprtM7t35me8i5s8LLoS108StnuX6DCZHODO4RHsf/OQWBhVsiX3Cx97nWXQ3vYZ7LGi/d5fj3wv0LOV9/kJnh6zhV8JAIoIB+VDz2mgHR/KujIOB9b3PBScqXt6r8a4y4NyJgQXmND65hQQ7W/xla5Gfgc0HygbqvSQgZUl34BF4VIgKk85DB0W22qSP5ZUpnhOVd5YOAJx0Wr/u8vAM51/otEQGaPf2PvayygwuVrk7id5QQqg+sJwXJabotubSbbj2LBUpFrcAa+lPWkW2Kgqo9UP9tOe6/vXdAAQF2mnccrKIwL03XDWNdfOooka/+VnH3gwpyAguR5nbpGBbu5aR4UX3PqoHXl35245Lz6bmQ2S4FYhAa690H9Grbo/koAJ319G+JRKtbq/W7ql1W3dBe8omF8gQQahVyFbsU9Wqrne1PvPh+vJOLUZFBabsB9zJ1vMBfHhHib8kY/b3iu+w8uag+AMWB6/rTOmd8361g2x1i7y+xZzIU1BSNF+hJlUQ5WaS6R++c8WuIlRiDmTEbE9DKAB4m0LnR4QLIldAw99gafjfnM1e2/he9aq9CGHwrtv+z5rwmzNT7/UABjmlMK7pxX6rX4Q2gzsF0Qebp3rt+O7DXCv78JhbsliP2DTJNcrkFl6mYDS7XJZPR1EeaXgzjpEDU5DgQql5saqkVOs8LgAmDgq2iyUz64JL2ZQXb2Ti06ZeSAlr38lGRe+HR9ws4pHgxaADIfv9mIPULNtqaTMb/4bd6vTVea9N7pwNtRsYXcO6SYtkKXpvnDV+qX0Wx3oMPTxzmj6uXs7j4v+azggFnFqk9gDmLLtGu8Ugc6hXHx0v3e339MCIDcRS1jeOCCrLtLRjHIDgLxHJpyfy3Fg5oMtEwFuBQPufbUj2+DvHinp27RM4QT3oZWhutiqttDZXJS1kEx3rSM+0LJkq5svxwZTsDQOxC+u6sfKivJyJ0BRQQC8YxJrTLtJVA4lVuDgxGmycZa7uCh7U2Kgy+mbd8G6rNF8U8Xs+qRLIp+7ZgHxFsJ91qhYc7+hZoCWnYJuhHj3fdHKOvo2PTgePRUser4XVvDbrExe9bRG5EWfqQ1FGX9BYweDSEpT6+D1HRPiHFw9WNCDPFdFIO18y6WUJedsJ5bw3SEjw0zXHG7zyONaSlW2mEPdLlNfYv+SMReUWN03KkctpdkDDqWLpdIaI9v3KI0wwv1rlBFjzquPyM39TkaoYU72CPNNBaUNA31ERzHSJ23ouYwIngA1heg7pHfhZjn2zdQW+Vd1fRaqKTo8a9goyeKlWzVmz3Qgq8XqvYOLYscGBcECyNJCYnIWvuBzfeXJpK3vltkXRHBWlcRrDaku7Ccv7uXD72Fg3dThPjCTPkuREZP+kKt6jYJSVLXZeNwVu2MWXimirYILxiRpMH74FbE/Xc/FAhmKOkxviSCeM2GI/qe5mhKa9HvxiQYZFx9ucEcH9Kug6KHBZFr/ByBxvmRH8OGIq42MTo2bEqMQxKS1W+rrAX+xHXvEfJ9uy/GO5onX6hLX+h3lGhbcbAbVhUywzI6ZMZws495YwtCMK4JY6/Hx+ZVIt6eWwxMtPlrRayDbCWKL/lvKSLJUqqMC39d8Y9znF2ezN3SOty7iPt9R2gxwY/vsW4sLmI53+hCsy29l03t+6hb1IJCNbzSEFF9Y/aFfwvv0LFOoU06BtoMzl5WVhjpBGWo2QJF7moszOlta6yYwL+JxubzZhBZz/O+6jTPZLZweConw9bEWrrEaLUaAYXRcOkDoxCDwBdogZV3dvhx1pTloU84J82oZqcePCcZpYixISCN/YkLO3UudCPbkV1YGhvI0IU3w1zVMxkDH8THbrk75F3t6TLEZmC0sFH4r3qF+C93WMgIjwZUKwtH1Bf",
"hash": true,
"msg": "success",
"time": "2026-03-21T05:21:19.803Z",
"tip": "成功"
}

解出来后是这样的(我手动改了下,想看完整版的自己解用之前的脚本)

1
2
{"mediaInfo":{"id":xxx,"channelId":xxx,"channelName":"xxx","channelAvatar":"xxx.png","channelFans":31702,"channelWorks":6220,"title":"牛逼","videoType":1,"videoTypeV2":6,"videoUrl":"xxxb4d2782xxx0.m3u8","preVideoUrl":"xxx33cf24b4d2782d7e679ed4521xxx1.m3u8","tags":[""],"tagList":[{"id":,"name":""},{"id":,"name":""},{"id":,"name":""},{"id":,"name":""},{"id":,"name":""}],"coverImg":"v3/image/2rj/72/2wv/1m1/xxx.jpg","coverImgV2":"","desc":"","actors":null,"payType":1,"playTime":1274,"preTime":3,"height":1280,"width":720,"price":0,"likes":94,"watchTimes":1463,"comments":38,"sells":0,"scores":20,"score":7.5,"myScore":0,"isLike":false,"isBuy":false,"isNew":false,"isChannel":false,"bango":"","addedTime":"2026-03-05T03:48:47Z","presale":false,"movieDiscount":100,"subscribeId":0,"isSubscribe":false,"vipLevel":0,"discountDesc":"","publishId":0,"publishName":"","publishAvatar":"","publishCared":false,"createdAt":"2026-02-19T04:01:54.356Z","canDownload":false,"timeNode":null},"jointGroup":null,"watchCount":0,"movieTickets":0,"playable":false,"preSale":false,"preSaleBuy":false,"preSalePrice":0,"preSaleDiscount":0,"code":6032,"msg":""}
[*] json=ok keys=['mediaInfo', 'jointGroup', 'watchCount', 'movieTickets', 'playable', 'preSale', 'preSaleBuy', 'preSalePrice']

有个重大发现”videoUrl”:”xxxb4d2782xxx0.m3u8”,”preVideoUrl”:”xxx33cf24b4d2782d7e679ed4521xxx1.m3u8”

如果我们能把preVideoUrl替换为videoUrl的链接不久发回来的是完整视频而不是预览视频了嘛

这里需要这样做是因为可能实际上改Vip不够(可能有服务端校验)

此思路是我逆向之前一个普通app来的,大家感兴趣也可以去看看

Android实战-逆向某li某li视频APP | Matriy’s blog

但是还是踩坑了我对其数据对象进行了注入却忽略了一个问题,注入时机不对,这个App比如说我注入了,但是注入太晚,这个preVideoUrl已经分配到很多地方了,导致又纠正了,因此我一开始的想法是顺着这个接口找到,刚收到响应,解密完的时机去进行hook,直接改,后来审计代码的时候无意间发现这个code和playable有点可疑,直接摸到解密完的时机去进行hook这两个字段成功了

把解密后的 /media/play 响应从不可播放 + 6032改成了可播放 + 200,url 那层只做兜底

然后成功hook了

frida代码(blutter_frida那些重复部分没加,也是hook了非常多的地方,有些地方其实是无效的,懒得改了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
const ShowNullField = false;
const MaxDepth = 5;
var libapp = null;

const Verbose = true;
const HookOffsets = {
parseUser: 0x67d774,
getUser: 0x74e230,
isVip: 0x74e180,
commonDecode: 0x6f695c,
jsonParse: 0xee0a50,
allocateCRa: 0xd542a8,
craToJson: 0xd1a2e8,
iRaToJson: 0xd5b92c,
qmlPlayableAsync: 0xe27524,
qmlOnBuyVideoAsync: 0xe27758,
qnlPlayableAsync: 0xdfde98,
qnlOnBuyVideoAsync: 0xdfe020,
};
const DesiredUserState = Object.freeze({
vipType: 2,
vipLevel: 2,
vipExpire: 4102444799n,
vipExpireTime: '2099-12-31T23:59:59Z',
liveVipExpire: '2099-12-31T23:59:59Z',
promotionExpiredAt: '2099-12-31T23:59:59.999Z',
cardName: '会员',
});
const DartCids = {
UTa: 997,
NSa: 1052,
CRa: 1112,
iRa: 1131,
};
const TaggedFields = {
userVipExpire: 0x2b,
userVipExpireTime: 0x2f,
userVipType: 0x33,
userCardName: 0x37,
userVipLevel: 0x9f,
userPromotionExpiredAt: 0xbf,
userLiveVipExpire: 0xcf,
userRights: 0xd3,
userVipRights: 0xd7,
rightIsOpen: 0x13,
craVideoUrl: 0x13,
craPreVideoUrl: 0x17,
craPayType: 0x2f,
craPreTime: 0x3b,
craIsBuyg: 0x7b,
craVipLevel: 0x9f,
iRaMediaInfo: 0xb,
};

const LogCounts = Object.create(null);
const PendingMediaPatchJobs = Object.create(null);
const MediaPatchRetryDelaysMs = [0, 25, 100, 250, 500];
const DecodeTraceDepth = 3;
const DecodeTraceStringLimit = 192;
const EnablePlaybackGatePatch = false;
const DecodeCallerOffsets = Object.freeze({
commonDecode: ['0xdb5a5c', '0xdb5c3c'],
jsonParse: ['0xdb5a70', '0xdb5c50'],
});

function smi(n) {
return n << 1;
}

function vipLog(msg) {
console.log(`[user-hook] ${msg}`);
}

function vipLogLimited(key, msg, maxCount = 8) {
const count = LogCounts[key] ?? 0;
if (count < maxCount)
vipLog(msg);
LogCounts[key] = count + 1;
}

function clipString(value, maxLen = DecodeTraceStringLimit) {
if (typeof value !== 'string')
return value;
if (value.length <= maxLen)
return value;
return `${value.slice(0, maxLen)}...<len=${value.length}>`;
}

function getLibappOffset(addr) {
if (libapp === null || addr === undefined || addr === null)
return 'n/a';
try {
return `0x${addr.sub(libapp).toString(16)}`;
} catch (e) {
return `${addr}`;
}
}

function callerMatches(returnAddress, offsets) {
return offsets.includes(getLibappOffset(returnAddress));
}

function getDartTrue(context) {
return context[NullReg].add(0x20);
}

function getDartFalse(context) {
return context[NullReg].add(0x30);
}

function getCompressedTagged(taggedPtr) {
return taggedPtr.toInt32() >>> 0;
}

function readTagged32(taggedPtr, fieldOff) {
return ptr(taggedPtr.add(fieldOff).readU32());
}

function writeTagged32(taggedPtr, fieldOff, value) {
taggedPtr.add(fieldOff).writeU32(value);
}

function patchRightsList(listTaggedPtr, source, dartTrueU32) {
if (listTaggedPtr.isNull() || !isHeapObject(listTaggedPtr))
return 0;

const listTptr = decompressPointer(listTaggedPtr);
const listPtr = listTptr.sub(1);
const listCls = Classes[getObjectCid(listPtr)];
if (listCls === undefined)
return 0;

let len = 0;
let dataPtr = null;
if (listCls.id === CidGrowableArray) {
len = listPtr.add(listCls.lenOffset).readU32() >> 1;
const dataTaggedPtr = ptr(listPtr.add(listCls.dataOffset).readU32());
if (dataTaggedPtr.isNull() || !isHeapObject(dataTaggedPtr))
return 0;
dataPtr = decompressPointer(dataTaggedPtr).sub(1);
} else if (listCls.id === CidArray) {
len = listPtr.add(listCls.lenOffset).readU32() >> 1;
dataPtr = listPtr;
} else {
return 0;
}

let patched = 0;
for (let i = 0; i < len; i++) {
const itemTaggedPtr = ptr(dataPtr.add(Classes[CidArray].dataOffset + i * CompressedWordSize).readU32());
if (itemTaggedPtr.isNull() || !isHeapObject(itemTaggedPtr))
continue;

const itemTptr = decompressPointer(itemTaggedPtr);
const itemPtr = itemTptr.sub(1);
const itemCls = Classes[getObjectCid(itemPtr)];
if (itemCls === undefined || itemCls.id !== DartCids.NSa)
continue;

writeTagged32(itemTptr, TaggedFields.rightIsOpen, dartTrueU32);
patched++;
}

if (patched !== 0 && Verbose)
vipLog(`patched ${patched} rights entries from ${source}`);
return patched;
}

function patchUserEntitlements(taggedPtr, source, dartTrueU32) {
let patched = 0;
patched += patchRightsList(readTagged32(taggedPtr, TaggedFields.userRights), `${source}.rights`, dartTrueU32);
patched += patchRightsList(readTagged32(taggedPtr, TaggedFields.userVipRights), `${source}.vipRights`, dartTrueU32);
return patched;
}

function fitsSmi31(value) {
const n = typeof value === 'bigint' ? Number(value) : value;
return Number.isInteger(n) && n >= -0x40000000 && n <= 0x3fffffff;
}

function toFridaU64(value) {
if (typeof value === 'bigint')
return uint64(value.toString());
return uint64(String(value));
}

function patchTaggedIntegerField(objTptr, fieldOff, value, label) {
const taggedValue = readTagged32(objTptr, fieldOff);
if (taggedValue.isNull()) {
vipLogLimited(`field-int-null-${label}`, `skip ${label}: null`);
return false;
}

if (!isHeapObject(taggedValue)) {
if (!fitsSmi31(value)) {
vipLogLimited(`field-int-range-${label}`, `skip ${label}: ${value.toString()} does not fit Smi`);
return false;
}
writeTagged32(objTptr, fieldOff, smi(Number(value)));
return true;
}

const valueTptr = decompressPointer(taggedValue);
const valuePtr = valueTptr.sub(1);
const valueCls = Classes[getObjectCid(valuePtr)];
if (valueCls === undefined || valueCls.id !== CidMint) {
vipLogLimited(
`field-int-type-${label}`,
`skip ${label}: unexpected ${valueCls ? valueCls.name : 'unknown'} field type`,
);
return false;
}

valuePtr.add(valueCls.valOffset).writeU64(toFridaU64(value));
return true;
}

function rewriteDartStringObject(taggedPtr, replacement, label) {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr)) {
vipLogLimited(`field-str-null-${label}`, `skip ${label}: null or non-string`);
return false;
}

const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
if (cls === undefined) {
vipLogLimited(`field-str-unknown-${label}`, `skip ${label}: unknown class`);
return false;
}

if (cls.id === CidString) {
const len = objPtr.add(cls.lenOffset).readU32() >> 1;
if (replacement.length !== len) {
vipLogLimited(
`field-str-len-${label}`,
`skip ${label}: replacement length ${replacement.length} != current length ${len}`,
);
return false;
}
if (/[^\x00-\x7f]/.test(replacement)) {
vipLogLimited(`field-str-ascii-${label}`, `skip ${label}: one-byte string cannot hold non-ASCII text`);
return false;
}
objPtr.add(cls.dataOffset).writeUtf8String(replacement);
return true;
}

if (cls.id === CidTwoByteString) {
const len = objPtr.add(cls.lenOffset).readU32() >> 1;
if (replacement.length !== len) {
vipLogLimited(
`field-str-len-${label}`,
`skip ${label}: replacement length ${replacement.length} != current length ${len}`,
);
return false;
}
objPtr.add(cls.dataOffset).writeUtf16String(replacement);
return true;
}

vipLogLimited(`field-str-type-${label}`, `skip ${label}: unexpected ${cls.name} field type`);
return false;
}

function patchUserStringField(objTptr, fieldOff, replacement, label) {
return rewriteDartStringObject(readTagged32(objTptr, fieldOff), replacement, label);
}

function patchUserObject(taggedPtr, source, dartTrueU32) {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return 0;

const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
if (cls === undefined || cls.id !== DartCids.UTa)
return 0;

writeTagged32(tptr, TaggedFields.userVipType, smi(DesiredUserState.vipType));
writeTagged32(tptr, TaggedFields.userVipLevel, smi(DesiredUserState.vipLevel));

const expirePatched = patchTaggedIntegerField(
tptr,
TaggedFields.userVipExpire,
DesiredUserState.vipExpire,
`${source}.vipExpire`,
);
let stringPatched = 0;
stringPatched += patchUserStringField(
tptr,
TaggedFields.userVipExpireTime,
DesiredUserState.vipExpireTime,
`${source}.vipExpireTime`,
) ? 1 : 0;
stringPatched += patchUserStringField(
tptr,
TaggedFields.userLiveVipExpire,
DesiredUserState.liveVipExpire,
`${source}.liveVipExpire`,
) ? 1 : 0;
stringPatched += patchUserStringField(
tptr,
TaggedFields.userPromotionExpiredAt,
DesiredUserState.promotionExpiredAt,
`${source}.promotionExpiredAt`,
) ? 1 : 0;
stringPatched += patchUserStringField(
tptr,
TaggedFields.userCardName,
DesiredUserState.cardName,
`${source}.cardName`,
) ? 1 : 0;

const patchedRights = patchUserEntitlements(tptr, source, dartTrueU32);

if (Verbose) {
vipLog(
`patched UTa from ${source}: ${tptr} vipType=${DesiredUserState.vipType} vipLevel=${DesiredUserState.vipLevel} ` +
`vipExpire=${expirePatched ? DesiredUserState.vipExpire.toString() : 'skip'} strings=${stringPatched} rights=${patchedRights}`,
);
}
return 1;
}

function readTaggedStringValue(taggedPtr) {
if (taggedPtr.isNull())
return 'null';

try {
const [, cls, value] = getTaggedObjectValue(taggedPtr, 2);
if (typeof value === 'string')
return value;
return `<${cls.name}>`;
} catch (e) {
return `<read-error:${e}>`;
}
}

function readTaggedMaybeString(taggedPtr) {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return null;

try {
const [, , value] = getTaggedObjectValue(taggedPtr, 2);
return typeof value === 'string' ? value : null;
} catch (e) {
return null;
}
}

function isLikelyMediaPlayJson(text) {
return typeof text === 'string'
&& text.includes('"mediaInfo"')
&& text.includes('"videoUrl"')
&& text.includes('"preVideoUrl"')
&& text.includes('"playable"')
&& text.includes('"code"')
&& text.includes('.m3u8');
}

function readJsonPrimitive(text, key) {
const match = text.match(new RegExp(`"${key}":(true|false|-?\\d+)`));
return match ? match[1] : null;
}

function readJsonString(text, key) {
const match = text.match(new RegExp(`"${key}":"([^"]*)"`));
return match ? match[1] : null;
}

function replaceJsonPrimitiveSameLength(text, key, newLiteral, changes) {
const regex = new RegExp(`("${key}":)(true|false|-?\\d+)`);
const match = text.match(regex);
if (match === null)
return text;

const oldLiteral = match[2];
if (oldLiteral === newLiteral)
return text;
if (newLiteral.length > oldLiteral.length) {
changes.push(`skip ${key}:${oldLiteral}`);
return text;
}

changes.push(`${key}:${oldLiteral}->${newLiteral}`);
return text.replace(regex, `${match[1]}${newLiteral.padEnd(oldLiteral.length, ' ')}`);
}

function patchMediaPlayJsonText(text) {
let patched = text;
const changes = [];

patched = replaceJsonPrimitiveSameLength(patched, 'playable', 'true', changes);
patched = replaceJsonPrimitiveSameLength(patched, 'code', '200', changes);
patched = replaceJsonPrimitiveSameLength(patched, 'payType', '0', changes);
patched = replaceJsonPrimitiveSameLength(patched, 'preTime', '0', changes);
patched = replaceJsonPrimitiveSameLength(patched, 'isBuy', 'true', changes);
patched = replaceJsonPrimitiveSameLength(patched, 'vipLevel', String(DesiredUserState.vipLevel), changes);
patched = replaceJsonPrimitiveSameLength(patched, 'preSaleBuy', 'true', changes);

return { text: patched, changes };
}

function summarizeMediaPlayJson(text) {
if (typeof text !== 'string')
return '<non-string>';

const playable = readJsonPrimitive(text, 'playable');
const code = readJsonPrimitive(text, 'code');
const payType = readJsonPrimitive(text, 'payType');
const preTime = readJsonPrimitive(text, 'preTime');
const isBuy = readJsonPrimitive(text, 'isBuy');
const vipLevel = readJsonPrimitive(text, 'vipLevel');
const videoUrl = clipString(readJsonString(text, 'videoUrl'));
const preVideoUrl = clipString(readJsonString(text, 'preVideoUrl'));

return `playable=${playable} code=${code} payType=${payType} preTime=${preTime} isBuy=${isBuy} vipLevel=${vipLevel} videoUrl=${videoUrl} preVideoUrl=${preVideoUrl}`;
}

function getTaggedFieldCid(objTptr, fieldOff) {
const taggedPtr = readTagged32(objTptr, fieldOff);
if (taggedPtr.isNull())
return CidNull;
if (!isHeapObject(taggedPtr))
return CidSmi;

try {
const tptr = decompressPointer(taggedPtr);
return getObjectCid(tptr.sub(1));
} catch (e) {
return -1;
}
}

function isTaggedDartNull(taggedPtr) {
if (taggedPtr.isNull())
return true;
if (!isHeapObject(taggedPtr))
return false;

try {
const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
return cls !== undefined && cls.id === CidNull;
} catch (e) {
return false;
}
}

function patchBooleanFieldIfBool(objTptr, fieldOff, dartTrueU32, label) {
if (getTaggedFieldCid(objTptr, fieldOff) !== CidBool)
return false;

writeTagged32(objTptr, fieldOff, dartTrueU32);
return true;
}

function patchIntegerFieldIfInt(objTptr, fieldOff, value, label) {
const cid = getTaggedFieldCid(objTptr, fieldOff);
if (cid !== CidSmi && cid !== CidMint)
return false;

return patchTaggedIntegerField(objTptr, fieldOff, value, label);
}

function patchMediaObject(taggedPtr, source, dartTrueU32) {
try {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return { matched: false, ready: false, patched: false };

const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
if (cls === undefined || cls.id !== DartCids.CRa)
return { matched: false, ready: false, patched: false };

const videoUrlTaggedPtr = readTagged32(tptr, TaggedFields.craVideoUrl);
const preVideoUrlTaggedPtr = readTagged32(tptr, TaggedFields.craPreVideoUrl);
const videoUrl = readTaggedStringValue(videoUrlTaggedPtr);
const preVideoUrl = readTaggedStringValue(preVideoUrlTaggedPtr);

if (isTaggedDartNull(videoUrlTaggedPtr))
return { matched: true, ready: false, patched: false, videoUrl, preVideoUrl };

writeTagged32(tptr, TaggedFields.craPreVideoUrl, getCompressedTagged(videoUrlTaggedPtr));
writeTagged32(tptr, TaggedFields.craPayType, smi(0));
writeTagged32(tptr, TaggedFields.craPreTime, smi(0));
writeTagged32(tptr, TaggedFields.craIsBuyg, dartTrueU32);
writeTagged32(tptr, TaggedFields.craVipLevel, smi(DesiredUserState.vipLevel));

if (Verbose) {
vipLog(
`patched CRa from ${source}: videoUrl=${videoUrl} preVideoUrl=${preVideoUrl} -> ${videoUrl}`,
);
}
return { matched: true, ready: true, patched: true, videoUrl, preVideoUrl };
} catch (e) {
vipLogLimited(`cra-patch-error-${source}`, `patch CRa failed from ${source}: ${e}`, 8);
return { matched: false, ready: false, patched: false };
}
}

function patchMediaWrapperObject(taggedPtr, source, dartTrueU32) {
try {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return { matched: false, patched: false };

const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
if (cls === undefined || cls.id !== DartCids.iRa)
return { matched: false, patched: false };

const mediaInfoTaggedPtr = readTagged32(tptr, TaggedFields.iRaMediaInfo);
const status = patchMediaObject(mediaInfoTaggedPtr, `${source}.mediaInfo`, dartTrueU32);
if (status.patched && Verbose)
vipLog(`patched iRa from ${source}`);
return { matched: true, patched: status.patched };
} catch (e) {
vipLogLimited(`ira-patch-error-${source}`, `patch iRa failed from ${source}: ${e}`, 8);
return { matched: false, patched: false };
}
}

function patchPlaybackGateObject(taggedPtr, source, dartTrueU32) {
try {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return 0;

const tptr = decompressPointer(taggedPtr);
const objPtr = tptr.sub(1);
const cls = Classes[getObjectCid(objPtr)];
if (cls === undefined)
return 0;

let patched = 0;
const details = [];

const nestedFieldB = readTagged32(tptr, 0xb);
const nestedField1B = readTagged32(tptr, 0x1b);

const nestedMediaStatus = patchMediaObject(nestedFieldB, `${source}.field_b`, dartTrueU32);
if (nestedMediaStatus.patched) {
patched++;
details.push('field_b.media');
}

const nestedMediaStatus1B = patchMediaObject(nestedField1B, `${source}.field_1b`, dartTrueU32);
if (nestedMediaStatus1B.patched) {
patched++;
details.push('field_1b.media');
}

if (patchIntegerFieldIfInt(tptr, 0x1f, 0, `${source}.field_1f`)) {
patched++;
details.push('field_1f=0');
}

const field23Cid = getTaggedFieldCid(tptr, 0x23);
if (field23Cid === CidBool) {
writeTagged32(tptr, 0x23, dartTrueU32);
patched++;
details.push('field_23=true');
} else if (field23Cid === CidSmi || field23Cid === CidMint) {
if (patchTaggedIntegerField(tptr, 0x23, 0, `${source}.field_23`)) {
patched++;
details.push('field_23=0');
}
}

const field27Cid = getTaggedFieldCid(tptr, 0x27);
if (field27Cid === CidBool) {
writeTagged32(tptr, 0x27, dartTrueU32);
patched++;
details.push('field_27=true');
} else if (field27Cid === CidSmi || field27Cid === CidMint) {
if (patchTaggedIntegerField(tptr, 0x27, 0, `${source}.field_27`)) {
patched++;
details.push('field_27=0');
}
}

if (patched !== 0 && details.length !== 0 && Verbose)
vipLog(`patched playback gate ${cls.name} from ${source}: ${details.join(' ')}`);
return patched;
} catch (e) {
vipLogLimited(`playback-gate-error-${source}`, `patch playback gate failed from ${source}: ${e}`, 8);
return 0;
}
}

function patchPlaybackAsyncState(taggedPtr, source, dartTrueU32) {
try {
if (taggedPtr.isNull() || !isHeapObject(taggedPtr))
return 0;

const rootTptr = decompressPointer(taggedPtr);
const rootPtr = rootTptr.sub(1);
const rootCls = Classes[getObjectCid(rootPtr)];
if (rootCls === undefined)
return 0;

let patched = 0;

patched += patchPlaybackGateObject(taggedPtr, `${source}.self`, dartTrueU32);

const viaField3f = readTagged32(rootTptr, 0x3f);
patched += patchPlaybackGateObject(viaField3f, `${source}.field_3f`, dartTrueU32);
if (!viaField3f.isNull() && isHeapObject(viaField3f)) {
const viaField3fTptr = decompressPointer(viaField3f);
patched += patchPlaybackGateObject(readTagged32(viaField3fTptr, 0xb), `${source}.field_3f.field_b`, dartTrueU32);
}

const viaFieldB = readTagged32(rootTptr, 0xb);
patched += patchPlaybackGateObject(viaFieldB, `${source}.field_b`, dartTrueU32);
if (!viaFieldB.isNull() && isHeapObject(viaFieldB)) {
const viaFieldBTptr = decompressPointer(viaFieldB);
const viaFieldBB = readTagged32(viaFieldBTptr, 0xb);
patched += patchPlaybackGateObject(viaFieldBB, `${source}.field_b.field_b`, dartTrueU32);
if (!viaFieldBB.isNull() && isHeapObject(viaFieldBB)) {
const viaFieldBBTptr = decompressPointer(viaFieldBB);
patched += patchPlaybackGateObject(
readTagged32(viaFieldBBTptr, 0xb),
`${source}.field_b.field_b.field_b`,
dartTrueU32,
);
}
}

if (patched !== 0 && Verbose)
vipLog(`patched playback async state from ${source}: root=${rootCls.name}`);
return patched;
} catch (e) {
vipLogLimited(`playback-state-error-${source}`, `patch playback state failed from ${source}: ${e}`, 8);
return 0;
}
}

function patchPlaybackAsyncArgs(context, source) {
if (!EnablePlaybackGatePatch)
return;

const dartTrueU32 = getCompressedTagged(getDartTrue(context));
let patched = 0;
for (let idx = 0; idx < 3; idx++) {
try {
patched += patchPlaybackAsyncState(getArg(context, idx), `${source}.arg${idx}`, dartTrueU32);
} catch (e) {
vipLogLimited(`playback-arg-error-${source}-${idx}`, `patch playback arg${idx} failed from ${source}: ${e}`, 4);
}
}

if (patched === 0) {
vipLogLimited(`playback-nohit-${source}`, `no playback state patched from ${source}`, 6);
}
}

function scheduleMediaPatch(taggedPtr, source, dartTrueU32) {
const jobKey = taggedPtr.toString();
if (PendingMediaPatchJobs[jobKey] === true)
return;

PendingMediaPatchJobs[jobKey] = true;

MediaPatchRetryDelaysMs.forEach((delayMs, attempt) => {
setTimeout(() => {
if (PendingMediaPatchJobs[jobKey] !== true)
return;

const status = patchMediaObject(taggedPtr, `${source}[${attempt}]`, dartTrueU32);
if (status.patched) {
delete PendingMediaPatchJobs[jobKey];
return;
}

if (attempt === MediaPatchRetryDelaysMs.length - 1) {
if (status.matched) {
vipLogLimited(
`cra-unresolved-${jobKey}`,
`CRa unresolved after retries from ${source}: videoUrl=${status.videoUrl} preVideoUrl=${status.preVideoUrl}`,
1,
);
}
delete PendingMediaPatchJobs[jobKey];
}
}, delayMs);
});
}

function onLibappLoaded() {
vipLog(`libapp base = ${libapp}`);

Interceptor.attach(libapp.add(HookOffsets.parseUser), {
onEnter: function () {
vipLogLimited(
`parseUser-caller-${getLibappOffset(this.returnAddress)}`,
`parseUser caller=${getLibappOffset(this.returnAddress)}`,
16,
);
},
onLeave: function (retval) {
init(this.context);
patchUserObject(retval, 'parseUser', getCompressedTagged(getDartTrue(this.context)));
}
});

Interceptor.attach(libapp.add(HookOffsets.getUser), {
onEnter: function () {
vipLogLimited(
`getUser-caller-${getLibappOffset(this.returnAddress)}`,
`getUser caller=${getLibappOffset(this.returnAddress)}`,
20,
);
},
onLeave: function (retval) {
init(this.context);
patchUserObject(retval, 'getUser', getCompressedTagged(getDartTrue(this.context)));
}
});

Interceptor.attach(libapp.add(HookOffsets.isVip), {
onEnter: function () {
vipLogLimited(
`isVip-caller-${getLibappOffset(this.returnAddress)}`,
`isVip caller=${getLibappOffset(this.returnAddress)}`,
24,
);
},
onLeave: function (retval) {
init(this.context);
retval.replace(getDartTrue(this.context));
vipLogLimited('isVip-forced', 'forced isVip -> true', 24);
}
});

Interceptor.attach(libapp.add(HookOffsets.commonDecode), {
onLeave: function (retval) {
init(this.context);
if (!callerMatches(this.returnAddress, DecodeCallerOffsets.commonDecode))
return;

const decodedText = readTaggedMaybeString(retval);
if (!isLikelyMediaPlayJson(decodedText))
return;

vipLogLimited(
`commonDecode-media-${readJsonPrimitive(decodedText, 'code') ?? 'na'}`,
`commonDecode media/play caller=${getLibappOffset(this.returnAddress)} ${summarizeMediaPlayJson(decodedText)}`,
12,
);
}
});

Interceptor.attach(libapp.add(HookOffsets.jsonParse), {
onEnter: function () {
init(this.context);
if (!callerMatches(this.returnAddress, DecodeCallerOffsets.jsonParse))
return;

const jsonTaggedPtr = this.context.x0;
const originalText = readTaggedMaybeString(jsonTaggedPtr);
if (!isLikelyMediaPlayJson(originalText))
return;

const patchResult = patchMediaPlayJsonText(originalText);
const changed = patchResult.text !== originalText;
const rewritten = changed
? rewriteDartStringObject(jsonTaggedPtr, patchResult.text, 'jsonParse.mediaPlay')
: false;
const effectiveText = rewritten ? patchResult.text : originalText;
const changeSummary = patchResult.changes.length !== 0 ? patchResult.changes.join(' ') : 'none';
const action = rewritten ? 'patched' : changed ? 'observed' : 'pass-through';

vipLogLimited(
`jsonParse-media-${readJsonPrimitive(originalText, 'code') ?? 'na'}`,
`${action} media/play jsonParse caller=${getLibappOffset(this.returnAddress)} changes=${changeSummary} ${summarizeMediaPlayJson(effectiveText)}`,
12,
);
}
});

Interceptor.attach(libapp.add(HookOffsets.allocateCRa), {
onEnter: function () {
vipLogLimited(
`allocateCRa-caller-${getLibappOffset(this.returnAddress)}`,
`allocateCRa caller=${getLibappOffset(this.returnAddress)}`,
24,
);
},
onLeave: function (retval) {
init(this.context);
scheduleMediaPatch(retval, 'allocateCRa', getCompressedTagged(getDartTrue(this.context)));
}
});

Interceptor.attach(libapp.add(HookOffsets.craToJson), {
onEnter: function () {
init(this.context);
patchMediaObject(getArg(this.context, 0), 'CRa::yjc', getCompressedTagged(getDartTrue(this.context)));
}
});

Interceptor.attach(libapp.add(HookOffsets.iRaToJson), {
onEnter: function () {
init(this.context);
patchMediaWrapperObject(getArg(this.context, 0), 'iRa::yjc', getCompressedTagged(getDartTrue(this.context)));
}
});

if (EnablePlaybackGatePatch) {
Interceptor.attach(libapp.add(HookOffsets.qmlPlayableAsync), {
onEnter: function () {
init(this.context);
patchPlaybackAsyncArgs(this.context, 'Qml::playableAsync');
}
});

Interceptor.attach(libapp.add(HookOffsets.qmlOnBuyVideoAsync), {
onEnter: function () {
init(this.context);
patchPlaybackAsyncArgs(this.context, 'Qml::_onBuyVideo');
}
});

Interceptor.attach(libapp.add(HookOffsets.qnlPlayableAsync), {
onEnter: function () {
init(this.context);
patchPlaybackAsyncArgs(this.context, 'Qnl::playableAsync');
}
});

Interceptor.attach(libapp.add(HookOffsets.qnlOnBuyVideoAsync), {
onEnter: function () {
init(this.context);
patchPlaybackAsyncArgs(this.context, 'Qnl::_onBuyVideo');
}
});
}

vipLog('user hooks installed');
}

function tryLoadLibapp() {
try {
libapp = Module.findBaseAddress('libapp.so');
} catch (e) {
if (e instanceof TypeError && e.message === "not a function") {
libapp = Process.findModuleByName('libapp.so');
if (libapp != null) {
libapp = libapp.base;
}
} else {
throw e;
}
}
if (libapp === null)
setTimeout(tryLoadLibapp, 500);
else
onLibappLoaded();
}
tryLoadLibapp();

如果有持久化的需求可以用算法助手注入frida代码,亲测可行

Xposed模块构建

但是由于需要hook native层,需要使用LSPosed + native hook,具体构建xposed项目按下面之前的文章所示

从零开始编写Xposed模块

Android Xposed 模块入门 - 淮城一只猫

某节拍APP逆向+XPosed模块编写 | Matriy’s blog

image-20260321165916688

此外native hook需要额外参考LSPosed-Native层Hook 夏洛魂的个人博客

添加如下属性

1
2
3
4
5
6
7
8
9
10
11
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MadouVipHook"
android:multiArch="true"
android:extractNativeLibs="false">

额外在assets下创建一个native_init,写入so库的名称,自己的

  1. MainHook.java,负责作为 LSPosed Java 入口,在目标进程加载时System.loadLibrary(“madou”)调用 native 入口

  2. hook_entry.cpp负责处理 native_init,监听 libapp.so,计算 libapp.so 基址,安装 native hook,维护 Dart AOT 运行时对象读写 helper

  3. json_parse_bridge.S负责桥接jsonParse,在不破坏 Dart 运行时寄存器现场的前提下,调用 C helper 做响应明文 patch

  4. user_hook_bridges.S负责桥接 parseUser,桥接 getUse,桥接 IsVip,把用户对象层 patch从不安全的裸C++ hook,改成与jsonParse一样的安全桥接方式。

实际持久化还是建议算法助手,我改native的时候崩了好几次

Frida 版之所以稳定,是因为它使用的是:

  • Interceptor.attach
  • onEnter
  • onLeave

也就是说,Frida 只是挂观察点,读取当前函数的寄存器上下文,再在合适时机改参数或返回值。而 native 模块里一开始采用的是:

  • 用 hook_func直接把目标函数入口替换成一个 C++ 函数
  • 在 C++ 函数里调用 backup trampoline
  • 然后继续写 Dart 对象

这两种方式看起来都叫hook,但对 Flutter AOT / Dart runtime 来说差别非常大。而Dart AOT 对寄存器使用非常敏感,从 jsonParse、parseUser、getUser、isVip 的反汇编能看到,x15 不是普通临时寄存器,而是 Dart 的栈 / frame 相关寄存器,x26/x27/x28也都承载了 runtime 上下文,x22 还参与 Null/true/false 常量对象定位,如果直接把函数入口替换成普通 C++ 函数,编译器会按 AArch64 C ABI 自己用寄存器,Dart 运行时依赖的现场可能在进入 backup trampoline 之前就被破坏,这也是为什么Frida 版稳定

解决方案

为了解决寄存器现场污染问题,最终采用了与 Frida 思路更接近的结构

汇编桥负责保存现场 -> 调 helper -> 恢复现场 -> 跳回原 trampoline,C helper 只负责高层逻辑判断和对象 patch。也就是把危险的寄存器现场处理与业务 patch 逻辑拆开。如jsonParse桥接方案最终加入了json_parse_bridge.S,madou_on_json_parse_enter(…)

桥接逻辑大致是:

  1. 保存关键寄存器:x0-x4x15x26-x28x30

  2. 调用 madou_on_json_parse_enter(json_tagged, return_address, x28)

  3. 在 helper 中:

    用 x28 << 32恢复 heap base,用返回地址计算 caller offset,只允许0xdb5a70和0xdb5c50,识别是否是 /media/play 的解密明文 JSON。若命中,则在 jsonParse 前对字符串做等长原地 patch

  4. 恢复寄存器现场

  5. br 到 backup trampoline

这样就实现了行为上接近 Frida onEnter,但部署形式是原生 so hook

其实就是Frida帮你隐式做了这层上下文保护,native inline hook 没做,所以我们自己用汇编桥把这层补上了,更准确地说,不是Xposed hook 不保存现场,而是,现在用的是 LSPosed native hook_func这类inline hook,我最开始写的是用普通 C/C++ 函数直接替换目标函数入口,这种写法会按标准 AArch64 C ABI 运行

AArch64 C ABI 是 ARM 64 位架构下,C 语言函数调用的规则标准

但 Flutter/Dart AOT 的这些函数,额外依赖 x15/x22/x26/x27/x28 这类 runtime 寄存器,所以一旦直接进普通 C++,编译器就可能把这些寄存器现场弄脏,而 Frida 的 Interceptor.attach 更像是在函数前后挂观察点,框架保留上下文,读 this.context、改参数/返回值,但不会把整个 Dart 函数改造成一个普通 C++ 函数

所以差异核心是Frida attach更接近旁路观察/前后插桩。最初的 native hook更接近整个函数入口被一个普通 ABI 的替身函数接管。这对 Dart AOT 就很危险bridges.S 做的事,本质上是手动保存关键寄存器现场调一个普通 C helper 去做高层逻辑再把现场恢复最后跳回原 trampoline 或返回结果,把 Dart 运行时依赖的寄存器环境恢复回去,这样原函数和运行时会觉得现场没有被普通 C++ 污染过。

下面会议json的那个桥为例解释

完整代码:

java层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.xx.madouviphook;

import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

public final class MainHook implements IXposedHookLoadPackage {
private static final String TAG = "MadouVipHook";
private static final String TARGET_PACKAGE = "com.qvBOX.mDXNd";
private static volatile boolean nativeLoaded;

@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) {
if (!TARGET_PACKAGE.equals(lpparam.packageName)) {
return;
}

XposedBridge.log(
TAG + ": handleLoadPackage package=" + lpparam.packageName +
" process=" + lpparam.processName +
" classLoader=" + lpparam.classLoader
);

if (!nativeLoaded) {
try {
System.loadLibrary("madou");
nativeLoaded = true;
XposedBridge.log(TAG + ": native library loaded");
} catch (Throwable t) {
XposedBridge.log(TAG + ": failed to load native library: " + t);
return;
}
}

try {
nativeOnPackageLoaded(lpparam.packageName, lpparam.processName);
} catch (Throwable t) {
XposedBridge.log(TAG + ": nativeOnPackageLoaded failed: " + t);
}
}

private static native void nativeOnPackageLoaded(String packageName, String processName);
}

CmakeList.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
cmake_minimum_required(VERSION 3.22.1)

project(madou LANGUAGES CXX ASM)

enable_language(ASM)

set_source_files_properties(
json_parse_bridge.S
user_hook_bridges.S
PROPERTIES
LANGUAGE ASM)

add_library(
madou
SHARED
hook_entry.cpp
json_parse_bridge.S
user_hook_bridges.S
)

find_library(log-lib log)

target_compile_features(madou PRIVATE cxx_std_17)

target_link_libraries(
madou
PRIVATE
${log-lib}
)

image-20260321200807191

LSPlant.h

1
2
3
4
5
6
7
8
9
10
11
typedef int (*HookFunType)(void *func, void *replace, void **backup);
typedef int (*UnhookFunType)(void *func);
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);

typedef struct {
uint32_t version;
HookFunType hook_func;
UnhookFunType unhook_func;
} NativeAPIEntries;

typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);

user_hook_bridges.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
.text
.align 2

.global replace_parse_user_bridge
.type replace_parse_user_bridge, %function
replace_parse_user_bridge:
sub sp, sp, #0x30
stp x30, x15, [sp, #0x00]
stp x19, x20, [sp, #0x10]
stp x22, x28, [sp, #0x20]

adrp x16, g_backup_parse_user_entry
ldr x16, [x16, :lo12:g_backup_parse_user_entry]
cbz x16, .Lparse_zero
blr x16

mov x19, x0
mov x1, x22
mov x2, x28
bl madou_after_user_object
mov x0, x19
b .Lparse_done

.Lparse_zero:
mov x0, #0

.Lparse_done:
ldp x22, x28, [sp, #0x20]
ldp x19, x20, [sp, #0x10]
ldp x30, x15, [sp, #0x00]
add sp, sp, #0x30
ret

.size replace_parse_user_bridge, .-replace_parse_user_bridge

.global replace_get_user_bridge
.type replace_get_user_bridge, %function
replace_get_user_bridge:
sub sp, sp, #0x30
stp x30, x15, [sp, #0x00]
stp x19, x20, [sp, #0x10]
stp x22, x28, [sp, #0x20]

adrp x16, g_backup_get_user_entry
ldr x16, [x16, :lo12:g_backup_get_user_entry]
cbz x16, .Lget_zero
blr x16

mov x19, x0
mov x1, x22
mov x2, x28
bl madou_after_user_object
mov x0, x19
b .Lget_done

.Lget_zero:
mov x0, #0

.Lget_done:
ldp x22, x28, [sp, #0x20]
ldp x19, x20, [sp, #0x10]
ldp x30, x15, [sp, #0x00]
add sp, sp, #0x30
ret

.size replace_get_user_bridge, .-replace_get_user_bridge

.global replace_is_vip_bridge
.type replace_is_vip_bridge, %function
replace_is_vip_bridge:
sub sp, sp, #0x20
stp x30, x15, [sp, #0x00]
stp x22, x28, [sp, #0x10]

adrp x16, g_backup_is_vip_entry
ldr x16, [x16, :lo12:g_backup_is_vip_entry]
cbz x16, .Lisvip_force
blr x16

.Lisvip_force:
add x0, x22, #0x20
ldp x22, x28, [sp, #0x10]
ldp x30, x15, [sp, #0x00]
add sp, sp, #0x20
ret

.size replace_is_vip_bridge, .-replace_is_vip_bridge

json_parse_bridges.S

第一段:保存现场

1
2
3
4
5
6
sub sp, sp, #0x50          #在 原生栈 sp 上开 0x50 字节空间
stp x0, x1, [sp, #0x00]
stp x2, x3, [sp, #0x10]
stp x4, x30, [sp, #0x20]
stp x15, x26, [sp, #0x30]
stp x27, x28, [sp, #0x40]

stp 是一次存两个寄存器

这里保存的是:

  • x0-x4:原函数参数
  • x30:返回地址
  • x15:Dart 很敏感的栈/frame 寄存器
  • x26-x28:Dart runtime 上下文寄存器

这里的关键点是: 我们不用 Dart 的 x15 做栈,而是临时借用原生 sp 保存现场,避免碰坏 Dart 自己的栈语义。

第二段:调用 helper

1
2
3
mov x1, x30
mov x2, x28
bl madou_on_json_parse_enter

这里没有动 x0,所以 helper 收到的参数其实是:

  • x0:原来的 json_tagged
  • x1:当前返回地址 x30
  • x2:当前 x28

也就是对应hook_entry.cpp:644 里的:

1
2
3
madou_on_json_parse_enter(uintptr_t json_tagged,
uintptr_t return_address,
uintptr_t x28_value)

helper 里做的事是:

  • 用 x28 还原 heap base
  • 用 return_address 判断 caller 是否是那两条解密链
  • 读取 json_tagged 指向的 Dart String
  • 如果像 /media/play 响应,就原地改字符串内容

注意:helper 改的是字符串对象指向的内存,不是改 x0 这个寄存器本身。

第三段:恢复现场

1
2
3
4
5
6
ldp x0, x1, [sp, #0x00]
ldp x2, x3, [sp, #0x10]
ldp x4, x30, [sp, #0x20]
ldp x15, x26, [sp, #0x30]
ldp x27, x28, [sp, #0x40]
add sp, sp, #0x50

这就是把刚才保存的值全部恢复回去。恢复完之后,对原始 jsonParse 来说,寄存器现场和没被我们插过 helper几乎一样。

第四段:跳回原 trampoline

1
2
3
4
adrp x16, g_backup_json_parse_entry
ldr x16, [x16, :lo12:g_backup_json_parse_entry]
cbz x16, .Lret_zero
br x16

意思是:

  • 从全局变量里取出 backup trampoline 地址
  • 如果为空,就走兜底返回 0
  • 否则 br x16 直接跳过去

这里特意用的是 br,不是 blr,因为:

  • br 是直接跳转,不额外改写返回链
  • 我们希望后面的原始 jsonParse -> 原始 caller这条返回路径保持正常
  • 这更像 Frida 的 onEnter 语义:先插一脚,再让原函数照常跑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
.text
.align 2

.global replace_json_parse_bridge
.type replace_json_parse_bridge, %function
replace_json_parse_bridge:
sub sp, sp, #0x50
stp x0, x1, [sp, #0x00]
stp x2, x3, [sp, #0x10]
stp x4, x30, [sp, #0x20]
stp x15, x26, [sp, #0x30]
stp x27, x28, [sp, #0x40]

mov x1, x30
mov x2, x28
bl madou_on_json_parse_enter

ldp x0, x1, [sp, #0x00]
ldp x2, x3, [sp, #0x10]
ldp x4, x30, [sp, #0x20]
ldp x15, x26, [sp, #0x30]
ldp x27, x28, [sp, #0x40]
add sp, sp, #0x50

adrp x16, g_backup_json_parse_entry
ldr x16, [x16, :lo12:g_backup_json_parse_entry]
cbz x16, .Lret_zero
br x16

.Lret_zero:
mov x0, #0
ret

.size replace_json_parse_bridge, .-replace_json_parse_bridge

hook_entry.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
#include <jni.h>
#include <android/log.h>
#include <dlfcn.h>
#include <link.h>

#include <atomic>
#include <cstring>
#include <mutex>
#include <optional>
#include <string>
#include <vector>

#include "LSPlant.h"

extern "C" void *g_backup_parse_user_entry;
extern "C" void *g_backup_get_user_entry;
extern "C" void *g_backup_is_vip_entry;
extern "C" void *g_backup_json_parse_entry;
extern "C" uintptr_t replace_parse_user_bridge(uintptr_t, uintptr_t, uintptr_t, uintptr_t, uintptr_t);
extern "C" uintptr_t replace_get_user_bridge();
extern "C" uintptr_t replace_is_vip_bridge();
extern "C" uintptr_t replace_json_parse_bridge(uintptr_t, uintptr_t, uintptr_t, uintptr_t, uintptr_t);
extern "C" void madou_after_user_object(uintptr_t, uintptr_t, uintptr_t);
extern "C" void madou_on_json_parse_enter(uintptr_t, uintptr_t, uintptr_t);

namespace {

constexpr const char *kTag = "MadouVipHook";
constexpr const char *kTargetLib = "libapp.so";
constexpr bool kEnableJsonParseHook = true;
constexpr bool kEnableCommonDecodeHook = false;
constexpr bool kEnableUserObjectHooks = true;
constexpr bool kEnableMediaObjectHooks = false;
constexpr uintptr_t kCallerCommonDecode1 = 0xdb5a5c;
constexpr uintptr_t kCallerCommonDecode2 = 0xdb5c3c;
constexpr uintptr_t kCallerJsonParse1 = 0xdb5a70;
constexpr uintptr_t kCallerJsonParse2 = 0xdb5c50;
constexpr uintptr_t kOffsetParseUser = 0x67d774;
constexpr uintptr_t kOffsetGetUser = 0x74e230;
constexpr uintptr_t kOffsetIsVip = 0x74e180;
constexpr uintptr_t kOffsetCraToJson = 0xd1a2e8;
constexpr uintptr_t kOffsetIraToJson = 0xd5b92c;
constexpr uintptr_t kOffsetJsonParse = 0xee0a50;
constexpr uintptr_t kOffsetCommonDecode = 0x6f695c;
constexpr uint32_t kCidNull = 154;
constexpr uint32_t kCidMint = 59;
constexpr uint32_t kCidBool = 61;
constexpr uint32_t kCidString = 90;
constexpr uint32_t kCidTwoByteString = 91;
constexpr uintptr_t kClassIdTagPos = 16;
constexpr uintptr_t kClassIdTagMask = 0xffff;
constexpr uint32_t kCidUta = 997;
constexpr uint32_t kCidNsa = 1052;
constexpr uint32_t kCidCra = 1112;
constexpr uint32_t kCidIra = 1131;
constexpr uintptr_t kNullTrueOffset = 0x20;
constexpr uintptr_t kStringLenOffset = 8;
constexpr uintptr_t kStringDataOffset = 16;
constexpr uintptr_t kMintValueOffset = 8;
constexpr uintptr_t kArrayLenOffset = 12;
constexpr uintptr_t kArrayDataOffset = 16;
constexpr uintptr_t kGrowableArrayLenOffset = 12;
constexpr uintptr_t kGrowableArrayDataOffset = 16;

constexpr uintptr_t kFieldUserVipExpire = 0x2b;
constexpr uintptr_t kFieldUserVipExpireTime = 0x2f;
constexpr uintptr_t kFieldUserVipType = 0x33;
constexpr uintptr_t kFieldUserCardName = 0x37;
constexpr uintptr_t kFieldUserVipLevel = 0x9f;
constexpr uintptr_t kFieldUserPromotionExpiredAt = 0xbf;
constexpr uintptr_t kFieldUserLiveVipExpire = 0xcf;
constexpr uintptr_t kFieldUserRights = 0xd3;
constexpr uintptr_t kFieldUserVipRights = 0xd7;
constexpr uintptr_t kFieldRightIsOpen = 0x13;

constexpr uintptr_t kFieldCraVideoUrl = 0x13;
constexpr uintptr_t kFieldCraPreVideoUrl = 0x17;
constexpr uintptr_t kFieldCraPayType = 0x2f;
constexpr uintptr_t kFieldCraPreTime = 0x3b;
constexpr uintptr_t kFieldCraIsBuyg = 0x7b;
constexpr uintptr_t kFieldCraVipLevel = 0x9f;

constexpr uintptr_t kFieldIraMediaInfo = 0xb;

HookFunType g_hook_func = nullptr;
UnhookFunType g_unhook_func = nullptr;
JavaVM *g_vm = nullptr;
std::once_flag g_process_once;
std::atomic<uintptr_t> g_libapp_base{0};
std::atomic<uintptr_t> g_heap_base{0};
std::atomic<bool> g_hooks_installed{false};

#if defined(__aarch64__)
using ParseUserFn = uintptr_t (*)(uintptr_t, uintptr_t, uintptr_t, uintptr_t, uintptr_t);
using GetUserFn = uintptr_t (*)();
using IsVipFn = uintptr_t (*)();
using ToJsonFn = uintptr_t (*)();
using JsonParseFn = uintptr_t (*)(uintptr_t, uintptr_t, uintptr_t, uintptr_t, uintptr_t);
using CommonDecodeFn = uintptr_t (*)(uintptr_t);

ParseUserFn g_backup_parse_user = nullptr;
GetUserFn g_backup_get_user = nullptr;
IsVipFn g_backup_is_vip = nullptr;
ToJsonFn g_backup_cra_to_json = nullptr;
ToJsonFn g_backup_ira_to_json = nullptr;
JsonParseFn g_backup_json_parse = nullptr;
CommonDecodeFn g_backup_common_decode = nullptr;
#endif

void log_info(const std::string &message) {
__android_log_print(ANDROID_LOG_INFO, kTag, "%s", message.c_str());
}

bool ends_with(const std::string &value, const std::string &suffix) {
if (value.size() < suffix.size()) {
return false;
}
return value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0;
}

uintptr_t current_return_address() {
#if defined(__aarch64__)
uintptr_t x30 = 0;
asm volatile("mov %0, x30" : "=r"(x30));
return x30;
#else
return 0;
#endif
}

std::optional<uintptr_t> current_libapp_caller_offset() {
const auto base = g_libapp_base.load(std::memory_order_relaxed);
const auto lr = current_return_address();
if (base == 0 || lr < base) {
return std::nullopt;
}
return lr - base;
}

std::optional<uintptr_t> libapp_caller_offset_from_return_address(uintptr_t lr) {
const auto base = g_libapp_base.load(std::memory_order_relaxed);
if (base == 0 || lr < base) {
return std::nullopt;
}
return lr - base;
}

bool caller_matches(uintptr_t off, uintptr_t a, uintptr_t b) {
return off == a || off == b;
}

template <typename T>
T read_mem(uintptr_t addr) {
return *reinterpret_cast<T *>(addr);
}

template <typename T>
void write_mem(uintptr_t addr, const T &value) {
*reinterpret_cast<T *>(addr) = value;
}

uint32_t get_object_cid(uintptr_t obj) {
const uint32_t tag = read_mem<uint32_t>(obj);
return (tag >> kClassIdTagPos) & kClassIdTagMask;
}

uintptr_t capture_heap_base() {
#if defined(__aarch64__)
uintptr_t x28 = 0;
asm volatile("mov %0, x28" : "=r"(x28));
const uintptr_t heap = x28 << 32;
if (heap != 0) {
g_heap_base.store(heap, std::memory_order_relaxed);
}
return heap;
#else
return 0;
#endif
}

uintptr_t ensure_heap_base() {
auto heap = g_heap_base.load(std::memory_order_relaxed);
if (heap != 0) {
return heap;
}
return capture_heap_base();
}

bool is_heap_object(uintptr_t tagged) {
return (static_cast<uint32_t>(tagged) & 1u) == 1u;
}

uint32_t compress_tagged(uintptr_t tagged) {
return static_cast<uint32_t>(tagged);
}

uintptr_t smi(int32_t value) {
return static_cast<uint32_t>(value << 1);
}

uintptr_t decompress_tagged(uintptr_t tagged) {
return ensure_heap_base() + static_cast<uint32_t>(tagged);
}

uintptr_t read_tagged_field(uintptr_t tptr, uintptr_t off) {
return static_cast<uintptr_t>(read_mem<uint32_t>(tptr + off));
}

void write_tagged_field(uintptr_t tptr, uintptr_t off, uintptr_t tagged) {
write_mem<uint32_t>(tptr + off, compress_tagged(tagged));
}

std::optional<std::string> read_dart_string(uintptr_t tagged) {
if (!is_heap_object(tagged)) {
return std::nullopt;
}

const uintptr_t tptr = decompress_tagged(tagged);
if (tptr == 0) {
return std::nullopt;
}

const uintptr_t obj = tptr - 1;
const auto cid = get_object_cid(obj);

if (cid == kCidString) {
const auto len = read_mem<uint32_t>(obj + kStringLenOffset) >> 1;
return std::string(reinterpret_cast<const char *>(obj + kStringDataOffset), len);
}

if (cid == kCidTwoByteString) {
const auto len = read_mem<uint32_t>(obj + kStringLenOffset) >> 1;
std::string out;
out.reserve(len);
auto *data = reinterpret_cast<const char16_t *>(obj + kStringDataOffset);
for (uint32_t i = 0; i < len; ++i) {
const char16_t ch = data[i];
out.push_back(static_cast<char>(ch <= 0x7f ? ch : '?'));
}
return out;
}

return std::nullopt;
}

std::optional<std::u16string> utf8_to_utf16(const std::string &text) {
std::u16string out;
out.reserve(text.size());

for (size_t i = 0; i < text.size();) {
const auto c0 = static_cast<uint8_t>(text[i]);
uint32_t code_point = 0;
size_t width = 0;

if ((c0 & 0x80u) == 0) {
code_point = c0;
width = 1;
} else if ((c0 & 0xE0u) == 0xC0u && i + 1 < text.size()) {
const auto c1 = static_cast<uint8_t>(text[i + 1]);
if ((c1 & 0xC0u) != 0x80u) {
return std::nullopt;
}
code_point = ((c0 & 0x1Fu) << 6) | (c1 & 0x3Fu);
width = 2;
} else if ((c0 & 0xF0u) == 0xE0u && i + 2 < text.size()) {
const auto c1 = static_cast<uint8_t>(text[i + 1]);
const auto c2 = static_cast<uint8_t>(text[i + 2]);
if ((c1 & 0xC0u) != 0x80u || (c2 & 0xC0u) != 0x80u) {
return std::nullopt;
}
code_point = ((c0 & 0x0Fu) << 12) | ((c1 & 0x3Fu) << 6) | (c2 & 0x3Fu);
width = 3;
} else if ((c0 & 0xF8u) == 0xF0u && i + 3 < text.size()) {
const auto c1 = static_cast<uint8_t>(text[i + 1]);
const auto c2 = static_cast<uint8_t>(text[i + 2]);
const auto c3 = static_cast<uint8_t>(text[i + 3]);
if ((c1 & 0xC0u) != 0x80u || (c2 & 0xC0u) != 0x80u || (c3 & 0xC0u) != 0x80u) {
return std::nullopt;
}
code_point = ((c0 & 0x07u) << 18) |
((c1 & 0x3Fu) << 12) |
((c2 & 0x3Fu) << 6) |
(c3 & 0x3Fu);
width = 4;
} else {
return std::nullopt;
}

if (code_point <= 0xFFFFu) {
out.push_back(static_cast<char16_t>(code_point));
} else if (code_point <= 0x10FFFFu) {
code_point -= 0x10000u;
out.push_back(static_cast<char16_t>(0xD800u + ((code_point >> 10) & 0x3FFu)));
out.push_back(static_cast<char16_t>(0xDC00u + (code_point & 0x3FFu)));
} else {
return std::nullopt;
}

i += width;
}

return out;
}

bool rewrite_dart_string_same_length(uintptr_t tagged, const std::string &replacement) {
if (!is_heap_object(tagged)) {
return false;
}

const uintptr_t tptr = decompress_tagged(tagged);
if (tptr == 0) {
return false;
}

const uintptr_t obj = tptr - 1;
const auto cid = get_object_cid(obj);
const auto len = read_mem<uint32_t>(obj + kStringLenOffset) >> 1;

if (cid == kCidString) {
if (replacement.size() != len) {
return false;
}
std::memcpy(reinterpret_cast<void *>(obj + kStringDataOffset), replacement.data(), replacement.size());
return true;
}

if (cid == kCidTwoByteString) {
const auto replacement16 = utf8_to_utf16(replacement);
if (!replacement16.has_value() || replacement16->size() != len) {
return false;
}
auto *data = reinterpret_cast<char16_t *>(obj + kStringDataOffset);
for (size_t i = 0; i < replacement16->size(); ++i) {
data[i] = (*replacement16)[i];
}
return true;
}

return false;
}

bool is_dart_null(uintptr_t tagged) {
if (tagged == 0) {
return true;
}
if (!is_heap_object(tagged)) {
return false;
}

const uintptr_t tptr = decompress_tagged(tagged);
if (tptr == 0) {
return false;
}
return get_object_cid(tptr - 1) == kCidNull;
}

bool patch_integer_field(uintptr_t tptr, uintptr_t off, uint64_t value) {
const auto tagged = read_tagged_field(tptr, off);
if (tagged == 0) {
return false;
}

if (!is_heap_object(tagged)) {
if (value > 0x3fffffffULL) {
return false;
}
write_tagged_field(tptr, off, smi(static_cast<int32_t>(value)));
return true;
}

const auto value_tptr = decompress_tagged(tagged);
if (value_tptr == 0) {
return false;
}

const auto value_obj = value_tptr - 1;
if (get_object_cid(value_obj) != kCidMint) {
return false;
}

write_mem<uint64_t>(value_obj + kMintValueOffset, value);
return true;
}

bool patch_string_field(uintptr_t tptr, uintptr_t off, const std::string &replacement) {
const auto tagged = read_tagged_field(tptr, off);
return rewrite_dart_string_same_length(tagged, replacement);
}

uint32_t get_tagged_cid(uintptr_t tagged) {
if (tagged == 0) {
return kCidNull;
}
if (!is_heap_object(tagged)) {
return 58;
}

const auto tptr = decompress_tagged(tagged);
if (tptr == 0) {
return 0xffffffffu;
}
return get_object_cid(tptr - 1);
}

int patch_rights_list(uintptr_t tagged_list, uintptr_t dart_true_tagged) {
if (!is_heap_object(tagged_list)) {
return 0;
}

const auto list_tptr = decompress_tagged(tagged_list);
if (list_tptr == 0) {
return 0;
}
const auto list_obj = list_tptr - 1;
const auto cid = get_object_cid(list_obj);

uint32_t len = 0;
uintptr_t data_obj = 0;

if (cid == 88) {
len = read_mem<uint32_t>(list_obj + kGrowableArrayLenOffset) >> 1;
const auto data_tagged = static_cast<uintptr_t>(read_mem<uint32_t>(list_obj + kGrowableArrayDataOffset));
if (!is_heap_object(data_tagged)) {
return 0;
}
const auto data_tptr = decompress_tagged(data_tagged);
if (data_tptr == 0) {
return 0;
}
data_obj = data_tptr - 1;
} else if (cid == 86) {
len = read_mem<uint32_t>(list_obj + kArrayLenOffset) >> 1;
data_obj = list_obj;
} else {
return 0;
}

int patched = 0;
for (uint32_t i = 0; i < len; ++i) {
const auto item_tagged = static_cast<uintptr_t>(read_mem<uint32_t>(data_obj + kArrayDataOffset + i * 4));
if (!is_heap_object(item_tagged)) {
continue;
}
const auto item_tptr = decompress_tagged(item_tagged);
if (item_tptr == 0 || get_object_cid(item_tptr - 1) != kCidNsa) {
continue;
}
write_tagged_field(item_tptr, kFieldRightIsOpen, dart_true_tagged);
patched++;
}

return patched;
}

bool patch_user_object(uintptr_t tagged, uintptr_t dart_true_tagged) {
if (!is_heap_object(tagged)) {
return false;
}

const auto tptr = decompress_tagged(tagged);
if (tptr == 0) {
return false;
}
const auto obj = tptr - 1;
if (get_object_cid(obj) != kCidUta) {
return false;
}

write_tagged_field(tptr, kFieldUserVipType, smi(2));
write_tagged_field(tptr, kFieldUserVipLevel, smi(2));
const bool expire_patched = patch_integer_field(tptr, kFieldUserVipExpire, 4102444799ULL);
const bool expire_time_patched = patch_string_field(tptr, kFieldUserVipExpireTime, "2099-12-31T23:59:59Z");
const bool live_expire_patched = patch_string_field(tptr, kFieldUserLiveVipExpire, "2099-12-31T23:59:59Z");
const bool promotion_patched = patch_string_field(tptr, kFieldUserPromotionExpiredAt, "2099-12-31T23:59:59.999Z");
const bool card_name_patched = patch_string_field(tptr, kFieldUserCardName, "会员");

const int rights_patched = patch_rights_list(read_tagged_field(tptr, kFieldUserRights), dart_true_tagged);
const int vip_rights_patched = patch_rights_list(read_tagged_field(tptr, kFieldUserVipRights), dart_true_tagged);

log_info("patched UTa user object expire=" + std::to_string(expire_patched ? 1 : 0) +
" vipExpireTime=" + std::to_string(expire_time_patched ? 1 : 0) +
" liveVipExpire=" + std::to_string(live_expire_patched ? 1 : 0) +
" promotion=" + std::to_string(promotion_patched ? 1 : 0) +
" cardName=" + std::to_string(card_name_patched ? 1 : 0) +
" rights=" + std::to_string(rights_patched) +
" vipRights=" + std::to_string(vip_rights_patched));
return true;
}

bool patch_media_object(uintptr_t tagged, uintptr_t dart_true_tagged) {
if (!is_heap_object(tagged)) {
return false;
}

const auto tptr = decompress_tagged(tagged);
if (tptr == 0) {
return false;
}
const auto obj = tptr - 1;
if (get_object_cid(obj) != kCidCra) {
return false;
}

const auto video_url = read_tagged_field(tptr, kFieldCraVideoUrl);
if (is_dart_null(video_url)) {
return false;
}

write_tagged_field(tptr, kFieldCraPreVideoUrl, video_url);
write_tagged_field(tptr, kFieldCraPayType, smi(0));
write_tagged_field(tptr, kFieldCraPreTime, smi(0));
write_tagged_field(tptr, kFieldCraIsBuyg, dart_true_tagged);
write_tagged_field(tptr, kFieldCraVipLevel, smi(2));

log_info("patched CRa media object");
return true;
}

bool patch_media_wrapper_object(uintptr_t tagged, uintptr_t dart_true_tagged) {
if (!is_heap_object(tagged)) {
return false;
}

const auto tptr = decompress_tagged(tagged);
if (tptr == 0) {
return false;
}
const auto obj = tptr - 1;
if (get_object_cid(obj) != kCidIra) {
return false;
}

const auto media_info = read_tagged_field(tptr, kFieldIraMediaInfo);
return patch_media_object(media_info, dart_true_tagged);
}

bool looks_like_media_play_json(const std::string &text) {
return text.find("\"mediaInfo\"") != std::string::npos &&
text.find("\"videoUrl\"") != std::string::npos &&
text.find("\"preVideoUrl\"") != std::string::npos &&
text.find("\"playable\"") != std::string::npos &&
text.find("\"code\"") != std::string::npos &&
text.find(".m3u8") != std::string::npos;
}

void replace_literal_if_present(std::string &text,
const std::string &from,
const std::string &to,
std::vector<std::string> &changes) {
const auto pos = text.find(from);
if (pos == std::string::npos) {
return;
}
text.replace(pos, from.size(), to);
changes.emplace_back(from + "->" + to);
}

std::pair<std::string, std::vector<std::string>> patch_media_play_json(std::string text) {
std::vector<std::string> changes;

replace_literal_if_present(text, "\"playable\":false", "\"playable\":true ", changes);
replace_literal_if_present(text, "\"code\":6032", "\"code\":200 ", changes);
replace_literal_if_present(text, "\"payType\":1", "\"payType\":0", changes);
replace_literal_if_present(text, "\"preTime\":3", "\"preTime\":0", changes);
replace_literal_if_present(text, "\"isBuy\":false", "\"isBuy\":true ", changes);
replace_literal_if_present(text, "\"vipLevel\":0", "\"vipLevel\":2", changes);
replace_literal_if_present(text, "\"preSaleBuy\":false", "\"preSaleBuy\":true ", changes);

return {text, changes};
}

std::optional<uintptr_t> find_module_base(const std::string &suffix) {
struct SearchState {
const std::string *suffix;
uintptr_t base;
} state{&suffix, 0};

dl_iterate_phdr(
[](dl_phdr_info *info, size_t, void *data) {
auto *state = reinterpret_cast<SearchState *>(data);
if (info == nullptr || info->dlpi_name == nullptr) {
return 0;
}

const std::string name(info->dlpi_name);
if (!name.empty() && ends_with(name, *state->suffix)) {
state->base = static_cast<uintptr_t>(info->dlpi_addr);
return 1;
}
return 0;
},
&state);

if (state.base == 0) {
return std::nullopt;
}
return state.base;
}

std::string join_changes(const std::vector<std::string> &changes) {
if (changes.empty()) {
return "none";
}

std::string out;
for (size_t i = 0; i < changes.size(); ++i) {
if (i != 0) {
out += ' ';
}
out += changes[i];
}
return out;
}

#if defined(__aarch64__)
uintptr_t dart_true_tagged() {
uintptr_t x22 = 0;
asm volatile("mov %0, x22" : "=r"(x22));
return x22 + kNullTrueOffset;
}

uintptr_t replace_parse_user(uintptr_t a0, uintptr_t a1, uintptr_t a2, uintptr_t a3, uintptr_t a4) {
capture_heap_base();
const auto result = g_backup_parse_user != nullptr ? g_backup_parse_user(a0, a1, a2, a3, a4) : 0;
patch_user_object(result, dart_true_tagged());
return result;
}

uintptr_t replace_get_user() {
capture_heap_base();
const auto result = g_backup_get_user != nullptr ? g_backup_get_user() : 0;
patch_user_object(result, dart_true_tagged());
return result;
}

uintptr_t replace_is_vip() {
capture_heap_base();
if (g_backup_is_vip != nullptr) {
g_backup_is_vip();
}
return dart_true_tagged();
}

uintptr_t replace_cra_to_json() {
capture_heap_base();

uintptr_t x15 = 0;
asm volatile("mov %0, x15" : "=r"(x15));
const auto self_tagged = read_mem<uintptr_t>(x15);
patch_media_object(self_tagged, dart_true_tagged());

return g_backup_cra_to_json != nullptr ? g_backup_cra_to_json() : 0;
}

uintptr_t replace_ira_to_json() {
capture_heap_base();

uintptr_t x15 = 0;
asm volatile("mov %0, x15" : "=r"(x15));
const auto self_tagged = read_mem<uintptr_t>(x15);
patch_media_wrapper_object(self_tagged, dart_true_tagged());

return g_backup_ira_to_json != nullptr ? g_backup_ira_to_json() : 0;
}

uintptr_t replace_common_decode(uintptr_t arg0) {
const auto caller = current_libapp_caller_offset();
const auto result = g_backup_common_decode != nullptr ? g_backup_common_decode(arg0) : 0;
if (!caller.has_value() || !caller_matches(*caller, kCallerCommonDecode1, kCallerCommonDecode2)) {
return result;
}

capture_heap_base();
const auto decoded = read_dart_string(result);
if (decoded && looks_like_media_play_json(*decoded)) {
log_info("commonDecode observed media/play payload caller=0x" + std::to_string(*caller));
}
return result;
}

void on_json_parse_enter_impl(uintptr_t json_tagged,
uintptr_t return_address,
uintptr_t x28_value) {
const uintptr_t heap = x28_value << 32;
if (heap != 0) {
g_heap_base.store(heap, std::memory_order_relaxed);
}

const auto caller = libapp_caller_offset_from_return_address(return_address);
if (!caller.has_value() || !caller_matches(*caller, kCallerJsonParse1, kCallerJsonParse2)) {
return;
}

const auto original = read_dart_string(json_tagged);
if (!original || !looks_like_media_play_json(*original)) {
return;
}

auto [patched, changes] = patch_media_play_json(*original);
if (changes.empty()) {
return;
}

const bool rewritten = rewrite_dart_string_same_length(json_tagged, patched);
log_info(std::string("jsonParse media/play ") +
(rewritten ? "patched" : "observe-only") +
" caller=0x" + std::to_string(*caller) +
" changes=" + join_changes(changes));
}

void after_user_object_impl(uintptr_t user_tagged,
uintptr_t x22_value,
uintptr_t x28_value) {
const uintptr_t heap = x28_value << 32;
if (heap != 0) {
g_heap_base.store(heap, std::memory_order_relaxed);
}

if (x22_value == 0) {
return;
}
patch_user_object(user_tagged, x22_value + kNullTrueOffset);
}
#endif

void install_libapp_hooks(uintptr_t base) {
if (base == 0) {
return;
}

g_libapp_base.store(base, std::memory_order_relaxed);

#if defined(__aarch64__)
if (g_hook_func == nullptr) {
log_info("hook function is not available yet");
return;
}

bool expected = false;
if (!g_hooks_installed.compare_exchange_strong(expected, true)) {
return;
}

const auto json_parse = reinterpret_cast<void *>(base + kOffsetJsonParse);
const auto common_decode = reinterpret_cast<void *>(base + kOffsetCommonDecode);
const auto parse_user = reinterpret_cast<void *>(base + kOffsetParseUser);
const auto get_user = reinterpret_cast<void *>(base + kOffsetGetUser);
const auto is_vip = reinterpret_cast<void *>(base + kOffsetIsVip);
const auto cra_to_json = reinterpret_cast<void *>(base + kOffsetCraToJson);
const auto ira_to_json = reinterpret_cast<void *>(base + kOffsetIraToJson);

int rc_parse_user = -1;
int rc_get_user = -1;
int rc_is_vip = -1;
int rc_cra_to_json = -1;
int rc_ira_to_json = -1;

if (kEnableUserObjectHooks) {
rc_parse_user = g_hook_func(parse_user,
reinterpret_cast<void *>(replace_parse_user_bridge),
reinterpret_cast<void **>(&g_backup_parse_user));
g_backup_parse_user_entry = reinterpret_cast<void *>(g_backup_parse_user);
rc_get_user = g_hook_func(get_user,
reinterpret_cast<void *>(replace_get_user_bridge),
reinterpret_cast<void **>(&g_backup_get_user));
g_backup_get_user_entry = reinterpret_cast<void *>(g_backup_get_user);
rc_is_vip = g_hook_func(is_vip,
reinterpret_cast<void *>(replace_is_vip_bridge),
reinterpret_cast<void **>(&g_backup_is_vip));
g_backup_is_vip_entry = reinterpret_cast<void *>(g_backup_is_vip);
}

if (kEnableMediaObjectHooks) {
rc_cra_to_json = g_hook_func(cra_to_json,
reinterpret_cast<void *>(replace_cra_to_json),
reinterpret_cast<void **>(&g_backup_cra_to_json));
rc_ira_to_json = g_hook_func(ira_to_json,
reinterpret_cast<void *>(replace_ira_to_json),
reinterpret_cast<void **>(&g_backup_ira_to_json));
}
int rc_json = -1;
int rc_decode = -1;

if (kEnableJsonParseHook) {
rc_json = g_hook_func(json_parse,
reinterpret_cast<void *>(replace_json_parse_bridge),
reinterpret_cast<void **>(&g_backup_json_parse));
g_backup_json_parse_entry = reinterpret_cast<void *>(g_backup_json_parse);
}

if (kEnableCommonDecodeHook) {
rc_decode = g_hook_func(common_decode,
reinterpret_cast<void *>(replace_common_decode),
reinterpret_cast<void **>(&g_backup_common_decode));
}

log_info("install_libapp_hooks base=0x" + std::to_string(base) +
" jsonHook=" + std::string(kEnableJsonParseHook ? "on" : "off") +
" commonDecodeHook=" + std::string(kEnableCommonDecodeHook ? "on" : "off") +
" userHooks=" + std::string(kEnableUserObjectHooks ? "on" : "off") +
" mediaHooks=" + std::string(kEnableMediaObjectHooks ? "on" : "off") +
" parseUserRc=" + std::to_string(rc_parse_user) +
" getUserRc=" + std::to_string(rc_get_user) +
" isVipRc=" + std::to_string(rc_is_vip) +
" craToJsonRc=" + std::to_string(rc_cra_to_json) +
" iraToJsonRc=" + std::to_string(rc_ira_to_json) +
" jsonParseRc=" + std::to_string(rc_json) +
" commonDecodeRc=" + std::to_string(rc_decode));
#else
log_info("install_libapp_hooks skipped: unsupported architecture");
#endif
}

void inspect_libapp_now() {
const auto base = find_module_base(kTargetLib);
if (!base.has_value()) {
log_info("libapp.so is not loaded yet");
return;
}

log_info("libapp.so already loaded before callback registration");
install_libapp_hooks(*base);
}

void on_library_loaded(const char *name, void *handle) {
if (name == nullptr) {
return;
}

const std::string lib_name(name);
if (!ends_with(lib_name, kTargetLib)) {
return;
}

log_info("observed target library load: " + lib_name);
if (handle != nullptr) {
log_info("target library handle is available");
}
if (const auto base = find_module_base(kTargetLib); base.has_value()) {
install_libapp_hooks(*base);
}
}

} // namespace

extern "C" void *g_backup_parse_user_entry = nullptr;
extern "C" void *g_backup_get_user_entry = nullptr;
extern "C" void *g_backup_is_vip_entry = nullptr;
extern "C" void *g_backup_json_parse_entry = nullptr;

extern "C" void madou_after_user_object(uintptr_t user_tagged,
uintptr_t x22_value,
uintptr_t x28_value) {
after_user_object_impl(user_tagged, x22_value, x28_value);
}

extern "C" void madou_on_json_parse_enter(uintptr_t json_tagged,
uintptr_t return_address,
uintptr_t x28_value) {
on_json_parse_enter_impl(json_tagged, return_address, x28_value);
}

extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *) {
g_vm = vm;
log_info("JNI_OnLoad");
return JNI_VERSION_1_6;
}

extern "C" JNIEXPORT void JNICALL
Java_com_bytectf_madouviphook_MainHook_nativeOnPackageLoaded(
JNIEnv *env,
jclass,
jstring packageName,
jstring processName) {
const char *package_name = packageName != nullptr ? env->GetStringUTFChars(packageName, nullptr) : "";
const char *process_name = processName != nullptr ? env->GetStringUTFChars(processName, nullptr) : "";

std::string message = "nativeOnPackageLoaded package=";
message += package_name != nullptr ? package_name : "";
message += " process=";
message += process_name != nullptr ? process_name : "";
log_info(message);

if (processName != nullptr) {
env->ReleaseStringUTFChars(processName, process_name);
}
if (packageName != nullptr) {
env->ReleaseStringUTFChars(packageName, package_name);
}

std::call_once(g_process_once, []() {
inspect_libapp_now();
});
}

extern "C" [[gnu::visibility("default")]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
if (entries != nullptr) {
g_hook_func = entries->hook_func;
g_unhook_func = entries->unhook_func;
}

log_info(std::string("native_init entries version=") +
(entries != nullptr ? std::to_string(entries->version) : "null"));
return on_library_loaded;
}