A assinatura SAML era válida. Esse nunca foi o problema.
O single sign-on do SAML tem um problema de replay embutido em seu próprio formato, e a maioria dos provedores de serviços o "resolve" justamente no único lugar que um atacante consegue alcançar.
Aqui está o cenário. Você é o provedor de serviços. Um usuário chega à sua tela de login, você envia um AuthnRequest ao provedor de identidade dele, e essa requisição carrega um ID aleatório. O IdP autentica o usuário e devolve uma SAML Response com InResponseTo="<that ID>". Você procura o ID, confirma que realmente o emitiu e o consome para que nunca possa ser usado duas vezes. Requisição vinculada à resposta, resposta usada uma única vez, replay derrotado. De manual.
Agora a pergunta que desmonta tudo: o que o IdP de fato assinou?
Não a Response. A Assertion. Entra, Okta e a maior parte do mercado assinam o elemento <Assertion> e deixam o envelope <Response> ao redor sem assinatura. Isso é normal, e a especificação permite. Mas o InResponseTo vive na <Response>. Então o campo em que toda a sua defesa contra replay se apoia é justamente o único campo que a assinatura não cobre. Você pode editá-lo, apagá-lo, fazer o que quiser com ele, e a assinatura da asserção continua validando perfeitamente, porque você nunca tocou na asserção.
Então observe o que acontece quando um atacante faz o óbvio.
O SAML tem duas variantes. Respostas iniciadas pelo SP respondem a uma requisição que você enviou, então carregam InResponseTo. Respostas iniciadas pelo IdP (não solicitadas) nunca foram pedidas, então não têm InResponseTo algum. Seu código, com razão, se ramifica com base nisso: se houver um InResponseTo, associe-o a uma requisição armazenada e consuma-o; se não houver, não há requisição para associar, então ele pula essa verificação. Esse salto é a vulnerabilidade inteira.
O ataque tem três passos e zero criptografia:
- Capture uma SAML Response real e bem-sucedida. Um login genuíno, uma assinatura genuína, uma asserção genuína.
- Apague o atributo
InResponseTo. A assinatura cobre a asserção, não o envelope, então ela continua verificando. - Devolva-a. Sem
InResponseTo, seu SP a trata como iniciada pelo IdP, segue o ramo sem verificação de replay, valida a assinatura (válida) e autentica o usuário.
Depois envie de novo. E de novo. A asserção agora é uma chave-mestra. Toda verificação da qual você se orgulhava continua passando: assinatura válida, certificado fixado, emissor correto, condições dentro da validade. O único controle que teria barrado um replay estava atrelado a um campo que o atacante apaga de graça.
Aqui está a armadilha em uma linha: você assinou a asserção, mas baseou o controle no envelope.
A correção não é mais validação. É validar a coisa certa. Pare de apoiar a proteção contra replay no InResponseTo, que não é assinado e é opcional, e apoie-a no próprio ID da asserção, que é assinado e está sempre presente. Toda asserção tem um ID, ele fica dentro da assinatura, e adulterá-lo quebra a verificação. Mantenha um cache de uso único de IDs de asserção, limitado pelo NotOnOrAfter de cada asserção para que o cache nunca cresça sem limite, e rejeite qualquer ID que você já tenha visto. Agora apagar o InResponseTo não rende nada ao atacante, porque o valor que você de fato verifica não pode ser forjado nem reproduzido.
Já que está mexendo nisso, trate o vínculo da requisição como defesa em profundidade, não como o controle principal. Continue associando o InResponseTo quando ele estiver presente. Continue rejeitando de imediato respostas não solicitadas se você nunca oferece login iniciado pelo IdP. Continue impondo o Audience e a janela de Conditions. Mas a verificação antirreplay que sustenta tudo precisa recair sobre bytes assinados, ponto final.
Conhecemos essa porque a encontramos auditando nosso próprio SAML antes de lançar, não porque um cliente a encontrou depois. E a lição foi muito além do SAML. Uma assinatura responde a exatamente uma pergunta: o detentor desta chave produziu estes bytes? Ela não diz que os bytes são recentes, que foram destinados a você, nem que você já não os aceitou uma vez. São três verificações distintas, e cada uma delas precisa ler um campo que a assinatura de fato cobre. No momento em que uma decisão de segurança depende de dados que ficam fora da assinatura, ela deixa de ser uma decisão de segurança e vira um pedido educado que o atacante tem toda a liberdade de recusar.
Assine o campo em que você baseia o controle, ou baseie o controle no campo que é assinado. Não existe terceira opção que sobreviva a um replay.
Se você prefere não implementar a validação SAML na mão e errar no caso de replay, esse é um bom motivo para deixar outra pessoa cuidar disso. O Authagonal baseia a proteção contra replay no ID assinado da asserção, então remover o InResponseTo não leva a lugar nenhum.