R8混淆不是加密:Android AAB包安全自检指南
2026/9/15 13:59:26 网站建设 项目流程

做了几年 Android 打包和逆向对抗,我越来越觉得国内很多团队的打包流程是“背对敌人上战场”。R8 一开,混淆一开,就默认安全了,结果 AAB 上传到 Play 或分发平台之后,别人下载下来用 bundletool 一拆,什么都能看明白。R8 确实能缩小包体、混淆类名方法名,但很多人没搞清楚一个根本问题:R8 是压缩和混淆,不是加密。它把 class 文件里的符号表改头换面,但类里的字符串、资源文件、Manifest 里的四大组件声明、FileProvider 的路径配置、签名证书信息,全都原封不动躺在 AAB 里。

这篇文章我就从 AAB 的实际结构出发,结合我拆过的几个包的经验,聊一聊 R8 之后到底还有什么东西是暴露的,以及打包前应该做什么样的自检。标题里提到的 R8、Android、AAB 这三个关键词,本质上是一条链:构建工具(R8)→ 分发格式(AAB)→ 运行环境(Android),安全短板往往就出在链路的连接处。

1. 先搞清楚:R8 到底帮你挡了什么,又漏了什么

1.1 R8 的核心职责是“瘦身”,不是“上锁”

R8 是 Android 官方在 AGP 3.4 之后默认启用的压缩器,它替代了原来的 ProGuard。它做的事主要有三件:

  • Shrinking(裁剪):从代码入口出发,分析哪些类、方法、字段没有被引用,然后直接删掉。
  • Optimization(优化):合并重复代码、移除无用参数、将某些方法内联化,减小执行路径。
  • Obfuscation(混淆):把类名、方法名、字段名改成 a、b、c 这种短名,增加反编译后的人工阅读难度。

这三件事全部发生在字节码层面。也就是说,R8 只对你写的 Kotlin/Java 代码里的符号做了改名和删除,它不会去修改 resources 目录下的 XML、assets 里的 json、res/raw 里的配置,也不会去加密你的字符串常量。

我在实际测试里见过很多次这样的场景:客户端里写死了内网接口地址、对象存储的 AccessKey、推送服务的 appKey,甚至测试环境的数据库地址。开启 R8 之后反编译一看,方法名虽然变成 a/b/c,但你顺着字符串索引一搜,那一堆明文地址直接摊在眼前。混淆挡住了人眼阅读,挡不住工具检索

1.2 R8 默认不会处理的四个重灾区

这里我要展开说四个 R8 甚至不会碰的地方,这也是整篇文章的主线:

资源文件。R8 的资源压缩(resource shrinking)只是移除未引用资源,但保留的资源内容一字不动。你 res/xml/ 下的网络配网文件、assets 下的 so 库名映射表,全部原文保留。之前在排查一个 IoT 类应用时,我从 AAB 的 assets 目录里直接找到了一整套设备配网协议文档,文本里有 AES 密钥和初始向量,这种问题 R8 完全无能为力。

AndroidManifest.xml。R8 会参与 manifest 处理,但只负责类名引用映射,不会把你写的 provider、activity 的 exported 属性变成 false,也不会把 FileProvider 的 paths 删掉。你声明的所有组件、权限、intent-filter 都会被原样打包进 AAB。反编译工具看 manifest 和看明文没有任何区别。

字符串常量。Java/Kotlin 里的所有字符串字面量,不管有没有被代码引用,只要最后被编译进 dex,就会以 UTF-8 明文形式存在。R8 的混淆步骤不会做字符串加密,ProGuard 时代的经典问题到今天依然存在。

签名与证书信息。AAB 的上传签名、应用签名、targeting 信息都在 META-INF 目录里明晃晃地放着。第三方下载你的包可以对你做签名比对、证书指纹提取,甚至判断你用的是 Debug 签名还是 Release 签名。这一点很多人完全没意识到。

注意:R8 不等于安全加固。安全加固是另一个技术栈,通常是修改 dex 结构、抽取指令、动态解密,和 R8 是两码事。如果你们团队的安全目标是“核心算法不泄露”,那 R8 帮不了你,需要的是加固厂商或自研的加壳方案。

2. AAB 格式拆解:泄露面比 APK 更宽

2.1 AAB 不是给用户直接用的,但解包非常方便

Android App Bundle 是 Google 在 2018 年推出的分发格式,不是一个压缩后的 APK,而是一个包含模块和元数据的包。AAB 到用户手机上要经历一步:Google Play 或第三方平台根据设备配置生成对应的 APK(即 split APK)

但注意,AAB 本身不是加密格式。它就是 ZIP 结构,你用解压工具能直接打开。里面的目录结构大概是:

base/ ├── manifest/ │ └── AndroidManifest.xml # 二进制 XML,可用工具解析 ├── dex/ │ └── classes.dex # R8 处理后的 dex ├── res/ # 资源文件,图片、xml 原样存在 ├── root/ │ ├── assets/ # 原始 assets 文件 │ └── META-INF/ # 签名、证书、清单 └── lib/ # 各 ABI 的 so 文件

攻击者拿到 AAB 之后,根本不需要等什么动态执行,直接静态拆包就能看个大概。比 APK 多出来的信息在于:AAB 里保留了模块的边界,targeting 配置,以及条件资源的分组规则,这些信息会暴露你的多模块设计逻辑。

我之前用bundletool build-apks把一个商业 App 的 AAB 转成 APK Set,输出内容里直接能看到这个项目的功能模块划分,比如支付模块、地图模块、推送模块、登录模块,全部以 split 的形式存在。模块之间的类依赖关系虽然已经被 R8 混淆过,但模块目录和资源归属依然是清楚的,这相当于给逆向者画了一张拓扑图。

2.2 资源表里藏着完整的“内部命名规范”

AAB 里的resources.pb是资源映射表,里面记录了所有资源名的原始名称(R.string.xxx、R.dimen.xxx),以及每个资源的配置限定符(夜间模式、屏幕密度、语言)。R8 的资源压缩只会删掉未使用的资源,不会对资源名做混淆。Android 官方的资源混淆能力在 AGP 里已经有雏形,但绝大多数项目根本没开。

这意味着什么?举个例子,我在一个金融类 App 的 AAB 里看到过这样的资源名:

<item name="pay_sdk_rsa_public_key" type="string"/> <item name="bank_card_cvv2_hint" type="string"/> <item name="api_v2_upload_encrypted_file" type="string"/>

资源名本身就是敏感信息。即使你不去看资源内容,光看名字,攻击者就知道:这个 App 的支付模块用了 RSA,而且有一个文件上传接口叫 api_v2。再加上assets/目录下常有的签名公钥、加密配置,一套攻击面的框架就拼出来了。

2.3 META-INF 目录泄露的构建信息

AAB 的 root/META-INF 里,除了签名相关的 .RSA、.SF、.MF 文件之外,还会有构建工具版本信息、打包时间戳、甚至 Gradle 插件的元数据。我用unzip -l扫过很多包,常见的泄露有:

  • 构建机器上生成的.DS_StoreThumbs.db文件,里面可能记录了目录结构。
  • META-INF/com/android/build/gradle/app-metadata.properties,直接写明 AGP 版本号。
  • 签名文件的名字和路径,如果团队用的是自定义别名,攻击者能根据别名做社工猜测。

单个信息看着无害,组合起来就是指纹。比如 AGP 版本号能帮助定位你们项目用的 Gradle 版本,再结合已知漏洞(比如某些版本 AGP 的依赖解析 bug),攻击者可以设计针对性的攻击手法。

3. 实操:拆一个 AAB,看它到底暴露了什么

3.1 准备一套顺手的小工具

做安全自查,我习惯用的是下面这套组合:

# 官方工具:用于把 AAB 转成 APK 集或直接解包 bundletool build-apks --bundle=app-release.aab --output=app.apks --mode=universal unzip app.apks -d apks_output # 二进制 XML 解析 # 可以直接用 jadx 打开 AAB,它会自动处理 manifest 和 resources jadx-gui app-release.aab # 如果只想快速看 manifest,用 apktool apktool d app-release.aab -o aab_decode

如果你只是做静态自检,jadx-gui是最省事的。它能直接读取 AAB 里的 dex、资源、XML,并且能反编译出 Java 代码。打开之后建议第一个看Resources面板里的字符串列表,那是一个巨大的信息泄露面。

3.2 第一步:先看 AndroidManifest,列出所有 export 组件

打开 manifest 之后,用 Ctrl+F 搜provideractivityservicereceiver四条标签,然后逐个检查android:exported的值。这里我特别强调 provider,因为在 AAB 场景下,FileProvider 的暴露是最容易被忽略、又最容易出问题的。

我见过一个包,里面声明了三个 FileProvider:

<provider android:name="androidx.core.content.FileProvider" android:authorities="com.example.app.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>

看第一眼,exported=false,好像没问题。但再看 grantUriPermissions=true,配合 paths 里的配置:

<paths> <external-path name="external_root" path="." /> <root-path name="root_all" path="/" /> </paths>

这里就是个大坑。external-path配了.代表整个外部存储可以共享,root-path/代表整个文件系统根目录都可以被共享。一旦有任意一个导出组件能接收Intent.ACTION_VIEW并处理content://URI,攻击者就能发起 File URI 攻击,把这个 App 的手机本地文件读出来。这种配置在线上包中出现的概率远比你想象的高。

我在实际拆包中还收集过一类典型路径,形如content://xxx.fileprovider/external_path/android/data/应用包名/files/。有些版本历史遗留的路径没有收拢,直接写在 paths 里,攻击者互换包名和路径,甚至能实现跨应用读取文件。这类问题通过静态排查 manifest 和 xml 就能百分之百发现,不需要任何高级手段。

3.3 第二步:搜字符串,找出硬编码的密钥和接口

在 jadx 或直接解包后用strings命令扫 dex,重点搜这几类关键字:

http:// https:// api_key apikey access_key secret private_key public_key BEGIN RSA PRIVATE KEY aes des rsa cipher password pwd token dbname jdbc: mongodb:// redis:// .pem .key .jks .keystore

我拿一个上游合作方的 AAB 做过测试,搜索结果里直接出现了完整的 RSA 私钥头。那个私钥最后被确认是用来做服务端接口签名的,泄露在客户端 App 里意味着任何人都能伪造合法请求。团队负责人当时的第一反应是疑惑:“我们不是开了 R8 吗?为什么还能看到这些?”——这正好回答了我前面说的:R8 不处理字符串字面量。

高德地图离线包下载路径、BLE 设备地址前缀、开发环境的 baseUrl,这类字符串我基本每次拆包都能扫到。建议把这一项做成流水线里的自动化检查,用 grep 或脚本扫一遍 release AAB,高危关键字命中就直接中断构建。

3.4 第三步:对比 debug 产物与 release 产物

很多人只检查 release AAB,但我建议 debug APK 也要检查一遍,而且优先查一查有没有把android:debuggable="true"混进线上包。这个问题在开启 R8 之后依然可能发生,因为 R8 不会修改 manifest 里的这个属性,它只认 AGP 的buildType配置。

我的自查命令是这样:

aapt dump badging app-release.apk | grep -E "debuggable|application-label" apktool d app-release.apk -o check_manifest && grep -r "debuggable" check_manifest/AndroidManifest.xml

如果你的 release 包显示debuggable=true,那攻击者可以直接用adb shell run-as 包名读取 App 的私有数据目录,数据库、SharedPreferences、缓存文件全部可见。这个问题比上面的字符串泄露更严重,因为它直接影响运行态数据。

3.5 常见问题速查:拆包过程中最常踩的坑

排查项检查方法风险等级说明
exported 组件查看 manifest 中所有组件 exported 属性导出组件可被外部唤起,结合 intent 可产生越权
FileProvider paths 过宽查看 res/xml/file_paths.xml配置./相当于开放全盘文件访问
硬编码密钥strings 扫描私钥头、AccessKey可直接伪造签名或访问第三方服务
测试接口地址strings 搜 inner/test/dev/uat暴露内网地址,辅助横向试探
debug 签名打包META-INF 中 CERT.RSA 指纹比对攻击者可直接重打包签名替代
资源名泄露业务resources.pb 中敏感名词低中辅助定向逆向,不属于直接漏洞
AGP/Gradle 版本泄露META-INF/com/android/build/ 下文件结合 CVE 库可定向探测漏洞

提示:自查时不要把目光只放在“能不能反编译”上。攻击者不需要完整看懂你的代码,只需要找到一处可利用的点。R8 降低了可读性,但不会降低可利用性。

4. 构建侧的几个硬动作:让 R8 之后也没东西可看

4.1 收紧 R8 的 keep 规则,别图省事全 keep

R8 混淆的效果很大程度上取决于 keep 规则怎么配。团队常见的两个极端是:

  • 一个极端是 keep 规则里写了-keep class com.你的包名.** { *; },整个项目全保住了,R8 直接变摆设。包体变大不说,混淆也等于没开。
  • 另一个极端是 keep 规则太少,运行时反射的类被裁剪掉,导致线上崩得莫名其妙。

我的经验是:默认不 keep,遇到问题再补。反射类、JNI 引用的类、Gson 的 model 类、自定义注解处理器生成的类,这些通常需要显式 keep。不要用包级通配符一把梭,建议分模块写清楚为什么 keep:

# Gson model 需要 keep,因为通过 TypeToken 反射创建 -keep class com.example.app.model.** { *; } # JNI 方法名要与 so 对齐,不能混淆 -keepclasseswithmembernames class * { native <methods>; }

同时打开 R8 的完整模式(AGP 8.0 之后默认就是 full mode),配合android.enableR8.fullMode=true,能优化掉更多未被覆盖的分支。要强调的是,keep 规则越细,反向者能看到的符号越少

4.2 把密钥和地址搬出客户端

这是根本解法里最有效的一项。凡是在客户端静态存储的密钥,无论你怎么混淆,都只是拖延时间。正确的方向是:

  • 接口地址放到远端配置,客户端动态获取。这样即使 AAB 被拆,里面也只有 bootstrap 域名,不包含全套后端 API 路径。
  • 签名密钥放到后端,客户端只提交待签名内容,后端完成签名后返回。客户端不再持有私钥。
  • 对象存储的临时凭证由后端下发,客户端使用后立即失效,不要长驻。

有些团队担心“动态获取”会多一次网络请求,我实际体验下来的结论是:增加一两个 RTT 的代价,远比密钥泄露后的事故修复成本低。更重要的是,暴露出内网地址本身就等于给攻击者发了一张地图,这一点你可以在自查报告里列为最高优先级整改项。

4.3 用 FileProvider 替换掉所有 file:// 暴露

如果你还在用类似file:///storage/emulated/0/...这种路径去分享文件,一定要尽快换掉。Android 7.0 开始强制要求使用 FileProvider,但很多历史代码还是把 file:// 硬生生传给了第三方 SDK。

正确做法是维护一份严格的 file_paths.xml,宁可少配,不要坑配。在自研文件分享功能时,我在实际项目中用的方案是单独建一个res/xml/file_paths_share.xml

<paths> <files-path name="share_cache" path="share/" /> <cache-path name="shared_cache" path="share/" /> </paths>

把可分享目录限定在 App 私有目录下的 share 子目录,而不去碰 external-path 或 root-path。这样即使某个第三方 SDK 通过 grantUriPermissions 拿到了临时读权限,能访问的也只是这个小目录,而不是整块存储。

4.4 buildType 差异化配置:让 debug 和 release 从底层分离

我强烈建议把 debug 和 release 的配置从 applicationId 到 buildConfig 字段都做彻底分离。原因很简单:AAB 反编译之后,攻击者第一眼看的往往就是 applicationId 和签名信息。如果你 debug 包和 release 包的 applicationId 一样,攻击者可以直接用 debug 包做动态调试,然后重打包上传攻击载荷。

我在项目里惯用的配置是:

buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' signingConfig signingConfigs.release buildConfigField "String", "API_BASE_URL", "\"https://api.example.com/\"" } debug { applicationIdSuffix ".debug" signingConfig signingConfigs.debug buildConfigField "String", "API_BASE_URL", "\"http://10.0.2.2:3000/\"" } }

这样 release AAB 里不会混入 debug 的网络配置、测试证书,也不会因为误用了 debug 签名而被直接重打包。applicationIdSuffix还能保证一台手机上 debug 和 release 可以共存,调试时不互相覆盖数据。

注意:AAB 的 versionCode 和签名信息请务必纳入自动化检查脚本。我在几个团队里推过一个最小脚本:解包 AAB,检测 META-INF/CERT.RSA 的指纹,如果匹配到 debug keystore 或公司默认 test keystore,构建直接失败。这个脚本 20 行不到,但能挡住一次高危事故。

5. 一些更细的检查项:覆盖 AAB 的边角信息

5.1 assets 和 res/raw 里的配置文件要单独审查

assets 目录下的 json、xml、sqlite 文件在打包时完全不会被处理。我见过有项目为了“方便运营配置”,把整套权限控制规则甚至功能开关都放在 assets 里,攻击者打开 AAB 就直接导出一份“功能清单”。如果这些文件里还有加密密钥的种子或混淆表,那 R8 的工作几乎等于白做。

建议在打包前跑一个脚本,扫描 assets 和 res/raw 下的文件扩展名,把 json、xml、properties、sqlite、db 这类文件全部列出来人工检查。不需要读完整内容,只看文件名就能发现很多问题,比如:

assets/ ├── api_list.json ├── rsa_key.pem ├── encrypt_config.xml └── internal_server_address.properties

这些文件名的敏感程度很高,要么在产品代码里移除,要么改成远端下发,无论如何不该出现在 AAB 里。

5.2 so 库版本和 BuildConfig 字段泄露的信息

有些团队用 native 层保护关键逻辑,但 so 库的名字、导出符号表又会暴露新的信息。lib/目录下的 so 库文件不会因为 R8 而发生变化,你可以用nm -Dreadelf看到导出函数名。如果适配了 arm64-v8a、armeabi-v7a、x86 多个 ABI,攻击者还能准确判断你们支持哪些设备,再结合 assets 里的型号白名单,完全可以设计针对特定型号的攻击。

BuildConfig 字段如果配置不当,也会泄露信息。比如把BUILD_TYPEFLAVORVERSION_NAME拼在某个请求头里,攻击者从网络包就能推断出你是哪个渠道版本。建议在 R8 配置里把 BuildConfig 中非必要的字段移除,或者用buildConfigField只保留必须字段。

5.3 利用 bundletool 做一次端到端“偷拍测试”

如果你时间有限,只做一项检查,那我建议做这个:用 bundletool 从 AAB 生成一个 universal APK,安装到模拟器或真机上,然后以攻击者的身份去读取你的私有目录。

# 安装 java -jar bundletool.jar install-apks --apks=app.apks --device-id=emulator-5554 # 尝试读取私有目录 adb shell run-as com.example.app ls -R /data/data/com.example.app/

注意,如果你的 App 设置了android:debuggable="false"run-as大概率会失败,这是好消息。但如果你是 debug 包,或者被检测到 debuggable=true,你就可能看到:

/data/data/com.example.app/ ├── databases/ │ ├── user.db │ └── local_config.db ├── shared_prefs/ │ ├── sp_login.xml │ └── device_remote_config.xml └── files/ └── upload_cache/

到这里你就应该理解,前面清单里列出的所有问题,最终都会汇集到这一步:攻击者能不能读到你的私有数据。静态检查解决的是“有哪些入口”,这步动态验证解决的是“入口能不能走得通”。

6. 实操心得:R8 + AAB 的安全意识应该怎么落地

6.1 安全自查要嵌入构建流水线,而不是想起来才做

我自己走过弯路。最早是每次发版前手动解包看一眼,后来发现靠人根本不可靠——发版前一天大家都在改 bug,没人有耐心去拆包查字符串。后面我改成了 CI 流水线里加一个 check task,每次打包 release 后自动执行,扫码清单包含三行:签名指纹、debuggable 状态、高危字符串关键字。命中就直接失败,发版流程拦下来再说。

这个 check task 不用写太复杂,核心就是调 aapt、grep、unzip 这几个命令。重点是把它放在构建产物生成之后、上传渠道之前,并且由 CI 而不是开发本地去执行,避免“我本机能过就行”的侥幸心态。

6.2 别被“R8 开启”四个字骗了,要定期接触真实的反编译视角

我见过太多团队把“我们开了 R8”当安全 KPI,但一句反问就能戳破:R8 开启之后你们拿 jadx 看过自己的包吗?拿 fiddler 抓过自己的流量吗?拿 frida 调试过自己的进程吗?

安全对抗的本质是你要比攻击者更早地了解自己的暴露面。R8 能压低代码可读性,AAB 重新定义了 App 的分发方式,但泄露的路径永远在那里。组件 exported、FileProvider 路径、硬编码值、debug 特性,这些在 R8 之前暴露,在 R8 之后依然暴露,在切成 AAB 后还多了一批构建元数据可以挖。

6.3 这个方向后续可以继续扩展

如果你对这个话题感兴趣,下一步可以从三个方向往下挖:一是把资源名混淆和字符串加密补上,配合 R8 形成更厚的静态层;二是引入运行时检测,对 debuggable、模拟器、root 环境做主动反制;三是做细粒度的组件权限管理,把 exported 组件收敛到唯一入口。每一块展开来都是足够写几篇文章的实战内容,但核心认知就一条:别把 R8 当终点,它是起点。

最后再分享一个小技巧。每次对外发版之后,我都会在本地留一份 AAB 和对应的 jadx 反编译工程,隔一个月再打开看看。不是为了复现 bug,而是用旁观者的视角审视:如果这个包不是我写的,我能从里面挖出什么?这个“自我攻击”的习惯帮团队堵掉过不止一次接口泄露和密钥外流。希望你们也能试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询