前言 混合开发(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.JSONObject 的 put 方法,试图在 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 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.Logimport de.robv.android.xposed.IXposedHookLoadPackageimport de.robv.android.xposed.XC_MethodHookimport de.robv.android.xposed.XposedHelpersimport de.robv.android.xposed.callbacks.XC_LoadPackageclass MainHook : IXposedHookLoadPackage { override fun handleLoadPackage (lpparam: XC_LoadPackage .LoadPackageParam ) { if (lpparam.packageName != "com.xxxx.xxx" ) return Log.e("MKR_HOOK" , "=== TARGET APP LAUNCHED ===" ) try { 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 .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("请先购买课程后再学习!" , "伪造许可成功!开始播放!" ) .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" ) .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 后,全部阻碍均被清除。底层虽然拦截到了鉴权失败的信息,但在送达前端之前已被模块替换为包含有效 sectiontype 与 savetype 的放行许可,成功拉起底层原生播放器加载缓存中的视频 URL。
五、 总结与思考 防篡改机制的降维绕过 :面对集成完整性校验(如 libsec.so)的套壳框架,静态修改重打包成本极高。利用动态 Hook(或网络抓包重写)从内存层面拦截数据流,是更为高效的降维打击手段。混合开发架构的通讯特性 :Hybrid App(如 APICloud、Cordova 等)通常不使用标准的 Java 网络框架,而是由底层 C++ 获取数据并直接注入 WebView。因此,寻找如 evaluateJavascript 这样的跨界“咽喉”至关重要。后端设计的逻辑缺陷 :本次破解得以成功的根本原因,在于后端在不需要的接口提前下发了敏感数据(真实的视频播放 URL),而在播放阶段仅进行轻量级的状态校验,缺少服务端基于 Token 的动态视频防盗链(如动态生成时效性的 m3u8 地址),导致客户端一旦被篡改即可无限制获取资源。