第2章 混淆原理解析:代码混淆、资源压缩、字符串加密等核心机制

做安卓安全这么多年,我经常被问到:“混淆到底干了什么?” 说白了,混淆不是让你的代码变成密文,而是把人类可读的逻辑变得面目全非。它和加密是两码事——加密要还原,混淆不需要。

这一章,我会从三个核心维度拆解:代码混淆、资源压缩、字符串加密。搞懂它们,你就知道 ProGuard / R8 的配置不是瞎写的。

混淆核心机制全景图 混淆 (Obfuscation) 代码混淆 资源压缩 字符串加密 类/方法重命名 成员字段混淆 控制流平坦化 无用资源删除 资源文件名缩短 资源 ID 重映射 编译期字符串替换 运行时解密 常量池隐匿 三者相互配合 注:控制流平坦化仅 R8 支持(ProGuard 无此功能)

2.1 代码混淆:把类名变成 a、b、c

代码混淆的核心就一个字:改。把有意义的类名字、方法名字改成无意义的短名。比如 com.example.app.LoginActivity 变成 a.a.a。这样反编译出来的代码,读起来就像天书。

我接手过一个老项目,混淆配置几乎没动。结果 APK 被扒出来,攻击者直接通过类名定位了核心支付逻辑。后来加上标准混淆,攻击成本翻了不止一倍。

混淆类型作用工具支持
重命名(Rename)类、方法、字段随机短名ProGuard、R8
移除无用代码(Shrink)删除未被引用的类/方法ProGuard、R8
优化(Optimize)内联、合并、简化字节码ProGuard、R8(R8更强)
控制流混淆(Obfuscate)打乱代码执行路径仅 R8
我个人的习惯: 对于 SDK 或者 AAR 库,只开启重命名 + 移除无用代码,不开启优化。优化有时会破坏反射调用的契约,曾经让我排查了一下午。

几个关键点要记住:

  • 反射调用必须保留:如果你用 Class.forName() 或 Method.invoke(),对应的类名、方法名不能混淆,否则运行时报 ClassNotFoundException。
  • JNI 方法不能混淆:C/C++ 层调用的 Java 方法名要保持不变,否则 native 层找不到对应方法。
  • Serializable/ Parcelable 序列化:字段名改了,反序列化就失败了——这也是经典踩坑点。

2.2 资源压缩:给 APK 减减肥

资源压缩想解决两个问题:一是把没用的资源删掉,二是把剩余资源的路径和 ID 搞乱。前者减少 APK 体积,后者增加逆向难度。

打个比方:你项目里可能残留了十几张旧版本的背景图,但 XML 里早就不引用了。资源压缩会把它们揪出来扔掉。

资源压缩的关键配置:
// build.gradle 中开启
android {
    buildTypes {
        release {
            minifyEnabled true    // 开启代码混淆
            shrinkResources true  // 开启资源压缩
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

切记:shrinkResources 必须和 minifyEnabled 一起用,因为资源删除依赖代码混淆的引用分析。我曾经单独开 shrinkResources 没开混淆,结果把正在使用的资源也删了,打包后多处界面白屏。

资源 ID 重映射 是 R8 新增的特性(ProGuard 不支持)。它会把资源文件名和 ID 映射成短的无意义值。比如 R.layout.activity_main 变成 R.layout.a。注意:如果代码中通过 getIdentifier() 动态获取资源 ID,这些资源必须显式保留。

2.3 字符串加密:保护敏感文本

代码混淆只能混淆类和方法名,但对字符串常量完全无效。比如你的 、加密密钥、日志里的敏感信息,只要在代码里写了明文,反编译后所有人都能看到。字符串加密就是来解决这个问题的。

实现方式分两种:编译期替换 和 运行时解密。

  • 编译期替换:在 Gradle 构建时通过插件(比如 DexGuard 或自定义 Transform)把字符串内容加密成字节数组,然后生成解密代码。这样反编译后看到的是一堆乱码字节,不是原文。
  • 运行时解密:在应用启动时,通过一个隐蔽的解密器还原字符串。解密逻辑一般内嵌在 native 层,防止被直接 Hook。
注意: 字符串加密会增加性能开销(每次使用都要解密),而且解密逻辑如果太简单,反编译后可以直接把解密函数提取出来跑一遍。我见过有人用 XOR 异或做加密,结果攻击者写个脚本三秒就全解了。建议结合 native 层或白盒算法。

哪些字符串应该加密? 我个人认为至少这三类:

  1. 网络接口的 BASE_URL:尤其是内网地址或测试域名,暴露后可能被重定向。
  2. 对称加密的密钥:千万别写死在 Java 代码里——放在 native 层或服务端下发是更好的选择。
  3. 敏感日志标签:有些 app 在 release 版里忘删的日志,包含路径信息或版本号,加密后能减少风险。

2.4 三种机制如何协同工作?

你可能已经看出来了:代码混淆管「名字」,资源压缩管「体积和路径」,字符串加密管「敏感内容」。它们各司其职,但又互相依赖。

  • 代码混淆 + 资源压缩:资源压缩依赖代码混淆的引用分析,所以必须同时开启。
  • 字符串加密 + 代码混淆:加密后的解密类必须保持在混淆白名单外,否则解密方法本身被混淆,运行时可能找不到。
  • 资源压缩 + 字符串加密:如果资源文件中包含字符串(比如 strings.xml),资源压缩只删不加密;加密需要额外工具(如 AndResGuard 或 DexGuard)。
避坑指南: 我曾经在一个项目里同时开了 ProGuard 的混淆和 AndResGuard 的资源混淆,结果 AndroidManifest.xml 里引用的 @string/app_name 被改成了 @string/a,导致应用名显示乱码。最后发现是 AndResGuard 的配置文件忘了加白名单。切记:所有在清单文件中使用的资源名都要保留。