闲来无事,想研究一下之前一直都看到就放弃的 Flutter,看看是不是真的有那么困难。
Flutter 运行机制初探
在开始逆向之前,我们先了解一下 Flutter 的基本运行原理。
查阅资料后发现,Flutter 在 Release 模式下使用 AOT(Ahead-Of-Time)编译,将代码直接编译成原生二进制;只有在 Debug 模式下才会使用 JIT(Just-In-Time)运行。
我原本以为 Flutter 始终使用 JIT 运行。网上很多人说"Flutter 相当于自带 VMP",其实这种说法并不准确。
现有工具的局限性
目前市面上已有的 Flutter 逆向工具,比如 reFlutter 和 blutter,都只支持安卓和 iOS 平台的 ARM 架构,对于 Windows 平台和 x64 架构完全没有支持。这就是为什么我们需要自己动手打造工具来从内存中提取所需信息,也是本文标题"从零开始"的由来(笑)。
我选择的研究目标是 Reqable,版本为 3.0.22。
目标程序初步分析
打开 Reqable 的安装目录,几个关键的二进制文件引起了我的注意:
flutter_windows.dll - 这相当于安卓平台下的 libflutter.so,是 Flutter 引擎的 Windows 版本
Reqable.exe - 主要执行文件,实际上只是个启动器
data/app.so - 包含实际的 Dart 编译代码
首先,我使用 ImHex 查看了 flutter_windows.dll,直接搜索 +0000:

从这里可以看到,样本使用的 Dart 版本是 3.3.4,Snapshot Hash 是 ee1eb666c76a5cb7746faf39d0b97547。
为了确认版本信息,我检查了 app.so 中的魔数 0xdcdcf5f5 后面的数据:

同时还能看到编译配置信息:
product no-code_comments dwarf_stack_traces_mode no-lazy_dispatchers dedup_instructions no-tsan no-asserts x64 windows no-compressed-pointers null-safety
平台差异分析
要开发适用于 Windows x64 平台的 Flutter 逆向工具,首先需要理解它与 Android ARM 平台的关键差异。

Flutter 使用了自己定制的一套 ABI,它会占用两个通用寄存器来存储上下文相关的数据:一个指向 Object Pool(对象池),另一个指向 dart::Thread。这对于逆向工作来说是个很好的突破口 - 对于 Dart 这种虚拟机语言,只要我们能获取到它的堆数据,就能从中提取出大部分函数、字面量等信息。
因此,了解 x64 平台下 Flutter 的特殊 ABI 实现就变得至关重要。
深入 Dart 源码
幸运的是,Flutter 的 Dart 部分是开源的。Dart 的源码可以在 GitHub 上找到。由于目标应用使用的是 Dart 3.3.4,我们需要切换到对应的分支。
在 instructions_x64.cc 文件中,我找到了 Flutter 在 x64 平台下的 ABI 实现:Thread Pointer 存储在 R14 寄存器中,ObjectPool Pointer 存储在 R15 寄存器中。
理论上,我们只需要编写代码从 ObjectPool 中提取各种数据就可以了。听起来很简单,对吧?但实际情况要复杂得多。
数据读取的挑战
为了读取 ObjectPool 的数据,我们显然需要 ObjectPool 的内存结构。来分析一下这附近的源码:



显然,所有继承 Object 的对象都仅仅是一个壳子,真正的数据保存在 UntaggedXXX 里面。而 untag() 其实只是返回一个内部指针 - 1 的值,大概是某种神秘的优化。在 UntaggedObjectPool 中,我们可以看到其真正的结构:

整理后即为:
struct UntaggedObjectPool {
AtomicBitFieldContainer<uword> tags_; // 8 bytes, 继承自 UntaggedObject
intptr_t length_; // 8 bytes
Entry entries[length_]; // 变长数组
uint8_t* entry_bits[length_]; // 类型信息位图
};
这是一个变长对象,使用 entry_bits 数组来存储每个条目的类型信息。
基于这个理解,我最初尝试编写了这样的代码:
auto proc = blook::Process::self();
for(auto& thread: proc->threads()) {
auto context = thread.capture_context();
if (memory_accessible(context->r14) && memory_accessible(context->r15)) {
auto thread = reinterpret_cast<dart::Thread*>(context->r14);
if (thread->os_thread()->id() == GetCurrentThreadId()) {
std::println("Existing Flutter thread detected! Thread ID: {}",
thread->os_thread()->id());
}
}
}
但这段代码完全不起作用。经过调试发现,Flutter 在与外部代码交互时,会把 ABI 恢复到正常状态。由于 Windows 系统暂停线程是在内核调度时完成的,我们捕获到的上下文都是 ABI 恢复正常的状态,自然找不到 R14 和 R15 中的关键数据。
巧妙的解决方案
既然无法通过线程上下文捕获获取数据,我决定换个思路:直接在 app.so 的代码中设置断点。
static blook::VEHHookManager::VEHHookHandler bp =
blook::VEHHookManager::instance().add_breakpoint(
blook::VEHHookManager::HardwareBreakpoint{
.address = reinterpret_cast<void*>(*ptr),
},
[](blook::VEHHookManager::VEHHookContext &ctx) {
auto thread = reinterpret_cast<dart::Thread*>(
ctx.exception_info->ContextRecord->R14);
auto object_pool = (dart::ObjectPoolPtr)
ctx.exception_info->ContextRecord->R15;
// ...
});
通过硬件断点,我们可以在 Flutter 执行 app.so 中的代码时捕获到正确的上下文。需要注意的是,R15 寄存器存储的不是指针,而是直接的 dart::ObjectPoolPtr 值,这个细节让我调试了好一会儿。
提取和分析数据
获取到 ObjectPool 后,我们就可以提取其中的函数和字符串信息了:
using namespace dart;
if (pool != ObjectPool::null()) {
const intptr_t length = pool->untag()->length_;
uint8_t *entry_bits = pool->untag()->entry_bits();
for (intptr_t i = 0; i < length; i++) {
auto entry_type = ObjectPool::TypeBits::decode(entry_bits[i]);
if (entry_type == ObjectPool::EntryType::kTaggedObject) {
auto &obj = pool.untag()->data()[i].raw_obj_;
__try {
if (obj->IsString()) {
std::println("String[{}]: content = {}", i,
read_dart_str(dart::StringPtr(obj)));
} else if (obj->IsFunction()) {
auto &func = dart::Function::Handle(dart::FunctionPtr(obj));
std::println("Function[{}]: name = {}, native_name = {}, addr = {:x}",
i, read_dart_str(func.name()),
read_dart_str(func.native_name()),
func.entry_point());
} else {
std::println("Other Object[{}]: class id = {}", i,
obj->GetClassId());
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
std::println("Failed to read object at index {}", i);
}
}
}
}
输出结果类似这样:

遗憾的是,这个样本开启了代码混淆功能(参考 Flutter Obfuscation),所以即使获取到了函数名和函数地址,实际意义也不大。不过,我们现在至少可以把字符串和其他对象信息注释到 app.so 中,大大提升逆向效率。
实际应用示例
让我们看看 app.so 中是如何访问 ObjectPool 中的对象的:

随便找一个函数,可以看到它访问了 object_pool + 0x974f 处的数据。根据我们之前分析的内存布局:
- 每个 entry 占 8 字节
- entry 前面有 16 字节的头部信息
- 由于 tag pointer 机制,实际地址需要加 1
我们可以计算出它访问的 object 索引:(0x974f - 16 + 1) / 8 = 4840,即第 4840 个对象。

于是,在写个脚本将字符串内容和函数名自动注解到对应地方以后,我们就基本恢复了对 app.so 逆向的能力了。当然,通过这种方式我们还可以获取到所有 dart 类型的信息。对于修改,除了在 Hook/Patch 时对 r14,r15 的使用要格外注意以外,也和普通native逆向没什么区别了。