La signature SAML était valide. Ce n'a jamais été le problème.
L'authentification unique SAML porte un problème de rejeu inscrit dans sa forme même, et la plupart des fournisseurs de services le « résolvent » au seul endroit qu'un attaquant peut atteindre.
Voici le décor. Vous êtes le fournisseur de services. Un utilisateur arrive sur votre page de connexion, vous envoyez un AuthnRequest à son fournisseur d'identité, et cette requête transporte un ID aléatoire. L'IdP authentifie l'utilisateur et renvoie une réponse SAML avec InResponseTo="<that ID>". Vous recherchez l'ID, confirmez que vous l'avez bien émis, et le consommez pour qu'il ne puisse jamais servir deux fois. Requête liée à la réponse, réponse utilisée une seule fois, rejeu déjoué. Un cas d'école.
Maintenant, la question qui fait tout s'écrouler : qu'est-ce que l'IdP a réellement signé ?
Pas la réponse. L'assertion. Entra, Okta, la plupart des acteurs du secteur signent l'élément <Assertion> et laissent l'enveloppe <Response> qui l'entoure non signée. C'est normal, et la spécification l'autorise. Mais InResponseTo se trouve sur le <Response>. Donc le champ sur lequel repose toute votre défense contre le rejeu est le seul champ que la signature ne couvre pas. Vous pouvez le modifier, le supprimer, en faire ce que bon vous semble, et la signature de l'assertion reste parfaitement valide, parce que vous n'avez jamais touché à l'assertion.
Alors regardez ce qui se passe quand un attaquant fait la chose évidente.
SAML se décline en deux variantes. Les réponses initiées par le SP répondent à une requête que vous avez envoyée, elles transportent donc InResponseTo. Les réponses initiées par l'IdP (non sollicitées) n'ont jamais été demandées, elles n'ont donc aucun InResponseTo. Votre code, à juste titre, se ramifie là-dessus : s'il y a un InResponseTo, on le fait correspondre à une requête stockée et on le consomme ; s'il n'y en a pas, il n'y a aucune requête à faire correspondre, on saute donc ce contrôle. Ce saut, c'est toute la vulnérabilité.
L'attaque tient en trois étapes et zéro cryptographie :
- Capturez une réponse SAML réelle et réussie. Une vraie connexion, une vraie signature, une vraie assertion.
- Supprimez l'attribut
InResponseTo. La signature couvre l'assertion, pas l'enveloppe, elle continue donc de se vérifier. - Renvoyez-la. Sans
InResponseTo, votre SP la traite comme initiée par l'IdP, emprunte la branche sans contrôle de rejeu, valide la signature (valide), et connecte l'utilisateur.
Puis renvoyez-la encore. Et encore. L'assertion est désormais un passe-partout. Tous les contrôles dont vous étiez fier passent toujours : signature valide, certificat épinglé, émetteur correct, conditions dans les délais. Le seul contrôle qui aurait arrêté un rejeu reposait sur un champ que l'attaquant supprime gratuitement.
Voici le piège en une phrase : vous avez signé l'assertion, mais votre contrôle portait sur l'enveloppe.
Le correctif n'est pas plus de validation. C'est valider la bonne chose. Cessez de fonder votre protection contre le rejeu sur InResponseTo, qui est non signé et optionnel, et fondez-la sur l'ID propre à l'assertion, qui est signé et toujours présent. Chaque assertion a un ID, il se trouve à l'intérieur de la signature, et le falsifier casse la vérification. Tenez un cache à usage unique des ID d'assertion, borné par le NotOnOrAfter de chaque assertion pour que le cache ne grossisse jamais sans limite, et rejetez tout ID déjà vu. Désormais, supprimer InResponseTo ne rapporte rien à l'attaquant, parce que la valeur que vous vérifiez réellement ne peut être ni falsifiée ni rejouée.
Tant que vous y êtes, traitez la liaison de la requête comme une défense en profondeur plutôt que comme le contrôle principal. Continuez de faire correspondre InResponseTo quand il est présent. Continuez de rejeter d'emblée les réponses non sollicitées si vous n'offrez jamais de connexion initiée par l'IdP. Continuez d'appliquer Audience et la fenêtre Conditions. Mais le contrôle anti-rejeu porteur doit reposer sur des octets signés, un point c'est tout.
Si nous connaissons celle-ci, c'est parce que nous l'avons trouvée en auditant notre propre SAML avant notre lancement, pas parce qu'un client l'a trouvée après coup. Et la leçon dépasse largement SAML. Une signature répond à exactement une question : le détenteur de cette clé a-t-il produit ces octets. Elle ne vous dit pas que les octets sont frais, qu'ils vous étaient destinés, ni que vous ne les avez pas déjà acceptés une fois. Ce sont trois contrôles distincts, et chacun d'eux doit lire un champ que la signature couvre réellement. Dès l'instant où une décision de sécurité dépend de données situées en dehors de la signature, elle cesse d'être une décision de sécurité et devient une aimable requête qu'un attaquant est libre de décliner.
Signez le champ sur lequel porte votre contrôle, ou faites porter votre contrôle sur le champ qui est signé. Il n'y a pas de troisième option qui survive à un rejeu.
Si vous préférez ne pas coder votre validation SAML à la main et vous tromper sur le cas du rejeu, c'est une bonne raison d'en laisser la gestion à quelqu'un d'autre. Authagonal fonde le rejeu sur l'ID d'assertion signé, si bien que retirer InResponseTo ne mène nulle part.