藏在设备上的秘密,终究藏不住

本文译自「You Can't Hide Secrets in Your Android App. Here's What to Do Instead.」,原文链接proandroiddev.com/you-cant-hi...,由Anang Suwasto发布于2026年8月25日。

如果你曾经发布过一款内置 API 密钥的 Android 应用,你可能在凌晨两点有过这样的疑问:"这真的安全吗?"

诚实的答案是:不。最终不安全,目前也不够安全。不。

本文将深入探讨所有常见的设备端密钥隐藏技巧最终都会失效的原因,并列举团队实际部署到生产环境中的三种方法,最后阐述真正有效的模式:将密钥完全从设备中移除,并使用认证机制来决定谁有权查询密钥。

威胁模型:假设攻击者拥有你的 APK

在探讨具体技术之前,明确你要防御的目标至关重要。一旦你的APK文件安装到某人的手机上,该人就拥有了以下权限:

  • 完全读取二进制文件。 他们可以从设备中提取 APK 文件,或从镜像站点下载,然后使用 jadx 或 apktool 等反编译器获取可读(但可能比较混乱)的 Java/Kotlin 源代码,或者使用反汇编器获取 Smali 或本地汇编代码。
  • 完全控制运行时。 使用已 root 的设备或模拟器,他们可以附加 Frida(一款动态插桩工具包),并钩住应用程序中的任何方法(包括本地代码),以便在应用程序"暴露"自身秘密的瞬间记录参数、返回值或中间变量。
  • 无限时间。 持续集成 (CI) 流水线不会感到无聊,赏金猎人也不会。

这意味着静态隐藏(混淆二进制文件中的值)和动态隐藏(在运行时巧妙地重新组合)都注定失败。如果应用程序能够计算出密钥,那么控制了应用程序执行环境的攻击者就可以让应用程序替他们计算,然后从网络传输或内存中读取密钥。让我们看看这种情况会如何发展。

尝试 1:构建配置 / 硬编码常量

最常见的方法:直接将密钥放入 build.gradle 或 Kotlin 常量文件中。

kotlin 复制代码
object ApiKeys {
  const val SECRET_KEY = "sk_live_51Hxxxxxxxxxxxxxxxxxx"
}

同样的错误还有一种看起来更"正式"的变体,那就是通过 Gradle 将其放入 BuildConfig 中,这样感觉更安全,因为它不是一个直接位于源代码树中的字符串:

sql 复制代码
android {
    buildTypes {
        release {
            buildConfigField "String", "API_KEY", "\"sk_live_51Hxxxxxxxxxxxxxxxxxx\""
        }
    }
}
ini 复制代码
// Usage in code
val apiKey = BuildConfig.API_KEY

这纯粹是外观上的问题。buildConfigField 只是在编译时生成一个 BuildConfig.java/BuildConfig.class 文件,并将该值嵌入到一个 public static final String 中,最终在 DEX 文件中以普通字符串常量的形式存在,与硬编码版本完全相同。反编译 APK 后,BuildConfig.API_KE = "sk_live_51Hxxxxxxxxxxxxxxxxxxx" 的显示效果与内联常量显示的效果一样清晰。即使将其从名为 Secrets.kt 的文件中移除,也不会从编译后的二进制文件中移除它。

**漏洞利用方法:**只需五分钟即可完成。解压 APK,在 classes.dex 文件上运行 jadx-gui,然后搜索该字符串。即使移除调试符号,字符串常量仍然以明文形式存储在 DEX 文件的字符串池中------默认情况下,字面字符串值没有任何混淆处理,即使使用 R8/ProGuard 等名称混淆器,字符串内容本身也未做任何修改。任何人都可以在几秒钟内使用 grep 命令从 strings.xml 文件或反编译输出中提取出明文密钥。这并非假设,扫描公开 APK 以查找泄露的密钥是一种众所周知的技术,每年都有成千上万个泄露的密钥通过这种方式被发现。

尝试 2:将密钥移至本地代码 (NDK/C++)

意识到 Java/Kotlin 字符串太容易被找到,团队通常会将秘密移到本地库中,理由是"逆向工程 ARM 汇编比阅读反编译的 Java 要困难得多"。

arduino 复制代码
// native-lib.cpp
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_app_SecretProvider_getApiKey(JNIEnv *env, jobject) {
    return env->NewStringUTF("sk_live_51Hxxxxxxxxxxxxxxxxxx");
}

问题所在: 这提高了标准,但并没有降低标准。它有两种失败方式:

  • 静态提取。 如果 libnative-lib.so 中的字符串没有被编码,直接对其进行字符串转储通常就能找到它。即使它被异或或分割,使用 Ghidra 或 IDA 的逆向工程师也可以追踪到小型解码例程。原生秘密揭示函数往往很短、自包含且易于识别,正是因为它们必须快速运行且不能依赖太多其他东西。
  • 动态提取(更简单的方法)。 为什么还要费劲反汇编呢?只需使用 Frida 钩住 JNI 导出并打印其返回值即可:
ini 复制代码
// Conceptual illustration - not a working exploit
Java.perform(function () {
    var SecretProvider = Java.use("com.example.app.SecretProvider");
    SecretProvider.getApiKey.implementation = function () {
        var result = this.getApiKey();
        console.log("Key: " + result);
        return result;
    };
});

本地代码已经帮你完成了解码密钥的繁重工作,你只需要留意它何时将结果返回给 Java 即可。这就是所有设备端密钥的核心问题:如果你的应用可以使用它,那么它迟早需要在内存中以明文形式重建密钥,而这一刻是可以被拦截的。

第三次尝试:双键/分键技巧

更复杂的变体:将密钥分成两(或更多)部分,将它们存储在不同的地方(一个在 Kotlin 中,一个在本地代码中,一个可能从低信任度的远程配置中获取),并且仅在最后一刻,在本地代码中,在网络调用之前将它们组合起来。

c 复制代码
std::string reconstructKey(const std::string &partA, const std::string &partB) {
    std::string key;
    for (size_t i = 0; i < partA.size(); i++) {
        key += (char)(partA[i] ^ partB[i % partB.size()]);
    }
    return key;
}

这种做法的一个常见变体是将密钥拆分为存储部分 (包含在二进制文件中,与之前相同)和运行时部分(从远程配置端点获取,或从设备安装 ID 或会话令牌等信息派生而来,因此仅在应用程序运行一段时间后才存在):

kotlin 复制代码
// Half A: baked into the app at build time (obfuscated/split, but static)
object KeyStore {
    val storedHalf: ByteArray = byteArrayOf(0x1A, 0x4F, 0x9C, 0x22 /* ... */)
}
// Half B: fetched at runtime, e.g. from your own remote config service
suspend fun fetchRuntimeHalf(): ByteArray {
    val response = remoteConfigApi.getKeyFragment() // returns a byte array
    return response.fragment
}
// Combined only in memory, right before use
suspend fun getApiKey(): String {
    val runtimeHalf = fetchRuntimeHalf()
    val combined = KeyStore.storedHalf.mapIndexed { i, b ->
        b xor runtimeHalf[i % runtimeHalf.size]
    }.toByteArray()
    return String(combined, Charsets.UTF_8)
}

其理念是,单独使用这两部分都无济于事,而且运行时部分根本不会存在于 APK 中。这听起来像是一种改进,但实际上只是转移了弱点,并没有真正消除它。

漏洞所在: 静态逆向工程确实更加棘手,因为单独分析各个部分毫无意义,你必须找到并理解组合逻辑。但这并不能改变一个基本事实:在 HTTPS 请求签名或设置标头之前,**完整的、组合的明文密钥已经存在于进程内存中。**攻击者根本不需要理解你巧妙的密钥拆分方案。他们可以:

  • 像之前一样,钩住组合密钥部分的函数(reconstructKey 或 getApiKey)。
  • 如果其中一半密钥在运行时通过网络获取,则使用代理工具(或未锁定的中间人攻击设置)直接拦截该调用,并在其与任何内容组合之前读取"运行时部分"响应。
  • 完全跳过你的代码,直接钩住调用栈中更低层级的函数,例如 TLS 库的 write 函数(通过 Frida 的 SSL/TLS 解除锁定 + 拦截技术),并在你的应用程序组装完成后,直接读取传出的 HTTP 请求及其所有头部信息。
  • 在网络调用发生时转储进程内存,并扫描高熵字符串。

无论你添加多少层间接加密,如果密钥必须以明文形式存在于待使用的设备上,那么它仍然可以从设备中提取出来。混淆会增加攻击成本,但永远不会使成本无限增加。因此,将"增加查找难度"作为目标,而不是"完全不可能",是完全错误的思路。

真正的解决方法:不要把秘密信息放在设备上

一旦你接受了 APK 中包含的任何内容最终都可能被读取这一事实,解决方案就显而易见了:密钥永远不应该离开你的基础架构。

模式 A:后端服务前端(API 网关)

应用不会直接使用嵌入式密钥调用第三方 API,而是调用你自己的后端,并使用对你有意义的凭据(用户会话令牌、应用颁发的 JWT、mTLS 或你的身份验证堆栈支持的任何凭据)进行身份验证。你的后端运行在你可控的环境中,不会提供给每个安装程序,它持有第三方密钥并代表应用发出调用。

具体而言:

  • 客户端向进行身份验证,而不是向第三方进行身份验证。
  • 你的后端使用仅由你控制的逻辑(速率限制、用户权限、业务规则)验证该请求。
  • 你的后端在服务器端附加真正的密钥并转发调用。
  • 响应被转发(并可选择进行过滤/整形)回客户端。

第三方密钥永远不会通过网络传输到设备,也不会存储在 APK 文件中。即使是完全反编译、完全插桩的应用副本也无法窃取任何信息,因为客户端只能看到你的 API,而不是你封装的 API。

这还为你带来了以前没有的功能:集中式速率限制、无需发布应用程序更新即可轮换真实密钥的能力、使用情况分析,以及无需触及底层供应商密钥即可撤销已泄露的客户端凭证的能力。

模式 B:敏感端点的随机数 + 认证

将密钥移到服务器端解决了密钥存储的问题,但这又引出了第二个问题:如何确定发送到后端的请求确实来自运行在真实设备上的未经修改的真实应用程序,而不是来自提取了客户端逻辑并现在使用脚本直接访问网关的人?

对于价格昂贵、易被滥用或涉及敏感信息(支付、兑换码、任何每次请求都有实际价值的内容)的端点来说,这一点尤为重要。

模式:

  1. 每个请求使用一个随机数 (nonce)。 客户端会向后端请求一个一次性使用的随机数(或生成一个带有时间戳的签名请求标识符)。这可以防止简单的重放攻击捕获的请求。
  2. 设备/应用认证。 客户端从 Google Play Integrity API 获取认证令牌,该令牌以加密方式验证三件事:应用是你发布的真实、未修改的二进制文件(而非重新打包/打补丁的 APK),应用是通过合法渠道(例如 Play 商店)安装的,并且设备本身未被 root、使用模拟器或以其他方式篡改。
  3. 将它们绑定在一起。 客户端将随机数和认证令牌与请求一起发送到后端。
  4. 后端验证,然后做出决定。 后端会调用 Google 的验证服务,检查认证令牌是否有效,是否与应用的签名证书和包名匹配,并检查 nonce 是否已被使用。如果任一检查失败,则拒绝请求,不回退,也不进行软允许。如果两项检查都通过,后端将继续执行,并应用模式 A 中真正的、用于保存密钥的逻辑。

这并不意味着滥用完全不可能------事实上,没有任何方法能做到这一点。Play Integrity 认证本身是一种概率信任,而非对物理设备完整性的加密保证。但这确实将目标从"提取静态字符串"提升到了"绕过谷歌的设备认证和硬件支持的完整性检查",这无疑是一个难度更高的问题,而且谷歌一直在不断加强安全措施,而不是你们那两个人的移动团队。

整合起来:纵深防御检查清单

  • APK 中不会包含任何第三方密钥(API 密钥、签名密钥、令牌),这些密钥不会存在于 Kotlin/Java 代码、原生代码、资源或未经身份验证获取的远程配置中。
  • 所有第三方 API 调用均通过你控制的后端代理,并使用你自己的应用/用户凭据进行身份验证。
  • 敏感/易被滥用的端点使用每个请求的 nonce 进行保护,以防止重放攻击。
  • 对于设备/应用信任至关重要的端点,后端会进行 Play Integrity(或等效的,例如 iOS 上的 App Attest)验证,如果验证失败则强制拒绝
  • 必须存在于服务器端的密钥会定期轮换并存储在合适的密钥管理器中,而不是作为后端环境变量提交到源代码控制系统中。
  • 定期对 APK 进行审核,反编译你自己的发布版本,并查找任何疑似密钥的内容,以免被他人冒用。

结论

在移动设备上隐藏秘密的任何技术最终都是一场与时间和攻击者动机的赛跑。当架构限制迫使你只能将秘密存储在 APK 中时,你的策略必须从被动隐蔽转变为主动加固

  • 部署运行时应用程序自我保护 (RASP): 实时检测调试、root/越狱环境、模拟器跟踪和动态检测框架,使应用程序能够在密钥泄露之前做出响应或终止。
  • 实现字符串加密: 通过在构建时加密密钥和敏感 API 端点,并在需要时才在内存中动态解密,防止它们以明文形式暴露在二进制文件的常量池中。

由于整个二进制文件和执行环境都掌握在攻击者手中,一个真正有决心的人只要拥有反编译器和Frida工具,最终总能找到破解方法。RASP和字符串加密并不能使秘密信息无法提取,它们只是提高了攻击成本,阻止了随意进行的逆向工程。

假设 APK 文件已完全被攻破。设计系统时,应确保即使密钥被盗,造成的损失也最小。

少隐藏,多核实。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
ai2work6 小时前
ch15 加载模型与初始化上下文
kotlin
hai_android7 小时前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin
hai_android8 小时前
Android MeasureSpec 详解
android·java·kotlin
又见情义10 小时前
RK3568 Android 13 本地 U 盘 OTA 升级实战:基于 RKUpdateService 的完整流程
android
Godikov11 小时前
Android10后台弹窗与APK自动更新
android
ai2work11 小时前
ch11 持久化:DataStore + Gson 多会话
kotlin
又见情义12 小时前
RK3568 Android 13 USB OTG/Host 切换调试经验分享
android
终端安全笔记13 小时前
iOS 27 之后「策略空转」:设备升级不报错,但旧策略不再管它
android·网络·安全·ios·智能手机
JMchen14 小时前
实战案例:实现120fps流畅的渐变进度条
android·kotlin·canvas