
Análise técnica aprofundada e anti-POC para CVE-2022-3602, um estouro de buffer em punycode no OpenSSL 3.0.x, com scripts de reprodução, análise de pilha e avaliação de mitigação do compilador.
Este documento e repositório é um relatório detalhado da CVE-2022-3602, um problema de estouro de buffer punycode no OpenSSL. É um "anti-POC" (o problema não parece ser explorável) destinado a pessoas que mantêm suas próprias compilações do OpenSSL e para mantenedores de compiladores.
Há uma CVE separada na mesma versão, a CVE-2022-3786, que também leva a estouros de buffer, mas um atacante não pode controlar o conteúdo nesse caso. Não há reprodução para esse problema aqui, mas esse problema pode levar a uma Negação de Serviço devido a falha.
Estouros e transbordos de buffer nunca são bons e se você estiver usando OpenSSL 3.0.x, é prudente atualizar o mais rápido possível.
Sinta-se à vontade para relatar quaisquer erros ou omissões através de issues do GitHub ou pull-requests.
Há um problema de off-by-one em como ossl_punycode_decode lida com a decodificação punycode que resulta em um estouro de 4 bytes. Esse problema é razoável apenas quando o OpenSSL processa uma cadeia de certificados e requer duas condições. Primeiramente, um certificado CA ou Intermediário em uma cadeia deve conter um campo de restrição de nome (name-constraint) que use punycode.
nameConstraints = permitted;email:xn-maccrthaigh-n7a.com
Em segundo lugar, o certificado folha deve conter um campo SubjectAlternateName (SAN) otherName que especifique uma string SmtpUTF8Mailbox.
otherName = 1.3.6.1.5.5.7.8.9;UTF8:[email protected]
quando acionado, o punycode no campo nameConstraints, mas não o punycode no campo otherName, será tratado pela análise punycode vulnerável do OpenSSL.
David Benjamin e Matt Caswell determinaram que a verificação de nameConstraint ocorre após a validação comum da cadeia de certificados e verificação de assinatura. Para a maioria das aplicações, isso significa que o problema não pode ser acionado com um certificado auto-assinado ou cadeia inválida.
Note que as aplicações s_client e s_server do openssl são destinadas para depuração e não param o processamento quando uma cadeia é inválida.
Uma CA ou Intermediário confiável terá que conter a carga maliciosa, e também terá que ter assinado o certificado folha que aciona o problema.
Pode haver alguns ambientes onde partes não confiáveis são as CAs ou Intermediários, por exemplo, um serviço de hospedagem que suporta CAs privadas fornecidas pelo cliente, mas isso não é comum.
A resposta para muitas aplicações será "não" por causa de como o compilador organizou a pilha e devido à presença de outras proteções como canários de pilha / cookies de pilha, preenchimento, PIE, FORTIFY_SOURCE.
O problema leva a um estouro de 32 bits na pilha. Isso não é suficiente para executar diretamente shellcode, mas pode ser suficiente para alterar o fluxo de controle de uma aplicação. Por exemplo, saltar para shellcode que foi embutido em uma cadeia de certificados X509 pode ser possível se esses dados também forem copiados para a pilha em um local executável.
Em todas as plataformas Linux que testei, o estouro ocorre no preenchimento e é inofensivo. Em teoria, um compilador pode dispor as variáveis de modo que o estouro ocorra em uma das outras variáveis na função ossl_a2ulabel.
Dependendo de inlining, a lista completa de variáveis presentes é:
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
e nenhuma me parece fornecer um caminho óbvio para escalada de privilégio ou controle interessante.
Anexei um tarball com ferramentas que podem ser usadas para criar reproduções e estouros com o máximo controle possível sobre todos os quatro bytes. A string de reprodução de referência (xn--ww90271...aaaa) estoura os quatro bytes com os valores 0xFF 0x0F 0x0F 0x0F. Se isso não falhar uma aplicação, é possível (provável?) que essa aplicação não seja vulnerável.
O script shell run-poc pode ser usado para gerar uma cadeia de certificados maliciosa. Um certificado CA malicioso é gerado a partir de ca.cnf e um certificado folha acionador é gerado a partir de leaf.cnf.
O certificado CA usa a seguinte carga de referência:
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Um script Python pode ser usado para gerar outras strings punycode para diferentes cargas.
Quando executado, run-poc executará um cliente e servidor openssl e tentará explorar o problema dez vezes.
Um OpenSSL vulnerável provavelmente falhará. Isso não significa que a versão do OpenSSL seja vulnerável a um RCE, já que canários de pilha e proteções de cookie de pilha também tipicamente causam uma falha (mais segura) da aplicação. Observe também que isso não altera a gravidade da outra CVE na mesma versão.
É surpreendentemente sutil obter controle quase total dos quatro bytes de estouro e requer explorar o decodificador punycode do OpenSSL com punycode não padrão / inválido. O tarball anexado contém um script que pode construir uma string que lida com a sutileza. O que se segue é uma explicação de como funciona.
O problema de segurança está em ossl_punycode_decode()
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode é invocada a partir de ossl_a2ulabel. O buffer pEncoded é um buffer de tamanho mais ou menos arbitrário que vem de uma cadeia de certificados X509. É a parte que vem após qualquer "xn--" em um campo nameConstraint. Veja a [reprodução] para como reproduzir tal cadeia de certificados.
pDecoded é um array de tamanho LABEL_BUF_SIZE de unsigned ints. LABEL_BUF_SIZE é 512, e na maioria das plataformas um unsigned int terá 4 bytes de largura. Então na maioria das plataformas pDecoded tem 2048 bytes de comprimento.
Dentro de ossl_punycode_decode(), o cerne do problema é esta verificação de comprimento incorreta:
if (written_out > max_out)
max_out corresponde a *pout_length que é sempre 512. E written_out mantém controle de quantos unsigned ints foram escritos em pDecoded. Como written_out é incrementado depois apenas após a escrita, esta verificação defeituosa permite que 513 unsigned ints sejam escritos em pDecoded. O resultado final se parece algo assim ...
pDecoded = [ ... , 'X , 'Y' , 'Z' ] 'P'
// Índices 509 510 511
Aqui, por convenção do C, os índices são baseados em zero, então o slot número 511 é o 512º elemento do array. 'P' é uma carga de quatro bytes que foi colocada fora dos limites, além do espaço alocado na pilha para o buffer buf em ossl_a2ulabel(), que é o que pDecoded aponta.
Quatro bytes é um pequeno estouro, e não é suficiente para carregar um nop-sled ou executar diretamente shellcode, mas é suficiente para alterar o fluxo de controle de uma aplicação. Por exemplo, saltar para shellcode que foi embutido em uma cadeia de certificados x509 pode ser possível, dependendo de como esses dados (ou fragmentos copiados desses dados) são armazenados e se essa memória é executável. No entanto, ainda há mais dificuldade para um possível atacante.
Primeiro, o preenchimento e alinhamento da pilha pelo compilador, ou defesas como canários de pilha, podem tornar qualquer exploração completamente impossível.
Segundo, há apenas um caminho para ossl_punycode_decode() e este caminho usa um buffer na pilha. Isso torna improvável que o problema possa ser usado para estouros simultâneos de 4 bytes em diferentes locais de memória.
Strings Punycode basicamente têm duas formas. Uma é xn--c1yn36f (點看) e outra é xn--maccrthaigh-n7a (maccárthaigh). A parte que vem após o último delimitador - é uma codificação bootstring de base 36 de quaisquer code-points unicode que não sejam ascii básicos comuns, juntamente com a posição da string para inseri-los. O importante agora é que o processo de decodificação em ossl_punycode_decode() produz dois valores. Um é 'n' que é o valor unsigned int do code-point a ser inserido, e o outro é 'i' que é a posição no buffer para inseri-lo.
A escrita pode acontecer de duas maneiras diferentes. Se i estiver em algum lugar no meio da string, então há um memmove() que primeiro "cria espaço" copiando tudo à direita em um slot:
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
e então escreve n no espaço que acabou de fazer:
pDecoded[i] = n;
se i estiver no final da string, então o memmove() não tem efeito porque o parâmetro final será 0. A outra linha torna-se um simples anexo.
Agora veremos as três maneiras diferentes de obter uma carga 'P' na posição de estouro e por que as restrições surgem.
A maneira mais simples de acionar o estouro é criar uma string punycode que tenha 511 caracteres ascii e dois caracteres não ascii. A codificação punycode de uma string de 513 caracteres, como "ÁÁAAAAAAAA...AAA", serviria. Neste caso, o que acontecerá é que quando written_out for 510, teremos um buffer disposto como ...
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// Índices 0 1 ... 509 510 511
estes são apenas os caracteres ascii básicos que foram copiados. Então analisamos a bootstring punycode e inserimos um 'Á' na posição 0. Embora pudesse ser qualquer posição entre 0 e 511 inclusive.
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// Índices 0 1 ... 509 510 511
repetimos então isto:
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// Índices 0 1 ... 509 510 511 512
isso fará com que o 'A' ascii comum estoure enquanto é "movido". A carga de quatro bytes neste caso torna-se 0x00 0x00 0x00 0x41. Como veremos, devido a como punycode funciona, esta é a única maneira que qualquer valor com o byte final na faixa ascii pode ser expresso.
Precisamos usar dois caracteres não ascii porque há uma verificação de limite correta no número de caracteres básicos, então isso deve ser menor que 512.
Uma restrição adicional de que o valor do byte final não pode ser 46 surge porque ossl_punycode_decode() é chamado na parte de uma string que precede um caractere literal .. Punycode é destinado a rótulos de domínio, que não podem conter pontos.
A próxima maneira mais simples de acionar o estouro é criar uma string de 513 caracteres com um caractere não ascii no final. Algo como "AAAAAAAAAA...AAÁ". Neste caso, para nossas duas etapas finais teremos:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // Índices 0 1 ... 510 511
e
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// Índices 0 1 ... 510 511
o caractere não ascii irá diretamente para a posição de estouro. O analisador punycode do OpenSSL não impõe que o valor de estouro aqui seja realmente um caractere unicode válido. É mais ou menos um processo de decodificação binária. Mas as nuances da decodificação punycode significam que o método 2 não é tão flexível quanto pode parecer inicialmente.
Em punycode, os valores n e i são ambos codificados como um único inteiro de comprimento variável que é então codificado em ascii usando base36. Pode parecer impossível codificar dois números não relacionados como um único inteiro, mas o truque engenhoso do punycode é usar o comprimento da string (até agora) como um campo oculto.
Por exemplo, suponha que temos uma string punycode com 4 caracteres básicos e um não básico, como AAÁAA. Isso será representado primeiro apenas como os caracteres básicos ... AAAA. O valor unicode de 'Á' é 225 e sua posição na string é 2. O truque é multiplicar o valor pelo comprimento mais um, e então adicionar a posição. Portanto, torna-se ((225 * (4 + 1)) + 2) que é 1127, e é assim que é codificado (em base 36 de comprimento variável).
Para decodificar, você vai ao contrário. 1127 / 5 é 225 e 1127 % 5 é 2. É assim que você recupera dois números de um. Mas observe que quanto mais longa a string fica, mais restrito você fica em quão grande o valor pode ser, ou então o múltiplo não caberá em um unsigned int. Em geral, se a string tiver M caracteres, então você perde log M bits de largura do valor.
Quando você está lidando com o 512º inteiro, perde 9 bits de largura. Usando o método 2, o valor mais alto que uma carga aparentemente de 32 bits pode ter é na verdade 2^23. Nem mesmo três bytes completos. O método 2 é sub-ótimo.
Para recuperar 4 bytes de controle, o meio mais eficiente é repetir o caractere da carga repetidamente. Até agora, deixei de fora dois outros detalhes relevantes de como o punycode é tratado.
O primeiro detalhe é que caracteres não ascii não são codificados na ordem da string, mas sim são codificados em ordem ascendente de valor. A string "ÉÁ" acabará sendo codificada como "Á na posição 1, É na posição 0" porque Á tem um valor menor (225) que É (233).
O segundo detalhe é que caracteres não ascii não são codificados como seus valores literais, mas como um delta relativo ao valor decodificado mais recentemente. Como o primeiro valor não tem valor anterior ao qual ser relativo, há um ponto de partida codificado de 128.
Essas pequenas nuances tornam o punycode muito eficiente em espaço, mas também significam que um caractere não ascii simplesmente não pode ser decodificado para um valor menor que 128. O menor delta é 0, e não há maneira de expressar um delta negativo. Então, se você quer um número menor que 128, tem que usar o método 1.
Isso também significa que a melhor estratégia para o máximo controle possível sobre a carga é fazer da carga o único valor na string completa, pois assim obtemos a largura total para trabalhar a partir de seu lugar na posição 0 na codificação. A string que você codifica acaba se parecendo com:
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
que será decodificada pelo OpenSSL como ...
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
com P na posição de estouro, e capaz de representar qualquer valor entre 128 e (2^32 - 1).
Tudo isso requer um codificador punycode não padrão e incluí um script que pode criar uma carga usando o método 1 ou o método 3 conforme necessário.
Além de atualizar o OpenSSL, existem outras mitigações?
Cadeias de Certificados são passadas em texto claro na maioria dos ambientes e uma cadeia maliciosa poderia ser bloqueada rejeitando conexões TCP que contenham um DER codificado 1.3.6.1.5.5.7.8.9 NID em um campo SubjectAlternateName OtherName.
Infelizmente, este campo poderia ser dividido arbitrariamente entre dois ou mais pacotes e realmente algum tipo de correspondência de padrão com estado é necessário para bloquear. Certificados também podem ser comprimidos, mas o OpenSSL 3.0.x não suporta compressão de certificados atualmente.
Além disso, com TLS1.3, cadeias de certificados de cliente são criptografadas na rede, e versões anteriores do TLS suportam cadeias de certificados criptografadas ao renegociar uma conexão existente. Isso às vezes é feito para autenticação de certificado iniciada pelo servidor. Um filtro de rede não será eficaz nesses casos.
Como posso saber se estou usando openssl 3 em um binário estaticamente ligado?
readelf -a [binary] | grep -i ossl_punycode_decode
irá procurar pela função vulnerável em um binário estaticamente ligado. Apenas OpenSSL >= 3.0 contém esta função.