我们在上线前审计了自家的认证服务器。那些漏洞是有形状的。
在请任何人把登录托付给我们之前,我们先像攻击者那样把自家的认证服务器过了一遍:不是靠运行它,而是靠读它。产出的是一份覆盖整个攻击面的书面评审:十六个编号章节,261 行,从 SAML 到 OIDC 联合登录、SCIM、管理 API、MFA 以及备份工具,每一条发现都带着严重等级和源码位置。其中八条被标为优先修复。我们用一个晚上修完了这八条,剩下的清单在半小时之后也清干净了。两周半之后,我们对多租户控制平面做了同样的一遍,又得到五条,在上生产的前一晚全部关闭。
有一个数字一直留在我们脑子里。测试套件在那个晚上之前报告 367 个用例全部通过,之后还是 367 个全部通过。没有一处变红,因为套件里根本没有任何一条对准过这些问题。测试只能证明你想到要测的东西,而没有人想到过要去测:把一个签名并不覆盖的 XML 属性删掉,会不会让一个被重放的断言走上一条更友善的分支。
如果把这样一份清单读成八个互不相关的漏洞,它对别人毫无用处。如果读成形状,它就是三种习惯,在各个子系统里反复出现,而写下这些代码的人是读过规范的。
client_id、转发头、InResponseTo 和那个邮箱声明
第一种习惯,是把调用方自己写进去的值,当成关于调用方的事实。评审里被标为 Critical 的那两条,各自都只是一个输入:一个查询参数,和一个邮箱声明。
MFA 策略是从登录表单 returnUrl 里解析出来的 client_id 推导出来的,而 /connect/authorize 从来不复查一次。把登录指向一个关闭了 MFA 的客户端,会话出来时就没有第二因素,然后拿到那个要求 MFA 的客户端上照样花得出去。这一条有自己的一篇文章。
按 IP 的限流以远端地址为键,而转发头是从 0.0.0.0/0 无条件信任的。每个请求带上一个 X-Forwarded-For 值,就把所有按 IP 的控制变成了建议性的,顺带还改写了审计日志里的 IP 那一列。细节在这里。
SAML 断言消费端在 SP 发起的路径(消费掉请求 id,一次性)和 IdP 发起的路径(查一个断言 id 缓存)之间做选择,判据完全只是响应里带没带 InResponseTo 属性。这个属性挂在 <Response> 元素上,而当只有断言被签名时,它并不在签名覆盖范围内,这恰恰是常见情况。把它删掉,一个被截获的响应就会走上另一条分支,而原始登录从来没往那条分支的重放缓存里写过任何东西,与此同时每一个签名都照样校验通过。为什么签名从来就不是问题所在。
还有,回访的联合登录用户是靠断言里的邮箱来定位的,所以任何一个连接都可以断言别人的地址,然后落到别人的账号上。这一条也写成了文章。
管理 API 是一座生产自身凭据的工厂
管理员授权只认一样东西:一个 scope 声明里带着管理员作用域的令牌。而有两个管理端点会把这个作用域放进一个新令牌里。
POST /api/v1/token 是客服工具用的模拟身份端点,它把自己的 scopes 查询参数切开,原样复制进一个访问令牌和一个刷新令牌,完全不去比对客户端已注册的 AllowedScopes。于是,一份管理员凭据,无论它的寿命多短,都能为任意用户、任意客户端铸出一个长寿命的管理员刷新令牌:这份权限会比你担心的那份凭据的轮换活得更久。与此同时,客户端创建接口直接从请求体绑定了一个原始的 client 对象,连 ClientSecretHashes 也一起绑,于是管理员可以注册一个持有管理员作用域、而且密钥哈希是自己早就知道的 client_credentials 客户端。列表和详情接口则把存下来的每一个哈希都原样回显出去。
现在,模拟身份端点会拒绝管理员作用域,其余的作用域也被客户端自己的 AllowedScopes 框住;创建和更新绑定的是一个没有密钥哈希字段的 DTO,任何响应里都不再带哈希。一个能自我签发的权限,不算权限。
一个连字符、一个没有归属的组,以及一个在干身份活儿的邮箱
第二种习惯,是让某个带名字的东西去顶替身份。
邮箱曾是联合登录的连接键:全局唯一、在很多提供商那里都存在,而且发出断言的那个连接说它是什么它就是什么。修法是改成用提供商加主体这一对来定位,而这一对是别的连接铸不出来的。
在控制平面里,那个名字是租户 slug。把 slug 转成存储表前缀的路由会把连字符去掉,于是 acme-corp 和 acmecorp 产出同一个前缀,也就意味着同一套表和同一把 Vault 签名密钥。两个可以分别注册的租户,共用一套表和一把签名密钥。把第二个注册下来,你看到的就是第一个的用户。修法不是写一个更聪明的前缀函数,而是禁止 slug 里出现连字符,让这个映射是单射的,这件事值得读完整版。
SCIM 是最扎心的一条,因为它有一半是对的。每一次用户读写都会复查一遍:这个资源是不是由发起调用的客户端开通的。而隔壁文件里的组,压根没有归属:创建时从来不盖归属,列表把 null 当作归属过滤条件传进去,于是返回该环境里的每一个组,而 get、replace、patch 和 delete 接受任意 id。知道一个组 id 就是全部的授权,而当多个开通客户端共享一个环境时,这就意味着跨客户端地读写组成员关系,而角色正是靠组成员关系授予的。现在,组会记录创建它的客户端,每一次读写都被限定在这个范围内。
Cookie 在 webhook 回答之前就已经签发了
第三种习惯,是在跑检查之前就先把不可逆的那件事做掉。
最清楚的一例是一个客户会掏钱买的功能:租户注册一个 webhook,认证时会去问它“允许这次登录吗”,而且可以把它设置成强制执行这个回答。这个钩子在每一条建立会话的路径上都跑了,没问题。问题只在于它跑在 SignInAsync 之后。于是一次拒绝会抛异常,请求返回 500,而浏览器手里早就攥着一个已签发的会话 cookie 了。任何忽略这个错误的客户端都算登录成功了,不管它被不被拦。那个用来拦登录的 webhook 没有拦住登录。
修法是把一行代码挪个位置,挪六次:在密码、OIDC、SAML、MFA 校验和 MFA 设置这几条路径上,把钩子放到 SignInAsync 之前跑;在令牌端点上,把它放到铸造令牌之前跑。库这边的改动和控制平面那边的改动前后相差两分钟落地。现在一次拒绝就是一个干净的 403,不带 Set-Cookie,还有一个端到端测试断言:被拒绝的登录不会签发 .AspNetCore.Cookies。
同样的次序问题还出现在另外三个更安静的地方。授权码的读取和删除是两步操作,所以同一个码的两次并发兑换可以都通过读取、都拿到令牌;现在授权存储对外提供 TryConsumeAsync,这是一个条件删除,只对真正删掉那一行的调用方返回 true。恢复流程在校验任何东西之前就先写入了实体:FileHashes 字段在文档里写着会在恢复时校验,实际上从来没被填充过,也从来没被检查过,这就让一份被篡改的备份变成了一个针对生产环境的写入原语。还有,Stripe webhook 先对事件采取动作,之后才记录自己见过这个事件,所以至少一次的重投可能让一个租户的套餐和状态来回抖动,或者重复发出催缴。这三处现在都是先检查。
保存时校验 URL,并不能证明拨号那一刻它还是那样
客户会交给我们 URL:认证 webhook、备份目标、留存回调。在保存的那一刻拒绝掉解析到私有地址的那些,是最显而易见的防线,而单靠它几乎一文不值,因为 DNS 不是一句承诺。一个在你校验时解析到公网的域名,完全可以在你连接时解析到 169.254.169.254。
所以真正承重的检查不在设置处理器里,而是 HTTP handler 自身上的一个连接回调。每一次向客户提供的 URL 发出的出站调用都会先解析主机,拒绝回环地址、RFC1918、唯一本地地址,以及云元数据服务所在的整个 169.254.0.0/16 链路本地段,然后把套接字直接开到那个已经通过校验的地址上,而不是开到主机名上,于是不再剩下第二次解析可供重绑定。自动重定向是关闭的,因为一个 302 就是又一次点名内部目标的机会。保存时的那个检查留下来,作为给客户看的一条错误提示,它本来也就只配干这个。
两个端点和一个只靠网络保护的备份文件
/_internal/cluster/gossip 是匿名映射的,而它的共享密钥检查是以注释而不是代码的形式存在的。/_internal/backchannel-logout 也是匿名映射的,在公网监听器上关掉了防伪造校验,接受一个 {"SubjectId": "..."} 这样的请求体,然后吊销该主体持有的每一份授权。两者在进程内都没有任何守卫。挡在它们前面的只有 ingress 配置,而考虑到上面那条关于转发头的发现,任何基于 IP 的防御本来就是可以伪造的。现在两者都要求一个以常数时间比对的共享密钥,或者在没有配置密钥时要求来源地址是内部地址。
备份也是同一副面孔。在使用本地密钥源的主机上,签名密钥表里存着 EC 私钥标量,而备份会把每一列原样序列化成明文的 gzip JSONL。任何能读到备份文件的人,都能为这份备份所属的颁发者伪造令牌。现在这张表默认被排除在外,只有加密目标才能显式选择包含它;而且每一个数据文件都会做 SHA-256 哈希写进清单,并在恢复流程写下第一行之前完成校验。
一个检查跑在哪里,决定了它大部分的价值
这些没有一条属于那种“没人想到过”意义上的检查缺失。MFA 实现了,也测过了。webhook 钩子在每条路径上都跑了。SSRF 校验是存在的。SCIM 用户在每一个动词上都被正确地限定了范围,而它所在的文件,就紧挨着组没有被限定范围的那个文件。几乎每一例里,逻辑都写了,也是对的,只是被装在了一个它干不了活的位置上:装在调用方提供的值上,装在调用方可以跳过的一步里,装在它本该阻止的那个状态之后,或者装在了隔壁那个资源上。
所以在每一个端点上,有用的问题不是“我们校验了吗”,而是另外三个。这个请求实际证明了什么,而不是声称了什么?这个标识符是调用方能说出口的东西,还是只有正确的调用方才握得住的东西?以及,在答案到达之前,有没有什么不可逆的事已经发生了?一份书面评审,一节一节地做,每条发现都带着严重等级,它主要的作用就是逼着这三个问题穿过那些你确信没问题的代码。我们的代码就是确信没问题的。是十六个章节的确信,外加八件本来会变成某个人的安全事件的事。
这些没有一条是客户能碰到的,因为那时候还没有客户。这就是把这件事放在上线之前、而不是放在第一份漏洞报告之后去做的理由:同一份清单,晚六个月写出来,就不再是一晚上的活儿,而是一条披露时间线。如果你想看这些答案的当前版本,我们的安全页面讲清楚了租户是怎么隔离的、静态存储里加密了什么,以及你的数据备份里究竟装了些什么。