SAML 签名是有效的。但问题从来不在这里。
SAML 单点登录的形态里天生就带着一个重放问题,而大多数服务提供方偏偏把它“解决”在攻击者唯一够得着的地方。
先把场景摆出来。你是服务提供方。用户来到你的登录页,你向他们的 identity provider 发出一个 AuthnRequest,这个请求带着一个随机的 ID。IdP 完成用户认证,回传一个 SAML Response,其中带着 InResponseTo="<that ID>"。你查一下这个 ID,确认它确实是你签发的,然后把它消费掉,让它永远无法被用第二次。请求与响应绑定,响应只用一次,重放被挫败。教科书式的做法。
现在,来一个能把它推翻的问题:IdP 到底签了什么?
不是 Response,是 Assertion。Entra、Okta,业内大多数厂商都只签 <Assertion> 元素,而把包在它外面的 <Response> 信封留作未签名。这很正常,规范也允许。但 InResponseTo 是挂在 <Response> 上的。也就是说,你整套重放防御所依赖的那个字段,恰恰是签名没有覆盖的那个字段。你可以改它、删它、随你怎么折腾,assertion 的签名依然完美通过验证,因为你根本没碰 assertion。
那就看看攻击者做出那个显而易见的动作时会发生什么。
SAML 有两种形态。SP 发起(SP-initiated)的响应回应的是你发出的请求,所以它们带着 InResponseTo。IdP 发起(未经请求的)响应从来没被要求过,所以它们根本没有 InResponseTo。你的代码很合理地据此分支:如果有 InResponseTo,就去匹配一条已存储的请求并消费掉;如果没有,就没有请求可匹配,于是跳过这项检查。而这一跳过,就是全部漏洞所在。
这个攻击只有三步,且不涉及任何密码学:
- 捕获一个真实的、成功的 SAML Response。一次真实的登录、一个真实的签名、一个真实的 assertion。
- 删掉
InResponseTo属性。签名覆盖的是 assertion,不是信封,所以它依然能通过验证。 - 把它回传过去。由于没有
InResponseTo,你的 SP 把它当成 IdP 发起的响应,走进那条没有重放检查的分支,验证签名(有效),然后让用户登录。
然后再回传一次。再一次。这个 assertion 现在成了一把万能钥匙。你曾经引以为傲的每一项检查依旧通过:签名有效、证书已固定、issuer 正确、conditions 在有效期内。唯一本能拦下重放的那道控制,偏偏挂在一个攻击者可以随手删掉的字段上。
一句话总结这个陷阱:你签的是 assertion,把关卡却设在了信封上。
修复之道不是加更多验证,而是验证正确的东西。别再把重放保护挂在 InResponseTo 上,它既未签名又是可选的;把它挂到 assertion 自身的 ID 上,那才是被签名的、且永远存在的。每个 assertion 都有一个 ID,它落在签名之内,对它做任何篡改都会破坏验证。维护一个一次性使用的 assertion ID 缓存,用每个 assertion 的 NotOnOrAfter 来给缓存设界,让它永远不会无限增长,并拒绝任何你已经见过的 ID。这样一来,删掉 InResponseTo 对攻击者毫无帮助,因为你真正核对的那个值既无法伪造,也无法重放。
既然动手了,就把请求绑定当作纵深防御,而不是主控制。有 InResponseTo 时依然去匹配它。如果你从不提供 IdP 发起的登录,就依然把未经请求的响应直接拒掉。依然强制校验 Audience 和 Conditions 的有效期窗口。但那道承重的防重放检查,必须落在被签名的字节上,没有例外。
我们知道这一点,是因为在上线之前审计自己的 SAML 时发现了它,而不是事后被某个客户发现。而这个教训的适用范围远远超出了 SAML。一个签名只回答一个问题:这些字节是不是由持有这把密钥的人产生的。它不会告诉你这些字节是新鲜的,不会告诉你它们是发给你的,也不会告诉你你没有已经接受过它们一次。这是三项彼此独立的检查,而其中每一项都必须去读一个签名真正覆盖到的字段。一旦某个安全决策依赖于落在签名之外的数据,它就不再是一个安全决策,而变成了一句客气的请求,攻击者大可一口回绝。
要么就签你设卡的那个字段,要么就把卡设在被签名的字段上。没有第三种选择能扛得住一次重放。
如果你宁可不去手搓 SAML 验证、也不想在重放这个环节栽跟头,那正是把它交给别人来运营的好理由。Authagonal 把重放判定挂在被签名的 assertion ID 上,所以剥掉 InResponseTo 也无济于事。