我们强制启用了 MFA。一个查询参数就把它关掉了。
我们上线了多因素认证。输入密码之后,登录页面会要求第二因素验证,一切如你所料。我们测试过,它能用,我们就继续往前走了。然后,在上线前对我们自己的 auth 服务器做的一轮排查中,我们发现只要改一下 URL,就能把整个流程跳过去。
事情的轮廓是这样的。在 OIDC 流程里,你的应用会把用户送到 /connect/authorize。如果用户还没登录,服务器会把他们弹到登录页面,并把原始目的地塞进一个参数里:/login?returnUrl=/connect/authorize?...。你输入密码,通过 MFA,登录流程把你送回那个 returnUrl,这时 /connect/authorize 签发授权码,token 随之而来。
Bug 藏在操作顺序里。密码那一步就把你登录了。也就是说,它设置了一个已认证的 cookie。MFA 只是流程中的下一个页面。而 /connect/authorize,那个真正发放 token 的 endpoint,在发放之前只问一个问题:这个请求是否已认证?它从没问过这个会话有没有通过第二因素验证。“已认证”和“已通过 MFA 认证”是同一个 cookie。
所以这个绕过一点都不高明。你提交密码,拿到已认证的 cookie。然后,你不去跟着流程走到 MFA 提示页,而是自己直接访问那个 returnUrl,也就是 /connect/authorize?...,服务器一开始就递给你的那个地址。Authorize 看到一个已认证的主体,跑完它唯一的那项检查,然后签发授权码。第二因素从头到尾没有发生。MFA 提示页只是走廊里的一个台阶,没有任何东西阻止你绕着它走过去。
这个问题之所以能活过测试,是因为正常路径滴水不漏。像普通用户一样在界面上一路点下去,你每次都会被要求验证。这道门是真的,只是装错了地方。OIDC 服务器的安全边界不是登录界面,而是 /connect/authorize,那个把会话变成 token 的地方。任何只存在于通往这个边界的页面里的要求,都只是建议性的,因为任何能发出底层请求的人都可以直接发出它。
这个视角的转换就是修复方案。MFA 不是你执行的一个步骤,而是会话要么有、要么没有的一个属性。所以我们不再把它建模成一个页面,而是建模成一个 claim。当你通过第二因素验证时,登录流程会往 auth cookie 里写入一个 mfa_authenticated 标记。/connect/authorize 不再接受“已认证”,它要求“已认证且带着那个标记”。一个只有密码的 cookie 如今在 token endpoint 是无效的:无论你把它指向哪个 URL,它都会被弹回去完成 MFA,因为它缺的是一个 claim,而不是一次页面访问。
联合认证在这套模型下自然就顺了。当用户通过一个自己已经执行过 MFA 的外部身份提供方登录时,由该 IdP 为第二因素作担保,所以联合登录会设置同一个标记。SSO 用户不会为 IdP 已经强制过的东西被要求再验证一遍。这个标记是唯一的事实来源,每一条能合法满足 MFA 的路径都会写入它。
可迁移的教训跟 MFA 本身没什么关系。它是这样一条规则:把控制措施强制在授予受保护之物的那个边界上,而不是在本应通向那里的界面步骤上。我们有这项检查。我们写了 MFA 逻辑,测试了,上线了。它只是被挂在了攻击者根本不必经过的那部分系统上。同意页面、条款确认、敏感 scope 的升级验证:都是同一种失败模式。如果签发凭证的 endpoint 不校验那个条件,那么负责询问的页面就只是一个建议。
我们在任何客户碰到它之前就抓住了这个问题,同一轮审计还翻出了它的几个同类。这就是为什么值得像攻击者一样过一遍自己的 auth,尤其是在每一个发放有价值东西的 endpoint 上问的不是“用户走完流程了吗”,而是“这个请求究竟证明了什么”。我们的请求证明的是一个密码。我们却把它当成了两个因素的证明。
在铸造 token 的 endpoint 上强制 MFA,而不是在询问它的页面上,这就是真正的 MFA 和一个建议之间的全部差别。Authagonal 在签发凭证的地方强制执行它,所以一条手改的 return URL 哪儿也去不了。