Flutter + Rust 混合应用逆向实战

背景

目标应用架构

某款移动应用采用了 Flutter + Rust 的混合技术栈:

  • Flutter:负责高性能 UI 层,使用 Dart 语言编写界面交互与状态展示。
  • Rust:通过 JNI(Java Native Interface)编译为原生动态库 libnative.so,提供高性能的核心服务,包括版权校验、专业版(Pro)权限控制等敏感逻辑。

这种架构将关键功能下沉到原生层,既保证了运行效率,也增加了常规逆向的难度——仅修改前端 Dart 代码往往无法绕过底层限制。

分析工具

本次逆向的核心工具是 Ghidra。它是一个开源的逆向工程框架,支持对多种架构(如 ARM64、x86)的二进制文件进行反编译,将机器码还原为可读的 C 语言伪代码,并提供交叉引用(XREF)、数据流分析等功能。我们借助它来分析 libnative.so,定位并修改关键指令。

目标

应用在 Dart 层展示”会员”状态与专业版有效期,但即使我们在 Dart 层强制设置为 Pro,功能仍然受限,并弹出错误提示”请先激活会员”。我们的目标是通过直接修改 Rust 原生库,彻底消除该错误,实现 Pro 权限的全局绕过。


第一阶段:Dart 层的探索与碰壁

理解 Pro 状态模型

在 Flutter 工程中,我们找到了一个与 Pro 状态相关的 Dart 类 LicenseInfo,其核心字段如下:

1
2
3
4
5
6
class LicenseInfo {
late bool isPro;
late int expire;
// 从 JSON 反序列化
LicenseInfo.fromJson(Map<String, dynamic> json) { ... }
}

前端通过以下三元表达式决定界面展示:

1
2
3
4
5
licenseInfo.isPro
? (DateTime.now().millisecondsSinceEpoch / 1000).floor() < licenseInfo.expire
? "会员有效"
: "未激活"
: "未激活"

由此可知:

  • isPro 是布尔开关,表示是否处于专业版状态。
  • expire 是一个秒级 Unix 时间戳,表示 Pro 的绝对截止时间。
    只需当前时间小于该时间戳,且 isProtrue,界面就会显示”会员有效”。

尝试纯 Dart 层修改

我们首先尝试绕过 Dart 层的判断:

  • 强制设置 isPro = true
  • expire 改为一个极远的未来时间戳(例如 2099 年)。

重新打包安装后,功能依然受限。点击某些 Pro 专属功能时,界面弹出一个明确的提示:“请先激活会员”。这说明权限判断并没有被我们在 Dart 层的修改所影响。

追踪错误来源

在 Flutter 工程源码中全局搜索字符串 "请先激活会员",结果为空。这意味着该提示并非由 Dart 代码硬编码,而是从其他层传入。结合架构特点,唯一可能的来源就是 Rust 原生库 libnative.so——Dart 只是将 Rust 返回的错误信息原样显示给了用户。

此时我们意识到:真正的 Pro 校验逻辑藏在 Rust 层,必须直接对原生库进行逆向与 Patch


第二阶段:Rust 层逆向与精准 Patch

1. 定位错误字符串

使用 Ghidra 加载 libnative.so
直接搜索文本 "请先激活会员" 没有任何结果,因为在反编译视图中,中文字符串可能被显示为字节序列(如 3F 3F)。
我们改用 UTF-8 编码的十六进制 搜索对应的字节序列。
Ghidra 在只读数据段中定位到了该字符串的存储地址 DAT_002e04d4

2. 追踪错误触发点

通过 Ghidra 的交叉引用(XREF)功能,发现有多处代码引用了 DAT_002e04d4,但它们的引用形式均为 add x0, x0, #0x4d4,只是将地址作为偏移加载,并非直接读取字符串内容。

真正使用该字符串来构造错误的函数是 app_core::export::verify_license::{{closure}}(地址 0x00807404)。
该函数内部是一个 try-catch 结构,调用 anyhow::error::<>::msg 创建一个包含”请先激活会员”的错误对象并抛出。

3. 追溯权限判断逻辑

在错误触发点附近,我们找到了两个关键的条件跳转指令:

1
2
008073c4  tbnz  w23, #0x0, LAB_008075cc  ; 若 w23 的 bit0 不为 0,跳转到正常流程
008073c8 tbz w24, #0x0, LAB_00807400 ; 若 w24 的 bit0 为 0,则跳转到错误触发

这里的 w23w24 来自一个名为 app_core::check_pro_status::{{closure}} 的函数。该函数返回一个结构体,存储在两个寄存器指向的内存区域:

  • [x20] :主状态码(一个字节)
  • [x20+1]:标志字节,其 bit0 直接决定 Pro 权限是否通过

继续向上追踪,我们看到了这段决定性的代码:

1
2
3
007dd6c4  strb  w8,  [x20]        ; 存储主状态码
007dd6c8 mov w8, #1 ; 将 w8 设置为 1
007dd6cc strb w23, [x20, #0x1] ; 标志字节 ← w23(来自上游计算,可能为 0)

问题所在:标志字节原本应该表示”是 Pro”,但这里用了一个不确定的寄存器 w23(它的 bit0 可能为 0,导致权限被拒)。
而就在前一条指令中,w8 已经被硬编码为 1——这恰恰是我们需要的值。

4. 单字节 Patch,全局绕过

我们只需要将标志字节的来源从 w23 改为 w8,即可强制标志恒为 1,使所有调用 check_pro_status 的逻辑都认定用户是 Pro。

修改内容:

  • 内存地址0x007dd6cc
  • 原始指令strb w23, [x20, #0x1] → 机器码 97 06 00 39
  • 新指令strb w8, [x20, #0x1] → 机器码 88 06 00 39

在十六进制编辑器中完成这个字节的替换后,重新签名并安装应用。
效果:所有 Pro 功能均可正常使用,”请先激活会员”提示彻底消失。

5. 可选加固:阻断云端配置

在逆向过程中,我们还发现应用会请求一个云端配置 URL(例如 https://cdn.example.com/cfg/config.json),用于动态下发版本信息或更新策略。虽然它不影响我们已绕过的 Pro 状态,但出于彻底隔离的考虑,我们可以选择将该 URL 字符串修改为等长的无效地址,从而阻止任何远程干预。


总结

  1. 分层验证意识
    当 Dart 层的修改无效时,通过搜索错误字符串的来源,迅速锁定验证逻辑在 Rust 原生层。这是混合应用逆向的典型思路:谁产生的错误,就从谁那里入手

  2. 十六进制字符串搜索
    Ghidra 中直接搜索中文字符可能因编码显示问题失败,使用 UTF-8 的十六进制编码搜索是定位字符串的可靠方法。

  3. 从错误回溯到权限判断
    通过交叉引用找到错误抛出的位置,再分析附近的跳转指令,逐步回溯到权限检查函数,最终定位到关键数据写入点。

  4. 最小化 Patch
    整个破解仅仅修改了一个字节——将源寄存器从不确定的 w23 改为硬编码为 1w8。这种做法不仅简单可靠,而且不会破坏原有的控制流或引入其他副作用。

  5. 工具链的配合
    Ghidra 的反编译、交叉引用和汇编视图三者的协同,使得我们能够以近乎阅读源码的方式分析一个无源代码的原生库。


Flutter + Rust 混合应用逆向实战
https://blog.elaina.cn/_posts/个人随笔/Flutter+Rust混合应用逆向实战/
发布于
2026年5月29日
许可协议