最近碰到这个挺多次的了,就想着弄懂一下😋

简介

Flutter 是谷歌的移动 UI 框架,建构在Dart VM之上,使用Dart语言开发的移动应用开发框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。

现在很多app都是用的该框架构建,而该原生框架是建立在app的native层,所以逆向时需要解包。

认识 Flutter 是什么? - 简书

Dart VM

Dart VM 是一个虚拟机,它为高级编程语言提供了执行环境,但它并不意味着在 Dart VM 上执行。它既能JIT即时编译、也能 AOT 提前编译。

在Dart VM下,AOT将源码转换成机器码打包进libapp.so,AOT 提前跑完后半段并把结果序列化成快照。

Dart VM

特点

无法常规抓包

  • Dart语言网络请求不通过代理发送

​ 有的抓包软件是作为中间代理,拦截网关从而获得流量包,而因为Dart语言的这个特点使得app无法正常抓包。

​ Flutter 的 HTTP 实现默认不读取 Android/iOS 的系统代理设置,流量直接走原始 Socket。

  • 自带SSL校验

​ Flutter 引擎把 Google 的 BoringSSL 静态编译进 libflutter.so,校验逻辑 在 ssl_crypto_x509_session_verify_cert_chain() 等函数里完成。

​ 它只信任预制在代码或系统目录里的特定公钥/指纹,用户安装的 Burp/Charles 根证书即使放进系统受信区也照样被拒绝。

无法正常反编译

如果按照正常的套路把apk拖到jadx里面会发现MainActivity里什么也没有

  • Dart 代码被 AOT 编译成原生机器码,而不是常见的字节码

flutter在release模式下直接把Dart编译成ARM/x86机器码存放到libapp.so中。

  • 代码的形态以Dart快照形式存在

辨别

怎么看它是不是flutter框架呢?

熟悉安卓的朋友都知道, assets文件是资源

image-20250922121028937

当你解包打开lib会发现有这两个so文件,有这里两个so文件,很容易辨别

image-20250922120936759

发布版本的apk文件里面 libapp.so 保存的是快照,libflutter.so则是引擎,其中包括一个dart的虚拟机。

对抗

抓包

《安卓逆向这档事》番外实战篇3-拨云见日之浅谈Flutter逆向 - 吾爱破解 - 52pojie.cn

【Flutter逆向】记一次某灰产APP(Sm**th)的逆向学习-CSDN博客

前两个方法都比较麻烦,我都理解了一下但没有试过。

  1. hook函数过证书校验

    找so文件里找可以绕过ssl验证的函数,用脚本hook,使函数返回true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function hook_dlopen() {
var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var so_name = args[0].readCString();
if (so_name.indexOf("libflutter.so") >= 0) this.call_hook = true;
}, onLeave: function (retval) {
if (this.call_hook) hookFlutter();
}
});
}

function hook_ssl_verify_result(address) {
Interceptor.attach(address, {
onEnter: function(args) {
console.log("Disabling SSL validation")
},
onLeave: function(retval) {
console.log("Retval: " + retval);
retval.replace(0x1);
}
});
}
function hookFlutter() {
var m = Process.findModuleByName("libflutter.so");
//利用函数前10字节定位
var pattern = "FF C3 01 D1 FD 7B 01 A9 FC 6F 02 A9FA 67 03 A9 F8 5F 04 A9 F6 57 05 A9 F4 4F 06 A9 08 0A 80 52 48 00 00 39";
var res = Memory.scan(m.base, m.size, pattern, {
onMatch: function(address, size){
console.log('[+] ssl_verify_result found at: ' + address.toString());
// Add 0x01 because it's a THUMB function
// Otherwise, we would get 'Error: unable to intercept function at 0x9906f8ac; please file a bug'
hook_ssl_verify_result(address);
},
onError: function(reason){
console.log('[!] There was an error scanning memory');
},
onComplete: function() {
console.log("All done")
}
});
}
  1. reflutter

    这个还挺麻烦的,还有手动签名什么的

  2. Reqable或者proxyPin

    这里我下载了Reqable,只选了独立模式

    这里的证书配置的话,如果有MT那很容易,记得把证书文件权限改为777就可以直接使用了

image-20250923171050692

反编译

libapp.so文件里面不止包含了AOT代码,还有Dart快照、Dart VM中的一些信息。

下面我随便拿了一个文件,试了一下readelf -s 命令

image-20250923175530477

  • _kDartVmSnapshotData: 代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地启动 Dart isolate,但不包含任何 isolate 专属的信息。
  • _kDartVmSnapshotInstructions:包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。
  • _kDartIsolateSnapshotData:代表 Dart 堆的初始状态,并包含 isolate 专属的信息。
  • _kDartIsolateSnapshotInstructions:所有 Dart 业务代码编译后的 ARM64 机器码。
  • _kDartSnapshotBuildId: 快照格式版本+Git Hash,调试/校验用

_kDartIsolateSnapshotInstructions即为我们需要重视的

由于Dart语言不断的发展,不同版本的Dart引擎其快照格式不同,不论是动态还是静态工具都在更迭。

动态逆向

reflutter

Impact-I/reFlutter: Flutter Reverse Engineering Framework

利用reflutter等工具编译修改过的libflutter.so并且重新打包到APK中,在启动APP的过程中,由修改过的引擎动态链接库将快照数据获取并且保存。

如果对应的Flutter版本reFlutter还未更新那就只能自己尝试Patch。、

自己patch

教程在下面这个链接里

Android-Flutter逆向 | LLeaves Blog

  1. snapshotHash

我们在libapp.so文件里可以看到关于快照版本的hash,但是具体是多少位看的好麻烦,直接用reflutter里面的脚本运行出来

1
python .\get_snapshot_hash.py D:\test\xxx\lib\arm64-v8a\libapp.so

image-20250923220920522

  1. Engine_commit

这个Engine_commit则在libflutter.so中找这个我找的好费劲还没找到::>_<::

有了这两个值之后,在下面的表格里就可以找到flutter和dart的版本

reFlutter/scripts/enginehash.tmp.csv at main · Impact-I/reFlutter

蒽,后面的编译好麻烦不是我现在的重点,先溜了。。。

工具
1
2
3
4
pip3 install reflutter
reflutter ....\test.apk
java -jar uber-apk-signer-1.2.1.jar --allowResign -a E:\release.RE.apk
adb -d shell "cat /data/data/com.example.test.flutter_demo/dump.dart" > dump.dart

reflutter工具实践之–xx一番赏app_reflutter教程-CSDN博客

得到release.RE.apk之后是要对齐和签名,但是我找的的两个题这两条路都不太通,主要原因是reflutter目前覆盖到3.13左右,我的题是3.19的要手动编译即上面那种方法,等哪天实践成功了回来补一下。

经过最后一条adb的命令就可以还原dart代码了,然后接下来就能分析了

flutter逆向助手

flutter逆向助手是reflutter的上位替代,省去了重新打包的麻烦,使用起来简单很多。直接选择文件之后就能得到反编译的文件。

[原创]flutter逆向助手-基于reflutter的简易化dart解析工具-Android安全-看雪论坛-安全社区|非营利性质技术交流社区

但是它依然没有解决我的问题,因为支持的版本依旧很低〒▽〒

reflutter和flutter逆向助手停止更新,flutterSDK过高,两个工具都无法破解,所以又有一个工具blutter,这是一个静态的

静态逆向

blutter的配置

1
2
3
4
git clone https://github.com/worawit/blutter --depth=1
cd .\blutter\
python .\scripts\init_env_win.py
python .\blutter.py D:\test\xxx\lib\arm64-v8a .\output

其实主要的命令是这几个,还需要ninja环境

使用最后一个命令的时候,要在x64 Native Tools Command Prompt中运行才能编译

最后文件会放在output中

asm 对dart语言的反编译结果,里面有很多dart源代码的对应偏移
ida_script so文件的符号表还原脚本
blutter_frida.js目标应用程序的 frida 脚本模板
objs.txt对象池中对象的完整(嵌套)转储,对象池里面的方法和相应的偏移量
pp.txt对象池中的所有 Dart 对象

image-20250922211252502

嗯还有一个点是,生成的addNames.py这个脚本,好像只能在ida7.x或者ida8.x下成功,但是相对于动态的两种,这已经能解决ctf中的绝大部分题目了。

练习

2025 Nepctf

这道题,没办法正常解出来,报错了

ai说是libapp.so 里找不到符号 _kDartVmSnapshotData,里面是Dart 的一些东西。

image-20250921211736288

ida打开在字符串里面意外发现了 _kDartVmSnapshotData,因为到0x346的时候突然出现了一堆0,所以报错了

image-20250925214249763

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def extract_snapshot_hash_flags(libapp_file):
with open(libapp_file, 'rb') as f:
elf = ELFFile(f)
# find "_kDartVmSnapshotData" symbol
dynsym = elf.get_section_by_name('.dynsym')
sym = dynsym.get_symbol_by_name('_kDartVmSnapshotData')[0]
#section = elf.get_section(sym['st_shndx'])
assert sym['st_size'] > 128
f.seek(sym['st_value']+20)
snapshot_hash = f.read(32).decode()
data = f.read(256) # should be enough
flags = data[:data.index(b'\0')].decode().strip().split(' ')

return snapshot_hash, flags

可以看到extract_dart_info里面的源码,是连续读取的我们修改一下改成从0x354开始读,然后继续尝试一下

image-20250926090856780

image-20250926091514270

蒽依旧报错这次是因为**.rodata 段**没找到,分析一下so

image-20250926091548962

看了一下正常的.rodata、.text、.bss段都被删去了,嘶难搞再跑到源码那分析了一下找.rodata 段的代码,代码的作用是找flutter的版本信息和Dart SDK的版本号

image-20250926092426162

出问题的地方应该是这边

image-20250926115057962

image-20250926115758729

修一下,经过好久的编译错误终于成功了,在官方wp里面找到了ida9还原函数名的方法

1
python blutter.py --dart-version 3.8.1_android_arm64 D:\test\nep\flutterpro\flutterPro\lib\arm64-v8a\libapp.so .\output

image-20250927181514296

在output文件夹里面asm/flutterpro/main.dart可以看到有两个回调函数其中ontap点击事件在ida里面分析,找到主要事务函数分析

不断ai

image-20250927210052046

大概就是指这一部分构成一个8*8的样子,构成了之后应该会有一点操作,点进这部分下面的一个sub_203624函数继续分析,一点一点分析实在是太慢了,然后就想hook去看看,结果很久很久一直不成功,后来在一个文章里面看到要用真机😊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
while ( 1 )
{
*(_QWORD *)(v10 - 24) = v17;
if ( (unsigned __int64)v16 <= *(_QWORD *)(v5 + 56) )
ArrayStub_32d270 = StackOverflowSharedWithoutFPURegsStub_32d378(ArrayStub_32d270, v15);
if ( v17 >= 8 )
break;
v18 = AllocateArrayStub_32d270();
for ( i = 0LL; ; ++i )
{
if ( (unsigned __int64)v16 <= *(_QWORD *)(v5 + 56) )
v18 = StackOverflowSharedWithoutFPURegsStub_32d378(v18, i);
if ( i >= 8 )
break;
*(_DWORD *)(v18 + 4 * i + 15) = 0;
}
v15 = *(_QWORD *)(v10 - 24);
v20 = *(_QWORD *)(v10 - 32);
*(_DWORD *)(v20 + 4 * v15 + 15) = v18;
v17 = v15 + 1;
ArrayStub_32d270 = v20;
}
v21 = ArrayStub_32d270;
for ( j = 0LL; ; ++j )
{
v23 = *(_QWORD *)(v10 - 8);
*(_QWORD *)(v10 - 56) = j;
if ( (unsigned __int64)v16 <= *(_QWORD *)(v5 + 56) )
ArrayStub_32d270 = StackOverflowSharedWithoutFPURegsStub_32d378(ArrayStub_32d270, v15);
if ( j >= 8 )
break;
v24 = 8 * j;
*(_QWORD *)(v10 - 48) = 8 * j;
v25 = 0LL;
while ( 1 )
{
*(_QWORD *)(v10 - 24) = v25;
if ( (unsigned __int64)v16 <= *(_QWORD *)(v5 + 56) )
StackOverflowSharedWithoutFPURegsStub_32d378(ArrayStub_32d270, v15);
if ( v25 >= 8 )
break;
*(_QWORD *)(v10 - 40) = *(unsigned int *)(v21 + 4 * j + 15) + (v7 << 32);
v26 = *(unsigned int *)(v23 + 19) + (v7 << 32);
MintSharedWithoutFPURegsStub_32d4f8 = 2 * ((int)v24 + (int)v25);
if ( v24 + v25 != MintSharedWithoutFPURegsStub_32d4f8 >> 1 )
{
MintSharedWithoutFPURegsStub_32d4f8 = AllocateMintSharedWithoutFPURegsStub_32d4f8();
*(_QWORD *)(MintSharedWithoutFPURegsStub_32d4f8 + 7) = v28;
}
*v16 = MintSharedWithoutFPURegsStub_32d4f8;
v16[1] = v26;
v29 = dart_core__StringBase::op_at_3090f4();
v30 = sub_272F98(v29, v6->Obj_0x142d0, v29);
if ( !((__int64)*(int *)(v30 + 19) >> 1) )
{
v42 = RangeErrorSharedWithoutFPURegsStub_32d7c0();
goto LABEL_46;
}
v32 = *(unsigned __int8 *)(v30 + 23);
v33 = *(_QWORD *)(v10 - 24);
v34 = 2 * (int)v33;
if ( v33 != v34 >> 1 )
{
v34 = AllocateMintSharedWithoutFPURegsStub_32d4f8();
*(_QWORD *)(v34 + 7) = v35;
}
v36 = 2 * v32;
v37 = *(_QWORD *)(v10 - 40);
v38 = (unsigned int)*(_QWORD *)(v37 - 1) >> 12;
v31[1] = v34;
v31[2] = v37;
*v31 = v36;
(*(void (**)(void))(v3 + 8 * (v38 - 1377)))();
ArrayStub_32d270 = *(_QWORD *)(v10 - 24);
v25 = ArrayStub_32d270 + 1;
v23 = *(_QWORD *)(v10 - 8);
j = *(_QWORD *)(v10 - 56);
v21 = *(_QWORD *)(v10 - 32);
v24 = *(_QWORD *)(v10 - 48);
}
ArrayStub_32d270 = j;
v21 = *(_QWORD *)(v10 - 32);
}

在这个函数里面,可以发现是有矩阵在相乘的

我看 不论是官方的还是其它大师的wp都是hook出了矩阵的key,在blutter给的frida脚本上修改

1
2
3
4
5
6
7
8
9
10
11
12

Interceptor.attach(libapp.add(0x203844), {
onEnter: function () {
console.log(this.context['x2'])
}
});

Interceptor.attach(libapp.add(0x203850), {
onEnter: function () {
console.log(this.context['x0'])
}
});

所以第一次加密这边是一个矩阵相乘

image-20250928170401932

再往下分析一下

image-20250927220959191

创建另一个DartObjectPool的结构体,然把v6修改成池子,变成了这样

image-20250927220440296

image-20250927220458428

点进去就会发现又一长串的字符串,可能是密文

从相乘后往下可以发现还有一个sub_2030D8函数,点进去里面也有很多类似于v6的东西,会发现里面有写莫名其妙的字符串和密文差不多

image-20250928170602142

image-20250928170658419

image-20250928170749249

所以可能逻辑就是

input -> len -> 矩阵相乘 -> RCNB -> compare

1
2
3
4
import rcnb

data = rcnb.decodeBytes("RƇǹþRȻňBRÇȠƀŔćnBŔCÑƁRĈȵþRƇƞƀŔȼNBŔCŇÞŔĊņßŔĈŃƀŔĊȠƃŔċȵƄRčņßŔcȠbŕĊNƄŔcǹƃŔĆŅƁŔĆŇƅŔĊƞƁŔĉȵƁRčńƁRȻƝƅŕĊņþRȼȠƁŔćƞbŔćŅƀŔĉŅƁŔĊÑBRĊȵƁRȼȵƄŕćȵƀRȻņBŔćņƁŔCƞƁŔĈÑƀŔĉƞƄRĊnƃRȼņƃŕĆŃþRƇƝƄŔcnƁRÇnBRȻŅßŔcńƃRćǸƄRčŅÞŔċȠBŔcÑBŔĊńƀŔĈńƀŔĊńƃŔċnƃRƇņƀŔcňƁŕċÑƃŔCÑƀŔĈǹƁŔĉņßŔČŇƀŔČǹþRƇƝÞŔCňÞŕĊňƁ")
print([int.from_bytes(data[i:i+2], byteorder='big') for i in range(0, len(data), 2)])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from z3 import Solver, Int, sat

key = [0x4, 0x6, 0x6, 0x7, 0x7, 0x6, 0x4, 0x4, 0xe, 0xf, 0x1, 0x5, 0x6, 0x5, 0x1, 0x3, 0x6, 0x5, 0x4, 0x7, 0x6, 0x7, 0x6, 0x7, 0x5, 0x3, 0x9, 0x4, 0x9, 0x5, 0xc, 0x5, 0x7, 0x6, 0x7, 0x6, 0x6, 0x7, 0x7, 0x7, 0x5, 0x1, 0x3, 0x5, 0xc, 0x2, 0x3, 0x4, 0x7, 0x6, 0x4, 0x4, 0x4, 0x6, 0x6, 0x6, 0x2, 0xd, 0x3, 0x5, 0xe, 0xf, 0xf, 0x5, 0x4, 0x6, 0x6, 0x7, 0x7, 0x6, 0x4, 0x4, 0xe, 0xf, 0x1, 0x5, 0x6, 0x5, 0x1, 0x3, 0x6, 0x5, 0x4, 0x7, 0x6, 0x7, 0x6, 0x7, 0x5, 0x3, 0x9, 0x4, 0x9, 0x5, 0xc, 0x5, 0x7, 0x6, 0x7, 0x6, 0x6, 0x7, 0x7, 0x7, 0x5, 0x1, 0x3, 0x5, 0xc, 0x2, 0x3, 0x4, 0x7, 0x6, 0x4, 0x4, 0x4, 0x6, 0x6, 0x6, 0x2, 0xd, 0x3, 0x5, 0xe, 0xf, 0xf, 0x5, 0x4, 0x6, 0x6, 0x7, 0x7, 0x6, 0x4, 0x4, 0xe, 0xf, 0x1, 0x5, 0x6, 0x5, 0x1, 0x3, 0x6, 0x5, 0x4, 0x7, 0x6, 0x7, 0x6, 0x7, 0x5, 0x3, 0x9, 0x4, 0x9, 0x5, 0xc, 0x5, 0x7, 0x6, 0x7, 0x6, 0x6, 0x7, 0x7, 0x7, 0x5, 0x1, 0x3, 0x5, 0xc, 0x2, 0x3, 0x4, 0x7, 0x6, 0x4, 0x4, 0x4, 0x6, 0x6, 0x6, 0x2, 0xd, 0x3, 0x5, 0xe, 0xf, 0xf, 0x5]
enc = [3879, 4271, 4182, 4951, 4753, 2999, 3842, 6611, 4718, 5457, 5122, 5534, 5695, 3657, 4630, 7665, 4624, 4843, 4866, 5493, 5393, 3633, 4286, 7709, 4483, 5040, 4992, 5293, 5501, 3293, 4495, 7342, 4251, 5003, 4743, 5202, 5345, 3154, 4404, 7079, 3835, 4503, 4051, 4247, 4534, 2815, 3648, 5681, 4601, 5432, 5132, 5434, 5554, 3802, 4573, 7904, 4752, 5223, 5307, 5762, 5829, 3838, 4728, 7723]

# --- 参数 ---
BLOCKS = 8 # 8 blocks of 8 equations each
BLOCK_SIZE = 8 # 8 variables per block

ASCII_LO = 32 # 可打印字符下限(空格)
ASCII_HI = 126 # 可打印字符上限(~)

plaintext = [] # 存储每个 block 解出的字符串

for block_idx in range(BLOCKS):
s = Solver()
vars_ = [Int(f"m_{block_idx}_{k}") for k in range(BLOCK_SIZE)]

for v in vars_:
s.add(v >= ASCII_LO, v <= ASCII_HI)
for row in range(BLOCK_SIZE):
total = 0
for col in range(BLOCK_SIZE):
# 修正 key_index 计算:确保不会超出 key 数组的范围
key_index = (64 * block_idx + 8 * row + col) % len(key)
total += vars_[col] * key[key_index]
enc_index = 8 * block_idx + row
s.add(total == enc[enc_index])

if s.check() == sat:
m = s.model()
chars = []
for k in range(BLOCK_SIZE):
val = m[vars_[k]].as_long()
# 如果在 ASCII 可打印范围则转为字符,否则显示数字(以便排查)
if ASCII_LO <= val <= ASCII_HI:
chars.append(chr(val))
else:
chars.append(f"[{val}]")
block_str = ''.join(chars)
print(f"Block {block_idx}: {block_str}")
plaintext.append(block_str)
else:
print(f"Block {block_idx}: no solution (unsat)")
plaintext.append(None)

# 合并并打印最终字符串(如果每块都解出)
if all(p is not None for p in plaintext):
full = ''.join(plaintext)
print("\nFull plaintext:")
print(full)
else:
print("\nNot all blocks solved; partial output above.")

2025 WMCTF

bultter一下,还原符号

image-20250922205429225

额好难分析我要死掉了,等我慢慢分析完整在写上来

2023 ACTF

(2 封私信) 【flutter对抗】blutter使用+ACTF习题 - 知乎