La firma SAML era válida. Ese nunca fue el problema.
El inicio de sesión único (SSO) de SAML tiene un problema de repetición integrado en su propia forma, y la mayoría de los proveedores de servicios lo "resuelven" en el único lugar al que un atacante puede llegar.
Este es el escenario. Tú eres el proveedor de servicios. Un usuario llega a tu página de inicio de sesión, envías un AuthnRequest a su proveedor de identidad, y esa petición lleva un ID aleatorio. El IdP autentica al usuario y devuelve una Response SAML con InResponseTo="<that ID>". Buscas el ID, confirmas que realmente lo emitiste y lo consumes para que no pueda usarse dos veces. Petición ligada a la respuesta, respuesta usada una sola vez, repetición derrotada. De manual.
Ahora la pregunta que lo rompe todo: ¿qué firmó realmente el IdP?
La Response no. La aserción. Entra, Okta y la mayoría del sector firman el elemento <Assertion> y dejan sin firmar el sobre <Response> que lo envuelve. Eso es normal, y la especificación lo permite. Pero InResponseTo vive en el <Response>. Así que el campo del que depende toda tu defensa contra la repetición es justo el único campo que la firma no cubre. Puedes editarlo, borrarlo, hacer con él lo que quieras, y la firma de la aserción sigue validándose a la perfección, porque nunca tocaste la aserción.
Así que observa lo que pasa cuando un atacante hace lo evidente.
SAML tiene dos variantes. Las respuestas iniciadas por el SP responden a una petición que tú enviaste, así que llevan InResponseTo. Las respuestas iniciadas por el IdP (no solicitadas) nunca se pidieron, así que no tienen ningún InResponseTo. Tu código, con toda lógica, se ramifica según esto: si hay un InResponseTo, lo emparejas con una petición almacenada y lo consumes; si no lo hay, no hay ninguna petición que emparejar, así que se salta esa comprobación. Ese salto es toda la vulnerabilidad.
El ataque son tres pasos y cero criptografía:
- Captura una Response SAML real y exitosa. Un inicio de sesión genuino, una firma genuina, una aserción genuina.
- Borra el atributo
InResponseTo. La firma cubre la aserción, no el sobre, así que sigue verificándose. - Reenvíala. Sin
InResponseTo, tu SP la trata como iniciada por el IdP, toma la rama sin comprobación de repetición, valida la firma (válida) y autentica al usuario.
Luego reenvíala otra vez. Y otra. Ahora la aserción es una llave maestra. Todas las comprobaciones de las que te enorgullecías siguen pasando: firma válida, certificado fijado, emisor correcto, condiciones en vigor. El único control que habría detenido una repetición dependía de un campo que el atacante borra gratis.
Aquí está la trampa en una línea: firmaste la aserción, pero pusiste el control en el sobre.
El arreglo no es más validación. Es validar lo correcto. Deja de basar la protección contra repetición en InResponseTo, que no está firmado y es opcional, y básala en el propio ID de la aserción, que sí está firmado y siempre está presente. Toda aserción tiene un ID, se encuentra dentro de la firma, y manipularlo rompe la verificación. Mantén una caché de un solo uso con los ID de las aserciones, acotada por el NotOnOrAfter de cada aserción para que la caché nunca crezca sin límite, y rechaza cualquier ID que ya hayas visto. Ahora borrar InResponseTo no le sirve de nada al atacante, porque el valor que realmente compruebas no se puede falsificar ni reproducir.
Ya que estás ahí, trata el vínculo con la petición como defensa en profundidad, no como el control principal. Sigue emparejando InResponseTo cuando esté presente. Sigue rechazando de plano las respuestas no solicitadas si nunca ofreces inicio de sesión iniciado por el IdP. Sigue aplicando Audience y la ventana de Conditions. Pero la comprobación antirrepetición que soporta el peso tiene que apoyarse en bytes firmados, y punto.
Esta la conocemos porque la encontramos auditando nuestro propio SAML antes de lanzar, no porque la encontrara un cliente después. Y la lección va mucho más allá de SAML. Una firma responde exactamente a una pregunta: ¿produjo estos bytes quien posee esta clave? No te dice que los bytes sean recientes, que fueran destinados a ti, ni que no los hayas aceptado ya una vez. Esas son tres comprobaciones distintas, y cada una tiene que leer un campo que la firma cubra de verdad. En el momento en que una decisión de seguridad depende de datos que están fuera de la firma, deja de ser una decisión de seguridad y se convierte en una petición amable que el atacante es libre de rechazar.
Firma el campo en el que pones el control, o pon el control en el campo que está firmado. No hay una tercera opción que sobreviva a una repetición.
Si prefieres no implementar a mano la validación de SAML y equivocarte en el caso de la repetición, esa es una buena razón para dejar que otro se encargue. Authagonal basa la protección contra repetición en el ID firmado de la aserción, así que quitar InResponseTo no lleva a ninguna parte.