如果你做过移动端逆向,大概率经历过这个场景:临时拿到一个 APK 样本,电脑不在身边,想看一眼包名、版本号、MD5、dex 数量和 so 架构,结果在手机上翻来翻去找不到一个顺手的工具,只能等回到工位再说。
这个痛点持续了一段时间后,我决定用 Flutter 把这些零散的逆向辅助功能收拢成一个 App,让“快速看一眼样本”不再依赖电脑。
先给结论:手机端不适合做重逆向,也不该硬塞 IDA、JEB 那种功能。真正适合手机的场景是信息收集、快速验证和现场取证。Flutter 在这类工具开发里,是平衡成本、平台能力和分发效率都比较好的选择。
这篇文章会从需求判断、技术选型、模块设计、核心代码实现和常见踩坑几个角度,完整复盘这个小工具 App 的开发过程。如果你也打算给自己做一个“随身逆向工具箱”,可以直接参考里面的思路和代码。
1. 为什么逆向工具箱要跑到手机上
很多人对移动逆向工具的想象,是把电脑上的完整环境搬进手机里。这个出发点从一开始就是错的。
逆向分析真正耗时的环节,通常不是某个单点操作,而是信息收集和交叉验证。拿到一个样本后,第一件事往往是算哈希、查包名、看 targetSdk、确认 lib 目录下有哪几个架构的 so 文件。这些操作本身不复杂,但需要多个工具配合,至少打开终端、文件管理器和 APK 查看器三个窗口。
把这些操作放到手机上有几个现实场景:
- 在会议现场、客户现场或 CTF 现场,手里只有一部手机。
- 临时收到一个链接下载的 APK,想先确认是不是官方包。
- 日常逛安全社区时,对比不同版本 APK 的哈希差异。
- 在无法展开全套 PC 工具的隔离环境,做简单的格式确认。
这类场景的共同点是:操作频次高、单次耗时短、对深度分析能力要求不高,但对“随取随用”的要求很高。
这正好是手机 App 的舒适区。它不追求替代 JetBrains、JEB 或 Frida,而是把逆向分析之前最繁琐的“确认身份”环节做掉。
2. 选型对比:为什么是 Flutter 而不是原生、Termux 或 Web
在确定用 Flutter 之前,我实际比较过三条常见路线。
第一条是纯原生 Android 开发。优点是能直接调用系统 API,性能和权限体验最好;缺点是需要同时维护两套端侧逻辑,而且这个工具后续如果还想延伸到 iOS、Windows 或 macOS,基本等于重写。
第二条是 Termux 加 Python 脚本。好处是贴近命令行习惯,很多逆向脚本可以直接复用;坏处是交互体验很初级,文件选择、结果复制、界面展示都要自己折腾,发到别人手机上更是几乎不可能。
第三条是 PWA 或 Web 工具。跨平台是强项,但受限于浏览器沙箱,读取本地大文件、调用系统分享、做深度的原生解析都不顺手,很多能力绕一圈回来还是要包一个原生壳。
四条路线的对比如下:
| 方案 | 跨平台 | UI 效率 | 原生能力 | 分发成本 | 维护成本 |
|---|---|---|---|---|---|
| 原生 Android | 差 | 中 | 强 | 中 | 高 |
| Termux + Python | 差 | 低 | 中 | 高 | 中 |
| PWA / Web | 强 | 中 | 弱 | 低 | 中 |
| Flutter | 强 | 高 | 中 | 低 | 低 |
Flutter 在这件事里的平衡点比较理想。纯 Dart 层就能覆盖哈希、编码、正则、文件解析大部分逻辑,换平台基本不用改;遇到读取 APK 元数据这类原生能力,也能用 MethodChannel 和平台层临时配合。再加上 Material 组件库自带一套能用得过去的 UI,工具箱这类“功能列表 + 工具页”的产品形态,天然适合 Flutter。
3. 能力边界:工具箱该收哪些模块
动手写代码之前,我先把功能边界画清楚。逆向工具包最容易犯的毛病是什么都想塞,最后每个模块都做不深。
我的原则是:只收“不需要动态分析、不需要长时间运行、不涉及敏感权限”的功能。最终第一版收敛成下面几个模块:
- 文件指纹:计算单个文件的 MD5、SHA1、SHA256,支持流式读取大文件。
- APK 快速解析:读取 APK 包名、版本号、版本名,统计 dex 和 so 文件数量。
- 编码转换:Base64、Hex 和 URL 编解码,支持批量互转。
- 正则测试:正则表达式匹配与子串提取结果预览。
- 颜色工具:颜色值与 RGB/Hex 互转,配合逆向 UI 还原时使用。
- 逆向笔记:本地保存分析过程的零散结论,支持导出文本。
没有包含的内容也很明确:不做动态调试、不做脱壳、不做内存 dump。这些功能要么需要 root,要么需要绑定 PC 端 agent,硬塞进手机 App 只会让工具失去“随手打开”的轻盈感。
PC 端很多人习惯“图吧工具箱”那种集合式工具,但移动端逆向没有对标的成熟产品。自己做的时候更应该克制,先把信息收集做透,比堆砌一堆用不上的按钮有价值。
3.1 模块划分与工程结构
为了让后续扩展新工具时不改动主框架,我按页面、服务、模型三层组织代码:
reverse_toolbox/ ├── lib/ │ ├── main.dart │ ├── pages/ │ │ ├── home_page.dart │ │ ├── file_tools_page.dart │ │ ├── apk_parse_page.dart │ │ └── encode_tools_page.dart │ ├── services/ │ │ ├── hash_service.dart │ │ ├── apk_parser_service.dart │ │ └── platform_channel_service.dart │ ├── models/ │ │ └── apk_info.dart │ └── utils/ │ └── display_utils.dart ├── android/ │ └── app/ │ └── src/main/kotlin/com/example/reverse_toolbox/MainActivity.kt ├── pubspec.yaml └── README.md页面层只负责布局和交互,所有计算逻辑放到 service 层,这样后续写单元测试也方便。
4. 环境准备与项目初始化
开发环境按 Flutter 官方稳定版来即可。以 Windows 环境为例,初始化项目前先确认下面几项:
flutter --version flutter doctorflutter doctor会检查 Android SDK、Android Studio、连接设备等基础环境。一个常见问题是刚安装 Flutter 后发现终端里还是提示找不到命令,通常是因为 PATH 没有在已打开的终端里刷新,重开一个终端窗口即可。
确认环境无误后,创建项目:
flutter create --org com.example --platforms android reverse_toolbox然后打开pubspec.yaml,加入工具需要的基础依赖:
# pubspec.yaml name: reverse_toolbox description: 一个用于 Android 逆向辅助的 Flutter 工具箱。 publish_to: "none" version: 1.0.0+1 environment: sdk: ">=3.2.0 <4.0.0" dependencies: flutter: sdk: flutter # 以下版本号以 pub.dev 当前稳定版为准,本文示例只表示版本段 file_picker: ^8.0.0 crypto: ^3.0.0 archive: ^3.5.0 package_info_plus: ^7.0.0 path_provider: ^2.1.0 share_plus: ^9.0.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 flutter: uses-material-design: true执行依赖安装:
flutter pub get如果这一步出现 Gradle 同步失败,优先检查 Flutter、Gradle 和 Android Gradle Plugin 的版本是否匹配。不要手动把 AGP 版本改到某个网上教程里的固定值,Flutter 模板本身会生成一套经过测试的版本组合。
5. 文件选择与哈希计算模块
工具箱里我第一个实现的是文件哈希模块,因为它几乎不依赖原生逻辑,但又非常实用。
5.1 文件选择
使用file_picker插件,用户点击按钮后调起系统文件选择器:
// lib/services/file_choose_service.dart import 'package:file_picker/file_picker.dart'; class FileChooseService { /// 选择任意文件,返回本地路径。 static Future<String?> pickFile() async { final result = await FilePicker.platform.pickFiles( allowMultiple: false, type: FileType.any, ); return result?.files.single.path; } }直接使用系统文件选择器,可以规避大部分 Android 分区存储的权限问题。不要在 Manifest 里申请一堆存储权限,那样既敏感,也不是很有效。
5.2 哈希计算的两种实现
最直观的哈希写法是读取文件全部字节后再计算,代码很短:
Future<Map<String, String>> _hashWithReadAsBytes(String filePath) async { final file = File(filePath); final bytes = await file.readAsBytes(); return { 'md5': md5.convert(bytes).toString(), 'sha1': sha1.convert(bytes).toString(), 'sha256': sha256.convert(bytes).toString(), }; }但真实工具不能这么写。逆向场景里经常需要计算几百 MB APK 的 MD5,一次性readAsBytes会导致内存暴涨,甚至直接 OOM。
正确做法是流式计算。crypto包提供了bind流式接口:
// lib/services/hash_service.dart import 'dart:io'; import 'package:crypto/crypto.dart'; class HashService { /// 流式计算文件哈希,适合大文件。 static Future<Map<String, String>> computeFileHash(String filePath) async { final file = File(filePath); final md5Digest = await md5.bind(file.openRead()).first; final sha1Digest = await sha1.bind(file.openRead()).first; final sha256Digest = await sha256.bind(file.openRead()).first; return { 'md5': md5Digest.toString(), 'sha1': sha1Digest.toString(), 'sha256': sha256Digest.toString(), }; } }注意这种方式会读取文件三次。如果文件特别大,可以在一次遍历中同时喂给多个算法,但考虑到手机端的使用频率,三次读取已经够用,代码也更容易理解。
页面层拿到路径后,展示一个 loading 状态,然后调用computeFileHash,把结果用share_plus导出成文本,方便发到群里和对方核对。
6. APK 快速解析:Dart 与原生通道配合
APK 解析是工具箱里最有“逆向味”的模块,也是第一个需要绕过纯 Dart 限制的功能。
6.1 解析思路
APK 本质上是一个 ZIP 包,所以先用archive包读取压缩包条目,统计出classes.dex数量和lib/arm64-v8a、lib/armeabi-v7a等目录下的 so 信息:
// lib/services/apk_parser_service.dart import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; class ApkParserService { /// 读取 zip 条目,统计 dex / so 基本信息。 static Map<String, Object> inspectArchive(String filePath) { final bytes = File(filePath).readAsBytesSync(); final archive = ZipDecoder().decodeBytes(bytes); final dexCount = archive.files .where((f) => f.name.endsWith('.dex')) .length; final soArchs = <String>{}; for (final file in archive.files) { if (file.name.startsWith('lib/') && file.name.endsWith('.so')) { final parts = file.name.split('/'); if (parts.length >= 2) { soArchs.add(parts[1]); } } } return { 'dexCount': dexCount, 'soArchs': soArchs.toList(), 'isFlutterApk': archive.files.any((f) => f.name.startsWith('lib/') && f.name.contains('libflutter.so')), }; } }但包名、版本号这些字段在 APK 里的AndroidManifest.xml中。这里的二进制 XML 不是常规 XML 格式,用纯 Dart 解析需要实现 AXML 格式解析器,工作量不小。实际开发中更快的做法是走原生通道,调用 Android 系统的包解析能力。
6.2 Flutter 侧平台通道代码
// lib/services/platform_channel_service.dart import 'package:flutter/services.dart'; class PlatformChannelService { static const MethodChannel _channel = MethodChannel('com.example.reverse_toolbox/apk'); static Future<Map<dynamic, dynamic>> parseApkMetaInfo(String path) async { try { final result = await _channel.invokeMapMethod<dynamic, dynamic>( 'parseApk', {'path': path}, ); return result ?? {}; } on PlatformException catch (e) { throw Exception('APK 解析失败: ${e.message}'); } } }6.3 Android 原生侧解析代码
打开android/app/src/main/kotlin/com/example/reverse_toolbox/MainActivity.kt:
package com.example.reverse_toolbox import android.os.Bundle import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "com.example.reverse_toolbox/apk" ).setMethodCallHandler { call, result -> when (call.method) { "parseApk" -> { val path = call.argument<String>("path") if (path.isNullOrEmpty()) { result.error("INVALID_ARGUMENT", "path is empty", null) } else { result.success(parseApkWithArchiveInfo(path)) } } else -> result.notImplemented() } } } private fun parseApkWithArchiveInfo(path: String): Map<String, Any?> { return try { val packageManager = packageManager // 注意:高版本 Android 对该 API 有限制,仅适合读取基础字段。 val packageInfo = packageManager.getPackageArchiveInfo(path, 0) if (packageInfo == null) { mapOf("success" to false, "message" to "无法解析该 APK") } else { mapOf( "success" to true, "packageName" to packageInfo.packageName, "versionName" to packageInfo.versionName, "versionCode" to packageInfo.versionCode ) } } catch (e: Exception) { mapOf("success" to false, "message" to (e.message ?: "unknown error")) } } }这里需要留意一个兼容性问题:getPackageArchiveInfo在较新的 Android 版本上并不稳定,尤其是针对 targetSdk 较高的应用,返回字段可能不完整。这个 API 在部分官方文档里已经标记为受限或 deprecated。所以我的定位是“尽力解析,解析不出来就提示用户”,不要在工具里承诺任何 APK 都能拿到完整元数据。
更完整的方案是解析 AXML 格式的 AndroidManifest.xml,但那是另一个工作量很大的方向,而且需要在 Dart 层维护字节级解析逻辑。如果后续有精力,我会考虑先用原生代码做一个轻量 AXML 解析器,再通过通道把结果传回 Flutter。
6.4 UI 展示
ApkParsePage拿到参数后,用卡片形式展示包名、版本名、versionCode、dex 数量、so 架构和是否 Flutter 应用。这样“一个 APK 是不是 Flutter 写的”也能快速判断,对日常样本分类很有帮助。
7. 编码转换与正则测试
这两个模块没有原生依赖,是纯 Dart 实现,开发速度很快,但使用频率极高。
7.1 编码转换服务
// lib/services/encode_service.dart import 'dart:convert'; class EncodeService { static String base64Encode(String text) { return base64Encode(utf8.encode(text)); } static String base64Decode(String text) { try { return utf8.decode(base64Decode(text)); } catch (e) { return '解码失败,请检查输入是否为合法 Base64 字符串'; } } static String hexEncode(String text) { return utf8.encode(text).map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); } static String urlEncode(String text) { return Uri.encodeComponent(text); } static String urlDecode(String text) { return Uri.decodeComponent(text); } }代码逻辑不难,但实际使用时要考虑边界情况。在线编码转换工具很多,但把数据粘贴到浏览器或聊天工具的二次流转过程本身就容易出错,本地 App 直接计算反而不容易泄漏敏感数据。
7.2 正则测试服务
// lib/services/regex_service.dart class RegexService { static List<String> matches(String pattern, String input) { if (pattern.isEmpty) { return const []; } try { final regExp = RegExp(pattern); return regExp.allMatches(input).map((match) => match.group(0) ?? '').toList(); } catch (e) { return ['正则表达式错误: $e']; } } }这个模块在分析 JS 加密函数或解析接口返回参数时很实用。比如拿到一段混淆参数,想快速验证 URL 里的签名格式,直接在手机上粘贴进去就能看到匹配结果,不用专门开一个在线正则网站。
其实做这事的过程中,我意识到工具箱里很多功能单独看都非常简单,但把它们整合进一个 App 后,价值来自工作流效率的提升。
8. 运行验证与打包发布
开发完成后,验证方式并不是“界面能打开”就算通过。我给自己列了一个验证清单:
8.1 功能验证
| 模块 | 验证操作 | 预期结果 |
|---|---|---|
| 文件哈希 | 选择一个 10MB 以上的 APK | 显示 MD5 / SHA1 / SHA256,且和 PC 端本地计算值一致 |
| APK 解析 | 选择一个未安装的普通 APK | 显示包名、版本名、versionCode、dex 数量 |
| 编码转换 | 输入中文文本转 Base64 再转回 | 结果与原始文本一致 |
| 正则测试 | 输入 IPv4 地址正则 | 能匹配出目标串 |
| 逆向笔记 | 新建笔记并导出 | 生成文本文件 |
运行代码:
flutter run连接真机调试比模拟器更接近真实使用场景。如果设备没有开启开发者模式,需要先在系统设置里打开开发者选项并授权 USB 调试。
8.2 打包发布
发布 Release 包:
flutter build apk --release产物在:
build/app/outputs/flutter-apk/app-release.apkDebug 包和 Release 包在部分场景下行为不太一致,尤其是文件选择器和平台通道相关功能,尽量用 Release 包做最终验收。
8.3 常见问题与排查
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| flutter 命令不存在或版本旧 | PATH 未刷新 | 新开终端后运行flutter --version | 重新打开终端,或手动刷新环境变量 |
| Gradle 同步失败 | Flutter 模板与 AGP 版本不匹配 | 查看android/settings.gradle等配置文件 | 保持 Flutter 稳定版,不要手动修改 AGP 大版本 |
| Windows 桌面构建报 CMake generator 错误 | 缺少 VS C++ 桌面开发组件 | 查看 CMake 日志 | 安装“使用 C++ 的桌面开发”工作负载 |
| 文件选择器无法访问文件 | 分区存储限制 | 观察file_picker的 onError 日志 | 确认走系统文件选择器,避免直接拼接路径操作 |
| 大文件计算哈希卡顿或崩溃 | 使用 readAsBytes 一次性读入 | 查看日志中的 OOM | 改用md5.bind(file.openRead())流式方案 |
getPackageArchiveInfo返回 null | 高版本 Android 限制 | 先用工具确认 APK 是否可正常解压 | 降级提示,或改用 AXML 解析方案 |
| 设置页弹窗主题色和主界面不一致 | Material 3 下showLicensePage走 dialogTheme | 查看ThemeData配置 | 统一配置dialogTheme和textTheme |
9. 合规底线与安全边界
逆向工具箱这个分类比较敏感,有些边界必须写在前面。
这个 App 的定位是协助安全分析人员对“自己开发的应用、开源项目、CTF 题目或已获取书面授权的样本”做信息收集与格式确认。不要把它用于分析未授权的商业应用、破解授权、绕过安全校验或传播恶意样本。
具体到实现层面,有两个设计约束:
第一,工具默认不申请 root 权限,也不需要 root。所有功能都基于公开 API 或文件格式本身完成。
第二,平台通道只做了读取元数据的操作,没有 hook 任何系统函数,没有修改目标 APK,也没有向目标 App 注入代码。
这些边界不是限制,反而是这个工具能长期用下去的基础。安全意识强的同事拿到这个工具箱,第一个问题往往就是“它需要哪些权限”。我的回答是:文件读取由系统文件选择器授权,其余功能不申请敏感权限。
10. 最佳实践与后续优化方向
最后沉淀几条工程层面的建议,给想自己实现类似工具的人参考。
10.1 保持计算逻辑纯 Dart
哈希、编码、正则这些能力尽量用 Dart 实现,不要图省事全部丢给原生。这样工具后续移植到 iOS、Windows 或 Linux 都不用重写核心逻辑。平台通道只用在真正需要系统 API 的地方,比如 APK 元数据解析。
10.2 用单个页面承载多个工具入口
工具箱类 App 的首页不需要复杂设计。一个ListView或GridView列出工具入口,点进去是独立工具页,可以快速扩展。新增一个工具时,只需要在工具列表注册入口,不需要动框架。
10.3 为每个工具补充“复制结果”功能
逆向场景里,结果是要和别人交流的。每个工具页都要留一个复制按钮,哪怕功能简单到只有 Base64 转码。这个细节能大幅提升实际使用体验。
10.4 后续方向
从产品角度看,下一步有几个值得扩展的方向:
- 离线集成一些常见的加密摘要算法标识识别规则。
- 接入在线查壳、签名信息查询 API,前提是服务方稳定且合规。
- 把 APK 的 AXML 解析做成纯 Dart 实现,彻底摆脱平台限制。
- 增加批量文件指纹计算,方便对比同一应用的多个版本。
- 考虑把正则工具升级为常用规则库,内置 HTTPS 链接、手机号、IP、关键参数名的快捷模板。
这些方向里,我对 AXML 解析的优先级评价最高。因为一旦实现,APK 模块就不再依赖 Android 系统 API 的兼容性,跨平台能力也会完全打开。工程量不小,但值得慢慢投入。
如果你也想做一个顺手的小工具,建议从文件哈希和编码转换这两个模块开始。它们实现成本低、日常使用频率高,跑通一个最小闭环后再逐步加入 APK 解析和正则工具。工具的边界和使用者的边界同样重要,希望这个思路也能在你的逆向工作流里找到合适的位置。