BLOG · #Engineering

客户端安全模型:它是输入,永远不是权威

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

English version: The client security model: an input, never an authority。本文是交易类 App 架构系列第六篇。

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

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

唯一的规则

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

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

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

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

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

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

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

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

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

安装包里没有秘密

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

后果是具体的:

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

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

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

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

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

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

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

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

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

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

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

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

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

什么时候可以砍掉这些

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

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

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

参考


上一篇:模块化与迁移 · 返回总览。