前阵子有个朋友找到我,说他们应用在某个安卓市场上被人换了图标重新上架,包里的广告SDK被替换,还多塞了一个恶意下载模块。更讽刺的是,用户不但没发现破绽,反而跑来问他们“你们App什么时候改版了”——因为那个盗版包装得实在太像了。
这个故事听起来像段子,却是很多 Android 团队每天都在面对的现实。移动应用攻击早就不是安全研究员PPT里的演示,而是一条完整的黑色产业链:重打包、内存注入、自动化批量刷量、模拟器跑号、改机工具,每一种都能直接影响收入和品牌信任。传统那种在客户端里写死几个校验、混淆一下、再上个加固就以为安全的思路,已经被证明扛不住了。
所以当 Play Integrity API 出现的时候,我身边不少做移动安全的人都觉得,这件事的思路终于对了:把“这设备可不可信、这个包是不是从正规渠道来、安装完整性是否被破坏”的判断,从客户端挪到云端去完成,让服务生态来背书。对企业级应用来说,这已经是当前对抗篡改、重打包、批量化作弊最重要的一道信任防线,也是我在多个项目里踩过坑、验证过、真正能放到线上的方案。
这篇内容我会把 Play Integrity API 的底层机制、接入架构、服务端校验逻辑、企业落地时的策略设计和真实排查经验完整讲透,适合移动端负责人、客户端开发和安全工程师参考。不说废话,直接进入正题。
1. 先搞清楚:Play Integrity API 到底在防什么
1.1 你面对的威胁:重打包、注入与批量作弊
在讨论技术方案之前,先把威胁模型讲清楚,因为很多团队接入 Play Integrity API 之后依然不知道怎么设定判定策略,根源就是没有想明白自己在防什么。
Android 应用的攻击大致分三类。
第一类是重打包。攻击者把官方包拆开,加入自己的逻辑,替换签名,再重新打包分发。这类攻击最容易出现在非官方应用市场和论坛帖子里,用户往往从搜索广告或者陌生人分享的链接中招。重打包之后,App 里的支付跳转、广告SDK、甚至登录凭证都可能被劫持。过去靠比对包体积、校验代码签名确实能拦截一部分,但攻击者连代码都能改,这些静态校验在他们眼里只是时间问题。
第二类是运行时注入和 Hook。攻击者通过调试工具挂载到进程里,直接改写方法返回值、绕过校验逻辑。我见过最典型的场景是,客户端做了“是否已付费”的判断,攻击者用注入工具把这个判断函数返回值一改,付费墙就直接消失了。这种攻击最大的特点是本地自检根本拦不住,因为你自检的代码本身也运行在同一个被控制的内存环境里。
第三类是批量化作弊。模拟器批量开号、设备农场群控、改机工具伪造设备信息,这类攻击集中在积分、签到、新人礼、秒杀这类营销场景。单看每个请求都很正常,但后台一看设备指纹高度相似、行为模式完全一致,就是典型的批量脚本。传统设备指纹方案可以被改机工具绕过,因为那些设备参数都在客户端本地读取。
Play Integrity API 针对的就是这三类问题中的共同核心:它帮你判断“当前运行环境是否可信、当前安装来源是否正规、设备是否通过了完整性和强完整性校验”,并且这个判断由云端发起、由生态根证书背书,攻击者在客户端本地再怎么能改,也改不了服务端的结论。
1.2 三类判定:设备、应用与账号
Play Integrity API 最核心的部分是它每次返回的 Integrity Token(完整性令牌),解码之后包含三个维度的判定结果。
第一维是设备完整性,即 Device Integrity。它用来判断设备本身是否可信,返回值一般有这几个档位:
- MEETS_STRONG_INTEGRITY:设备通过了最高级别的完整性校验,通常由硬件支持的密钥库提供证明,是最可信的档位。
- MEETS_DEVICE_INTEGRITY:设备通过了基础完整性校验,但没有强完整性那么严格。
- MEETS_BASIC_INTEGRITY:设备通过了一些基本检查,但可能是 root 过的设备或存在某种风险。
- MEETS_VIRTUAL_INTEGRITY:设备是模拟器,不能提供任何完整性保证。
第二维是应用完整性,即 App Integrity。它判断当前安装的包是否来自 Google Play 且未被篡改,核心返回字段是:
- PLAY_RECOGNIZED:包通过 Google Play 识别,安装来源可信。
- UNRECOGNIZED_VERSION:包未被识别,这通常意味着安装包不是从 Google Play 正规渠道来的,或者已经被重打包。
- UNEVALUATED:由于某些原因没有评估。
第三维是账号许可,即 Account Integrity。它判断当前设备上登录的 Play 账号是否具备合法授权,返回 LICENSED 或 UNLICENSED。
这三个维度是独立返回的,组合起来就能形成一套非常清晰的信任图谱。比如一个设备如果返回 MEETS_DEVICE_INTEGRITY + PLAY_RECOGNIZED + LICENSED,那基本可以认为用户是在一台正常设备上,从正规渠道安装的官方版本。如果返回 MEETS_BASIC_INTEGRITY + UNRECOGNIZED_VERSION,那大概率就是一台 root 过的设备上装了一个来路不明的包。
1.3 为什么说这是信任模型的革命
我在开篇用了“革命性”这个词,可能有人觉得夸张。但做过客户端安全的人应该理解,过去我们所有的本地自检本质上都是不可靠的,因为判断逻辑和被判断的对象运行在同一个环境中,攻击者打破了进程边界之后,所有本地校验都可以被模拟和绕过。
Play Integrity API 的革命性在于它把安全决策从客户端转移到了服务端。客户端只是发起请求,真正做判断的是基础设施侧的信任根。它把“我的客户端是否可信”变成了“整个设备与安装链路是否可信”,这是一个完全不同层面的信任模型。
而且这个方案是生态级别的,不需要自己维护公私钥、不需要自己搭验证服务器,也不需要自己分发信任凭证。它直接利用 Google Play 生态已有的信任链,对企业来说节约了大量安全基础设施建设成本。当然,它也不是万能的,后面章节我会提它的局限性和降级策略。
2. 接入前的准备工作:账号、指纹与依赖
2.1 前置条件:Play Console 与云项目的绑定
接入 Play Integrity API 的第一步不是写代码,而是把应用和云端项目正确关联起来。这一步的坑非常多,我见过不少团队代码写完了、联调却怎么都过不了,最后发现是 Play Console 的关联关系没配好。
你需要准备这几样东西:
- 一个 Play Console 账号,并且应用已经发布到内部测试轨道或更高版本轨道。注意,仅存在于草稿状态的应用可能无法正常使用完整性检测。
- 一个 Google Cloud 项目,用于接收 Play 服务返回的判定结果。
- 一个服务账号,后续服务端解密和验证令牌时需要用到它的私钥。
- 应用包名和签名 SHA-256 指纹,这个必须和当前发布的签名一致。
在 Play Console 的应用配置页面里,找到“应用完整性”相关的设置入口,将你的云项目绑定到当前应用。绑定之后,Play 服务才会把完整性判定结果投递到你的云项目下。日常开发中如果换了电脑签名或者上传了新的签名密钥,一定要回 Play Console 同步更新指纹,否则测试阶段会发现所有请求都返回异常结果。
2.2 客户端依赖与初始化
客户端的接入方式非常简单。Gradle 里加上 Play Integrity 相关的依赖,然后在应用初始化阶段拿到 IntegrityManager 实例。
// Gradle 依赖示例,具体版本以官方文档当前发布为准 dependencies { implementation("com.google.android.play:integrity:1.4.0") }初始化代码:
val integrityManager = IntegrityManagerFactory.create(applicationContext)这里有个重要的设计点:不需要你在初始化时做任何登录或者获取用户态的逻辑,Play Integrity API 是由系统服务直接响应请求的,所以调用方式是标准异步模式。你只需要关心返回的 IntegrityToken 字符串,后续怎么处理它才是关键。
3. 客户端请求与服务端校验:完整数据链路
3.1 nonce 的设计:抗重放的关键
很多团队在接入 Play Integrity API 时,把注意力放在客户端请求上,却忽略了 nonce 的设计。这个字段的重要性怎么强调都不过分,它是防止攻击者重放合法结果、绕过服务端校验的核心。
nonce 是一段随机字符串,客户端在发起完整性请求之前生成,发起请求时传进去,服务端收到返回结果后校验这个 nonce 是否和自己之前下发给客户端的值一致。这保证了每次校验请求都是独立的,攻击者即使截获了一个合法的校验结果,也无法在另一个请求中重用。
nonce 的生成需要满足几个要求:
- 每次请求都必须不一样,不能复用。
- 建议至少 16 字节(128 位)随机数,用安全随机数生成器生成。
- 服务端必须校验 nonce 的匹配性,校验完成后及时销毁。
我自己在生成时习惯用 SecureRandom 生成 16 字节,再 Base64 编码。这样做的好处是兼容性好,服务端不需要处理特殊字符。
private fun generateNonce(): String { val bytes = ByteArray(16) SecureRandom().nextBytes(bytes) return Base64.encodeToString(bytes, Base64.NO_WRAP) }注意不能让客户端自己决定一个固定的 nonce,否则攻击者可以先自己拿到一个合法响应,再用同样 nonce 伪造后续请求。
3.2 请求时机与缓存策略
我一直觉得,Play Integrity API 的接入方案里,最容易被忽略的是“什么时候请求”。有些团队图省事,在应用启动时请求一次,拿到结果之后存起来用一整天。这个方案的问题是,攻击者完全可以在首次请求之后就对客户端动手脚,之后一整天的校验结果都是失效的。
正确思路是把完整性校验嵌到业务的关键路径里。登录、支付、领取奖励、签到、参与活动之前,发起一次完整性请求,拿到结果再放行。只有这样才能保证“每次关键操作发生时,设备状态都是可信的”。
当然,敏感操作每次都校验会带来延迟,所以需要缓存策略。我的经验是区分校验等级:普通操作可以用一个 5 到 10 分钟的短期缓存,比如用户浏览首页、查看列表,没必要每次都卡一个网络请求。但涉及资金操作和账号安全操作,必须实时校验,不能用缓存。
请求发出去之后,需要设置合理的超时时间。网络不稳定或者 GMS 服务响应慢时,不能让用户一直等下去。我的经验是超时控制在 10 秒左右,并且把超时视为校验失败,走当前场景的降级逻辑,比如阻止高风险操作,但允许浏览类操作继续。
val request = IntegrityTokenRequest.builder() .setNonce(nonce) .build() integrityManager.requestIntegrityToken(request) .addOnSuccessListener { response -> val token = response.token() // 把 token 交给服务端进行最终校验 } .addOnFailureListener { e -> // 本次校验失败,走降级逻辑 }3.3 服务端验签与判定逻辑
这一步是最容易出事的环节。很多客户端团队会把 IntegrityToken 拿到手之后,本地解析一下字段,然后决定是否放行。这种做法的危害在于,攻击者根本不需要破解你的逻辑,只需要 Hook 掉本地校验函数,让一切都按放行结果走就行。所以我的态度很明确:Play Integrity 的校验结果必须在服务端验证,客户端永远只把它当成一个“待验证的凭证”。
服务端要做的事情分为三步。第一步,验签,确认返回的令牌确实由 Google 服务签发,而不是攻击者伪造的字符串。第二步,解密并解析出字段,拿到设备完整性、应用完整性、账号许可三类判定结果。第三步,校验 nonce 和时效性,确认这一次请求的业务上下文和令牌是绑定的。
// 伪代码,核心校验流程示意 public IntegrityResponse verifyIntegrityToken(String token, String expectedNonce) { // 1. 用 Google 发布的证书验证 JWS 签名 // 2. 解密载荷,得到 integrity verdict 字段 // 3. 校验 nonce 是否与本次业务请求下发的一致 // 4. 校验请求时间是否在合理时间窗内 // 5. 返回判定结果给业务层 }服务端的判定逻辑不是简单判断“是否通过”,而是需要根据业务场景设定阈值。比如一个论坛应用,要求 PLAY_RECOGNIZED 和 MEETS_DEVICE_INTEGRITY 就足够,但一个金融应用,风控要求就必须上升到 MEETS_STRONG_INTEGRITY,否则就要触发风险提示或者人工验证。具体阈值设计在下一章展开。
4. 企业落地中的策略设计与阈值设定
4.1 不同业务场景下的判定阈值
Play Integrity API 返回的是原始判定,真正决定它价值的是业务侧如何消费这些判定结果。我把常见企业应用分为三类,它们的策略差异很大。
第一类是内容型应用,比如资讯、社区、视频类。这类应用最怕的是重打包带来的内容替换和隐私劫持,对实时性要求不高。门槛可以设为 PLAY_RECOGNIZED,设备状态只做参考。因为内容型应用用户广泛,设备差异大,过于严格的设备校验会误伤大量真实用户。
第二类是交易型应用,比如电商、支付、金融类。这类应用直接和资金挂钩,攻击者重打包一次就可能批量盗刷或者篡改支付流程。门槛必须设为 MEETS_DEVICE_INTEGRITY 或 MEETS_STRONG_INTEGRITY,同时要求 PLAY_RECOGNIZED 和 LICENSED。有一次我们做电商活动,攻击者用重打包后的客户端去抢占秒杀名额,服务端发现大量请求的 appIntegrity 不在 PLAY_RECOGNIZED 状态,直接拦掉之后活动数据立刻恢复了正常。
第三类是游戏或积分激励类应用,核心风险是模拟器和批量养号。这里对 MEETS_VIRTUAL_INTEGRITY 要特别敏感,因为模拟器环境下无法保证任何完整性,可以默认拒绝模拟器参与排行、结算、对战、奖励领取等场景。但要注意,测试同学日常使用模拟器跑功能,所以内部测试包要单独处理,不要一刀切。
4.2 降级策略:没有 GMS 的设备怎么办
Play Integrity API 依赖 Google Play 服务,但并不是所有 Android 设备都有 GMS 认证。国内大量设备出厂不带 GMS,或者用户自行移除/停用了 Play 服务,这类设备发起完整性请求往往直接失败。
这里不能把失败一律当风险拦掉,否则会损失大量正常用户。我的建议是设计三层降级。
设备支持 Play 服务并成功返回结果,正常消费判定字段。设备支持 Play 服务但返回失败,根据失败码区分是网络原因、版本原因还是服务异常,临时放行但打上风险标签,后续用其他风控手段补查。设备不支持 Play 服务,直接走独立风控通道,比如结合设备指纹、行为分析、短信验证码等人工复核手段。
实际运营中,无 GMS 设备的用户群里可能混着攻击者,但也确实有很多普通用户,重点在于这类用户不能被一刀切封禁,而是进入更高等级的风控检查池。
4.3 与现有安全体系的关系
接入 Play Integrity API 之后,很多团队会误以为可以撤掉加固和自研风控了。这是个大坑。Play Integrity 解决的是信任根问题,它告诉你设备是否可信、包是否来源正规,但它不会告诉你用户行为是否恶意、请求序列是否是脚本。这两者需要配合。
我的经验是,Play Integrity 应该作为整个风控体系里的“一号信任因子”,加在风控决策的最前端。它先过滤掉一批明显的重打包、模拟器和不可信设备请求,剩下的通过请求再交给后端的行为风控和业务风控去分析。加固、混淆、RASP 这些手段也不是多余的,它们提高了攻击者的本地分析成本,让 Play Integrity 的绕过更难实现。四者是叠加关系,不是替代关系。
5. 常见问题与排查实录
5.1 返回结果不符合预期的排查思路
我梳理了几个接入和上线过程中最高频的问题,大家可以对照排查。
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 所有请求都返回 UNEVALUATED | 应用未在 Play Console 正确发布,或云项目绑定关系缺失 | 检查 Play Console 应用完整性设置,确认云项目已绑定 |
| 测试包总是 UNRECOGNIZED_VERSION | 测试包没有从 Google Play 渠道安装,可能是直接侧载的 debug 包 | 通过 Play Console 内部测试轨道安装测试包 |
| 模拟器上返回 MEETS_VIRTUAL_INTEGRITY | 这是模拟器的正常判定,不代表异常 | 业务策略按模拟器拦截或降级 |
| 服务端验签失败 | 云项目的服务账号私钥配置错误,或服务器时间偏差过大 | 检查 JWS 签名使用的证书和密钥,校准服务器时间 |
| 签名更新后大量请求失败 | Play Console 中的签名指纹和当前包签名不一致 | 更新 Play Console 的签名指纹,等待生效 |
5.2 性能、超时与用户体验
接入 Play Integrity API 之后,最直观的变化就是关键操作变慢了。这个请求要走一次网络链路,还要等待服务端评估,耗时通常在 200ms 到 1s 之间。如果设计不当,用户会明显感知到卡顿。我见过一个项目把校验放在点击支付按钮的同步路径里,结果用户一顿操作之后转圈转了好几秒,线上投诉立刻飙升。
正确的做法是提前预取和异步化。用户在某个页面停留时,后台提前生成 nonce 并缓存完整性 token,真正触发敏感操作时直接拿已经返回的结果提交服务端校验。如果缓存过期,则临时走一次同步请求,但要做好加载态提示和超时兜底。
还有一点容易被忽视:请求生命周期管理。Activity 或 Fragment 的回调里发起请求后,如果页面已经销毁,回调可能触发空指针。一定要在 onDestroy 里取消请求,或者用应用级别的生命周期管理,避免在无效上下文中处理回调。
5.3 Play Integrity 与加固/风控的互动关系
很多团队反馈,上了 Play Integrity 之后,本地加固好像“没用了”。其实不是没用,而是各自负责的层面不同。加固和混淆解决的是代码静态分析难度,阻止攻击者快速读懂你的逻辑。Play Integrity 解决的是运行环境和安装来源可信度。如果一个包被篡改到面目全非,Play Integrity 的 appIntegrity 直接不给 PLAY_RECOGNIZED,攻击者连正常业务逻辑都进不去。
所以我的建议是不要在两者之间做取舍。主力 App 该加固加固,该上 Play Integrity 上 Play Integrity,两者都上之后再叠加服务端行为风控,这个组合是目前企业级 Android 应用最务实的防护方案。
6. 避坑指南与监控告警
6.1 我踩过的最贵的几个坑
第一个坑是本地校验。早期 Pilot 的时候,我们图省事在客户端把 token 解析完直接判断,结果上线一周就有攻击者用 Hook 工具把判断结果全部改成放行。当时复盘,问题不在于 Play Integrity 本身,而在于我们错误地把它放在了客户端可信边界内。
第二个坑是 nonce 复用。有一次服务端同学为了“优化性能”,允许同一个 nonce 在短时间内重复使用,结果攻击者拿到一个合法响应,一顿重放,直接突破了签到限制。后来改成每个请求独立 nonce,并且服务端加了幂等校验,这个问题才彻底解决。
第三个坑是测试环境混乱。团队里用模拟器测功能,发现结果不对,就有人在代码里写“如果是模拟器就放行”的逻辑。这个逻辑悄悄进了生产,等于给攻击者开了一扇大窗。后来我们强制区分构建类型,调试包可以用宽松策略,生产包必须全量校验,杜绝测试逻辑泄漏到线上。
6.2 灰度发布与线上监控指标
接入 Play Integrity API 不是一次性上线,而是需要灰度演进。我推荐的分阶段策略是先只记录不拦截,把判定结果的分布情况统计出来。观察几天之后,确认正常用户的判定分布符合预期,再对高风险结果做告警,最后逐步把拦截策略放开。
线上必须监控几个核心指标:PLAY_RECOGNIZED 比例、MEETS_DEVICE_INTEGRITY 比例、MEETS_STRONG_INTEGRITY 比例、UNEVALUATED 比例、服务端验签失败率。这些指标发生剧烈波动时,通常意味着三种情况:签名更新未同步、Play 服务大面积异常、或者出现了新的绕过方式。提前设置好告警,比事后看用户投诉舒服得多。
6.3 后续扩展思路
Play Integrity API 能做的远不止“判断设备是否可信”。把它的结果和业务数据打通之后,可以做很多精细化的运营策略。比如新用户注册时对 UNRECOGNIZED_VERSION 的包自动要求手机号二次验证;营销活动里对非 PLAY_RECOGNIZED 的请求直接不发放权益;金融类操作中要求 MEETS_STRONG_INTEGRITY 才允许修改安全设置。
另外,它还能辅助做版本更新策略。如果某天下载渠道被恶意植入了篡改包,服务端能把 UNRECOGNIZED_VERSION 的请求引导到应用内升级页,主动把用户导回正规渠道,而不是被动等待用户被引流走。
最后再分享一点个人体会:安全是一个持续对抗的过程,Play Integrity API 解决的问题只是整个链路里最基础的信任确认。它确实是一次信任模型的革命,但革命之后的每一天,还是要靠服务端策略迭代和线上监控去守住阵地。我见过太多团队把安全寄托在某一个单点上,最后都是要付出代价的。把 Play Integrity 用好、和现有的风控体系联动起来,这条路才是长期靠谱的。