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

免责声明

本文仅记录在本人设备和合法取得样本上的学习研究过程,用于讨论 Flutter 应用结构、二进制分析和运行时调试方法。请仅在拥有软件、设备或明确授权的前提下复现。不得将文中方法用于绕过付费机制、侵害著作权或开发者权益、传播修改版应用,亦不得用于任何违法用途。文中出现的接口、账号信息与测试结果仅反映特定版本和环境,不构成长期有效性或安全性保证。因不当使用造成的损失或法律责任,由使用者自行承担。

本文记录了对「古文岛 3.2.0」进行 VIP 逆向的完整过程,包括每一步的思路、尝试、失败原因和最终方案。适合有一定 Android 基础但没做过 Flutter 逆向的读者。


第一章:认识敌人——古文岛是什么

1.1 第一步永远是看结构

拿到一个 APK,第一件事不是急着反编译,而是看它的组成结构。用 Python 解压 APK:

1
2
3
4
5
6
import zipfile
z = zipfile.ZipFile('古文岛 3.2.0(320).apk')
for n in z.namelist():
if n.endswith('.dex') or n.endswith('.so'):
info = z.getinfo(n)
print(f'{info.file_size:>12} {n}')

输出:

1
2
3
4
5
10450076 classes.dex          ← 10MB 的 dex
2255552 classes2.dex ← 2MB 的 dex
20120496 lib/arm64-v8a/libapp.so ← 20MB 的 so!
11107920 lib/arm64-v8a/libflutter.so ← 10MB 的 Flutter 引擎
6817104 lib/arm64-v8a/libcronet*.so ← 6MB 的 Chromium 网络栈

1.2 关键判断:这是什么技术栈

看到 libflutter.so + libapp.so 的组合,立刻就能确定——这是 Flutter 应用。

这两个 so 的分工:

1
2
3
4
5
你的 Dart 源码  →  Dart AOT 编译器  →  ARM64 机器码  →  打包进 libapp.so
↓
libflutter.so (Dart VM + Skia 渲染引擎)
↓
直接画到 Android Surface 上
  • 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
2
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
2
3
4
5
6
7
8
9
┌─────────────┐     MCP/JSON-RPC      ┌──────────────┐
│ AI 客户端 │ ←──────────────────→ │ idalib_server│
│ (ZCode等) │ SSE over HTTP │ (headless) │
└─────────────┘ └──────┬───────┘
│ 内嵌调用
┌──────┴───────┐
│ IDA idalib │
│ (分析引擎) │
└──────────────┘

关键:它用的是 idalib(IDA 的 headless SDK),不是 GUI 版。idalib 直接在 Python 进程里加载分析二进制,不弹窗口。这就是为什么全程看不到 IDA 界面——它跑在一个 python.exe 进程里。

2.2 启动 IDA 分析

1
2
# 启动 idalib server,加载 libapp.so
python -m ida_pro_mcp.idalib_server --host 127.0.0.1 --port 1960 --verbose libapp.so

等待约 90 秒(20MB 的 .so 需要跑自动分析),直到 server_health 返回 status: ok。

2.3 通过 SSE 调用 IDA

ida-pro-mcp 用的是 SSE (Server-Sent Events) + POST 的传输方式:

1
2
3
4
5
6
7
8
9
10
客户端                          idalib server
│ │
│── GET /sse ──────────────────→│ (建立 SSE 长连接)
│←── data: /sse?session=xxx ───│ (返回 endpoint 路径)
│ │
│── POST /sse?session=xxx ────→│ (发送 JSON-RPC 请求)
│ {"method":"tools/call", │
│ "params":{"name":"decompile",│
│ "args":{"addr":"0x9d13b0"}}} │
│←── data: {"result":{...}} ────│ (通过 SSE 流返回响应)

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
2
3
4
5
6
7
data = open('libapp.so','rb').read()
for pat in [b'isVIP', b'isSVIP', b'isSVIPOnPlatform', b'getUserInfo']:
idx = data.find(pat)
if idx != -1:
# 读取上下文
ctx = data[max(0,idx-30):idx+100]
print(f'{pat.decode()} @ 0x{idx:x}: {ctx}')

2.6 发现的关键信息

VIP 字段名:

字段 用途
isVIP VIP 状态 getter
isSVIP 超级 VIP 状态 getter
isSVIPOnPlatform 平台级 SVIP 判断
isVipRequired 某功能是否需要 VIP
isPermanentSVIP 永久 SVIP
getSVIPRemainDays SVIP 剩余天数
verifyPayCompleted 支付校验

API 端点(从 libapp.so 提取的 .aspx 路径):

1
2
3
router/user/getUserInfo.aspx    ← 获取用户信息(含VIP状态)
Ali/AliCheck.aspx ← 支付宝校验
Wechat/WxCheck.aspx ← 微信校验

URL:

1
2
3
https://m.guwendao.net/app/DefaultGwd.aspx
https://pay.guwendao.net/
https://oss.guwendao.net/

2.7 反编译热点函数

反编译了 xref 最多的 4 个函数(sub_9D13B0 8138 xref 等)。结果全是 Dart VM runtime stub——栈分配、GC 检查、对象头解析。

1
2
3
4
5
6
7
__int64 sub_9D6FE0(__int64 a1, __int64 a2) {
*(_QWORD *)(v2 - 16) = v6; // 保存栈帧
*(_QWORD *)(v2 - 8) = v7;
if (v2 - 16 <= *(_QWORD *)(v4 + 72))
sub_13049EC(); // GC stack overflow 检查
...
}

这是 Dart AOT 的典型特征:热点函数是 VM 内部代码,不是业务代码。业务逻辑在 Dart snapshot 里,需要 blutter 才能还原。

2.8 Dart AOT 编译标记

从 snapshot header 读到:

1
2
3
1ce86630892e2dca9a8543fdb8ed8ed8  ← build id
product no-code_comments no-dwarf_stack_traces_mode
dedup_instructions compressed-pointers arm64 android
  • product = Release 模式
  • dedup_instructions = 相同机器码只存一份(patch 一处影响多处)
  • compressed-pointers = 对象指针用 32 位 tag-based 格式

这就是 patch libapp.so 极其困难的原因:没有 blutter 无法定位函数地址,即使定位了也因 dedup_instructions 和 compressed-pointers 难以安全 patch。


第三章:为什么 MT 管理器不行

3.1 完整原因链

1
2
3
4
5
6
1. dex 是空壳 → 改 dex 没用
2. libapp.so 是 Dart AOT → MT 无法解析
3. 40,772 个函数全部无名 → 无法定位任何业务逻辑
4. 字符串是 Dart OneByteString 对象 → MT 的字符串引擎识别不全
5. 改字符串名无用 → VIP 判断是 getter 返回 bool,不是字符串比较
6. dedup_instructions + compressed-pointers → patch 极易崩溃

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。

实现:

  1. 反编译 APK(apktool)
  2. 在 App.onCreate 注入 System.setProperty("http.proxyHost", "127.0.0.1")
  3. 重打包签名
  4. 手机配代理 + 装证书
  5. mitmproxy 脚本篡改 JSON

失败原因:

  • cronet 不走系统代理 → 核心请求绕过代理
  • SSL Pinning 可能存在
  • 装系统证书需要 root
  • 影响 VPN:系统代理和 VPN 冲突

5.2 方案 B:frida-gadget 注入 APK

思路:把 frida-gadget.so 嵌入 APK,启动时自动加载 JS 脚本 hook SSL_read。

实现:

  1. 反编译 APK
  2. 放入 libfrida-gadget.so + config + hook.js
  3. 在 App.onCreate 里 System.loadLibrary("frida-gadget")
  4. 重打包签名
  5. 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
2
adb shell "su -c 'ls /data/data/com.guwendao.gwd/shared_prefs/'"
adb shell "su -c 'cat /data/data/com.guwendao.gwd/shared_prefs/*.xml'"

结果:只有推送 SDK 的配置,没有 VIP 相关字段。

第二步:检查 SQLite 数据库

1
2
adb shell "su -c 'ls /data/data/com.guwendao.gwd/databases/'"
# 发现 gwd.db

拉取 gwd.db 分析:

1
2
3
import sqlite3
db = sqlite3.connect('gwd.db')
# 但表是空的——数据在 WAL 文件里

第三步:发现真正的数据库

1
2
adb shell "su -c 'ls /data/data/com.guwendao.gwd/app_flutter/'"
# 发现 shared_flutter.db!
1
2
3
4
5
6
7
db = sqlite3.connect('shared_flutter.db')
cur.execute('SELECT * FROM user')
# 结果:
# id: 2047573
# nicheng: 一个小人人
# vipTimeSpan: 1658678400000 → 2022-07-25 (已过期)
# svipTimeSpan: 946656000000 → 2000-01-01 (早已过期)

关键发现:user 表有 vipTimeSpan 和 svipTimeSpan 字段,存储的是毫秒时间戳。客户端通过比较这个值和当前时间来判断是否是 VIP。

6.2 直接修改数据库验证

1
2
db.execute('UPDATE user SET svipTimeSpan=9999999999999, vipTimeSpan=9999999999999')
db.commit()

推回手机,启动 app → VIP 状态显示了! 但过一会又消失了——app 联网同步后被服务端覆盖。

6.3 构建 LSPosed 模块

6.3.1 模块结构

1
2
3
4
5
6
7
8
9
lspmod2/
├── AndroidManifest.xml ← 声明 Xposed 模块
├── apktool.yml ← apktool 配置
├── assets/
│ └── xposed_init ← 内容: com.guwendao.viphook.MainHook
└── smali/com/guwendao/viphook/
├── MainHook.smali ← 入口类,注册 hook
├── SqlHook.smali ← hook execSQL,篡改 bindArgs
└── CvHook.smali ← hook update/insert/replace

6.3.2 AndroidManifest.xml

1
2
3
4
5
6
7
8
9
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.guwendao.viphook">
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="34" />
<application android:label="古文岛解锁">
<meta-data android:name="xposedmodule" android:value="true" />
<meta-data android:name="xposeddescription" android:value="古文岛 VIP/SVIP 解锁" />
<meta-data android:name="xposedminversion" android:value="93" />
</application>
</manifest>

6.3.3 MainHook.smali — 入口

MainHook 实现了 IXposedHookLoadPackage 接口,在目标 app 加载时注册 hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.method public handleLoadPackage(...)V
# 只处理 com.guwendao.gwd
if (!packageName.equals("com.guwendao.gwd")) return

# Hook 1: execSQL(String, Object[]) — sqflite 用这个执行 SQL
XposedHelpers.findAndHookMethod(
"android.database.sqlite.SQLiteDatabase",
classLoader, "execSQL",
String.class, Object[].class, new SqlHook())

# Hook 2: execSQL(String) — 无参数版本
XposedHelpers.findAndHookMethod(
"android.database.sqlite.SQLiteDatabase",
classLoader, "execSQL",
String.class, new SqlHook())

# Hook 3-5: update/insert/replace — 兜底
...
.end method

6.3.4 SqlHook.smali — 核心逻辑

beforeHookedMethod(写入前篡改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.method protected beforeHookedMethod(...)V
# 1. 获取 SQL 语句 (args[0])
sql = (String) args[0]

# 2. 检查是否含 sviptimespan 或 viptimespan
if (!sql.toLowerCase().contains("sviptimespan") &&
!sql.toLowerCase().contains("viptimespan")) return

# 3. 获取 bindArgs (args[1])
bindArgs = (Object[]) args[1]

# 4. 遍历 bindArgs, 把所有 Long 类型的值替换成 9999999999999
for (int i = 0; i < bindArgs.length; i++) {
if (bindArgs[i] instanceof Long) {
bindArgs[i] = Long.valueOf("9999999999999")
Log.i("GWDUnlock", "REPLACED [" + i + "]")
}
}
.end method

afterHookedMethod(写入后强制覆盖,防递归):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.method protected afterHookedMethod(...)V
# 0. 检查防递归标志位
if (isUpdating) return

# 1. 检查 SQL 是否是 INSERT/UPDATE/REPLACE user 表
if (!sql.contains("user") ||
(!sql.contains("insert") && !sql.contains("replace") && !sql.contains("update")))
return

# 2. 设置标志位(防止 execSQL 触发自身递归)
isUpdating = true

# 3. 执行强制 UPDATE
db.execSQL("UPDATE user SET svipTimeSpan=9999999999999, vipTimeSpan=9999999999999")

# 4. 恢复标志位
isUpdating = false
.end method

6.3.5 编译签名

1
2
3
4
5
6
7
8
9
# apktool 编译
java -jar apktool.jar b lspmod2 -o module.apk

# uber-apk-signer 签名
java -jar uber-apk-signer.jar -a module.apk \
--ks unlock.keystore --ksAlias unlock --ksPass "$KEYSTORE_PASSWORD" --ksKeyPass "$KEYSTORE_PASSWORD"

# 安装
adb install module-aligned-signed.apk

6.3.6 LSPosed 启用

  1. 打开 LSPosed 管理器
  2. 模块页 → 启用「古文岛解锁」
  3. 作用域 → 勾选「古文岛」(com.guwendao.gwd)
  4. 强制停止古文岛,重新打开

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
2
3
4
5
# 错误 — getResult() 是方法,不是字段
iget-object v0, p1, ...->getResult()Ljava/lang/Object;

# 正确 — result 是 public 字段
iget-object v0, p1, ...->result:Ljava/lang/Object;

坑 3:const-wide 大数字

1
2
3
4
5
6
# 错误 — smali 汇编器不支持大 hex 字面量
const-wide v5, 0x9184e729fff

# 正确 — 用 Long.valueOf(String) 绕过
const-string v5, "9999999999999"
invoke-static {v5}, Ljava/lang/Long;->valueOf(Ljava/lang/String;)Ljava/lang/Long;

坑 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Python zipfile → 判断 Flutter
↓
idalib (IDA MCP) → 分析 libapp.so 结构
↓
Python bytes search → 搜 VIP 字段名
↓
adb pull + sqlite3 → 发现 shared_flutter.db user 表
↓
直接改 DB → 验证 VIP 字段有效
↓
apktool 反编译 → 写 smali hook
↓
uber-apk-signer 签名 → 安装测试
↓
adb logcat → 验证 hook 生效

第八章:经验总结

8.1 Flutter 应用的逆向路线

1
2
3
4
5
6
7
8
9
10
1. 判断是否 Flutter → 看 libapp.so + libflutter.so
2. 分析 libapp.so → IDA/survey_binary + bytes search
3. 找本地存储 → 检查 databases/、shared_prefs/、app_flutter/
4. 找 VIP 字段 → grep "vip"/"svip"/"viptime"
5. 验证字段 → 直接改 DB 看 UI 变化
6. 选择 hook 方案:
- LSPosed(Java 层,能 hook SQLite)
- Frida(Native 层,能 hook SSL_read,但 Android 15 不兼容)
- blutter(Dart 层,需版本匹配)
- blutter + patch(最彻底但最难)

8.2 关键教训

  1. 先找存储再 hook:不要上来就写 hook,先搞清楚 VIP 状态存在哪里。直接改数据库验证有效,再写 hook 拦截写入。

  2. smali 手写要小心寄存器:.registers N 的 N 必须大于代码里用到的最大寄存器编号。多写一个不碍事,少写一个直接 VerifyError。

  3. 防递归是必须的:如果 hook 了 execSQL,在 afterHook 里又调用 execSQL,必须用标志位防止无限递归。

  4. sqflite 走 execSQL 不走 update:Flutter 的 sqflite 插件底层用 SQLiteDatabase.execSQL(String, Object[]),不是 update/insert。hook 错方法等于白干。

  5. LSPosed 碰不到 Dart 层:这是根本限制。Dart 的网络请求走 cronet 或 dart:io HttpClient,收到响应后直接在 Dart 内存里处理,不经过 Java 层。

  6. frida 在 Android 15 上不稳定:17.x 和 16.x 都有注入失败的问题。如果你用 Android 15 + HyperOS,不要指望 frida 能稳定工作。

8.3 下一步突破方向

  1. blutter:如果能找到匹配 Dart 版本的 blutter,就能反编译 libapp.so,定位 isSVIPOnPlatform getter 的机器码地址,直接 patch 成恒定返回 true。这是最彻底的方案。

  2. Zygisk native hook:用 Zygisk 注入一个 .so 到目标进程,在 .so 的 JNI_OnLoad 里用 inline hook 框架(如 Dobby/ShadowHook)hook SSL_read。不需要 frida,纯 native 实现。

  3. 找到可用的 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