Como contornar a autenticação SAML SSO com diferenciais de parser

Falhas críticas de bypass de autenticação SAML (CVE-2025-25291 + CVE-2025-25292) foram descobertas no ruby-saml até a versão 1.17.0. Atacantes que possuírem uma única assinatura válida, criada com a chave usada para validar respostas ou declarações SAML da organização alvo, podem usá-la para construir suas próprias declarações SAML e, assim, efetuar login como qualquer usuário. Em outras palavras, isso 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 de ruby-saml.

Neste artigo, detalharemos as vulnerabilidades de bypass de autenticação SAML 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 mais uma vez. Essa biblioteca é usada em outros projetos e produtos populares. Descobrimos uma instância explorável dessa vulnerabilidade no GitLab e notificamos sua equipe de segurança para que eles possam tomar 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, relacionado 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 em 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 engagement 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 conjunto, 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 GitHub bug bounty, quanto eu notamos a mesma coisa durante a revisão do código: ruby-saml estava usando dois XML parsers diferentes durante o caminho do código de verificação de assinatura. Nomeadamente, REXML e Nokogiri. Enquanto REXML é um XML parser 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 a análise de XML e HTML.

Parece que Nokogiri foi adicionado ao ruby-saml para suportar a canonicalização 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 XML parser de 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 SAML devido a um parser differential!

A realidade era realmente mais complicada do que isso.

De modo geral, quatro etapas foram envolvidas na descoberta deste bypass de autenticação SAML:

  1. Descobrir que dois XML parsers diferentes são usados durante a revisão do código.
  2. Estabelecer se e como um parser differential poderia ser explorado.
  3. Encontrar um parser differential real para os parsers em uso.
  4. Aproveitar o parser differential para criar um exploit completo.

Para provar o impacto de segurança desta vulnerabilidade, foi necessário completar todas as quatro etapas e criar um exploit de bypass de autenticação SAML completo.

Entenda como as respostas SAML são validadas

Descrição da imagem

As respostas da Security Assertion Markup Language (SAML) são usadas para transportar 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 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 browser do usuário final. Isso torna óbvio porque deve haver algum tipo de verificação de assinatura em jogo para evitar que o usuário adultere a mensagem.

Vamos dar uma olhada rápida em como seria uma resposta SAML simplificada:

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 a parte inteira da Signature) é canonicalizada e então comparada com o DigestValue (C) e o SignedInfo (D) é canonicalizado e verificado contra o SignatureValue (E). Neste exemplo, a declaração da resposta SAML é assinada e, em outros casos, a resposta SAML inteira é assinada.

Buscando por parser differentials

Aprendemos que ruby-saml usava dois XML parsers 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}
)

Quando estiver lendo 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))

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.

Então, 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 canonicalizado:

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 canonicalizado.

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 de REXML. Do bypass de autenticação SAML 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 é retirado 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 contra ele é 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 situação:

  1. A declaração é extraída e canonicalizada com Nokogiri, e então hashed. Em contraste, o hash contra o qual será comparado é extraído com REXML.
  2. O elemento SignedInfo é extraído e canonicalizado com Nokogiri, e 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 REXML vê uma assinatura e 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 SAML. Isso significa que um atacante, que está de posse de uma única assinatura válida que foi criada com a chave usada para validar respostas ou declarações SAML da organização alvo, pode usá-la para construir declarações para quaisquer usuários que serão aceitos por ruby-saml. Tal assinatura pode vir de uma declaração ou resposta assinada 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 poderia 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 é canonicalizado e verificado contra o SignatureValue (B) que foi extraído da assinatura vista por REXML.

A declaração é recuperada via Nokogiri procurando por seu ID. Esta declaração é então canonicalizada e hashed (C). O hash é então comparado com o hash contido no DigestValue (D). Este DigestValue não tem assinatura correspondente.

Então, duas coisas acontecem:

  • Um SignedInfo válido com DigestValue é verificado contra uma assinatura válida (o que confere).
  • Uma declaração canonicalizada fabricada é comparada com seu digest calculado (o que também confere).

Isso permite que um atacante, que está de posse de uma declaração assinada válida para qualquer usuário (não privilegiado), fabrique declarações e, como tal, se passe por 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 de 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 estamos cientes de quaisquer indicadores de comprometimento confiáveis. 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á disseram 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, representando 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 está sendo assinado aqui? A declaração inteira? Não!

O que é assinado é o elemento SignedInfo e o elemento SignedInfo contém um DigestValue. Este DigestValue é o hash da declaração canonicalizada com o elemento de assinatura removido antes da canonicalização. 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 parser differentials Ruby-SAML: enquanto o hash e a assinatura verificam 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. Ou, alternativamente, use um padrão menos complicado para transportar um nome de usuário criptograficamente assinado entre dois sistemas, mas aqui estamos nós.

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 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 XML parsers em uso.

Conclusão

Como mostrado mais uma vez: confiar em dois parsers 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 de Nokogiri não poderia ter impedido o parser differential, mas poderia ter impedido pelo menos uma exploração prática dele.

A correção inicial para os bypasses de autenticação não remove um dos XML parsers 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 parser differentials. A remoção de um dos XML parsers já estava planejada por outros motivos 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 funcionalidade crítica 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 de ruby-saml. Publicaremos uma prova de conceito de exploit em uma data posterior no repositório GitHub Security Lab.

Agradecimentos

Agradecimentos especiais a Sixto Martín, mantenedor do ruby-saml, e Jeff Guerra do programa GitHub Bug Bounty.

Agradecimentos especiais também a ahacker1 por dar contribuições para este artigo.

Linha do tempo

  • 2024-11-04: Um relatório de bug bounty demonstrando um bypass de autenticação SAML foi relatado contra um ambiente de teste do GitHub avaliando ruby-saml para autenticação SAML.
  • 2024-11-04: O trabalho começou para identificar e testar possíveis mitigações.
  • 2024-11-12: Um segundo bypass de autenticação SAML foi encontrado por Peter que torna as mitigações planejadas para o primeiro 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 para ruby-saml, o mantenedor responde imediatamente.
  • 2024-11-14: O trabalho em patches potenciais pelo mantenedor e ahacker1 começa. (Uma das ideias iniciais era remover um dos XML parsers, mas isso não era viável sem quebrar a compatibilidade com versões anteriores).
  • 2025-02-04: ahacker1 propõe uma correção não compatível com versões anteriores.
  • 2025-02-06: ahacker1 também propõe uma correção compatível com versões anteriores.
  • 2025-02-12: O prazo de 90 dias dos avisos do GitHub Security Lab termina.
  • 2025-02-16: O mantenedor começa a trabalhar em uma correção com a ideia de ser compatível com versões anteriores e mais fácil de entender.
  • 2025-02-17: Contato inicial com o GitLab para coordenar um lançamento de seu produto on-prem com o lançamento da biblioteca ruby-saml.
  • 2025-03-12: Uma versão corrigida de ruby-saml foi lançada.

Este conteúdo foi auxiliado por Inteligência Artificiado, mas escrito e revisado por um humano.

Via The GitHub Blog

Leave a Comment