-
-
[原创]一次安卓混合应用实时对战玩法的逆向分析实录:从抓包失败到全链路 Hook
-
发表于: 6天前 253
-
一次安卓混合应用实时对战玩法的逆向分析实录:从抓包失败到全链路 hook
本文记录对某头部教育 App「实时口算 PK」玩法的一次完整安全研究过程:如何从零开始还原一个加密传输 + 本地判分 + WebView 混合架构的对战协议,以及过程中踩过的七个值得记录的坑。所有内容已脱敏,不含可直接复用的代码与目标信息,仅供移动安全与逆向工程学习交流。
0. 背景
朋友家孩子沉迷某口算 PK,勾起了我的好奇:这种实时对战答题的玩法,题目和判定逻辑是怎么做的?服务端判分还是客户端判分?本着研究目的,我在自己的设备、自己的账号上做了一次完整分析。
研究环境:x86_64 模拟器(Android 12,root)+ Frida 17 + Python 驱动脚本。
1. 为什么抓包这条路走不通
第一反应是上抓包工具(Charles/mitmproxy),但很快发现三个拦路虎:
- 证书锁定:应用内置了目标域名的证书校验,中间人代理直接失败;
- 业务层再加密:即使绕过证书锁定,核心接口的响应体在 TLS 之上还有一层自定义加密(base64 封装的密文),抓到的也是密文;
- 协议伪造无意义:即使解开密文,每个请求还带签名参数,且服务端握有「开局/提交」两个时间戳,伪造耗时对不上立即穿帮。
结论:抓包只能看到"信封",看不到"信"。要拿到明文,必须在解密发生的地方——应用进程内部——下钩子。这正是 Frida 的主场。
2. 架构还原:三层夹心的混合应用
通过 hook 网络层(OkHttp)、JSON 解析层(Gson/org.json)和 WebView 桥,逐渐还原出完整的架构:
┌─────────────────────────────────────────┐
│ WebView (H5 游戏页面,Vue SPA) │ ← 玩法逻辑全在这
│ 手写板 → 识别请求 → 答案判定 → 提交 │
└──────────────┬──────────────────────────┘
│ JS Bridge (双向)
┌──────────────┴──────────────────────────┐
│ 原生层 (Android) │
│ · 手写 OCR 识别(native so) │
│ · 加解密(H5 调原生加解密桥) │
│ · 代理网络请求(带签名) │
└──────────────┬──────────────────────────┘
│ TLS + 业务层加密
┌──────────────┴──────────────────────────┐
│ 服务端 │
└─────────────────────────────────────────┘
几个关键观察:
- H5 → 原生走 JS Bridge,消息是 JSON 字符串,原生侧用 Gson 解析——hook
Gson.fromJson(String, Type)能看到 H5 发来的一切; - 原生 → H5 走
WebView.loadUrl("javascript:(window.xxx && window.xxx(\"...\"))")——hookloadUrl能看到原生回给 H5 的一切; - 核心接口响应是密文,H5 拿到后调原生解密桥——hook 解密结果的序列化点,明文到手。
3. 核心发现:判分在客户端
解密出对战匹配接口的响应后,一个有意思的事实浮出水面:
响应里除了题目,还带着每道题的正确答案。
为什么这么设计?因为这是竞速玩法——判定必须零延迟才有游戏体验,所以判分放在了客户端本地(识别结果 vs 本地答案即时比对),答完只把结果上报。而「答案必须随题下发」就是这个玩法的结构性弱点:只要你能看到解密后的数据,开局的瞬间你就已经知道了所有答案。
更妙的是,后续分析手写识别协议时发现:连每次识别请求的参数里都携带了当前题目的期望答案——这意味着替换逻辑甚至不需要维护自己的答案表,每一轮识别往返都是自包含的。
风控侧当然知道这个问题,于是打了两个补丁:单题平均耗时低于阈值触发人机验证、验证题库硬编码在前端 JS 里。这两个补丁的分析过程本文按下不表——它们恰好验证了一个规律:客户端反作弊对于拥有完全控制权的对手来说,只是延迟变量。
4. 踩坑实录(本文真正的干货)
坑 1:hook System.loadLibrary 直接把应用搞崩
早期想监控 native 库加载,hook 了 System.loadLibrary。应用启动即崩:UnsatisfiedLinkError: dlopen failed。
根因:Android 的 System.loadLibrary 内部用 VMStack.getCallingClassLoader() 依赖调用栈帧来确定从哪个 classloader 的 lib 目录找 so。Frida 的 hook 在调用链里插了一帧蹦床,"调用者"变成了 Frida 的基础设施,classloader 解析随之错位。
教训:栈敏感的方法不要 hook。遇到这类方法,去 hook 它内部某个显式传参的层。
坑 2:Frida 17 的 Java bridge 拆包了
session.create_script() 里 Java 直接 ReferenceError。
根因:Frida 17 起官方把 Java/ObjC bridge 从 agent 核心拆成了独立 npm 包。CLI 工具自带注入,但 Python API 裸加载脚本没有。
解法:npm i frida-java-bridge + frida-compile 把 bridge 打包进脚本:
import Java from 'frida-java-bridge';
Java.perform(() => { /* ... */ });
坑 3:bridge 会把 Java String 自动转成 JS string
日志代码里 s.length() 报 TypeError: not a function——因为参数 s 已经是 JS 字符串,length 是属性不是方法。
教训:新版本 bridge 的自动封送规则要留意,字符串参数统一 String(s) 转换后再操作最稳。
坑 4:hook 实现里的异常会打断原始调用,进而杀死应用
某个 hook 的日志逻辑抛了 JS 异常,导致原始方法没执行完,Java 层拿到损坏的返回值,两秒后 NPE 崩溃。
教训:所有 hook 实现用防御式结构——日志逻辑全包 try/catch,原始调用放在保护块之外,保证应用行为永远不受分析代码影响:
SomeClass.method.implementation = function (arg) {
try { logSomething(arg); } catch (e) { /* 吞掉 */ }
return this.method(arg); // 永远执行
};
坑 5:跨线程持有 WebView 包装对象 → 原生 SIGSEGV
在 loadUrl hook 里把 this(WebView)存进闭包,延时 2.5 秒后调用其方法——应用直接原生崩溃 Bad access due to invalid address,连着两次。
根因:hook 实现里的 this 是 Frida 的临时句柄,跨延时/跨线程使用时底层引用已失效,JNI 调用变成野指针。
解法:需要延后使用的对象用 Java.retain(obj) 固化成稳定引用;并且 WebView 方法必须在主线程调,配合 Java.scheduleOnMainThread。
坑 6:script.unload() 的原生死锁
GUI 里「停止」按钮偶尔冻结整个程序。加了超时也不行——卡的不是等待,是进程级锁。
根因:长时间运行的会话中,在 JS 运行时中段执行 unload() 会触发 Frida 原生层的死锁(GIL 被占不释放),整个 Python 进程冻结。
解法:根本不 unload。用消息通道(script.post / agent 侧 recv)给脚本发「休眠开关」——hook 保持安装但全部旁路,行为上等于停止,且随时可以再武装。
坑 7:双端计数器漂移
自动化脚本按"我处理到第 N 题"记账,hook 按"第 M 个回调"记账,一旦中间多出任何一次意外回调(过渡期抢答、重试),两边错位,后续答案全部串题。
解法:找到唯一的权威数据源,双端都从它同步。本例中游戏自身的埋点事件携带权威的题号,hook 以它为准计算目标;更进一步,直接从每轮请求参数里取当前题的期望答案,让每一轮往返自包含、彻底免疫错位。
这个坑的教训比技术本身更有价值:分布式状态如果不是单一事实来源,漂移只是时间问题。
附:GUI 的两个卡死(tkinter 视角)
- 同步 RPC 在 UI 主线程上发起,agent 繁忙时挂死界面 → 改
post/recv消息通道,零阻塞; - tkinter 的
after调度链里任何一处未捕获异常都会静默断链,表现为界面假死 → 事件循环全程异常保护。
5. 给开发方的防御建议
站在防守视角,这次研究暴露的问题和改进方向:
- 判分必须回归服务端。客户端判分 + 答案下发是结构性弱点,所有后续补丁都是在弱点上打补丁。竞速玩法可以用"客户端预展示 + 服务端权威判定"的折中。
- 不要把反作弊规则硬编码在前端 JS 里。混淆只是提高门槛,明文分析 bundle 就能还原全部规则(本研究就是这样找到触发阈值的)。
- 遥测一致性校验是最有效的信号。正常对局产生完整的埋点流(每题耗时、抬笔事件、笔画数据),自动化工具很难长期完美伪造全部遥测——服务端应消费这些信号做离线风控,而不是依赖客户端弹窗拦截。
- 本地存储的"免验证次数"类字段形同虚设,可被任意改写。
6. 结语
这次研究最有意思的不是结果,而是路径:抓包失败 → 转向进程内 hook → 三层架构逐层还原 → 找到结构性弱点 → 用最小干预利用它。移动端混合应用把玩法逻辑放在 H5、把安全能力放在原生层、把信任放在客户端——这个架构本身,就是最大的攻击面。
七个坑里有一半(栈敏感方法、临时句柄、unload 死锁)是 Frida 的深水区,踩过的人不多,写出来希望后来者少走弯路。
声明:本研究在本人自有设备与账号上进行,仅用于安全技术学习,未对任何服务的其他用户造成影响,不提供、也不包含任何可直接使用的工具代码。请遵守目标服务的用户协议与所在地法律法规。