十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

R8混淆后AAB拆包仍裸奔?资源与字符串防护实战指南

R8混淆后AAB拆包仍裸奔?资源与字符串防护实战指南 前阵子一个很现实的问题摆在我面前团队把 App 打成 Android App BundleAAB发到应用商店代码混淆早就开了R8 也默认跑着大家自然觉得“没有问题”。结果我随手把 AAB 拆开一看差点没绷住——资源文件是明文的assets 目录下整个配置文件裸奔甚至几处 buildConfig 里塞的调试信息都原封不动躺在那里。R8 确实在干活但我发现很多人包括之前的我自己对它的“混淆”能力有严重的误判R8 只处理 Java/Kotlin 这一层的符号和流程带不走资源、带不走字符串、更带不走你藏在 native 库里的秘密。这篇文章就顺着“R8 之后 AAB 还暴露了什么”这条线把拆包、排查、防护的完整思路讲清楚适合正在做 Android 上架、对代码安全有要求、或者刚接手 AAB 打包流程的开发者参考。1. 先搞清楚 AAB 和 APK 玩法差在哪1.1 AAB 不是拿来直接装的它是一套“半成品”AAB 全称 Android App Bundle是 Google Play 主推的上传格式。它不是最终的安装包而是把 App 的资源、代码、so 库按模块整理成一个压缩包。商店下载时再根据用户的设备屏幕密度、CPU 架构、语言动态生成对应的 APK这叫“拆分 APK”。所以 AAB 里的内容和最终用户装到手机上的内容不完全等价但绝大多数情况下核心 dex、资源、配置都会原样搬进 base APK差异只是剔除掉了不适用的部分。这个机制带来的认知偏差很常见很多人觉得 AAB 是给平台用的自己没必要管太多。但实际上 AAB 的拆包比 APK 更容易因为它的目录结构更规整。谷歌提供了官方工具 bundletool一行命令就能把 AAB 转换成一组 APK再解压就能看到所有东西。这就意味着只要别人拿到了你的 AAB从商店 CDN 抓包、从合作方转手、甚至一张截图泄露了下载链接就能像打开一个 ZIP 一样逐层翻看。1.2 为什么说 R8 的“混淆”和“加密”是两码事R8 是 Android 官方在 AGP 3.4 之后默认的代码缩减和混淆工具替代了老的 ProGuard。它能做三件事代码缩减砍掉没引用的类和方法、资源缩减移除未使用资源、代码混淆把类名、方法名、字段名替换成 a、b、c 之类的短名。这确实大幅提高了逆向阅读的难度但也仅此而已。混淆不等于加密。加密是数据层面的变换没有密钥拿不回来混淆只是改名字返回的还是原始逻辑和原始字符串。R8 不会替你处理以下内容资源目录名和资源文件名assets 目录里的文件内容代码里的字符串常量Native so 库的函数符号和内部逻辑AndroidManifest.xml 中声明的组件、权限、Provider 路径三方 SDK 的包名和类名如果你的 keep 规则把它们全保留了我见过不少项目的混淆规则写得很粗直接 keep 了一大票第三方 SDK 和自定义库。R8 再厉害规则一放开等于穿着外套洗澡。后面我会专门讲 keep 规则为什么是双刃剑。2. 拆开 AAB 看真相哪些东西基本等于裸奔2.1 bundletool 拆包实操先把环境准备好下载 bundletool 的 jar 包GitHub 上有正式 release。然后在命令行执行java -jar bundletool.jar build-apks --bundleapp-release.aab --outputapp.apks生成的是一个 zip 文件直接解压你会看到一堆 APK其中最关键的是splits/base-master.apk或类似名字的基础包。再用 apktool 或 jadx 打开这个基础 APK就可以开始看内部结构了。如果是正式发布的包通常还会需要签名相关参数但 AAB 里内嵌了签名信息用--modeuniversal可以生成一个包含所有内容的通用 APK操作更省事java -jar bundletool.jar build-apks --bundleapp-release.aab --outputapp.apks --modeuniversal打开之后重点扫这几个目录res/、assets/、dex/、lib/、AndroidManifest.xml。一次性把可见内容列出来你就知道自己的 App 在逆向者眼里长什么样了。2.2 常见 AAB 内部目录的风险等级目录/文件内容风险说明res/布局、图片、字符串、颜色等高层级资源基本完全暴露布局结构可还原assets/内置 JSON、SQLite、Web 资源、JS 脚本文件内容完全明文配置改一改就能换行为dex/编译后的字节码经 R8 混淆后才可读性降低未混淆则和源码等价lib/so 库R8 不处理函数名若未 strip等于给了地图AndroidManifest.xml组件声明、权限、Provider、元数据暴露攻击面和业务入口META-INF/签名信息、清单可被用于分析签名算法和证书链看清楚了吗R8 在 dex 这一层确实下了功夫但 dex 之外的东西它一个都管不着。如果你在 assets 里放了一个api_config.json里面写着服务器域名、接口路径、甚至 API Key——那恭喜逆向者拿到 AAB 之后第一件事就是看这个文件。2.3 一次真实的拆包事故复盘我以前接手过一个项目前任把整个debug开关写进了BuildConfig打包时又是 release 包没删干净。结果拆出来的BuildConfig.java旁边直接挂着DEBUG true。你说这算不算 bug严格来说编译没错但发布包了还开着调试开关攻击者利用 adb 连上后能得到大量内部日志和堆栈信息。更离谱的是某次版本还把第三方云存储的 secretKey 写在了assets/keys.json当时发布后不到一周就被人刷爆了存储配额账单直接炸了几个数量级。这件事给我最大的教训是不要假设“R8 能帮你藏消息”。哪怕混淆全部打开你写在代码里的字符串常量依然会出现在 dex 的字符串池里jadx 打开就能全局搜索“password”“secret”“token”这些关键词整个信息几乎等于白给。3. R8 到底保护了什么、没保护什么3.1 它真正有效的地方R8 混淆和优化之后打断的不仅是类名和函数名的可读性还有一些控制流层面的重排。比如把条件判断、循环结构做一些等价变换让反编译出来的代码不是逐行对应的“源码”形状。这会让刚入门的逆向者感到很痛苦毕竟 smali 里全是a.a.a.b()这种标识符。但它解决不了逻辑层面的问题。哪怕代码再乱只要方法跑起来行为就能被观察。攻击者用 Frida Hook 一下关键函数照样能拿到输入输出。所以在“R8 之后 AAB 还暴露了什么”这个命题下我的答案分两部分静态上资源和字符串基本裸奔动态上逻辑层是否有保护取决于你自己跟 R8 关系不大。3.2 最容易误伤配置的场景R8 的 keep 规则里最常见的问题是开发者担心反射调用被删于是干脆-keep class com.myapp.** { *; }一把梭。这么写的结果是你自己写的那堆核心类全保留原名R8 的工作直接白做。我之前统计过一个项目混淆前 APK 里的 dex 有 90% 的类名没变最终安装包大小是瘦下来了但安全性几乎为零。更隐蔽的一个问题是Keep注解的滥用。很多人为了方便直接在 Model 类、接口类上随手加Keep甚至整个包都标上。R8 很听话把它们全部保留。这样结果就是数据库模型字段名、JSON 解析用的 key、序列化的字段名统统留在原样。攻击者仅凭这些类名和字段名基本能反推出你的业务数据结构和接口格式。3.3 为什么默认规则在“暴露”前反而是缺点每当你接到一个 AAB 的生成任务脑子里要有这么一根弦R8 只是基础防护不是安全边界。它更像给门上了把普通挂锁防的是君子不是小人。对于大多数常规开发者开 R8 足够了但涉及支付、登录、核心算法、风控逻辑、前端加密绝不能只靠 R8。如果你不做额外保护AAB 拆包后能看到什么我就直接说结论绘制逻辑和页面跳转完全可还原布局 XML 本身就是设计图业务数据模型、接口路径、字段名从混淆后的字符串池里捞出来assets 里的远程配置、图片、JS、wasm 原样可读so 库导出的 Java Native 接口函数名 JNI 绑定完全暴露接口结构4. 字符串和密钥最刺眼的暴露点4.1 字符串池是所有反编译工具的“重灾区”Java 和 Kotlin 编译后所有字符串常量都集中在 dex 的字符串池里。虽然 R8 会在优化时做字符串拼接合并、删无用常量但业务字符串不会消失。你用 jadx 打开混淆过的 dex虽然类名是a.b.c但只要在文字搜索搜一下中文文案、URL、或者http马上就能定位到具体逻辑位置。想象一下攻击者的操作流程jadx 打开 base-master.apk 搜索 api_key 搜索 http 搜索 secret 搜索 password 搜索 /v1/ 搜索 token命中任何一个直接点进去看调用链业务逻辑的核心枢纽就暴露了。尤其一些项目会把 AesKey、IV、RSA 公钥、甚至私钥写在代码里这种“秘密”在 R8 混淆面前等于零保护。4.2 常见 key 泄露场景统计根据我接触的项目key 泄露大致分这几类云端存储的 AccessKey/SecretKey写在assets或res/raw下加密工具的种子密钥作为字符串常量写在工具类里地图、推送、支付 SDK 的 AppKey直接被当作 public 常量四处引用后端 API 签名用的 salt从BuildConfig里读取但未剥离数据库明文密钥、SharedPreferences 加密密钥这些内容即使经过 R8字符串不变即使做了字符串加密比如用 StringFog 这类工具混淆字符串也只是把明文字符串变成一段解密代码攻击者通过运行时 Hook 一样能拿回明文。4.3 字符串保护该怎么做才不自我欺骗先说结论没有绝对安全的客户端密钥隔离但可以显著提高门槛。思路是按层级拆分不把核心密钥内置到 App 里。能由服务端下发的就通过接口动态下发不能下发的离线场景至少做设备绑定或指纹校验。把密钥拆分成多段分别藏在 native 层和 Java 层。比如 A 段在 so 里B 段从 manifest 读C 段在运行时拼接。注意这种方式不适合零基础团队复杂度和崩溃排查成本较高。加白盒加密算法。把密钥内嵌到算法实现里让提取出来的不是一段明文而是一个过程。适用于金融、风控类的核心场景但代价是性能和兼容性。运行时检测调试器和 Hook 框架。检测到 Frida、Xposed 环境就拒绝启动或走降级逻辑一定程度增加动态分析的成本。以我自己的经验最适合绝大多数应用的是第一条和第二条组合。不需要过度设计别让安全方案成为新的崩溃来源。5. keep 规则写不好等于帮攻击者开灯5.1 R8 默认规则隐藏的“明文开关”R8 在压缩和混淆时会读取 AAPT 生成的proguard-android-optimize.txt和proguard-defaults.txt。这些默认规则会保留很多 Android 框架层的类比如Activity、Service、Fragment这是为了系统能正确反射调用四大组件。换句话说即使你完全没写 keep 规则系统组件这类类名大概率也还在。所以如果你在清单里注册了某个自定义ContentProvider它的 authority 是content://com.example.fileprovider这个类名和路径在 R8 后基本不会被混淆因为系统组件不能瞎改。攻击者对着AndroidManifest.xml一照就能知道你有文件读写能力、有哪些入口组件。顺着这样一个 Provider 的路径再挖一挖很可能找到路径穿越或配置不严导致的文件访问问题。5.2 keep 规则与反射之间的动态平衡业务里用反射最频繁的几个点Gson / Moshi 序列化字段名必须保留所以会 keep 模型类和字段Room 数据库实体类名和字段名常被保留让表结构一目了然EventBus / Metro 之类的注解处理器注册类需要 keepWebView JS 调用JavascriptInterface 方法名必须保留针对这些场景更合理的做法是精确到成员而不是整包 keep。比如-keep class com.myapp.model.User { public java.lang.String name; public int age; }只保留确实会被 Gson 反射到的字段其余能混淆的尽量混淆。R8 在某些版本下还会自己分析序列化调用能进一步缩小 keep 范围。原则就是keep 越小暴露面越小。5.3 反射框架隐藏的坑市面上很多网络请求框架、路由框架都依赖反射他们文档里一般会给你一段 keep 规则。你照着复制粘贴倒没问题问题是粘贴后顺手加了注释// 这段别删然后几年没人动它。随着 SDK 升级规则可能已经过时或冗余但还在那“保护”着你不需要保护的类。我的建议是每年做一次 keep 规则的“债务清理”。把大段 keep 拆开一段一段临时注释后重新打包测试看哪些删除后功能不受影响就果断删掉。这个工作最好用自动化脚本配合单元测试跑一版 release否则手测太费劲。清理完再用 jadx 打开 dex对比一下混淆后的类名比例你会看到明显差异。6. 实战防护路线从 R8 出发做一层纵深增强6.1 构建侧能做的事先说构建侧的优化这些都是不太费人力但效果明显的点开启完整 R8 功能不要只开minifyEnabled不开shrinkResources。资源缩减不仅能减小包体也能去掉可能暴露调试信息的 unused 资源。buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } }发布前剥离调试信息。在release构建里把buildConfigField(boolean, DEBUG_LOG, false)配好不输出日志。Logger 类里用if (BuildConfig.DEBUG)包住所有输出。统一做资源混淆。不是简单依赖shrinkResources而是用 AndResGuard 这类工具把资源路径重命名比如把res/layout/activity_main.xml变成res/layout/a.xml。缺点是部分平台和动态取资源的方式会受影响需要充分回归测试。Native 符号表剥离。在 CMake 或 ndk-build 脚本里加上-s参数strip 掉 so 中的符号表。否则攻击者用nm一看到Java_com_myapp_core_Cipher_decrypt直接把破解路径指到鼻子上了。6.2 加密和混淆工具有哪些工具/方案能解决的问题局限性R8 默认混淆代码符号名混淆不加密字符串、不保护 nativeStringFog字符串混淆运行时可被 Hook 还原AndResGuard资源路径混淆对动态引用资源兼容性有风险加固服务兜底方案全 dex 加壳、防静态脱壳对运行性能和兼容性有影响服务端动态配置关键参数不下发到客户端需要网络链路配合选型时不要盲目堆叠。我的建议是中小型 App 做到 StringFog 基础资源混淆就足够大型或金融类再考虑加壳。至少要保证“拆包之后不是一眼裸奔”的程度而不是把所有希望寄托在某个单一工具上。6.3 代码混淆后别忘了检查 Crash 堆栈还原这是个非常实际的问题开了 R8 之后线上崩溃日志里全是a.a.a.a: NullPointerException没有行号找问题的时候真的能逼疯人。所以混淆后再做一个 mapping 文件的上传和保存利用 mapping 工具反向还原堆栈。常见做法是把 mapping 文件自动备份到 CI 服务器或者对象存储。前后要对应版本号保存好否则发布后过几个月再来看崩溃日志你会发现根本没法对照当时的映射。这个环节虽然不直接影响“暴露”但直接关系到你后续迭代的效率很多团队在这里吃过亏。6.4 拆掉误导性信息别把“不可逆”当成“不可读”讲一个我踩过的坑曾经有同事说“R8 混淆之后的代码是不可逆的”然后大家就放心地上线了。直到竞品工程师用一个下午就把我们的支付回调签名算法摸透了原因不是类名混淆没生效而是字符串池里的签名 key 和拼接规则太显眼。对方顺着签名相关类的方法调用一路找两三步就定位到核心方法。所以我后来在团队里定了一个很简单的规矩每次发布前自己用 jadx 拆一遍自己打的包先当一回攻击者。做逆向就是最好的安全检查。尤其要把自己 App 包里面的信息挖一遍看到不该出现的内容就记下来后续版本去清理。7. 用拆包验证倒推改进建立“自黑”检查清单一提到安全建议很多人会觉得“我们又不是大厂不会有人针对我们”。这个想法可以理解但你得知道自动化扫描工具早就成为了灰产标配。不需要“有人针对”只要你的包被批量爬取高危漏洞和敏感信息就会被自动筛出来。所以每次发版前做一次“自黑检查”是唯一靠谱的防线。我在团队里建立了一个简单清单用 bundletool 拆 AAB用 jadx 打开 dex窗口搜索“http”“api”“key”“secret”“debug”等关键词看看命中点和业务逻辑的关系检查assets目录里的配置文件是否含有任何人不想公开的数据检查res/raw和assets里有没有私钥、证书、备份数据库检查 native so 是否有nm -D能看到的敏感函数名检查BuildConfig里 DEBUG 是否被错误置为 true检查是否残留.jks、.keystore、google-services.json增量备份之类的文件检查AndroidManifest.xml中 export 的组件是否真的需要对外暴露这套清单不需要花多少时间但能拦住绝大多数“低级失守”。我自己的经验是第一次手动查一个中型 App大概一小时能查到一堆问题清理完之后后续维护的成本会低很多。8. 写在最后一点实际的话R8 是好工具但它的定位更像“让代码不那么好读”而不是“让代码无法读取”。AAB 打包流程本身又是一个全新的暴露面很多人没意识到商店手里那份“半成品”其实比 APK 更规整、更好逆向。如果你把 R8 当唯一防线那坑早就埋好了。我现在的习惯是所有核心配置能上服务端就上服务端实在要内置也拆层保护keep 规则一年清一次每次发版前拆包自检一遍。整个过程不复杂也不会把交付周期拖慢多少但能明显降低“家底被看光”的概率。先从拆一次自己的 AAB 开始吧看到结果之后你会回来感谢这次排查的。
返回列表