古文岛 Flutter 应用逆向实战全过程

古文岛 Flutter 应用逆向实战全过程
沧浪同学免责声明
本文仅记录在本人设备和合法取得样本上的学习研究过程,用于讨论 Flutter 应用结构、二进制分析和运行时调试方法。请仅在拥有软件、设备或明确授权的前提下复现。不得将文中方法用于绕过付费机制、侵害著作权或开发者权益、传播修改版应用,亦不得用于任何违法用途。文中出现的接口、账号信息与测试结果仅反映特定版本和环境,不构成长期有效性或安全性保证。因不当使用造成的损失或法律责任,由使用者自行承担。
本文记录了对「古文岛 3.2.0」进行 VIP 逆向的完整过程,包括每一步的思路、尝试、失败原因和最终方案。适合有一定 Android 基础但没做过 Flutter 逆向的读者。
第一章:认识敌人——古文岛是什么
1.1 第一步永远是看结构
拿到一个 APK,第一件事不是急着反编译,而是看它的组成结构。用 Python 解压 APK:
1 | import zipfile |
输出:
1 | 10450076 classes.dex ← 10MB 的 dex |
1.2 关键判断:这是什么技术栈
看到 libflutter.so + libapp.so 的组合,立刻就能确定——这是 Flutter 应用。
这两个 so 的分工:
1 | 你的 Dart 源码 → Dart AOT 编译器 → ARM64 机器码 → 打包进 libapp.so |
libflutter.so= Dart 虚拟机 + Flutter 渲染引擎(Skia/Impeller)libapp.so= 你的业务代码(Dart 编译后的机器码 + snapshot data)classes.dex= Android 壳层(FlutterActivity、插件注册、推送 SDK)
1.3 验证 dex 是空壳
APK 有 12MB 的 dex,看着像有业务代码。用 strings 搜索 dex 里的 vip/member/guwendao:
1 | strings classes.dex | grep -iE "vip|member|guwendao" |
一个都没有。 这证明 dex 纯粹是 Flutter 的 Android 胶水层——只负责承载 Flutter 渲染、注册插件、初始化推送 SDK。业务逻辑全在 libapp.so 里。
这就是 MT 管理器失效的第一层原因:MT 改的是 dex,但 dex 里没有业务代码。
第二章:用 IDA 分析 libapp.so
2.1 工具介绍:ida-pro-mcp
传统逆向用 IDA Pro 的 GUI 界面手动操作。但这次我们用的是 mrexodia/ida-pro-mcp——一个把 IDA Pro 通过 MCP 协议暴露给 AI 的工具。
它的架构:
1 | ┌─────────────┐ MCP/JSON-RPC ┌──────────────┐ |
关键:它用的是 idalib(IDA 的 headless SDK),不是 GUI 版。idalib 直接在 Python 进程里加载分析二进制,不弹窗口。这就是为什么全程看不到 IDA 界面——它跑在一个 python.exe 进程里。
2.2 启动 IDA 分析
1 | # 启动 idalib server,加载 libapp.so |
等待约 90 秒(20MB 的 .so 需要跑自动分析),直到 server_health 返回 status: ok。
2.3 通过 SSE 调用 IDA
ida-pro-mcp 用的是 SSE (Server-Sent Events) + POST 的传输方式:
1 | 客户端 idalib server |
2.4 survey_binary — 二进制全景图
调用 survey_binary 工具,一次返回:
| 项目 | 值 |
|---|---|
| 函数数 | 40,772(仅 1 个命名,其余全是 sub_XXXX) |
| 字符串数 | 261,907 |
| 代码段 | .text 0x9c0000–0x1320860(约 9.8MB) |
| 数据段 | .rodata 0x340–0x9b11e0(Dart snapshot data) |
| 入口点 | _kDartVmSnapshotInstructions 等 Dart 特有符号 |
| imports | 空(符号完全剥离) |
2.5 搜索业务字符串
Dart AOT 的字符串不是 C 字符串(不以 \0 结尾),IDA 的 strings 缓存认不出全部。改用 Python 直接搜原始字节:
1 | data = open('libapp.so','rb').read() |
2.6 发现的关键信息
VIP 字段名:
| 字段 | 用途 |
|---|---|
isVIP |
VIP 状态 getter |
isSVIP |
超级 VIP 状态 getter |
isSVIPOnPlatform |
平台级 SVIP 判断 |
isVipRequired |
某功能是否需要 VIP |
isPermanentSVIP |
永久 SVIP |
getSVIPRemainDays |
SVIP 剩余天数 |
verifyPayCompleted |
支付校验 |
API 端点(从 libapp.so 提取的 .aspx 路径):
1 | router/user/getUserInfo.aspx ← 获取用户信息(含VIP状态) |
URL:
1 | https://m.guwendao.net/app/DefaultGwd.aspx |
2.7 反编译热点函数
反编译了 xref 最多的 4 个函数(sub_9D13B0 8138 xref 等)。结果全是 Dart VM runtime stub——栈分配、GC 检查、对象头解析。
1 | __int64 sub_9D6FE0(__int64 a1, __int64 a2) { |
这是 Dart AOT 的典型特征:热点函数是 VM 内部代码,不是业务代码。业务逻辑在 Dart snapshot 里,需要 blutter 才能还原。
2.8 Dart AOT 编译标记
从 snapshot header 读到:
1 | 1ce86630892e2dca9a8543fdb8ed8ed8 ← build id |
product= Release 模式dedup_instructions= 相同机器码只存一份(patch 一处影响多处)compressed-pointers= 对象指针用 32 位 tag-based 格式
这就是 patch libapp.so 极其困难的原因:没有 blutter 无法定位函数地址,即使定位了也因 dedup_instructions 和 compressed-pointers 难以安全 patch。
第三章:为什么 MT 管理器不行
3.1 完整原因链
1 | 1. dex 是空壳 → 改 dex 没用 |
3.2 MT 管理器在 2024 年的定位
MT 不是被淘汰了,而是能处理的 app 比例在下降:
- 2018 年:MT 通吃 90% 的 app
- 2024 年:约 40~60%(Flutter/RN Hermes/Unity IL2CPP/加固 都处理不了)
但 MT 仍然是第一步判断工具:打开 APK 看 lib 目录,5 秒判断框架类型。
第四章:攻击面分析
不管什么框架,攻击面只有三层:
4.1 网络层(最通用)
拦截 HTTPS 响应,篡改服务端返回的 VIP 字段。
障碍:
- 古文岛用 cronet(不走系统代理)
- 可能有 SSL Pinning
- HTTPS 需要装 CA 证书(Android 7+ 需 root)
工具:mitmproxy / Frida hook SSL_read
4.2 运行时层(最灵活)
Hook 函数调用,篡改返回值。
障碍:
- Dart getter 无法用 LSPosed hook(LSPosed 只 hook Java 层)
- frida 17.x 在 Android 15 上注入失败
- 需要 blutter 定位 Dart getter 地址
工具:Frida / Xposed / LSPosed
4.3 二进制层(最难但最彻底)
静态反编译,理解逻辑后 patch 机器码。
障碍:
- 需要 blutter(Dart AOT 反编译器)
- 需要匹配 Dart SDK 版本
- dedup_instructions + compressed-pointers 让 patch 不安全
工具:IDA Pro / Ghidra / blutter
第五章:方案尝试与失败分析
5.1 方案 A:mitmproxy 代理篡改
思路:设置系统 HTTP 代理,拦截 API 响应,把 isSVIP:false 改成 true。
实现:
- 反编译 APK(apktool)
- 在 App.onCreate 注入
System.setProperty("http.proxyHost", "127.0.0.1") - 重打包签名
- 手机配代理 + 装证书
- mitmproxy 脚本篡改 JSON
失败原因:
- cronet 不走系统代理 → 核心请求绕过代理
- SSL Pinning 可能存在
- 装系统证书需要 root
- 影响 VPN:系统代理和 VPN 冲突
5.2 方案 B:frida-gadget 注入 APK
思路:把 frida-gadget.so 嵌入 APK,启动时自动加载 JS 脚本 hook SSL_read。
实现:
- 反编译 APK
- 放入 libfrida-gadget.so + config + hook.js
- 在 App.onCreate 里
System.loadLibrary("frida-gadget") - 重打包签名
- gadget 自动加载 JS 脚本
失败原因:
- 第一次闪退:smali 寄存器声明错误(
.registers 3但用了 v3)→ 修复为.registers 5 - 第二次不闪退但 hook 没生效:gadget .so 加载成功,但 config 文件没被读取
- 原因:frida-gadget 17.x 在 Android 15 上 config 查找逻辑有问题
- 换 16.x gadget:同样的问题——config 不生效,gadget 在 listen 模式而非 script 模式
5.3 方案 C:frida-server + Frida CLI
思路:手机 root + frida-server,PC 端 frida CLI spawn 注入。
失败原因:
- frida 17.19.0 在 Android 15 (SDK 35) 上注入进程时 SIGSEGV
- 崩溃日志:
signal 11 (SIGSEGV), x8 = 0x46524944 ("FRID")
- 崩溃日志:
- frida 16.7.19 同样超时:
TimedOutError: unexpectedly timed out while waiting for signal - 根本原因:Android 15 + HyperOS 的安全机制限制了 frida 的 ptrace 注入
5.4 方案 D:LSPosed 模块(Java 层 hook)
这是最终成功的方案。 详细过程见第六章。
第六章:LSPosed 模块方案——完整实现
6.1 发现 VIP 存储位置
这是最关键的突破。通过直接检查手机上的数据库文件,发现了 VIP 状态的存储位置。
第一步:检查 SharedPreferences XML 文件
1 | adb shell "su -c 'ls /data/data/com.guwendao.gwd/shared_prefs/'" |
结果:只有推送 SDK 的配置,没有 VIP 相关字段。
第二步:检查 SQLite 数据库
1 | adb shell "su -c 'ls /data/data/com.guwendao.gwd/databases/'" |
拉取 gwd.db 分析:
1 | import sqlite3 |
第三步:发现真正的数据库
1 | adb shell "su -c 'ls /data/data/com.guwendao.gwd/app_flutter/'" |
1 | db = sqlite3.connect('shared_flutter.db') |
关键发现:user 表有 vipTimeSpan 和 svipTimeSpan 字段,存储的是毫秒时间戳。客户端通过比较这个值和当前时间来判断是否是 VIP。
6.2 直接修改数据库验证
1 | db.execute('UPDATE user SET svipTimeSpan=9999999999999, vipTimeSpan=9999999999999') |
推回手机,启动 app → VIP 状态显示了! 但过一会又消失了——app 联网同步后被服务端覆盖。
6.3 构建 LSPosed 模块
6.3.1 模块结构
1 | lspmod2/ |
6.3.2 AndroidManifest.xml
1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
6.3.3 MainHook.smali — 入口
MainHook 实现了 IXposedHookLoadPackage 接口,在目标 app 加载时注册 hook:
1 | .method public handleLoadPackage(...)V |
6.3.4 SqlHook.smali — 核心逻辑
beforeHookedMethod(写入前篡改):
1 | .method protected beforeHookedMethod(...)V |
afterHookedMethod(写入后强制覆盖,防递归):
1 | .method protected afterHookedMethod(...)V |
6.3.5 编译签名
1 | # apktool 编译 |
6.3.6 LSPosed 启用
- 打开 LSPosed 管理器
- 模块页 → 启用「古文岛解锁」
- 作用域 → 勾选「古文岛」(com.guwendao.gwd)
- 强制停止古文岛,重新打开
6.4 踩过的坑
坑 1:smali 寄存器不足
1 | java.lang.VerifyError: register index out of range (3 >= 3) |
原因:声明了 .registers 3(只有 v0/v1/v2),但代码里用到了 v3。
修复:改为 .registers 5。
坑 2:smali 方法签名错误
1 | # 错误 — getResult() 是方法,不是字段 |
坑 3:const-wide 大数字
1 | # 错误 — smali 汇编器不支持大 hex 字面量 |
坑 4:afterHookedMethod 无限递归
1 | execSQL hook 的 afterHook 里又调用 execSQL → 触发 hook → afterHook → ...→ 卡死 |
修复:用静态 boolean 标志位 isUpdating,执行 UPDATE 时设 true,跳过 hook。
坑 5:frida-gadget config 不生效
frida-gadget 17.x 在 Android 15 上 config 文件查找逻辑有问题。gadget 加载了但没读到 config,回退到 listen 模式而非 script 模式。换 16.x 同样的问题。
坑 6:frida 在 Android 15 上注入崩溃
1 | signal 11 (SIGSEGV), x8 = 0x46524944 ("FRID") |
frida 17.19.0 和 16.7.19 在 Android 15 (SDK 35) + HyperOS 上都无法注入目标进程。
坑 7:AndroidManifest 缺少 xposedmodule
第一次编译的 APK 没有在 manifest 里声明 xposedmodule meta-data,LSPosed 不识别。修复 manifest 后解决。
坑 8:sqflite 不走 Java 层的 update/insert
LSPosed hook 了 SQLiteDatabase.update、insert、insertWithOnConflict——全部报错或从未触发。因为 sqflite 底层用的是 execSQL(String, Object[]),不是 update/insert。
6.5 最终效果
| 场景 | 效果 |
|---|---|
| 启动后日常使用 | ✅ 会员有效(SVIP/永久会员) |
| 进金紫会员中心 | ❌ 显示”未开通”(Dart 层直接用网络响应,不写数据库) |
| 退出重启 | ✅ 恢复 SVIP(hook 拦截了数据库写入) |
根本限制:LSPosed 只能 hook Java 层。古文岛会员中心页面的网络请求走 Dart 层的 cronet,收到响应后直接在 Dart 内存里更新 UI,不经过 Java 层。要彻底解决需要 hook Dart 网络层(frida 或 blutter)。
第七章:工具链总结
7.1 用到的工具
| 工具 | 用途 | 关键点 |
|---|---|---|
| Python zipfile | APK 解压分析 | 查看结构、提取文件 |
| androguard | APK 元数据解析 | 读包名、Application 类、SDK 版本 |
| apktool | APK 反编译/重编译 | smali ↔ dex 转换 |
| uber-apk-signer | APK 签名 | 自动 zipalign + v2/v3 签名 |
| ida-pro-mcp (idalib) | 二进制分析 | headless IDA,65 个 MCP 工具 |
| Python bytes search | 直接二进制搜索 | 比 IDA 快,绕过 Dart 字符串格式 |
| sqlite3 | 数据库分析 | 查看 user 表的 viptime/sviptime |
| LSPosed | Java 层 hook | hook execSQL 篡改写入值 |
7.2 工具调用流程
1 | Python zipfile → 判断 Flutter |
第八章:经验总结
8.1 Flutter 应用的逆向路线
1 | 1. 判断是否 Flutter → 看 libapp.so + libflutter.so |
8.2 关键教训
先找存储再 hook:不要上来就写 hook,先搞清楚 VIP 状态存在哪里。直接改数据库验证有效,再写 hook 拦截写入。
smali 手写要小心寄存器:
.registers N的 N 必须大于代码里用到的最大寄存器编号。多写一个不碍事,少写一个直接 VerifyError。防递归是必须的:如果 hook 了
execSQL,在 afterHook 里又调用execSQL,必须用标志位防止无限递归。sqflite 走 execSQL 不走 update:Flutter 的 sqflite 插件底层用
SQLiteDatabase.execSQL(String, Object[]),不是update/insert。hook 错方法等于白干。LSPosed 碰不到 Dart 层:这是根本限制。Dart 的网络请求走 cronet 或 dart:io HttpClient,收到响应后直接在 Dart 内存里处理,不经过 Java 层。
frida 在 Android 15 上不稳定:17.x 和 16.x 都有注入失败的问题。如果你用 Android 15 + HyperOS,不要指望 frida 能稳定工作。
8.3 下一步突破方向
blutter:如果能找到匹配 Dart 版本的 blutter,就能反编译 libapp.so,定位
isSVIPOnPlatformgetter 的机器码地址,直接 patch 成恒定返回 true。这是最彻底的方案。Zygisk native hook:用 Zygisk 注入一个 .so 到目标进程,在 .so 的 JNI_OnLoad 里用 inline hook 框架(如 Dobby/ShadowHook)hook
SSL_read。不需要 frida,纯 native 实现。找到可用的 frida 版本:也许某个特定版本的 frida 能在 Android 15 上工作,需要逐一测试。
本文档基于实际逆向分析编写,以古文岛 3.2.0(Flutter)为分析样本。
工具环境:Windows + Python 3.13 + apktool 2.9.3 + IDA Pro (idalib) + LSPosed + KernelSU
手机环境:Redmi (HyperOS / Android 15 / SDK 35) + KernelSU + LSPosed






