Foram descobertas vulnerabilidades críticas de bypass de autenticação SAML (CVE-2025-25291 + CVE-2025-25292) no ruby-saml até a versão 1.17.0. Ataques que possuam uma única assinatura válida criada com a chave usada para validar respostas SAML ou declarações da organização alvo podem usá-la para construir declarações SAML e, assim, fazer login como qualquer usuário. Em outras palavras, poderia ser usado para um ataque de tomada de conta.
Usuários de ruby-saml devem atualizar para a versão 1.18.0. Referências a bibliotecas que usam ruby-saml (como omniauth-saml) também precisam ser atualizadas para uma versão que referencie uma versão corrigida do ruby-saml.
Nesta publicação, detalhamos as vulnerabilidades de bypass de autenticação recém-descobertas na biblioteca ruby-saml, usada para single sign-on (SSO) via SAML no lado do provedor de serviços (aplicativo). O GitHub não usa atualmente ruby-saml para autenticação, mas começou a avaliar o uso da biblioteca com a intenção de usar uma biblioteca de código aberto para autenticação SAML novamente. Esta biblioteca é usada em outros projetos e produtos populares. Descobrimos uma instância explorável desta vulnerabilidade no GitLab e notificamos sua equipe de segurança para que tomem as medidas necessárias para proteger seus usuários contra possíveis ataques.
O GitHub usou anteriormente a biblioteca ruby-saml até 2014, mas mudou para nossa própria implementação SAML devido à falta de recursos no ruby-saml naquele momento. Após relatos de bug bounty sobre vulnerabilidades em nossa própria implementação (como CVE-2024-9487, relacionada a declarações criptografadas), o GitHub decidiu recentemente explorar o uso de ruby-saml novamente. Então, em outubro de 2024, uma vulnerabilidade bombástica surgiu: um bypass de autenticação no ruby-saml (CVE-2024-45409) por ahacker1. Com evidências tangíveis de superfície de ataque explorável, a mudança do GitHub para ruby-saml teve que ser avaliada mais a fundo agora. Como tal, o GitHub iniciou um bug bounty privado para avaliar a segurança da biblioteca ruby-saml. Demos a pesquisadores de bug bounty selecionados acesso a ambientes de teste do GitHub usando ruby-saml para autenticação SAML. Em paralelo, o GitHub Security Lab também revisou a superfície de ataque da biblioteca ruby-saml.
Como não é incomum quando vários pesquisadores estão olhando para o mesmo código, tanto ahacker1, um participante no programa de bug bounty do GitHub, quanto eu notamos a mesma coisa durante a revisão do código: ruby-saml estava usando dois analisadores XML diferentes durante o caminho do código de verificação de assinatura. Ou seja, REXML e Nokogiri. Enquanto REXML é um analisador XML implementado em Ruby puro, Nokogiri fornece uma API wrapper fácil de usar em torno de diferentes bibliotecas como libxml2, libgumbo e Xerces (usado para JRuby). Nokogiri suporta análise de XML e HTML.
Parece que Nokogiri foi adicionado ao ruby-saml para suportar canonicalization e potencialmente outras coisas que o REXML não suportava naquele momento.
Nós dois inspecionamos o mesmo caminho de código no validate_signature
de xml_security.rb e descobrimos que o elemento de assinatura a ser verificado é primeiro lido via REXML e, em seguida, também com o analisador XML do Nokogiri. Então, se REXML e Nokogiri pudessem ser enganados para recuperar diferentes elementos de assinatura para a mesma consulta XPath, poderia ser possível enganar o ruby-saml para verificar a assinatura errada. Parecia que poderia haver um potencial bypass de autenticação devido a um parser differential!
A realidade era realmente mais complicada do que isso.
Em linhas gerais, quatro estágios estavam envolvidos na descoberta deste bypass de autenticação:
- Descobrir que dois analisadores XML diferentes são usados durante a revisão do código.
- Estabelecer se e como um parser differential poderia ser explorado.
- Encontrar um parser differential real para os analisadores em uso.
- Aproveitar o parser differential para criar um exploit completo.
Para provar o impacto de segurança desta vulnerabilidade, foi necessário completar todos os quatro estágios e criar um exploit de bypass de autenticação completo.
Revisão rápida: como as respostas SAML são validadas
As respostas da security assertion markup language (SAML) são usadas para transportar informações sobre um usuário conectado do provedor de identidade (IdP) para o provedor de serviços (SP) em formato XML. Frequentemente, a única informação importante transportada é um nome de usuário ou um endereço de e-mail. Quando o HTTP POST binding é usado, a resposta SAML viaja do IdP para o SP através do navegador do usuário final. Isso torna óbvio por que deve haver algum tipo de verificação de assinatura em jogo para impedir que o usuário adultere a mensagem.
Vamos dar uma olhada rápida em como seria uma resposta SAML simplificada:
Nota: na resposta acima, os namespaces XML foram removidos para melhor legibilidade.
Como você deve ter notado: a parte principal de uma resposta SAML simples é o seu elemento de declaração (A), enquanto a principal informação contida na declaração é a informação contida no elemento Subject
(B) (aqui o NameID contendo o nome de usuário: admin). Uma declaração real normalmente contém mais informações (por exemplo, datas NotBefore
e NotOnOrAfter
como parte de um elemento Conditions
).
Normalmente, a Assertion
(A) (sem toda a parte Signature
) é canonicalized e então comparada com o DigestValue
(C) e o SignedInfo
(D) é canonicalized e verificado contra o SignatureValue
(E). Neste exemplo, a declaração da resposta SAML é assinada e, em outros casos, toda a resposta SAML é assinada.
Buscando por parser differentials
Aprendemos que o ruby-saml usava dois analisadores XML diferentes (REXML e Nokogiri) para validar a resposta SAML. Agora, vamos dar uma olhada na verificação da assinatura e na comparação do digest.
O foco da seguinte explicação está no método validate_signature
dentro de xml_security.rb.
Dentro desse método, há uma ampla consulta XPath com REXML para o primeiro elemento de assinatura dentro do documento SAML:
sig_element = REXML::XPath.first(
@working_copy,
"//ds:Signature",
{"ds"=>DSIG}
)
Dica: ao ler os trechos de código, você pode diferenciar as consultas para REXML e Nokogiri observando como elas são chamadas. Os métodos REXML são prefixados com REXML::
, enquanto os métodos Nokogiri são chamados em document
.
Mais tarde, o SignatureValue
real é lido deste elemento:
base64_signature = REXML::XPath.first(
sig_element,
"./ds:SignatureValue",
{"ds" => DSIG}
)
signature = Base64.decode64(OneLogin::RubySaml::Utils.element_text(base64_signature))
Nota: o nome do elemento Signature
pode ser um pouco confuso. Embora ele contenha a assinatura real no nó SignatureValue
, ele também contém a parte que é realmente assinada no nó SignedInfo
. Mais importante, o elemento DigestValue
contém o digest (hash) da declaração e informações sobre a chave usada.
Portanto, um elemento Signature
real poderia se parecer com isto (informações de namespace removidas para melhor legibilidade):
<Signature>
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<Reference URI="#_SAMEID">
<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" /></Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<DigestValue>Su4v[..]</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>L8/i[..]</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>MIID[..]</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
Mais tarde, no mesmo método (validate_signature
), há novamente uma consulta para a(s) Signature
(s)—mas desta vez com Nokogiri.
noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG)
Então, o elemento SignedInfo
é retirado dessa assinatura e canonicalized:
noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => DSIG)
canon_string = noko_signed_info_element.canonicalize(canon_algorithm)
Vamos lembrar que este canon_string
contém o elemento SignedInfo
canonicalized.
O elemento SignedInfo
é então também extraído com REXML:
signed_info_element = REXML::XPath.first(
sig_element,
"./ds:SignedInfo",
{ "ds" => DSIG }
)
Deste elemento SignedInfo
, o nó Reference
é lido:
ref = REXML::XPath.first(signed_info_element, "./ds:Reference", {"ds"=>DSIG})
Agora, o código consulta o nó referenciado procurando por nós com o ID do elemento assinado usando Nokogiri:
reference_nodes = document.xpath("//*[@ID=$id]", nil, { 'id' => extract_signed_element_id })
O método extract_signed_element_id
extrai o ID do elemento assinado com a ajuda do REXML. Do bypass de autenticação anterior (CVE-2024-45409), há agora uma verificação de que apenas um elemento com o mesmo ID pode existir.
O primeiro dos reference_nodes
é pego e canonicalized:
hashed_element = reference_nodes[0][..]canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces)
O canon_hashed_element
é então hashed:
hash = digest_algorithm.digest(canon_hashed_element)
O DigestValue
para comparar é então extraído com REXML:
encoded_digest_value = REXML::XPath.first(
ref,
"./ds:DigestValue",
{ "ds" => DSIG }
)
digest_value = Base64.decode64(OneLogin::RubySaml::Utils.element_text(encoded_digest_value))
Finalmente, o hash (construído a partir do elemento extraído por Nokogiri) é comparado com o digest_value
(extraído com REXML):
unless digests_match?(hash, digest_value)
A canon_string
extraída algumas linhas atrás (um resultado de uma extração com Nokogiri) é posteriormente verificada contra signature
(extraída com REXML).
unless cert.public_key.verify(signature_algorithm.new, signature, canon_string)
No final, temos a seguinte constelação:
- A declaração é extraída e canonicalized com Nokogiri e, em seguida, hashed. Em contraste, o hash contra o qual será comparado é extraído com REXML.
- O elemento SignedInfo é extraído e canonicalized com Nokogiri – ele é então verificado contra o SignatureValue, que foi extraído com REXML.
Explorando o parser differential
A questão é: é possível criar um documento XML onde o REXML vê uma assinatura e o Nokogiri vê outra?
Acontece que sim.
Ahacker1, participando do bug bounty, foi mais rápido em produzir um exploit funcional usando um parser differential. Entre outras coisas, ahacker1 foi inspirado pelas vulnerabilidades de XML roundtrips publicadas por Juho Forsén da Mattermost em 2021.
Não muito depois, eu produzi um exploit usando um parser differential diferente com a ajuda do Ruby fuzzer da Trail of Bits chamado ruzzy.
Ambos os exploits resultam em um bypass de autenticação. Isso significa que um invasor, que está em posse de uma única assinatura válida criada com a chave usada para validar respostas SAML ou declarações da organização alvo, pode usá-la para construir declarações para qualquer usuário que será aceito pelo ruby-saml. Tal assinatura pode vir de uma declaração assinada ou resposta de outro usuário (não privilegiado) ou, em certos casos, pode até vir de metadados assinados de um provedor de identidade SAML (que pode ser acessível publicamente).
Um exploit pode se parecer com isto. Aqui, uma Signature
adicional foi adicionada como parte do elemento StatusDetail
que é visível apenas para Nokogiri:
Em resumo:
O elemento SignedInfo
(A) da assinatura que é visível para Nokogiri é canonicalized e verificado contra o SignatureValue
(B) que foi extraído da assinatura vista pelo REXML.
A declaração é recuperada via Nokogiri procurando por seu ID. Esta declaração é então canonicalized e hashed (C). O hash é então comparado ao hash contido no DigestValue
(D). Este DigestValue foi recuperado via REXML. Este DigestValue não tem nenhuma assinatura correspondente.
Portanto, duas coisas acontecem:
- Um SignedInfo válido com DigestValue é verificado contra uma assinatura válida (o que confere).
- Uma declaração canonicalized fabricada é comparada com seu digest calculado (o que também confere).
Isso permite que um invasor, que está em posse de uma declaração assinada válida para qualquer usuário (não privilegiado), fabrique declarações e, como tal, personifique qualquer outro usuário.
Verifique se há erros ao usar Nokogiri
Partes dos exploits atualmente conhecidos e não divulgados podem ser interrompidas verificando se há erros de análise do Nokogiri nas respostas SAML. Infelizmente, esses erros não resultam em exceções, mas precisam ser verificados no membro errors
do documento analisado:
doc = Nokogiri::XML(xml) do |config|
config.options = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET
end
raise "XML errors when parsing: " + doc.errors.to_s if doc.errors.any?
Embora isso esteja longe de ser uma correção perfeita para os problemas em questão, torna pelo menos um exploit inviável.
Indicadores de comprometimento
Não temos conhecimento de nenhum indicador de comprometimento confiável. Embora tenhamos encontrado um potencial indicador de comprometimento, ele só funciona em ambientes semelhantes a debug e, para publicá-lo, teríamos que revelar muitos detalhes sobre como implementar um exploit funcional, então decidimos que é melhor não publicá-lo. Em vez disso, nossa melhor recomendação é procurar logins suspeitos via SAML no lado do provedor de serviços de endereços IP que não se alinham com a localização esperada do usuário.
SAML e assinaturas XML: tão confuso quanto possível
Alguns podem dizer que é difícil integrar sistemas com SAML. Isso pode ser verdade. No entanto, é ainda mais difícil escrever implementações de SAML usando assinaturas XML de forma segura. Como outros já afirmaram antes: é provavelmente melhor desconsiderar as especificações, pois segui-las não ajuda a construir implementações seguras.
Para recapitular como a validação funciona se a declaração SAML for assinada, vamos dar uma olhada no gráfico abaixo, que mostra uma resposta SAML simplificada. A declaração, que transporta as informações protegidas, contém uma assinatura. Confuso, certo?
Para complicar ainda mais: o que é assinado aqui? Toda a declaração? Não!
O que é assinado é o elemento SignedInfo
e o elemento SignedInfo
contém um DigestValue
. Este DigestValue
é o hash da declaração canonicalized com o elemento de assinatura removido antes da canonicalization. Este processo de verificação em duas etapas pode levar a implementações que têm uma desconexão entre a verificação do hash e a verificação da assinatura.
Este é o caso para estes diferenciais de analisador Ruby-SAML: enquanto o hash e a assinatura são verificados por conta própria, eles não têm conexão. O hash é realmente um hash da declaração, mas a assinatura é uma assinatura de um elemento SignedInfo
diferente contendo outro hash. O que você realmente quer é uma conexão direta entre o conteúdo hashed, o hash e a assinatura. (E uma vez que a verificação é feita, você só quer recuperar informações da parte exata que foi realmente verificada.) Ou, alternativamente, use um padrão menos complicado para transportar um nome de usuário criptograficamente assinado entre dois sistemas – mas aqui estamos.
Neste caso, a biblioteca já extraiu o SignedInfo
e o usou para verificar a assinatura de sua string canonicalized, canon_string
. No entanto, não o usou para obter o valor do digest. Se a biblioteca tivesse usado o conteúdo do SignedInfo
já extraído para obter o valor do digest, teria sido seguro neste caso, mesmo com dois analisadores XML em uso.
Como mostrado mais uma vez: confiar em dois analisadores diferentes em um contexto de segurança pode ser complicado e propenso a erros. Dito isto: a explorabilidade não é automaticamente garantida em tais casos. Como vimos neste caso, verificar se há erros Nokogiri não poderia ter impedido o diferencial do analisador, mas poderia ter interrompido pelo menos uma exploração prática dele.
A correção inicial para os bypasses de autenticação não remove um dos analisadores XML para evitar problemas de compatibilidade de API. Como observado, a questão mais fundamental foi a desconexão entre a verificação do hash e a verificação da assinatura, que era explorável através de diferenciais de analisador. A remoção de um dos analisadores XML já estava planejada por outras razões e provavelmente virá como parte de uma versão principal em combinação com melhorias adicionais para fortalecer a biblioteca. Se sua empresa depende de software de código aberto para funcionalidades críticas para os negócios, considere patrociná-los para ajudar a financiar seu desenvolvimento futuro e lançamentos de correção de bugs.
Se você é um usuário da biblioteca ruby-saml, certifique-se de atualizar para a versão mais recente, 1.18.0, contendo correções para CVE-2025-25291 e CVE-2025-25292. Referências a bibliotecas que usam ruby-saml (como omniauth-saml) também precisam ser atualizadas para uma versão que referencie uma versão corrigida do ruby-saml. Publicaremos uma prova de conceito de exploit em uma data posterior no repositório GitHub Security Lab.
Este conteúdo foi auxiliado por Inteligência Artificial, mas escrito e revisado por um humano.
Via The GitHub Blog