ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter 双端代码混淆实战:Dart、Android、iOS 三层防护配置指南

Flutter 双端代码混淆实战:Dart、Android、iOS 三层防护配置指南 你这个工程如果叫 Flutter-Notebook多半和我一样是个“技术债笔记本”加“示例代码合集”性质的项目——所有用过的 Flutter 组件、踩过的坑、能跑通的 Demo 全塞在里面既能当自己的速查手册也能对外展示。但问题往往出在对外展示这一步。我一直以为 Flutter 把 Dart 代码 AOT 编译成机器码天然就够“安全”了。直到有一次把刚打好的 release 包丢进反编译工具里扫了一遍自己的产物类名、接口常量、注释残留全都挂在外面甚至有测试环境接口地址和几个第三方 SDK 的 key 明文。那一刻我才意识到Flutter 项目的安全配置不能凭感觉Android 和 iOS 双端必须每一层都单独处理。这篇文章我就用 Flutter-Notebook 的实战配置过程把 Dart 层、Android 原生层、iOS 原生层三种混淆方案完整梳理一遍。你会看到命令怎么敲、配置怎么改、混淆之后崩溃堆栈怎么还原以及我在验证过程中踩过的几个坑。1. Flutter 项目为什么必须单独做代码混淆1.1 Dart 层AOT 编译之后的符号并不是完全安全的很多人有一个固定认知Dart 代码编译成libapp.so之后已经是二进制的机器码谁会去看机器码但实际上机器码不影响符号信息的可读性。Dart 编译器在 AOT 编译过程里仍然会把类名、方法名、库名作为符号和字符串信息留在产物里。你不需要懂汇编只要用字符串搜索工具在libapp.so里扫一眼就能把业务模块的包结构猜个八九不离十。Flutter 官方从较早版本开始就提供了--obfuscate参数它做的是 Dart 层符号混淆。开启之后Dart VM 会在编译发布产物时把符号替换成无意义的名字同时要求你必须配合--split-debug-info把真实符号单独导出否则后面所有崩溃堆栈都看不懂。这里有个很关键、也很容易被忽略的边界--obfuscate只混淆符号和标识符名不混淆字符串字面量。如果你把 API 域名、数据库字段、密钥直接硬编码写死在 Dart 代码里混淆之后它们依然原样躺在二进制里。所以它解决的是“类名结构泄露”的问题不是“敏感字符串泄露”的问题。1.2 原生层和资源文件另一块容易忽略的信息泄露面一个 Flutter 安装包里的代码并不是只有 Dart。Android 侧有 MainActivity、自定义 Platform Channel 的原生 Handler、第三方 SDK 的初始化代码iOS 侧有 AppDelegate、ViewController、各种原生桥接类。这些代码不受--obfuscate控制。Android 原生层靠的是 R8/ProGuard也就是在build.gradle里开启minifyEnabled。如果不开Java/Kotlin 类名几乎就是明文反编译 APK 之后包名、Activity、插件注册类的结构一目了然。iOS 原生层则靠 Xcode 的 Release 构建配置做符号剥离。没有做剥离的 Mach-O 文件用nm命令一拉Objective-C 的 selector、Swift 的方法名全都能看到。对于做 iOS 逆向分析iOS reversing的人来说这种包基本等于打开门让别人参观。还有资源文件。AndroidManifest 里第三方平台的 AppIdiOS 的 Info.plist 里各种 URL Scheme资源目录下的配置文件这些都不会参与任何形式的混淆。所以完整的安全配置至少要从“Dart 层 Android 原生层 iOS 原生层”三个面同时下手缺一个就只能防住一部分。1.3 Flutter-Notebook 这种多示例项目的特殊性Flutter-Notebook 和普通商业 App 还不一样它是典型的多示例聚合工程。每个示例模块可能都自己注册了若干个 MethodChannel或者引用了不同的第三方插件有些模块甚至会用来测试反射、动态注册这类机制。这意味着一旦开启完整混淆回归测试的复杂度会成倍增加。尤其是开启 R8 之后如果某个示例代码里用了反射去创建类而这个类没有被 keep 规则保住R8 会把类标记为“无用并移除”运行的时候直接抛ClassNotFoundException。普通 App 踩到这个坑还好排查多示例工程里这种类分布很散没有完整跑一遍所有模块根本发现不了。所以我在给 Flutter-Notebook 做混淆之前先列了一个清单确认哪些模块用了反射、哪些模块用到了字符串拼接的类名、哪些原生的第三方 SDK 有动态注册逻辑。这些都会直接影响 keep 规则怎么写。2. Android 侧完整的混淆链路R8 与 Flutter --obfuscate 的分工2.1 先跑出第一条 Flutter 混淆命令先把最基础的命令列出来这是后续所有操作的前提flutter build apk --obfuscate --split-debug-infobuild/symbols/flutter flutter build appbundle --obfuscate --split-debug-infobuild/symbols/flutter如果你平时用flutter build apk不加这两个参数Dart 层就等于没做任何混淆。--split-debug-info指向一个用于存放符号文件的目录这个参数几乎可以说是必须的因为不导出符号的话线上崩溃堆栈会变成一堆没有意义的乱码你根本没法排查问题。还有一个细节这两个参数只在 release 构建下生效debug 模式即使敲了命令也不会混淆因为调试阶段必须保留可读符号否则开发者都没法定位问题。所以想要验证效果务必构建 release 包。2.2 开启 Android 原生的 R8 收缩和混淆Dart 层的命令加完之后再用反编译工具看 APK你会发现MainActivity这类原生类还是原样躺在里面。因为 Flutter 的--obfuscate管不到 Android 原生层。在 Android Studio 中打开android/app/build.gradle.kts找到 release 构建类型默认情况下它是这样的android { buildTypes { release { signingConfig signingConfigs.getByName(release) isMinifyEnabled false isShrinkResources false proguardFiles( getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro ) } } }我们需要把isMinifyEnabled改为trueisShrinkResources改为true这样 R8 才会接管 Java/Kotlin 层的收缩与混淆android { buildTypes { release { signingConfig signingConfigs.getByName(release) isMinifyEnabled true isShrinkResources true proguardFiles( getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro ) } } }isShrinkResources负责移除无用的资源文件它依赖 R8 先判定哪些类被保留。开启之后资源体积通常能小一些但也要注意资源目录下那些被动态引用的文件比如自定义字体、预置的 json 配置这些存在误删风险打完包之后建议对关键页面做一轮回归。再说 keep 规则。Flutter Gradle 插件在构建 release 时会自动引入一份flutter_proguard_rules.pro里面已经保护了io.flutter.**的核心类。但你自己的业务代码和自定义插件注册类R8 不知道哪些需要保留必须手动写规则。我的做法是先按应用包名保留自定义的 platform channel handler-keep class com.example.flutter_notebook.platform.** { *; } -keep class io.flutter.plugins.** { *; } -keepclassmembers class * { android.webkit.JavascriptInterface methods; }最后一条是为了 WebView 场景里 JavaScript 调用原生方法时反射能找到方法。如果你的工程里没有 WebView 相关逻辑可以不加但多示例项目里经常有人某天加一段 WebView 调试代码留着更省心。如果你的应用里用了 Gson、EventBus、Hilt 这类依赖反射的库需要在proguard-rules.pro里针对它们的实际规则做引导。这和原生 Android 项目完全一致Flutter 并不会帮你绕开。2.3 验证 Android 混淆效果解包后看一些关键词这一步非常建议做而且只针对你自己构建出来的安装包完全正当合规。步骤很简单unzip -q build/app/outputs/flutter-apk/app-release.apk -d apk-out strings apk-out/lib/arm64-v8a/libapp.so | grep -iE MainActivity|apiKey|api\.example|flutter_notebook如果--obfuscate生效libapp.so里跟 Dart 类名相关的符号基本搜不到apiKey这类如果只是在常量池里出现过搜索结果可能还是会存在因为字符串字面量本来就不混淆。所以这个命令的正确预期是类名方法名消失字面量字符串可能仍有。原生层的验证可以打开 Android Studio把 APK 文件直接拖进 APK Analyzer查看 dex 里的类。开过 R8 之后原来的com.example.flutter_notebook.MainActivity会被重命名成类似a.a.a的形式。如果看到的还是完整包名路径说明 R8 没有在原生层生效回去检查isMinifyEnabled。3. iOS 侧没有 ProGuard我们靠什么守防线3.1 --obfuscate 在 iOS 上的边界iOS 侧的 Dart 层混淆方式几乎和 Android 一致构建命令换成flutter build ipa --obfuscate --split-debug-infobuild/symbols/ios这里要特别提醒不要试图在 iOS 设备模拟器Simulator里验证混淆效果。模拟器的调试路径和真机 AOT 不完全一样最外面的 App 结构也不是最终发布形态。我的经验是直接看 ipa 里的 App.framework路径通常是build/ios/archive/Runner.xcarchive/Products/Applications/Runner.app/Frameworks/App.framework/App拿到这个二进制之后再用字符串搜索的方式检查 Dart 层符号是否被替换这样看到的是真实发布产物比在模拟器里瞎猜准确得多。3.2 Xcode 构建设置里的符号剥离选项iOS 原生层没有通过 gradle 开启 R8 这种操作但 Xcode 的 Release 构建配置里有一组合起来效果等同的设置。我在 Flutter-Notebook 工程里重点检查了四项Strip Linked Product设为YES发布时剥离可执行文件里的符号表。Strip Style设为All Symbols把调试符号和非调试符号都剥掉。Debug Information FormatRelease 下设为DWARF with dSYM File保证 dSYM 文件照常生成。Deployment Postprocessing设为YES让 strip 操作在拷贝阶段执行。Strip Linked Product和Strip Style决定的是要不要剥离符号Debug Information Format决定剥离之后 dSYM 是否还能留下来。后两项是配套的如果你为了安全把前两项开了但把 dSYM 关了那线上崩溃日志来了之后就是一堆内存地址谁也解不出方法名。从 Xcode 默认模板创建的项目通常 Release 配置下这些值都是合理的。但如果你是从网上旧工程改过来的或者 handoff 过很多手务必亲自检查一遍 Build Settings。如果你担心中间被第三方库的构建脚本覆盖了设置就把这些配置提交到工程文件里并在 CI 脚本里加一个检查。iOS 原生层甚至不用等到 App 数据泄露只要符号没剥离一个nm命令就能看到 AppDelegate 和所有原生桥接方法的命名这对做 iOS 逆向分析的人来说诱惑力太大了。3.3 云打包场景下的 dSYM 管理现在很多团队 iOS 包都走 GitHub Actions 之类的云打包方案一次构建的整个 runner 环境用完即焚。如果你只是简单跑一句flutter build ipa生成出来的 xcarchive 随着 runner 销毁一起没了后面线上崩溃时你手里根本没有 dSYM。我的 GitHub Actions workflow 里会这样加一步先把 dSYM 找出来find build/ios/archive -name *.dSYM -type d然后在 workflow 中把build/ios/archive整体作为 artifact 上传。这一步看起来不起眼但实际救过我好几次因为 Flutter 的 dSYM 不止一个Runner 本身的、Flutter 引擎的、以及部分插件的都会分散在 xcarchive 下面。dSYM 文件必须私有保存绝对不能把它塞进 ipa 里分发出去。只要发布包里带了一份完整的 dSYM符号剥离就失去了意义因为拿到包的人同样能获得符号表等于你亲手把安全防线拆了。3.4 iOS 16 开发者模式与真机验证的坑如果想在真机上安装测试包来确认混淆后的运行状态iOS 16 之后有一个新的门槛设备必须先开启“开发者模式”。在“设置-隐私与安全性”底部能看到这个入口不打开的话Xcode 连接真机时经常会卡在 preparing device 状态或者安装测试包失败。这个问题看似和混淆不直接相关但恰恰是混淆验证里最容易卡住的环节。因为你要验证的不只是“包能不能装上”还要验证打开开混淆之后Dart 层和原生层之间的 bridge 是否正常第三方 SDK 是否还能完成注册。真机调试是绕不开的一步提前把开发者模式打开能省掉很多无谓的时间消耗。4. 混淆之后的崩溃堆栈还原这一套流程别等到线上才学4.1 Flutter 层用 --split-debug-info 还原 Dart 堆栈混淆之后不是结束而是另一个问题的开始用户线上反馈一个崩溃堆栈长这样#0 0x000000010abcdef (libapp.so:0x123456) #1 0x000000010abcdf0 (libapp.so:0x234567)没有符号表你根本不知道这是哪个具体业务方法。这时候flutter symbolize命令派上用场了flutter symbolize -i stack.txt -d build/symbols/iosstack.txt是从崩溃平台导出或者用户上报的原始堆栈-d指向之前--split-debug-info指定的目录。命令会把里面那些无法识别的 Dart 符号替换回人类可读的方法名和行号例如package:flutter_notebook/main.dart:45。整个过程不需要连上设备也不需要重新构建几秒钟就能还原出来。这也就是为什么我一直强调--split-debug-info不能随便填一个临时目录。它的产物等于线上排查的唯一地图搞丢了就只能抓瞎。4.2 Android 原生层mapping.txt 与崩溃平台Android 原生层的堆栈还原依赖 R8 生成的mapping.txt默认路径在android/app/build/outputs/mapping/release/mapping.txtR8 在混淆时会把原先的类名和方法名映射关系记录在这个文件里。线上崩溃平台收到一个a.a.a(Unknown Source:2)之类的堆栈通过 mapping 文件就能还原成真实类名和行号。Firebase Crashlytics、Bugly 这类服务一般都有上传入口在接入 SDK 时把 mapping 传到对应用户项目崩溃详情页会自动还原。我踩过的坑是想当然地以为mapping.txt会被构建产物一起保留在某个稳定位置结果某个时间从旧版本找文件发现已经被新构建覆盖了。正确的做法是每次 release 构建之后把 mapping 文件按版本号归档到私有存储或者内部文件仓库。4.3 iOS 层atos / symbolicatecrash / 第三方平台iOS 侧对应的还原工具是atos可以用它从 dSYM 里查询特定内存地址对应的符号xcrun atos -o Runner.app.dSYM/Contents/Resources/DWARF/Runner -arch arm64 -l 0x100000000 0x0000000100a1234其中-l后面是二进制的主加载地址最后一个地址是崩溃日志里的具体地址。难点在于你需要知道加载地址在新版 iOS 崩溃日志里往往还需要结合 ASLR slide 做换算手工操作容易算错。所以个人建议别直接上 atos优先接一个支持上传 dSYM 的崩溃平台。平台化的好处是你只要把 dSYM 压缩包上传之后所有符号化都是自动的。对于 Flutter-Notebook 这种示例项目即使不接商业平台至少也把 dSYM 按版本号归档好将来需要排查时至少有原始素材。4.4 我踩过的一个坑把 --split-debug-info 存到 build 目录被清理掉这个坑必须单独拿出来讲因为我为了它白折腾过一个晚上。当时我把命令写成--split-debug-infobuild/symbols构建、测试、上传都正常。过了两天发现新版本线上有个隐蔽崩溃想用flutter symbolize还原结果发现build/目录在开发过程中执行过一次flutter clean整个符号目录被清理得干干净净。从那以后我把所有构建产物相关但需要长期保存的文件全部挪到工程目录外的独立路径并且按版本号组织flutter build apk --obfuscate --split-debug-info../symbols-flutter-notebook/android/1.0.0 flutter build ipa --obfuscate --split-debug-info../symbols-flutter-notebook/ios/1.0.0../symbols-flutter-notebook/不参与flutter clean不会被随意删除。构建完成后我再手动或者通过脚本把这个目录同步到内网某个固定位置。这样即使本机文件丢失至少还有一份和版本号一一对应的符号存档。5. 这套双端混淆配置的最终形态与检查清单5.1 完整的发布构建脚本下面是我平时发布 Flutter-Notebook 时用的脚本骨架把版本号、符号目录、产物路径都统一管理起来#!/bin/bash VERSION1.0.0 SYMBOL_ROOT./symbols-flutter-notebook mkdir -p $SYMBOL_ROOT # Android 产物 flutter build appbundle \ --obfuscate \ --split-debug-info$SYMBOL_ROOT/android/$VERSION # iOS 产物 flutter build ipa \ --obfuscate \ --split-debug-info$SYMBOL_ROOT/ios/$VERSION # 归档核心文件 mkdir -p release/$VERSION cp build/app/outputs/flutter-apk/app-release.apk release/$VERSION/ cp android/app/build/outputs/mapping/release/mapping.txt release/$VERSION/mapping.txt find build/ios/archive -name *.dSYM -type d | tar -czf release/$VERSION/dsym.tar.gz -T -脚本跑完之后把整个release/$VERSION和symbols-flutter-notebook目录一起归档。以后任何时候来崩溃日志都能按版本找到对应的符号文件。5.2 上线前 10 分钟检查清单我在 Flutter-Notebook 里把下面这张表贴在了发版文档最前面每次发布前对着过一遍检查项说明如果遗漏Dart 层 --obfuscate构建命令带 obfuscate 参数类名和方法名可被扫描--split-debug-info 目录符号文件已导出并按版本归档崩溃堆栈无法还原Android minifyEnabledrelease 配置开启 R8原生类名裸奔proguard 规则自定义 handler/反射类已 keep运行时类找不到mapping.txt已归档原生层崩溃无法定位iOS Strip选项Release 下开启符号剥离Mach-O 符号完整暴露dSYM 文件已从云打包机拉取并归档iOS 崩溃无法还原dSYM 未进 ipa发布包不含符号表符号剥离失效真机测试开启开发者模式跑通核心用例混淆后运行时问题漏测这张表说白了就是把上面所有技术点浓缩成最后的操作动作。多花 10 分钟能避免上线后花一整个通宵去看那些根本没法还原的崩溃报告。5.3 混淆不是万能的最后说点实际体会。代码混淆是性价比很高的基础防护但它解决的主要是“让逆向者不舒服、让扫描工具读不到清晰结构”这个问题不是绝对保险。字符串字面量、MethodChannel 名称、第三方平台的 AppKey这些东西在混淆之后仍然有可能被翻出来。所以 Flutter-Notebook 里涉及真正敏感的信息我都遵循一条原则能不放客户端就不放客户端必须放的则做动态下发或服务端校验。混淆承担的是“提高门槛”的角色而不是“替你藏秘密”的角色。这个项目现在每次发版已经固定成一套半自动化流程脚本构建完先检查符号目录、mapping、dSYM 是否归档再上传分发平台。前面这些步骤看着繁琐但都是拿了一次次线上事故换回来的经验。希望这篇双端混淆配置的全过程能让你在自己项目配置时少踩几个坑。
返回列表