Foram descobertas vulnerabilidades críticas de bypass de autenticação SAML (CVE-2025-25291 + CVE-2025-25292) no ruby-saml, em versões até a 1.17.0. A falha permite que atacantes, com uma única assinatura válida criada com a chave de validação de respostas SAML, construam declarações SAML e acessem contas de qualquer usuário, representando um risco significativo de account takeover. A atualização para a versão 1.18.0 é crucial, assim como a atualização de bibliotecas que utilizam ruby-saml.
Este artigo detalha as vulnerabilidades 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 utiliza ruby-saml para autenticação atualmente, mas avaliava a biblioteca para autenticação SAML de código aberto. A vulnerabilidade foi encontrada no GitLab, cuja equipe de segurança foi notificada.
O GitHub usou a biblioteca ruby-saml até 2014, migrando para uma implementação própria devido à falta de recursos. Após relatos de vulnerabilidades na implementação própria, como a CVE-2024-9487, o GitHub reavaliou o uso do ruby-saml. Em outubro de 2024, uma vulnerabilidade de authentication bypass (CVE-2024-45409) foi descoberta por ahacker1. O GitHub iniciou um programa de bug bounty para avaliar a segurança da biblioteca ruby-saml, concedendo acesso a ambientes de teste e revisando a superfície de ataque.
Durante a revisão de código, tanto ahacker1 quanto eu notamos que o ruby-saml utilizava dois parsers XML diferentes durante a verificação de assinatura: REXML e Nokogiri. REXML é implementado em Ruby, enquanto Nokogiri oferece uma API para bibliotecas como libxml2. Nokogiri foi adicionado para suportar a canonicalização e outras funcionalidades não suportadas pelo REXML.
Na função validate_signature
em xml_security.rb
, o elemento de assinatura é lido primeiro com REXML e depois com o parser XML do Nokogiri. Se fosse possível enganar REXML e Nokogiri para recuperarem diferentes elementos de assinatura para a mesma consulta XPath, poderíamos induzir o ruby-saml a verificar a assinatura errada. Isso indicava um potencial bypass de autenticação SAML devido a um parser differential.
A realidade, no entanto, era mais complexa.
A descoberta deste authentication bypass envolveu quatro etapas:
- Identificação do uso de dois parsers XML diferentes durante a revisão de código.
- Determinação de como um parser differential poderia ser explorado.
- Localização de um parser differential real para os parsers em uso.
- Utilização do parser differential para criar um exploit completo.
Para comprovar o impacto na segurança, foi necessário completar todas as etapas e criar um exploit de authentication bypass funcional.
Revisão rápida: como as respostas SAML são validadas
As respostas da security assertion markup language (SAML) são usadas para transmitir informações sobre um usuário autenticado do provedor de identidade (IdP) para o provedor de serviços (SP) em formato XML. Frequentemente, a única informação importante é 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 explica a necessidade de verificação de assinatura para impedir a manipulação da mensagem.
Uma resposta SAML simplificada pode ser visualizada na imagem:
Nota: os namespaces XML foram removidos para melhor legibilidade.
A parte principal de uma resposta SAML simples é o elemento assertion (A), e a informação contida no Subject
(B) (o NameID com o nome de usuário: admin). Uma assertion real contém mais informações (datas NotBefore
e NotOnOrAfter
no elemento Conditions
).
Normalmente, a Assertion
(A) (sem a parte Signature
) é canonicalizada e comparada com o DigestValue
(C), e o SignedInfo
(D) é canonicalizado e verificado com o SignatureValue
(E). Neste exemplo, a assertion da resposta SAML é assinada, mas em outros casos, toda a resposta SAML é assinada.
Buscando por parser differentials
O ruby-saml usa dois parsers XML diferentes (REXML e Nokogiri) para validar a resposta SAML. Vamos examinar a verificação da assinatura e a comparação do digest.
A explicação a seguir se concentra no método validate_signature
dentro de xml_security.rb
.
Dentro desse método, há uma 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, identifique as consultas para REXML e Nokogiri pela forma como são chamadas. Métodos REXML são prefixados com REXML::
, enquanto métodos Nokogiri são chamados em document
.
Posteriormente, 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 confuso. Ele contém a assinatura real no nó SignatureValue
, e a parte realmente assinada no nó SignedInfo
. O elemento DigestValue
contém o digest (hash) da assertion e informações sobre a chave usada.
Um elemento Signature
real poderia ser:
<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á outra consulta para Signature
(s), mas desta vez com Nokogiri.
noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG)
O elemento SignedInfo
é obtido dessa assinatura e canonicalizado:
noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => DSIG)
canon_string = noko_signed_info_element.canonicalize(canon_algorithm)
Lembre-se que canon_string
contém o elemento SignedInfo
canonicalizado.
O elemento SignedInfo
também é extraído com REXML:
signed_info_element = REXML::XPath.first(
sig_element,
"./ds:SignedInfo",
{ "ds" => DSIG }
)
A partir deste elemento SignedInfo
, o nó Reference
é lido:
ref = REXML::XPath.first(signed_info_element, "./ds:Reference", {"ds"=>DSIG})
O código consulta o nó referenciado procurando 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. A partir do authentication bypass 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
é obtido e canonicalizado:
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 pelo Nokogiri) é comparado com o digest_value
(extraído com REXML):
unless digests_match?(hash, digest_value)
O canon_string
extraído anteriormente (resultado de uma extração com Nokogiri) é mais tarde verificado com signature
(extraído com REXML).
unless cert.public_key.verify(signature_algorithm.new, signature, canon_string)
No final, temos a seguinte situação:
- A assertion é extraída e canonicalizada com Nokogiri, e então hashed. Em contraste, o hash contra o qual será comparado é extraído com REXML.
- O elemento SignedInfo é extraído e canonicalizado com Nokogiri – ele é então verificado com o SignatureValue, que foi extraído com REXML.
Explorando o parser differential
Seria possível criar um documento XML onde REXML vê uma assinatura e Nokogiri vê outra?
A resposta é sim.
Ahacker1, participante do bug bounty, foi mais rápido em produzir um exploit funcional usando um parser differential, inspirado nas vulnerabilidades de XML roundtrips publicadas por Juho Forsén da Mattermost em 2021.
Pouco depois, produzi um exploit usando um parser differential diferente com a ajuda do fuzzer Ruby da Trail of Bits chamado ruzzy.
Ambos os exploits resultam em um authentication bypass. Um atacante, com uma assinatura válida criada com a chave usada para validar respostas ou assertions SAML da organização, pode usá-la para construir assertions para qualquer usuário, aceitas pelo ruby-saml. A assinatura pode vir de uma assertion ou resposta assinada de outro usuário, ou de metadados assinados de um provedor de identidade SAML (acessíveis publicamente).
Um exploit pode adicionar uma assinatura como parte do elemento StatusDetail
, visível apenas para Nokogiri:
Em resumo:
O elemento SignedInfo
(A) da assinatura visível para Nokogiri é canonicalizado e verificado com o SignatureValue
(B) extraído da assinatura vista por REXML.
A assertion é recuperada via Nokogiri procurando por seu ID, canonicalizada e hashed (C). O hash é comparado com o hash contido no DigestValue
(D), recuperado via REXML. Este DigestValue não tem assinatura correspondente.
Duas coisas ocorrem:
- Um
SignedInfo
válido comDigestValue
é verificado com uma assinatura válida (verificação bem-sucedida). - Uma assertion canonicalizada fabricada é comparada com seu digest calculado (verificação bem-sucedida).
Um atacante, com uma assertion assinada válida para qualquer usuário, pode fabricar assertions e se passar por qualquer outro usuário.
Verificação de erros ao usar Nokogiri
Partes dos exploits atualmente conhecidos podem ser impedidas verificando erros de análise Nokogiri nas respostas SAML. Os 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 não seja uma solução perfeita para os problemas, torna pelo menos um exploit inviável.
Indicadores de comprometimento
Não temos conhecimento de indicadores de comprometimento confiáveis. Encontramos um potencial indicador, mas ele só funciona em ambientes de debug. Publicá-lo revelaria muitos detalhes sobre como implementar um exploit funcional. Recomendamos procurar por logins suspeitos via SAML no lado do provedor de serviços, de endereços IP que não correspondem à localização esperada do usuário.
SAML e assinaturas XML: confuso ao extremo
Alguns dizem que integrar sistemas com SAML é difícil. Talvez seja verdade, mas implementar SAML usando assinaturas XML de forma segura é ainda mais difícil. É melhor desconsiderar as especificações, pois segui-las não ajuda a construir implementações seguras.
A validação funciona da seguinte forma se a assertion SAML for assinada. A assertion, que transporta a informação protegida, contém uma assinatura. Confuso, não é?
Para complicar ainda mais: o que é assinado aqui? A assertion inteira? Não!
O que é assinado é o elemento SignedInfo
, que contém um DigestValue
. Este DigestValue
é o hash da assertion canonicalizada com o elemento de assinatura removido antes da canonicalização. Este processo de verificação em duas etapas pode levar a implementações com uma desconexão entre a verificação do hash e a verificação da assinatura. Este é o caso para estes parser differentials de Ruby-SAML: enquanto o hash e a assinatura são verificados individualmente, eles não têm conexão. O hash é um hash da assertion, mas a assinatura é uma assinatura de um elemento SignedInfo
diferente, contendo outro hash. O ideal é uma conexão direta entre o conteúdo hashed, o hash e a assinatura.
Neste caso, a biblioteca já extraiu o SignedInfo
e o usou para verificar a assinatura de sua string canonicalizada, canon_string
. No entanto, não o utilizou para obter o valor do digest. Se a biblioteca tivesse usado o conteúdo do SignedInfo
extraído para obter o valor do digest, teria sido segura mesmo com dois parsers XML em uso.
Conclusão
Confiar em dois parsers diferentes em um contexto de segurança pode ser complicado e propenso a erros. A explorabilidade não é garantida automaticamente nesses casos. Verificar erros do Nokogiri não impede o parser differential, mas pode impedir pelo menos uma exploração prática.
A correção inicial para os authentication bypasses não remove um dos parsers XML para evitar problemas de compatibilidade de API. O problema fundamental era a desconexão entre a verificação do hash e a verificação da assinatura, explorável via parser differentials. A remoção de um dos parsers XML já estava planejada e deve vir como parte de uma versão principal, com melhorias adicionais para fortalecer a biblioteca. Se sua empresa depende de software de código aberto para funcionalidades críticas, considere patrociná-los para ajudar a financiar seu desenvolvimento futuro e lançamentos de correção de bugs.
Se você usa a biblioteca ruby-saml, atualize para a versão mais recente, 1.18.0, com 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. Publicaremos uma prova de conceito de exploit posteriormente no repositório do GitHub Security Lab.
Agradecimentos
Agradecimentos especiais a Sixto Martín, mantenedor do ruby-saml, e a Jeff Guerra do programa GitHub Bug Bounty.
Agradecimentos também a ahacker1 por suas contribuições para este artigo.
Cronograma
- 2024-11-04: Relato de bug bounty demonstrando um authentication bypass contra um ambiente de teste do GitHub avaliando ruby-saml para autenticação SAML.
- 2024-11-04: Iniciado o trabalho para identificar e testar potenciais mitigações.
- 2024-11-12: Um segundo authentication bypass foi encontrado, tornando as mitigações planejadas inúteis.
- 2024-11-13: Contato inicial com Sixto Martín, mantenedor do ruby-saml.
- 2024-11-14: Ambos os parser differentials são relatados ao ruby-saml, e o mantenedor responde imediatamente.
- 2024-11-14: Início do trabalho em potenciais patches pelo mantenedor e ahacker1. (Uma das ideias iniciais era remover um dos parsers XML, mas isso não era viável sem quebrar a compatibilidade retroativa).
- 2025-02-04: ahacker1 propõe uma correção não compatível retroativamente.
- 2025-02-06: ahacker1 também propõe uma correção compatível retroativamente.
- 2025-02-12: Termina o prazo de 90 dias dos comunicados do GitHub Security Lab.
- 2025-02-16: O mantenedor começa a trabalhar em uma correção com a ideia de ser compatível retroativamente e mais fácil de entender.
- 2025-02-17: Contato inicial com o GitLab para coordenar o lançamento de seu produto on-prem com o lançamento da biblioteca ruby-saml.
- 2025-03-12: Lançada uma versão corrigida do ruby-saml.
Este conteúdo foi auxiliado por Inteligência Artificiado, mas escrito e revisado por um humano.
Via The GitHub Blog