最近在分析一个Flutter应用时,发现纯靠静态分析根本走不通——几万行Dart代码被AOT编译进libapp.so,用逆向工具打开全是机器码和偏移量。后来把reFlutter和Frida串联起来用,才算是把这套工具链跑通。这篇文章就把完整过程、踩过的坑、以及每一步背后为什么要这么干,全部写出来。
需要先说明一个重要前提:以下所有内容仅讨论授权范围内的安全研究与逆向分析,比如分析自己开发的应用、或经过明确授权的渗透测试与漏洞挖掘。任何针对未授权应用的分析都属于违规行为,这条红线务必要守住。
1. 为什么Flutter应用这么难逆?先搞清楚敌人是谁
1.1 Dart AOT编译机制带来的三个“拦路虎”
Flutter应用难逆向的根源在于编译机制。Dart代码在release模式下会通过AOT(Ahead-Of-Time)编译为原生机器码,直接打包进libapp.so,而不是像普通Android应用那样把Java/Kotlin字节码放进classes.dex。这带来三个直接后果:
第一,没有smali代码可看。传统Android逆向里用jadx反编译dex、读混淆后逻辑的老路,在Flutter应用上基本作废。虽然包内可能还有classes.dex,但通常只是Flutter的启动器或插件桥接层,真正的业务逻辑全在libapp.so里。
第二,符号信息被剥离。调试信息、函数名、字符串常量在AOT快照里被压缩成符号表和字符串表,虽然并非完全不可读,但可读性极差。直接用IDA或Ghidra打开libapp.so,看到的是一堆偏移量和Dart运行时辅助函数,很难快速定位业务函数所在位置。
第三,字符串与逻辑分离。Java层可以用strings命令轻松抽出常量,Flutter里字符串也存在快照中,但分布零散且没有清晰的常量池索引。想通过验证提示语定位登录校验函数,很多时候连搜个关键词都搜不到完整句子。
1.2 为什么最终选了reFlutter + Frida的组合
只靠静态分析,效率太低。我尝试过用IDA的Flutter脚本插件恢复Dart类名和函数名,但由于AOT快照格式复杂,恢复效果很有限,很多函数只能看到dart::core这类运行时符号,业务方法名全是<anonymous>或者偏移量。
后来引入了reFlutter。它能将应用内Flutter引擎相关的动态库替换为带调试信息的版本,相当于给原本黑盒的libapp.so“开了一扇窗”。替换后,Dart函数名、行号、类结构被恢复,可以像分析普通原生So库一样去定位关键函数。
但reFlutter只是解决了“看得见”的问题。要修改函数返回值、动态打印参数、绕过校验逻辑,仍然需要运行时注入能力,这就是Frida的工作。Frida通过JS/Python脚本在进程内注入代码,可以随时hook函数、读写内存、主动调用目标方法。
两者集成以后,工作流变成了:先用reFlutter恢复符号信息,静态层面快速锁定可疑函数和偏移量;再用Frida动态hook确认逻辑、修改行为、验证猜想。静态定位和动态验证互相配合,比单干其中任何一套都高效得多。
1.3 这套工具链的边界在哪里
需要冷静看待的是,reFlutter + Frida并非万能。比如混淆过的Dart代码,函数名虽然能恢复,但逻辑已经被打散,阅读起来依然费力;比如加了VMP或商业壳的Flutter应用,reFlutter替换引擎后可能直接闪退,需要先过反调试和壳的检测。
另外,iOS平台上的Flutter逆向和Android差异很大,reFlutter目前主要面向Android平台,iOS上更多要靠Frida配合动态调试工具手工处理。所以这套方案更适合的场景是:Android平台的Flutter应用、未经商业级加固、目标是理解业务逻辑或绕过某个校验点,而非彻底还原算法。
2. 环境准备:把地基打牢再开工
2.1 操作系统和基础工具清单
我在这个项目里用的系统是Ubuntu 20.04,另外也在macOS上跑通过。Windows环境下也能做,但路径分隔符、adb驱动、签名的坑会多一些,建议新手优先上一台Linux或macOS机器。
基础工具方面,需要准备:
- Android SDK Platform-Tools(主要用adb)
- apktool或解包重打包工具,用于拆解和重组APK
- 对应版本的reFlutter库文件
- Frida和frida-tools套件
- 一台已root的Android测试机,或开启了root的模拟器
这里有一个容易忽略的点:reFlutter库文件的版本必须和Flutter引擎版本严格匹配。版本不匹配会导致运行时崩溃或无响应。建议先解包target APK,读取libflutter.so的导出表或查看AndroidManifest.xml里的引擎版本信息,再去匹配对应版本的reFlutter库。
2.2 快速验证目标APK是否适合这套方案
在正式动手之前,先做一个“快速判断”:把APK解压,检查是否存在lib/arm64-v8a/libflutter.so和lib/arm64-v8a/libapp.so。如果两个都存在,说明这是一个标准的Flutter release包,可以走reFlutter路线。如果缺少libflutter.so但存在libapp.so,很可能是被二次封装或加固引擎,需要先做脱壳处理。
再检查有没有加壳特征,比如DEX加固第三方SDK或VMP框架。这可以通过查看lib目录下的so文件名判断。如果存在大量没有标准命名的加密so,或者运行时存在反调试行为,那么即便换上了reFlutter库,也可能在注入阶段就被检测到。遇到这种情况,不建议继续硬碰,而是先给应用脱壳、绕过反调试再说。
2.3 Frida环境安装与版本选择的几个细节
Frida环境分为两部分:PC端和Android设备端。PC端直接pip install frida-tools即可,Android端需要下载frida-server并push到设备上运行。
这里要特别说明版本匹配问题:PC端的frida版本必须与设备端的frida-server版本保持一致,否则attach时会报“unable to connect to remote frida-server”一类的错误。我习惯统一使用最新稳定版,push前先frida-server --version核对一遍。
设备端注意二进制架构,常见是arm64,老设备是arm,模拟器还有x86_64。下载错架构会导致frida-server无法运行或闪退。运行frida-server前需要root权限,如果设备没有root,可以考虑用模拟器替代。
2.4 准备一个Demo应用用于演示
为了不碰未经授权的应用,我建议读者拿自己开发的Flutter App或者公开的CTF训练样本作为靶子。例如构建一个带登录校验的Flutter Demo,里面有一个会校验用户名密码的函数。下面所有示例都会以这个“模拟项目X”为背景。
这里说几个实操时会用到的小命令:
# 在设备上运行frida-server(先push到/data/local/tmp) adb push frida-server /data/local/tmp/ adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &" # 确认frida连接正常 frida-ps -U如果执行frida-ps -U能看到设备进程列表,说明环境OK。这一步测试很关键,如果连不上,后面所有hook都无法进行。常见原因包括frida-server未启动、端口被占用、USB调试授权未开启。
3. 静态侧准备:用reFlutter重组目标APK
3.1 reFlutter的原理:一个库文件的“障眼法”
reFlutter的核心思路很朴素——把目标APK里的Flutter引擎库文件替换成包含调试信息的版本。因为Dart AOT快照格式是平台相关的,但引擎在加载快照时会依赖符号表来解析函数名、类名。原版release引擎把这些符号和调试信息剥离了,reFlutter则提前准备带调试符号的引擎版,让快照里的函数信息可以“恢复原形”。
简单类比:原版引擎像一台没有仪表盘的汽车,跑起来没问题,但出了问题你不知道哪里坏了;reFlutter换上的引擎则带了一套仪表盘,虽然车还是那辆车,但你能看到转速、油温、故障码,能定位到具体Dart代码和行号了。
注意reFlutter并不是一个全自动脱壳工具,它需要你按目标应用当前的引擎版本去匹配对应补丁库。配错版本就是白干,还会引入额外的运行时问题,这一点后面会讲。
3.2 具体重组步骤:从解包到签名全流程
第一步,解包APK。使用apktool或直接用unzip解出目录,并保留完整文件结构。apktool的好处是还能处理资源文件和manifest,但要注意apktool有极小概率在反编译资源时改动原始资源风格,导致后续签名安装失败,如果遇到这种情况,建议改用unzip + zip重打包方案,二选一,看哪个顺用。
apktool d target.apk -o target_re第二步,替换so文件。解包后找到target_re/lib/arm64-v8a/libflutter.so和libapp.so,用对应版本的reFlutter库替换。同时,部分Flutter版本还会依赖libflutter.so之外的附加so文件,如果应用包含多个ABI目录,建议先只处理主用ABI,比如arm64-v8a,避免打包体积膨胀。
第三步,重打包。可以用apktool自带的build功能,也可以直接用zip命令把解包目录重新压缩为zip再改名为apk。重打包时注意不要引入额外的文件层级,保持原APK里的相对路径结构。
apktool b target_re -o target_rebuilt.apk第四步,签名。这一步几乎是新手最容易卡住的地方。Android 7.0及以上应用默认校验v2签名,如果用旧版jarsigner签出来的包,安装时会报“INSTALL_PARSE_FAILED_NO_CERTIFICATES”。正确做法是用zipalign对齐后,再用apksigner签名:
zipalign -v 4 target_rebuilt.apk target_aligned.apk apksigner sign --ks my.keystore --out target_signed.apk target_aligned.apk如果重打包前没有先卸载设备上原装有签名的App,那么安装时可能报签名冲突,需要先卸载原App再安装,或者在测试机上直接卸载重装。注意这一步会导致设备上的原有数据被清掉,测试场景可接受。
3.3 重组后的验证:怎么确认reFlutter真的生效了
安装完成后,不要急着丢进Frida,先做一个静态验证。打开应用,如果正常进入主界面不闪退,说明so替换基本成功。此时再用grep方式看libapp.so里的字符串信息,如果原本找不到的Dart业务字符串能搜到了,这就是最直接的证据。
另一个验证方法是观察日志。原版release Flutter应用在崩溃时通常只会打印一段无关痛痒的“FATAL EXCEPTION”,而reFlutter替换后,日志会输出包含Dart文件路径、行号、函数名的完整的堆栈,这正是后面做定位的入口。
3.4 重打包静检修炼手册:遇到闪退怎么办
常见情况有几种:
- 机型/ABI不匹配导致
dlopen失败。检查目标目录下是否有对应架构的so,且reFlutter库是否针对该架构编译。 - 引擎版本不匹配,运行时直接崩在启动初始化。解决方法是重新确认包内原引擎版本,找对应reFlutter库。
- 签名问题,报“INSTALL_FAILED_UPDATE_INCOMPATIBLE”或“INSTALL_FAILED_SIGNATURE_INVALID”。需要保证zipalign和apksigner依次执行,且签名key与目标设备上已有的旧包签名一致,否则必须卸载重装。
- Android 11以上对文件路径和so加载有更强的限制,如果包内存在不规范的so路径,可能在安装时就被拦截。
这些坑踩过一次后就熟了,最关键的还是版本匹配:别贪方便随便拿一个reFlutter库去替换,一定要先确认原包版本。
4. 动态侧发力:Frida脚本Hook Dart层的实战操作
4.1 Frida如何注入Flutter进程
Frida的注入机制是在目标进程中创建ffi桥接,通过JS引擎执行脚本。对于原生Android应用,直接frida-ps -U看到进程,再frida -U -p 进程号即可attach。对于Flutter进程,其实没有特殊要求,但如果想稳定hook Dart函数,建议等待进程完全启动后再执行脚本。
实操中我更推荐用frida -U -f 包名这样以spawn方式启动应用,再在脚本里用setTimeout给定延迟等待Flutter引擎初始化完毕。直接-p方式attach正在运行的Flutter进程,有时会抓到初始化的中间状态,找不到目标模块。
4.2 定位Dart函数之前的准备工作
Frida脚本要能hook Dart函数,首先要拿到函数在内存中的真实地址。由于reFlutter已经替换了引擎,libapp.so里通常可以读到一个名为DartSnapshot或带符号的导出表,配合Frida的符号枚举能力,能直接通过名字找到一些Dart运行时函数。
对于业务函数,我更推荐另一种方式:先用字符串搜索。Flutter AOT快照中会保留业务里出现的字面量,例如校验提示语、接口日志等。Frida脚本里可以用Memory.scan扫描libapp.so的只读数据段,找到目标字符串在内存中的偏移量,再根据该偏移量反推附近可能引用的函数。
不过这种方式定位精度有限,通常还需要借助调试器配合:在reFlutter替换后,Frida可以读取应用输出的崩溃堆栈中的Dart函数名及其偏移地址,这个地址就是hook的首选目标。
4.3 一个可用的Hook示例:跟踪Dart函数调用
以模拟项目X的登录校验函数为例。假设reFlutter恢复后,看到核心函数名为LoginManager.verifyCredential,而它编译进libapp.so后的地址偏移为0x1A2B3C。Frida脚本写法如下:
// hook_dart_demo.js const libapp = Process.findModuleByName("libapp.so"); if (!libapp) { console.log("[-] libapp.so not found"); } else { const base = libapp.base; const target = base.add(0x1A2B3C); Interceptor.attach(target, { onEnter(args) { // 对于Dart函数,args[0]通常是指向函数对象的指针 // args[1]和args[2]是前两个参数对应的Dart对象地址 console.log("[+] verifyCredential called"); console.log(" this =", args[0], ", arg0 =", args[1], ", arg1 =", args[2]); }, onLeave(retval) { console.log("[*] verifyCredential returned =", retval); retval.replace(ptr(1)); // 将返回值替换为1,模拟通过校验 } }); }使用时,先启动frida-server,再执行:
frida -U -f com.example.demo -l hook_dart_demo.js --no-pause在应用里触发登录动作,脚本就会输出函数调用信息,并强制把返回值改成true。这就是一个最简单的绕过校验流程。
4.4 参数读取进阶:从Dart对象到字符串
上面示例中,args[1]是Dart对象的引用,直接打印是一串地址,要转成可读内容,需要解析Dart对象。基础做法是利用Dart对象的内部布局,读取组合对象的head后再取字符数据。不同Dart版本布局有差异,但一个通用技巧是:
function readDartString(pointer) { // 简化的Dart字符串读取,实际需适配版本 // 通常对象头是可选的,字符内容在偏移0x8之后 if (pointer.isNull()) return null; const lenPtr = pointer.add(0x4); const len = lenPtr.readU32(); if (len > 0x10000) return null; return pointer.add(0x8).readUtf8String(len); }这只是一个示意方案。在真实项目中,我更喜欢用Frida调用Dart运行时提供的调试接口。reFlutter替换后,运行时内部有完整的对象符号,直接通过导出函数Dart_NewStringFromCString和Dart_StringToCString来转换Dart字符串,比手工解析对象布局稳定得多。前提是版本环境匹配。
4.5 主动调用与动态调试的配合
Hook只是为了观察和修改,真正常需要做的是基于已恢复的函数列表,主动调用某个Dart函数,测试其在不同输入下的行为。例如,模拟项目X里有一个签名验证函数,我需要确认它对一个伪造参数是否返回true。可以直接在Frida脚本里找到该函数地址,使用NativeFunction声明函数签名并调用:
const funcPtr = libapp.base.add(0x2C8A90); const dartFunc = new NativeFunction(funcPtr, "bool", ["pointer", "pointer"]); const arg = Memory.allocUtf8String("fake_argument"); const result = dartFunc(ptr(0), arg); console.log("主动调用结果 =", result);注意Dart函数在AOT编译后的调用约定不完全等同于普通C函数。直接按C方式调用有时会崩溃,稳妥的做法是在onEnter时拿到真实参数对象再改动,而非手动构造参数。主动调用只适用于无状态、参数结构简单的场景。
5. 组合打法:静态加动态逐层攻破的完整流程
5.1 第一轮:从崩溃信息和行为差异寻找线索
拿到一个Flutter目标后,我一般不会立刻上Frida。先运行应用,输入各种异常数据、触发报错,观察日志输出。如果reFlutter已替换成功,崩溃日志中会包含准确的Dart文件位置和函数名,这些是第一手线索。
举个例子,模拟项目X输入错误密码时会弹Toast“密码错误”,在这句提示出现的位置,崩溃日志或JS Console,甚至Frida的Stalker跟踪,都能找到对应的Dart调用链。用Frida的Interceptor.attach钩住Toast模块的字符串显示函数,就能把触发点反查出来。
对于没有错误提示的应用,可以采用输入差异对比:正常输入和异常输入各跑一次,用Frida跟踪同一区域的行为差异,定位到分流点。
5.2 第二轮:用reFlutter恢复的符号表精确定位
在拿到崩溃点或行为差异点之后,回到reFlutter工程,查看对应Dart源码级别的符号表。此时可以获取具体函数名、类名、行号,甚至看到参数和局部变量的声明。结合IDA查看该函数所在地址附近的汇编代码,就能大概理解函数的校验逻辑。
注意不要试图一次还原整个应用,效率低且容易迷失。把精力集中在触发点上:先看函数入口的参数传递,再看函数内的分支判断,最后看返回值的出口。Flutter AOT代码里即使是简单的if判断也会被编译成一长串比较和跳转,所以纯汇编阅读会痛苦,更好的办法是用Frida动态打印不同路径的返回值差异。
5.3 第三轮:Hook确认,动态修改验证猜想
定位到目标函数后,Frida脚本要洞察“它在返回什么、依赖什么”。在函数入口加断点打印所有参数,在出口打印返回值,修改其中一个参数观察分支走向。这个过程相当于给函数的逻辑“画图”,一步步把心理模型做出来。
例如定位到校验核心函数后,先打印参数类型,发现第一个参数是一个Dart List,里面存有服务端返回的signature,第二个参数是本地算出的digest,那么逻辑可以还原为“将服务端signature和本地digest进行比较”。接着在函数返回前强制替换返回值,如果客户端行为随之改变,说明校验点找对了。如果没变化,可能还有第二道校验,继续沿调用栈往下找即可。
5.4 第四轮:从Dart层延伸到Native层
Flutter应用也常常包含原生插件,类似签名校验这种安全性要求高的逻辑可能会放在native库中,只通过Dart层的Platform Channel调用。遇到这种情况,静态侧用IDA分析native库中的JNI函数签名,快速定位到校验函数;动态侧用Frida附加到对应so模块,hook JNI相关函数或自定义导出函数。
整套流程的关键心得是:先用reFlutter的符号恢复能力在Dart层拿到高层的“地图”,再用Frida在运行时逐点验证,最后才下沉到native层。不要反着来,否则一开始就陷进机器码里,效率会非常低。
6. 实战踩坑与排查技巧记录
6.1 Frida连接类问题
连接不上,最常见的是frida-server没有启动或版本不匹配。执行frida-ps -U时报错,十有八九是版本问题。先核对PC端和设备的frida版本:在PC上frida --version,在设备上执行/data/local/tmp/frida-server --version,保持两者一致。
USB连接类问题也很多。Android需要使用debug授权且选择“文件传输”而非“仅充电”模式;Linux系统下还可能涉及udev规则,如果adb devices看不到设备,就检查这些基础项。
另外,frida-server启动后设备上会开放端口,一般是27042,如果端口被占用,可以改参数启动/data/local/tmp/frida-server -l 0.0.0.0:12345,PC端连接时指定-H 127.0.0.1:12345。这块在模拟器和多设备环境里特别常用。
6.2 reFlutter替换后的崩溃类问题
崩溃集中在两个阶段:启动阶段和运行阶段。启动阶段崩溃,优先怀疑库版本不匹配或ABI不对。打开logcat检查有没有dlopen failed、library "libflutter.so" not found这类的报错,根据报错的路径检查包内so目录。
运行阶段崩溃,例如进入某个页面才崩,通常是对应Dart库里的符号无法正确解析,或者应用在运行期对引擎进行了额外校验。这时可以先用Frida打印堆栈,确定崩溃发生在哪个Dart函数。
还有一种情形,设备是32位系统而包内只打包了arm64-v8a目录,安装后理论上也不该闪退,但部分模拟器只支持x86架构,需要下载x86版本的reFlutter库配套使用。这类兼容性问题很常见,务必看清设备ABI。
6.3 Frida脚本不生效的各种原因
写好的脚本一点反应都没有,首先要确认目标进程是否真的加载了libapp.so,有的应用有多个进程,业务逻辑在子进程里,Frida默认attach的可能是主进程。用frida-ps -U | grep 包名查看进程列表,确认是否存在多进程。
其次,函数地址偏移判断错误。reFlutter不同版本的符号表偏移会不一样,同一个应用在不同so版本上的偏移可能不同。建议先用Frida打印模块基址和导出符号,确认方向正确再hook。
6.4 其他隐藏较深的细节
脚本里使用console.log打印大量信息时,Frida控制台可能会卡顿或延迟,这是正常的,可以做流式输出到文件排查,或用setTimeout分段输出。
应用开启了反调试时,Frida attach可能会触发崩溃。碰到这种场景,普通方案是先绕过反调试,例如修改ptrace调用或hook检查函数,但这需要额外的时间和精力。不是所有应用都值得硬啃,评估一下收益,有时更换方向反而更划算。
重打包相关,需要注意原包可能配置了android:debuggable="false",reFlutter替换后并不强制设置debuggable,所以Frida的spawn和attach不受影响。但如果你发现attach报权限错误,可以考虑临时通过adb shell am set-debug-app -w --persistent 包名开启调试,或者直接在模拟器上使用userdebug系统。
6.5 反调试与加固的应对思路
遇到加固壳时,不要指望reFlutter一步到位。先把加固壳脱掉,拿到原始dex和so,再走reFlutter流程。加固方案一般会有自己的检测逻辑,比如检测Frida特征,这时Frida的gadget模式和定制版frida-server可能派上用场。但这块深入下去是另一个专题,本次只提示方向,不复述细节。
7. 我自己的几点心得
工具链跑通后,最大的感受是“静态定位与动态验证缺一不可”。只靠reFlutter恢复出的Dart符号,有时还是会被代码分支绕晕;只靠Frida无头绪乱hook,往往不知道先hook哪里。先静态拿到地图,再动态逐点验证,是这个组合最舒服的工作模式。
另外,代码版本管理很重要。替换库、重打包、Frida脚本、符号偏移量,这些都要记录版本号。我吃过几次亏:同一份Frida脚本换了reFlutter库版本后完全失效,排查了半天才发现是偏移变了。建议每次分析都在工程目录里保留完整的版本清单。
最后还有一点:分析过程中建立的Frida脚本库是可复用资产。针对常见的Dart字符串读取、参数打印、返回值替换,提前写好封装脚本,下一次遇到类似目标能省大量时间。我就是一边做一边把功能沉淀成库,后面再碰到Flutter应用,基本能在半小时内完成“符号恢复 + 动态Hook”的入门级排查工作。希望这篇文章能帮你少走一些弯路,顺利把你自己的Flutter逆向分析工具链跑起来。