资讯动态

Flutter逆向实战:reFlutter与Frida组合分析AOT编译应用

发布时间:2026/10/22 5:02:06 来源:尧图企业网站定制
最近在分析一个Flutter应用时发现纯靠静态分析根本走不通——几万行Dart代码被AOT编译进libapp.so用逆向工具打开全是机器码和偏移量。后来把reFlutter和Frida串联起来用才算是把这套工具链跑通。这篇文章就把完整过程、踩过的坑、以及每一步背后为什么要这么干全部写出来。需要先说明一个重要前提以下所有内容仅讨论授权范围内的安全研究与逆向分析比如分析自己开发的应用、或经过明确授权的渗透测试与漏洞挖掘。任何针对未授权应用的分析都属于违规行为这条红线务必要守住。1. 为什么Flutter应用这么难逆先搞清楚敌人是谁1.1 Dart AOT编译机制带来的三个“拦路虎”Flutter应用难逆向的根源在于编译机制。Dart代码在release模式下会通过AOTAhead-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主要用adbapktool或解包重打包工具用于拆解和重组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重组目标APK3.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:12345PC端连接时指定-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:debuggablefalsereFlutter替换后并不强制设置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逆向分析工具链跑起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑