Como contornar a autenticação SAML SSO de forma eficiente

Uma vulnerabilidade crítica de bypass de autenticação SAML foi descoberta na biblioteca ruby-saml, afetando versões até a 1.17.0. Atacantes com uma assinatura válida podem criar suas próprias declarações SAML e logar como qualquer usuário, resultando em potencialAccount Takeover. A atualização para a versão 1.18.0 é crucial, assim como a atualização de referências em bibliotecas que utilizam ruby-saml, como omniauth-saml, para versões corrigidas.

Neste artigo, vamos detalhar as vulnerabilidades de bypass de autenticação SAML recém-descobertas na biblioteca ruby-saml. Essa biblioteca é usada para single sign-on (SSO) via SAML no lado do provedor de serviços (aplicativo). Embora o GitHub não utilize atualmente o ruby-saml para autenticação, estava avaliando seu uso para autenticação SAML. A biblioteca é utilizada em outros projetos e produtos populares. Uma instância explorável dessa vulnerabilidade foi encontrada no GitLab, e a equipe de segurança foi notificada para proteger os usuários contra ataques.

O GitHub utilizou a biblioteca ruby-saml até 2014, migrando para uma implementação SAML própria devido à falta de recursos na ruby-saml na época. Após relatos de bug bounty sobre vulnerabilidades na implementação própria, como CVE-2024-9487, o GitHub decidiu explorar novamente o uso da ruby-saml. Em outubro de 2024, uma vulnerabilidade crítica foi descoberta: um bypass de autenticação SAML na ruby-saml (CVE-2024-45409) por ahacker1.

Com evidências de superfície de ataque explorável, a mudança do GitHub para ruby-saml precisava ser avaliada mais a fundo. Assim, o GitHub iniciou um programa de bug bounty privado para avaliar a segurança da biblioteca ruby-saml. Pesquisadores selecionados tiveram 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.

Como é comum quando vários pesquisadores analisam o mesmo código, tanto ahacker1, participante do programa de bug bounty do GitHub, quanto outro pesquisador notaram o mesmo durante a revisão do código: ruby-saml estava usando dois XML parsers diferentes durante o caminho de código da verificação de assinatura: 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 à ruby-saml para suportar a canonicalização e outras funcionalidades que REXML não suportava na época. Ambos inspecionaram o mesmo caminho de código no validate_signature de xml_security.rb e descobriram que o elemento de assinatura a ser verificado é lido primeiro via REXML e, em seguida, também com o XML parser do Nokogiri. Se REXML e Nokogiri pudessem ser induzidos a recuperar diferentes elementos de assinatura para a mesma consulta XPath, seria possível enganar ruby-saml para verificar a assinatura errada. Parecia haver um potencial bypass de autenticação SAML devido a um parser differential!

A realidade era mais complexa do que isso.

De forma geral, quatro etapas foram envolvidas na descoberta desse 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 dessa vulnerabilidade, foi necessário completar todas as quatro etapas e criar um exploit de autenticação completo.

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 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 mostra 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:

Como você deve ter notado: a parte principal de uma resposta SAML simples é 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}
)

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.

Então, um elemento Signature real poderia ser assim (informações de namespace removidas para melhor leitura):

<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 esta canon_string contém o elemento SignedInfo canonicalizado.

O elemento SignedInfo é então extraído também 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 signed element id usando Nokogiri:

reference_nodes = document.xpath("//*[@ID=$id]", nil, { 'id' => extract_signed_element_id })

O método extract_signed_element_id extrai o signed element id 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 é tomado 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 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) é mais tarde 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:

  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 – 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 REXML vê uma assinatura e Nokogiri vê outra?

A resposta é 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, outro pesquisador produziu 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 possui uma única assinatura válida criada com a chave usada para validar respostas ou declarações SAML da organização-alvo, pode usá-la para construir declarações para qualquer usuário que será aceito por ruby-saml. Tal assinatura pode vir de uma declaração ou resposta assinada de outro usuário (sem privilégios) 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 ser assim. 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 ao hash contido no DigestValue (D). Este DigestValue foi recuperado via REXML. Este DigestValue não tem assinatura correspondente.

Assim, duas coisas acontecem:

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

Isso permite que um atacante, que possui uma declaração assinada válida para qualquer usuário (sem privilégios), fabrique declarações e, assim, 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 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 nenhum indicador de comprometimento confiável. Embora tenhamos encontrado um indicador potencial de comprometimento, ele só funciona em ambientes semelhantes aos de depuração 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 por 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 relembrar 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 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.

Neste caso, a biblioteca já extraiu o SignedInfo e usou-o 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 segura neste caso, mesmo com dois XML parsers em uso.

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 capacidade de exploração 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 bypass de autenticação SAML 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 um grande lançamento 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 fazem uso de ruby-saml (como omniauth-saml) também precisam ser atualizadas para uma versão que referencie uma versão fixa de ruby-saml. Publicaremos uma prova de conceito de exploit em uma data posterior no repositório GitHub Security Lab.

Agradecimentos especiais a Sixto Martín, mantenedor de ruby-saml, e Jeff Guerra do programa GitHub Bug Bounty.
Agradecimentos especiais também a ahacker1 por dar inputs para este post do blog.

Cronograma

  • 2024-11-04: 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: Trabalho começou para identificar e testar potenciais mitigações.
  • 2024-11-12: Um segundo bypass de autenticação SAML foi encontrado que torna as mitigações planejadas para o primeiro inúteis.
  • 2024-11-13: Contato inicial com Sixto Martín, mantenedor de 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 fixa 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