深入 Android 端侧 AI 模型安全防护全链路:从模型加密存储到 TEE 推理的 IP 保护架构
去年在做端侧 LLM 项目时,安全团队丢过来一个问题:你们打包进 APK 的 .tflite 模型文件,我一分钟就能从 res/raw 里解出来。更让人头皮发麻的是,模型文件里能直接看到权重数值——这就是把训练好的 IP 裸奔着发布。
端侧 AI 的安全防护,核心矛盾在于:模型文件必须在设备上,但模型文件不能被人拿走。这个问题没有完美答案,但可以架设多层防线,让攻击成本高到不值得。
模型文件的脆弱面
Android 端侧模型最常见的部署方式是放在 assets 或 raw 目录,随 APK 打包。随便一个 APK 解包工具就能拿到原文件:
apktool d app.apk -o output/
find output/res/raw -name "*.tflite"
即使开了代码混淆,ProGuard 也不会处理模型文件本身。ONNX、TensorFlow Lite 格式的模型,直接用 Netron 打开就能看到完整的模型结构图和权重信息。
更大的问题是,推理引擎加载模型时必须解密到内存中,而内存 dump 的门槛并不高。一个加了 Frida 的调试环境就能在运行时把解密后的模型抓出来。
总结下来就三个问题:模型怎么存、密钥怎么管、推理在哪跑。
第一层:加密存储——模型文件不是 assets
第一道防线是模型文件落地即加密,而非发布前加密再打包——区别在于,前者可以做到每个设备密钥不同。
我选 AES-256-GCM,GCM 模式自带完整性校验,能防止密文被篡改后喂给解密逻辑:
fun encryptModel(input: File, output: File, key: SecretKey) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv // GCM 自动生成 12 字节 IV
output.outputStream().use { out ->
out.write(iv) // 写入文件头部
input.inputStream().use { inp ->
val buffer = ByteArray(8192)
var len: Int
while (inp.read(buffer).also { len = it } != -1) {
out.write(cipher.update(buffer, 0, len))
}
out.write(cipher.doFinal())
}
}
}
IV 放在文件头 12 字节,解密时先读 IV 再初始化 cipher。不要用固定 IV——GCM 模式最怕 nonce 重用,一旦重用,认证密钥的机密性直接崩溃。
加密算法选型上我踩过坑:最初图省事用了 ECB 模式,完全没考虑到模型文件中大量重复结构在 ECB 下会产生规律性泄漏。换成 GCM 后才踏实。
第二层:密钥管理——Keystore 的硬件绑定
加密文件只是第一步,真正致命的是密钥放哪。硬编码到代码里等于没加密;放 SharedPreferences 等于给 root 设备留后门。
Android Keystore 的 硬件绑定(Hardware-Backed) 特性是这道防线的关键。在支持 StrongBox 的设备上,密钥存储在独立安全芯片中,即使系统被 root 也无法导出私钥:
val keyGenSpec = KeyGenParameterSpec.Builder(
"model_encryption_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.setIsStrongBoxBacked(true)
.build()
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(keyGenSpec)
keyGenerator.generateKey()
setUserAuthenticationRequired(false) 是个关键取舍——推理是后台任务,不能每次加载模型都弹指纹,但这也意味着设备解锁状态下密钥可用。安全性和可用性永远是跷跷板。
Keystore 另一个容易踩的坑是密钥失效。Android 系统升级或锁屏方式切换可能触发 KeyPermanentlyInvalidatedException,必须兜底重建:
fun getOrCreateKey(): SecretKey {
return try {
(keyStore.getEntry("model_encryption_key", null)
as KeyStore.SecretKeyEntry).secretKey
} catch (e: KeyPermanentlyInvalidatedException) {
keyStore.deleteEntry("model_encryption_key")
generateKey() // 重建密钥并触发模型重加密
}
}
第三层:TEE 推理——明文不离开安全域
即使加密存储、密钥硬件保护,推理时模型仍会解密到用户态内存——Frida hook Cipher.doFinal 就能截获明文。
TEE(Trusted Execution Environment)提供了一块与 Android OS 物理隔离的执行区域。解密和推理都在里面完成,Normal World 拿不到模型明文:
// 运行在 TEE 内部(Trusty / QSEE)
fun teeInference(encryptedModel: ByteArray, input: FloatArray): FloatArray {
val key = teeKeymaster.getKey("model_encryption_key")
val model = teeCrypto.decryptAesGcm(key, encryptedModel)
val interpreter = teeTfLiteInterpreter(model)
return interpreter.run(input) // 仅结果出 TEE,模型不出
}
纯把 TF Lite 塞进 TEE 通用核上跑,性能惨不忍睹。一个 100MB 级别的模型,在 TEE 通用核上推理可能耗时数秒,而 DSP 上只需几十毫秒。
实际项目中我更倾向的方案是 DSP 推理 + TEE 密钥管理:模型解密在 TEE 中完成,推理计算放到 DSP/GPU 上,密钥不离开安全域。这需要在 Native 层与 TEE Driver 通信:
int tee_inference(const uint8_t* encrypted_model, size_t model_len,
const float* input, float* output, size_t output_len) {
struct qseecom_handle* handle = nullptr;
qseecom_start_app(&handle, "model_inference_ta", TA_SIZE);
struct tee_request req = { .cmd_id = CMD_INFERENCE,
.data = encrypted_model, .data_len = model_len,
.input = input, .input_len = input_len * sizeof(float) };
qseecom_send_cmd(handle, &req, sizeof(req));
qseecom_send_cmd(handle, &req, sizeof(req));
memcpy(output, handle->sbuf, output_len * sizeof(float));
qseecom_shutdown_app(&handle);
return 0;
}
TA(Trusted Application)需要编译成独立 ELF 镜像并签名,预置到系统分区。对普通应用开发者,这通常需要和 OEM 合作。
全链路分层架构
三层方案不是平替关系,而是纵深防御:
┌─────────────────────────────────────────┐
│ Normal World │
│ ┌──────────┐ ┌─────────────────┐ │
│ │ 加密模型 │ │ CA 发起推理请求 │ │
│ │ (磁盘) │──│ 传入密文+输入 │ │
│ └──────────┘ └──────┬──────────┘ │
├─────────────────────────────────────────┤
│ TEE Driver (QSEECOM / Trusty IPC) │
├─────────────────────────────────────────┤
│ Secure World (TEE) │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Keymaster │──│ TA: 解密+推理编排 │ │
│ │ 密钥不导出 │ │ 明文仅 TEE 可见 │ │
│ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────┘
每层的契约:存储层保证文件不可直接解析,密钥层保证密钥不可导出,推理层保证明文不出 TEE。
工程约束与现实取舍
完整落地三层方案,你会撞上三个硬墙:
TEE 内存限制。TEE 可用内存通常只有几 MB 到几十 MB,端侧 LLM 动辄数百兆。实践中只能对模型分片——敏感层(attention 权重)在 TEE 中跑,非敏感层扔到 DSP/GPU。
TA 更新机制僵化。预置到系统分区的 TA 只能随 OTA 更新,模型架构调整可能被 TA 的版本节奏拖住。对于迭代频繁的产品,这是个持续痛点。
TEE 世界切换开销。单次切换约 50-200μs,高频推理场景下累积可观,必须做批处理。
基于这些约束,我的优先级排序是:前两层全量部署,TEE 推理按需启用。对大多数应用,加密存储 + Keystore 硬件绑定 + 代码混淆 + 反调试,攻击门槛已经足够高。
还有两个必须一起上的防御措施:一是 APK 开启 extractNativeLibs=false,避免 .so 文件落地,增加 hook 难度;二是在 Native 层做反调试检测,发现 Frida 或 ptrace 时主动 crash——环境完整性一旦被破,上面所有防线都形同虚设。