前言

混合开发(Hybrid App)因其跨平台、迭代快的特性,在目前的移动端开发中占据了相当的比例。本文以一款基于 APICloud 框架开发的 App 为例,完整复盘一次针对其本地业务逻辑及后端鉴权进行逆向分析与动态 Hook 的实战过程。

该目标 App 的核心业务逻辑主要由 Vue.js 编写,并通过打包工具封装在 APK 的 assets/widget 目录下。分析的最终目的是绕过其课程播放的 VIP 权限限制。

一、 静态篡改与完整性自毁机制

1.1 业务逻辑定位

使用解包工具(如 APK Editor Studio 或 MT 管理器)提取目标 APK,定位到核心逻辑文件 assets/widget/script/page/lesson_detail.js。通过代码审计,发现控制购买状态的核心变量及逻辑如下:

1
2
3
4
5
6
7
8
9
data: {
// ...
isBuy: -1,
// ...
},
init: function () {
this.ajaxPost(xqwxt_url + '&do=lesson&op=init', { id: id }, function (vm, ret) {
vm.isBuy = ret.data.isbuy; // 关键状态赋值
// ...

初步方案是在本地直接修改 JS 文件,强行将初始状态置为 isBuy: 1 或通过拦截回调修改 ret.data.isbuy

1.2 静态打包与闪退排查

完成文件修改后,重新签名并打包 APK。然而,使用该测试包覆盖安装或卸载重装后,App 在启动瞬间直接闪退,未抛出任何常规的 Java 层 Crash 弹窗。

为查明原因,通过 adb logcat 捕获系统日志,过滤出目标包名的核心报错:

1
2
3
4
7627  7627 E com.cfy.xqwxt: Not starting debugger...
... cmp=ComponentInfo{com.xxx.xxxx/com.uzmap.pkg.LauncherUI}
7627 7656 E LB : fail to open file: No such file or directory
4359 5919 E SuggestManager: openApp name = com.miui.home

分析结论

异常发生在底层的 C/C++ 加载阶段(com.uzmap.pkg 为 APICloud 的底层包名)。在 APK 的 lib/ 目录下存在一个 libsec.so 文件。这是 APICloud 的安全防护模块。在引擎初始化时,它会对 assets 目录下的核心文件进行 MD5 或哈希校验。一旦检测到文件被篡改,底层 C++ 代码会直接抛出文件读取异常或调用 exit(0),导致进程强行终止。

因此,静态改包的路径在此类具有底层防篡改校验的框架前宣告失效

二、 动态注入与架构盲区

既然无法修改本地文件,战术转向使用 LSPosed 进行内存劫持(Hook)。

2.1 尝试 Hook 网络层(失败)

最初的思路是 Hook Android 标准的网络库。假设目标 App 底层使用 OkHttp 或是原生的 HttpURLConnection,我们可以在其获取响应体(ResponseBody)时,将返回的 JSON 中的 "isbuy":0 替换为 "isbuy":1

同时,也尝试了 Hook org.json.JSONObjectput 方法,试图在 JSON 解析阶段进行拦截。

结果:模块成功加载,但目标方法均未被调用。

2.2 混合架构的网络流分析

在 APICloud 这种深度套壳的架构中,网络请求往往不经过 Java 层的原生网络库。其底层 C++ 引擎(集成 V8 引擎)会直接发起网络请求,在拿到服务器返回的纯文本数据后,不经过 Java 层的 JSONObject,而是直接通过 JNI 调用将数据传递给内置的 JS 引擎,由 JSON.parse 处理。这就导致了我们在 Java 层常规网络与解析组件上布下的 Hook 彻底失效。

三、 突破口:WebView JS 通信桥梁

尽管底层逻辑被封装在 C++ 中,但数据最终必须渲染到前端页面。在 Android 系统中,C++ / Java 层与 Web 前端通信的唯一合法通道是 WebView 的代码注入接口:evaluateJavascript 或老式的 loadUrl("javascript:...")

这是架构上的绝对“咽喉”节点。我们调整 LSPosed 模块,对 android.webkit.WebView 进行 Hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拦截 evaluateJavascript
XposedHelpers.findAndHookMethod(
"android.webkit.WebView",
lpparam.classLoader,
"evaluateJavascript",
String::class.java,
android.webkit.ValueCallback::class.java,
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val script = param.args[0] as? String ?: return
Log.e("MKR_HOOK", "拦截到数据: ${script.take(200)}")
// ... 注入篡改逻辑
}
}
)

挂载模块后,Logcat 成功输出以下关键日志:

1
04-15 19:06:41.312 24621 E MKR_HOOK: 🎯 抓到 V8 通信通道: if(window._api$cb_){_api$cb_.on('23',JSON.parse('{\"errno\":0,\"message\":\"succ\",\"data\":{\"isbuy\":0,...

至此,数据流终于被成功拦截。

四、 数据篡改与后端鉴权对抗

拦截到数据后,进入数据处理阶段。此阶段遇到了两个主要的技术细节问题:转义字符与后端的二次鉴权。

4.1 转义字符与正则匹配替换

从拦截到的日志可以看出,底层传递的并非标准的 JSON 对象,而是一段带有反斜杠转义的字符串:{\"isbuy\":0

若使用常规的 .replace("\"isbuy\":0", "\"isbuy\":1"),将无法匹配到目标。必须对转义形式进行全量适配。

4.2 拦截二次鉴权(播放凭证伪造)

在处理完 isbuy 状态后,前端 UI 成功变为“已购买”状态。但点击播放视频时,依然弹出错误提示:“请先购买课程后再学习!”。

通过再次抓包分析,发现播放动作触发了一个二次鉴权请求 op=getSection。服务器返回了如下拒绝报文:

1
2
3
4
5
{
"errno": -2,
"message": "请先购买课程后再学习!",
"data": ""
}

前端接收到 errno: -2 时中断了原生播放器的调用。幸运的是,通过对该 App 前期业务逻辑的逆向发现,在其初始化目录接口(op=getCatalogList)中,服务端已经将所有真实的视频播放地址(videourl)下发到了本地内存

这意味着,这个 op=getSection 仅仅是一个状态询问接口。我们只需要在 WebView 层将这个“拒绝报文”篡改为“许可报文”,前端就会无条件调用内存中的 URL 进行播放。

4.3 终极 Payload 编写

由于返回的 JSON 中存在空格、空字符串 "" 以及转义反斜杠,基于 Kotlin 编写了无死角的字符串替换逻辑。完整 LSPosed 模块代码如下:

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

import android.util.Log
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage

class MainHook : IXposedHookLoadPackage {

override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
if (lpparam.packageName != "com.xxxx.xxx") return

Log.e("MKR_HOOK", "=== TARGET APP LAUNCHED ===")

try {
// 目标:拦截现代 WebView 的数据交互通道 evaluateJavascript
XposedHelpers.findAndHookMethod(
"android.webkit.WebView",
lpparam.classLoader,
"evaluateJavascript",
String::class.java,
android.webkit.ValueCallback::class.java,
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val script = param.args[0] as? String ?: return

// 设置门卫白名单:包含状态字段或后端拒绝提示
if (script.contains("isbuy") || script.contains("is_free") || script.contains("请先购买")) {
Log.e("MKR_HOOK", "🎯 抓到 V8 通信通道: ${script.take(200)}...")

val newScript = script
// ================== 突破第一层:解锁 UI 和目录 ==================
.replace("\"isbuy\":0", "\"isbuy\":1")
.replace("\"isbuy\":-1", "\"isbuy\":1")
.replace("\"isbuy\": 0", "\"isbuy\":1")
.replace("\"isbuy\": -1", "\"isbuy\":1")
.replace("\"is_free\":0", "\"is_free\":1")
.replace("\"is_free\":\"0\"", "\"is_free\":\"1\"")
.replace("\\\"isbuy\\\":0", "\\\"isbuy\\\":1")
.replace("\\\"isbuy\\\":-1", "\\\"isbuy\\\":1")
.replace("\\\"is_free\\\":0", "\\\"is_free\\\":1")
.replace("\\\"is_free\\\":\\\"0\\\"", "\\\"is_free\\\":\\\"1\\\"")

// ================== 突破第二层:拦截后端的播放拒绝,伪造凭证 ==================
.replace("请先购买课程后再学习!", "伪造许可成功!开始播放!")
.replace("请先购买课程后再学习!", "伪造许可成功!开始播放!")

// 拦截 errno (覆盖带空格和不带空格的情况)
.replace("\"errno\":1", "\"errno\":0")
.replace("\"errno\":-1", "\"errno\":0")
.replace("\"errno\":-2", "\"errno\":0")
.replace("\"errno\": 1", "\"errno\":0")
.replace("\"errno\": -1", "\"errno\":0")
.replace("\"errno\": -2", "\"errno\":0")
.replace("\\\"errno\\\":1", "\\\"errno\\\":0")
.replace("\\\"errno\\\":-2", "\\\"errno\\\":0")
.replace("\\\"errno\\\": -2", "\\\"errno\\\":0")

// 拦截 data (覆盖 "" 空字符串和 null)
.replace("\"data\":\"\"", "\"data\":{\"sectiontype\":1,\"savetype\":1}")
.replace("\"data\": \"\"", "\"data\":{\"sectiontype\":1,\"savetype\":1}")
.replace("\\\"data\\\":\\\"\\\"", "\\\"data\\\":{\\\"sectiontype\\\":1,\\\"savetype\\\":1}")
.replace("\\\"data\\\": \\\"\\\"", "\\\"data\\\":{\\\"sectiontype\\\":1,\\\"savetype\\\":1}")
.replace("\"data\":null", "\"data\":{\"sectiontype\":1,\"savetype\":1}")
.replace("\"data\": null", "\"data\":{\"sectiontype\":1,\"savetype\":1}")
.replace("\\\"data\\\":null", "\\\"data\\\":{\\\"sectiontype\\\":1,\\\"savetype\\\":1}")

param.args[0] = newScript
Log.e("MKR_HOOK", "👑 === WebView 数据注入成功 ===")
}
}
}
)
} catch (t: Throwable) {
Log.e("MKR_HOOK", "HOOK ERROR: ${t.message}")
}
}
}

编译模块并在 LSPosed 中启用,重启目标 App 后,全部阻碍均被清除。底层虽然拦截到了鉴权失败的信息,但在送达前端之前已被模块替换为包含有效 sectiontypesavetype 的放行许可,成功拉起底层原生播放器加载缓存中的视频 URL。

五、 总结与思考

  1. 防篡改机制的降维绕过:面对集成完整性校验(如 libsec.so)的套壳框架,静态修改重打包成本极高。利用动态 Hook(或网络抓包重写)从内存层面拦截数据流,是更为高效的降维打击手段。
  2. 混合开发架构的通讯特性:Hybrid App(如 APICloud、Cordova 等)通常不使用标准的 Java 网络框架,而是由底层 C++ 获取数据并直接注入 WebView。因此,寻找如 evaluateJavascript 这样的跨界“咽喉”至关重要。
  3. 后端设计的逻辑缺陷:本次破解得以成功的根本原因,在于后端在不需要的接口提前下发了敏感数据(真实的视频播放 URL),而在播放阶段仅进行轻量级的状态校验,缺少服务端基于 Token 的动态视频防盗链(如动态生成时效性的 m3u8 地址),导致客户端一旦被篡改即可无限制获取资源。