APK 里的钥匙,一直就放在门口:聊聊加固真正该做什么

21 阅读7分钟

做 Android 加固,有个事实大家心照不宣:DEX 是加密了,可解密要用的东西,也一起打在包里。

我在做 Shellsmith 的时候,把这件事当成了设计的起点。它的技术文档里写得很直白:包裹密钥有两个输入,一个是签名证书指纹,一个是 build_id,两样都在包内明文头部。拿到 APK,就能离线算出密钥,再解出全部业务 DEX。

那加密还有什么用?有用,但要说清楚它用在哪。

它的作用是:改包、重签之后,应用跑不起来;同时抬高自动化扫描的成本。 "别人拿到 APK 就看不到代码",这一层做不到。

想做到,只有一条路:让密钥不在包里。

下面我按"一个加固工具到底该有哪几层能力"的顺序,把 Shellsmith 现在能做的事讲一遍。

第一层:载荷与签名绑定,让"改包重签"失效

加固时,工具读取原签名证书,压缩并加密业务 DEX,再把加密载荷和证书绑定。

结果是:换证书重签,应用就无法启动。 这是防二次打包最基础的一道门。

这一层同时说明了一个边界:因为绑定用的材料在包内,它挡的是"篡改",不是"静态分析"。所以才有后面几层。

第二层:包外密钥,让"光有 APK 不够解密"成立

这是整套能力里我最看重的一层,叫 BYOS:密钥放在你自己的 HTTPS 服务里,APK 只带服务地址和一个 token。

流程是这样:

  • 加固时,工具给每个产物随机生成 32 字节密钥材料,注册到你的服务,换回 token。APK 里没有这 32 字节。
  • 应用启动时才去取。请求带着授权证明,以及本次请求临时生成的 RSA-2048 公钥。
  • 服务端验完授权、确认没被重放,用这把公钥把密钥加密返回。手机解密、校验,再去解 DEX。

几个我觉得值得单独说的设计:

授权不能只看 APK 里的公开信息。 证书指纹、包名、版本号,这三样都在 APK 里,任何人都能原样重放请求。所以协议要求设备授权,不是可选项。目前有两种:

  • 设备批准:设备在 Android Keystore 里生成密钥,服务端登记公钥、批准或撤销。不依赖 Google 或 HMS,适合国内和自有渠道,Android 7.0 起可用。
  • Play Integrity:Google Play 分发时使用,服务端调用 Google 校验,要求 Android 10 及以上。

防重放是硬规定。 服务端要对应用 ID 加 nonce 做原子唯一插入,至少保留 600 秒,并发重放只允许成功一次。

失败就是失败。 Play 校验不过,不会悄悄换成设备授权;取钥失败,也不会退回离线加载。没有"降级"这条后门。

密钥和服务器主密钥是两种材料。 服务端用自有主密钥、KMS 或 HSM 封存它,封存时绑定租户、应用、token、证书、授权方式和策略版本。

命令行里,构建凭据走环境变量,不进命令历史:

bash

export SHELLSMITH_BYOS_API_KEY=...

shield protect -i app.apk -o protected-unsigned.apk \
  --profile balanced --byos-auth device_approval \
  --byos-endpoint https://keys.example.com/shellsmith

服务端那边有完整的协议文档、固定测试向量和参考实现说明,以及一份从连接检查到完整启动的分阶段验收清单。

第三层:运行时,每次启动都查,缓存也要认证

很多壳有个老毛病:第一次启动做检查,之后命中缓存就直接放行。

Shellsmith 的做法是:

  • 安全检查每次启动必经,缓存命中也不能绕过。
  • 缓存要认证:加固时对原始 DEX 算出规范化根摘要,写进 Manifest,由最终 APK 的签名保护。运行时缓存必须文件齐全、不多不少、只读、摘要一致,任何一条不满足,就删掉重建,删不掉就失败关闭,不加载可疑缓存。
  • 壳库完整性在检查最前面执行,按 ABI 核对摘要,缺失、重复或不匹配都失败关闭,Release 默认开启。
  • 环境策略可选:兼容模式保留反调试,不因 Root 信号拒绝启动,适合工控设备、模拟器;严格模式在反调试、注入或高置信度 Root 信号命中时拒绝解密。test-keys、模拟器特征这类弱信号,不会单独触发拦截。

这里也要说实话:缓存认证解决的是来源和完整性,不是保密。能读应用私有目录的攻击者,仍然能读到认证通过的明文 DEX。所以当前方案不是完整的内存 DEX 方案,Root 环境下也不能当作安全保证。

第四层:关键代码,从"整包加密"推进到"方法级保护"

整包加密之外,Shellsmith 提供 PVM2 方法级保护:把选中的方法转成虚拟机字节码执行。

我最看重的不是它能转多少,而是它对"保护了什么"这件事不撒谎:

  • 每个产物有逐方法报告,把"没选中、策略排除、不支持的语义、转换失败、产物未验证"分开统计。
  • 零转换,或必须保护的方法没覆盖,直接失败,不会静默关掉保护。
  • 跳过的方法不算已保护。
  • 启动、前台、方法入口共用同一套进程级安全状态,方法入口按固定间隔复查,后台调用也受门禁约束。

验证也给了数字,比如在真实 APK 上跑过 620 组输入、62,620 次调用,在多个 Android 版本的模拟器和真机上做过启动与断言回归。同时文档里也写明:这不代表全部业务流程都验证过,性能数据来自单台设备。

第五层:别漏了路由表,还有 iOS

路由索引加密。 ARouter、TheRouter 的路由索引常以明文躺在 assets 里,类名和页面路径一览无余。开启后,它会被写成认证加密的文件,壳在 DEX 解密后于内存中解密,不再留明文。

iOS 同样是"可核验"思路。 它只改独立工作副本,不碰你的原工程;接入 freeRASP 运行时检测和可选的 Swift 字符串保护,再用 Apple 原生工具链归档、导出,最后核验签名、Team ID、Bundle ID、Entitlements、arm64、隐私清单和 dSYM。

有一点我特别坚持: "检测到"和"阻断了"是两回事。 默认只检测和通知,业务可以在支付、密钥导出前自己查询风险状态;想要强阻断,要显式开启,并且文档会告诉你它可能带来崩溃日志、数据丢失和审核风险。

它不解决什么

我宁愿把这些放在前面:

  • 不承诺无法逆向,目标是提高静态分析、篡改和非法重签的成本。
  • 包外密钥只面向 Android APK,不支持 AAB、iOS 和 Android 4.4 兼容运行时;默认的离线加固不需要任何服务。
  • 撤销只影响后续取钥,已经进入授权进程的密钥收不回来;授权进程里的 Hook、内存提取,也不是包外密钥能单独挡住的。
  • 加固不能替代服务端鉴权、密钥管理和应用自身的安全设计。

最后

把五层放在一起看,其实就是在回答一个问题:攻击者拿到 APK 之后,还缺什么?

  • 重签,缺原证书。
  • 离线解密,缺包外的密钥。
  • 绕过检查,缺一次不被每次启动拦下的机会。
  • 读懂核心逻辑,缺把方法还原回来的成本。

而每一层能做到什么、做不到什么,工具都会老老实实告诉你。

所有加固、签名和证书处理都在本机完成,APK 不上传。桌面端支持 Windows、macOS、Linux,代码是 MIT OR Apache-2.0 双协议。

项目地址:github.com/kairowan/Sh…