# 客户端安全模型：它是输入，永远不是权威

> 任何被你交付到一台不属于你的设备上的控制，都可以被移除。仅这一个事实就决定了移动端安全的大部分答案：客户端检查买的是时间，判断由服务端做，安装包里没有秘密，而真正生效的是栈里最低的那层策略。

- Canonical (HTML): https://kaguc.com/blog/client-security-model-zh/
- Date: 2026-08-28


*English version: [The client security model: an input, never an authority](/blog/client-security-model/)。本文是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列第六篇。*

从一个让人不适的前提出发：这台设备不是你的。它属于某个可能好奇、可能已被入侵、也可能怀有敌意的人。这个人可以查看内存、拦截流量、给二进制打补丁，并且重放你的应用能发出的任何请求。

这个前提不是悲观主义。它只是唯一一个在“事实果真如此”时仍然成立的假设。

## 唯一的规则

**客户端是输入，永远不是权威。**

每一个与安全相关的判断，比如这个用户能不能做这件事、它要花多少钱、限额是多少、此刻是否允许，都在服务端、基于服务端持有的状态做出。客户端**也可以**检查，为了快和体验好，但它的检查只是一种便利；无论客户端有没有做这件事，服务端的行为都必须完全一致。

检验方法很直接：如果有人绕过你界面里的所有检查，手工构造你应用的请求，会不会发生坏事？如果会，那个检查就是承重的，而承重的检查不能放在一台你不掌控的设备上。

这和[订单只由服务端确认](/blog/order-reliability-zh/)是同一条原则，只不过从状态换成了授权。客户端校验是体验，服务端校验是安全。它们在代码里长得像，实质上完全不是一回事。

## 客户端加固买的是时间，不是阻止

代码混淆、Root 和越狱检测、完整性校验、反调试、本地存储加密，这些都值得做，但值得把它们的作用说准确。

它们抬高攻击的成本和时间，但不阻止攻击。它们每一个都运行在攻击者的硬件上，而任何运行在那里的东西，都能被一个足够有耐心的人找到并移除。

有用的表述是：加固是一个**针对攻击者投入的限流器**。这有价值，因为大多数攻击是机会主义的，成本本身就是真实的威慑。它只有在被当成保证时才变得危险：有人据此设计出一个会信任“已自证未被篡改”的客户端的服务端，于是一次绕过就解锁了一切，因为真正的控制从来就不在那里。

所以：把防御分层，假设每一层都会失效，并确保它们背后站着的那个服务端，本来就会拒绝。

## 安装包里没有秘密

任何随应用一起发出的东西都是可读的：字符串、密钥、地址、证书，以及你分散在三个文件里又做了异或的那个聪明设计，都能被一个带反汇编器、有一个下午时间的人还原出来。

后果是具体的：

- **不要把 API 密钥放进应用。** 一个嵌在客户端里的共享密钥，是一个多绕了几步的公开密钥。如果某个凭证能授予权限，它就不能随包发布。
- **先认证，再换取短期令牌。** 应用用**用户**提供的东西证明身份，然后持有一个作用域窄、生命周期短的令牌。这样泄漏出去的是一个会过期的令牌，而不是一把不会过期的钥匙。
- **在服务端签名。** 如果某个请求必须用密钥签名，那么密钥应该待在服务端所在的地方。
- **用平台的密钥库**保存静态令牌，同时仍然假设一台已 Root 的设备能读到它们。密钥库抬高成本，但不会让设备变得可信。

把秘密加密后放进安装包，只是意味着解密密钥也在安装包里。这不是对该技术的批评，这就是问题本身的定义。

## 真正生效的是最低那层策略

有一条值得推广到安全之外的规则：**一个系统的行为，取决于它最低、最具体的那份配置，而不是它顶层的声明。**

平台的策略是分层的：清单里的声明、一个配置文件、一条按域名的覆盖、一个按构建类型的变体。当它们互相矛盾时，更具体的那层胜出；于是完全可能出现这种情况：顶层一个让人安心的开关，被它下面的某样东西悄悄否定了。一个团队读了第一行，认定系统是安全的，然后错了好几年，因为什么错误都没有报出来，那个覆盖本身是一个合法特性。

由此得到的实践是：**审计实际行为，而不是声明的意图。** 去读你真正发布的那个构建的最终合并配置。用观察来验证，比如检查真实流量、确认运行时状态，而不是读一遍声明就相信它。“我们声明了什么”和“它实际做什么”是两个问题，而只有第二个有后果。

## 每一个入口都是一份没写文档的 API

攻击面不只是网络。任何别的应用或网页能够调用的东西，都是你正在暴露的接口，无论你有没有为它写文档：

- **导出组件与深链接。** 如果别的应用能带参数拉起你的页面，那些参数就是攻击者可控的输入。要严格按白名单校验 scheme、host 和 path；绝不让一个链接本身就能授权敏感操作；并且要在目的地重新校验会话，而不是假定调用方是合法的。
- **网页到原生的桥。** 一座桥会把一组可调用的函数暴露给被加载的任意页面，而这组函数**就是**一份拥有你应用权限的公开 API。要限制哪些来源可以使用它，把暴露面保持在最小，并把每个参数都当作敌意输入来校验。一座能触及账户或支付功能的桥，应当假设调用它的页面是恶意的。
- **自定义 URL scheme 与应用间消息。** 同样的道理：你无法验证是谁在调用，所以这次调用本身不能成为授权。

上面每一项，都是一个特性（“让合作方能深链进应用”）悄悄变成接口的地方，而接口需要威胁模型。

## 传输，以及证书固定这笔交易

全部加密，并且要确认这一点。这里的失败形态不是“我们忘了上 TLS”，而是为某一个遗留接口开的一个窄口子，悄悄变成了全局放行。

证书固定是一笔真实的交易，而不是一条最佳实践。它能挫败“安装了根证书的中间人”，而那正是用户设备上现实存在的攻击。它同时也意味着，一次你没计划好的证书轮换，可能让所有已安装的应用集体失效。如果你要做固定：固定到中间证书或者带上备用固定值，配一个远程熔断开关，并且在真正需要之前先演练一次轮换。如果你没有能力稳定地运营这些，那么把标准 TLS 校验做扎实，好过把证书固定做脆弱。

## 什么时候可以砍掉这些

上面大部分内容，其投入应当与攻击者的收益成正比。如果你的应用只有公开数据和可逆动作，把钱花在花哨的加固上是选错了地方。

而在任何规模下都**不可省略**的是这几项，因为真实事故正是从这里来的：每一个请求的授权都在服务端强制执行、安装包里没有秘密、TLS 没有例外、令牌短期且作用域受限、把每一个深链接和桥调用都当作敌意输入。

真正要紧的是这份不起眼的清单。入侵很少优雅：一个被遗忘的接口信任了客户端传来的用户 id、一个调试开关跟着发布上线了、一个内网地址仍然可达。先把这些做对，然后再加固，理由应当是“你确实拥有值得攻击者花时间的东西”，而不是“这样显得严谨”。


## 参考

- J. H. Saltzer, M. D. Schroeder，["The Protection of Information in Computer Systems"](https://web.mit.edu/Saltzer/www/publications/protection/)，*Proc. IEEE* 63(9)，1975——完全仲裁（每次访问都在权威处检查）与开放设计（安全不依赖机制保密）。
- OWASP，[Mobile Application Security Verification Standard (MASVS)](https://mas.owasp.org/MASVS/)——本文压缩的那几组控制项：存储、加密、认证、网络、平台交互、抗篡改。
- OWASP，[Pinning Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Pinning_Cheat_Sheet.html)——证书固定何时有用、何时把你自己锁死，以及如何轮换。
- Android 开发者文档，[Network security configuration](https://developer.android.com/privacy-and-security/security-config)——分层策略的具体例子：按域名的条目覆盖基础配置。

---

*上一篇：[模块化与迁移](/blog/modular-evolution-zh/) · 返回[总览](/blog/trading-app-architecture-zh/)。*

