Uma introdução descomplicada às vulnerabilidades em C/C++
Aviad Hahami
15 de abril de 2022
0 minutos de leituraLumos!
Após o anúncio da Snyk sobre o suporte a dependências não gerenciadas (em sua maioria, bibliotecas de C/C++), pensamos que seria útil apresentar à nossa comunidade que não usa C alguns perigos comuns e de alto risco que ameaçam os usuários de C. Pense nesta apresentação como um guia para iniciantes em vulnerabilidades de C e C++. Vamos mostrar como identificá-las, os problemas que podem causar e como corrigi-las.
Vulnerabilidades magníficas e onde encontrá-las
C e C++ (no restante do post, usaremos “C” para nos referirmos às duas) são consideradas linguagens de programação de baixo nível. Ao comparar linguagens de baixo nível com linguagens de alto nível (como JS, Python etc.), vemos que a maior diferença está no processo de gerenciamento de memória da máquina. Enquanto linguagens de alto nível gerenciam alocação, consumo e a liberação de memória, C transfere ao desenvolvedor a responsabilidade de gerir a memória. Isso permite aos desenvolvedores ter precisão no desempenho de suas rotinas e nas otimizações de implementação, mas também introduz diversos problemas que são exclusivos desses tipos de linguagem.
Estouro de buffer (CWE-121) e gravação fora dos limites (CWE-787)
Os estouros de buffer provavelmente são a vulnerabilidade mais notória relacionada à memória. Ainda que seja complicado explorar esses estouros, a vulnerabilidade é simples: você excede o buffer alocado. No que diz respeito à definição, “estouro de buffer” geralmente se refere à exploração propriamente dita da vulnerabilidade. Por outro lado, “estouro de buffer baseado em pilha” e “gravação fora dos limites” são a mesma fraqueza e, por isso, serão abordadas em conjunto.
O ato de explorar pode ser difícil porque nem sempre é possível “escrever o código” na pilha (ou heap), mesmo ao transbordá-la. Ao longo do tempo, foram feitas tentativas de dificultar e mitigar esse problema, e alguns mecanismos de defesa foram desenvolvidos. Esses mecanismos dependem de diversos fatores e variam conforme o SO, os recursos de kernel, o compilador e outros para defender um código. Temos mecanismos como o ASLR (aleatorização do layout do espaço de endereço), stack canaries, DEP (prevenção de execução de dados) e muitos outros. Todos eles são usados para impedir bugs que corrompem a memória, como o estouro de buffer. Durante o runtime, a falha em qualquer um desses mecanismos faz com que o SO pare de executar e gere um SEGFAULT, dificultando o processo de exploração.
Quero dar um exemplo dessa vulnerabilidade e de sua exploração. Considere o seguinte programa em C:
1#include <stdlib.h>
2#include <unistd.h>
3#include <stdio.h>
4
5int main(int argc, char **argv)
6 volatile int modified;
7 char buffer[64];
8
9 modified = 0;
10 gets(buffer);
11
12 if(modified != 0) {
13 printf("you have changed the 'modified' variable\n");
14 } else {
15 printf("Try again?\n");
16 }
17}
O exemplo é cortesia de 0xRick,como vistono blog dele.
Analisando o código (mesmo sem conhecer C), vemos que o buffer
está alocado como um buffer de 64 caracteres e que modified
é um número inteiro. Legal.
Também vemos na linha 9 que modified
está definido com um valor 0 e, na linha 12, verificamos se o valor é 0 ou não. Se você ainda não adivinhou, nosso objetivo é deixá-lo diferente de zero. Na linha 10, usamos a função gets
para ler desde stdin(=user input) até a variável buffer
. Para quem não conhece gets
\: ela lê desde stdin até um determinado buffer, até que um caractere de nova linha (\\n
) seja lido.
Como você já sabe (porque assistiu ao vídeo do YouTube acima) que a pilha cresce em um endereço mais baixo e que isso se deve à estrutura de dados da pilha, modified
fica “abaixo” do buffer
na memória. Em outras palavras, se escrevermos mais de 64 caracteres no buffer
, começaremos a sobrescrever o valor de modified
. E essa é o incrível estouro do buffer!
Esse exemplo específico não é muito danoso (porque é uma demonstração). Mas imagine o que aconteceria se você sobrescrevesse o valor de uma variável de senha ou para o URL que o mecanismo busca. Mitigar isso é simples: a recomendação é usar a função fgets
, que também verifica o comprimento da entrada, e não apenas a existência do “caractere de fim de sequência”.
Use-after-free (CWE-416)
As vulnerabilidades do tipo use-after-free (uso após liberação) ocorrem, como já diz o nome, quando se usa uma referência de variável após ela ter sido liberada. Essa vulnerabilidade é resultado de uma falha no gerenciamento da memória referente ao fluxo do software, quando a variável é usada após ter sido criada, realizando assim uma ação inesperada ou tendo um efeito residual imprevisto no aplicativo.
Para ver essa vulnerabilidade e sua exploração na prática, recomendo o vídeo de LiveOverflow sobre como ele a explora em um desafio.
Estouro/estouro negativo de número inteiro (CWE-190 e CWE-191)
Estouros e estouros negativos de números inteiros são dois tipos similares de bugs (e, posteriormente, vulnerabilidades) que ocorrem devido às representações de números em computadores.
Não vou detalhar esses tipos de variáveis ou como exatamente os números são representados em computadores, mas faço questão de citar que existem dois métodos principais de representação de números: com sinal e sem sinal. Enquanto as variáveis com sinal podem representar números negativos e positivos, as sem sinal armazenam apenas números positivos.
Um estouro de número inteiro significa que o valor solicitado para a máquina armazenar é maior que o valor máximo armazenável. Um estouro negativo de número inteiro significa que a máquina foi solicitada a armazenar um valor menor que o valor mínimo (por exemplo, pedir para um número inteiro sem sinal armazenar um número negativo).
Nos dois casos, o resultado é similar. O valor “reinicia” (ou seja, recomeça no início ou no fim do intervalo armazenável) e, assim, muda seu valor. Em caso de estouro, o valor reiniciado começa do 0 e, para estouro negativo, começa no valor máximo armazenável (ou seja, o número inteiro de 8 bits sem sinal reinicia no 256 \[decimal]).
Este diagrama ilustra os tipos de variáveis e os valores que podem armazenar:
Um exemplo de exploit de um estouro de número inteiro que levou a um estouro de buffer foi capturado no OpenSSH v3.3 (CVE-2002-0639).
Considere o seguinte trecho de código:
1nresp = packet_get_int();
2if (nresp > 0) {
3 response = xmalloc(nresp*sizeof(char*));
4 for (i = 0; i < nresp; i++)
5 response[i] = packet_get_string(NULL);
6}
Vamos supor que nresp é 1073741824
e sizeof(char\*)
é 4 (que é o tamanho típico de um apontador), o nresp*sizeof(char*)
gera um estouro (porque reinicia e resulta no valor 0). Assim, xmalloc()
recebe e aloca um buffer de 0 byte. O loop a seguir causa um estouro de buffer no heap porque escrevemos em uma locação de memória não alocada, que pode, por sua vez, ser usada por um invasor para executar um código arbitrário.
Desreferência de apontador nulo (CWE-467)
Desreferência é quando realizamos uma ação sobre um valor em um endereço. Para explicar melhor essa vulnerabilidade, vamos analisar um exemplo:
1#include <stddef.h>
2
3void main(){
4 int *x = NULL;
5 *x = 1;
6}
De acordo com o padrão C, a execução do código acima pode resultar em um comportamento indefinido. No entanto, a maioria das implementações entram em pânico com um SEGFAULT, que é quando o software tenta acessar uma área restrita de memória (cometendo, assim, uma violação de acesso de memória). Com isso, o software é encerrado pelo sistema operacional.
Leitura fora dos limites (CWE-125)
A leitura fora dos limites ocorre quando “é feita uma leitura fora do local/buffer designado”. O resultado dessa vulnerabilidade pode ser uma falha no sistema (no melhor cenário) ou divulgação de informações internas do aplicativo (como senhas de outros usuários), o que não é nada bom.
Para analisarmos um exemplo dessa vulnerabilidade, veja este trecho de código do aplicativo PureFTPd. Considere a linha 17. Se o comprimento de s1
for maior que o de s2
, como a linha 8 itera sobre o comprimento de s1
-, as informações que acessaremos na linha 10 excederão os limites de s2
. Isso resultará em uma leitura fora dos limites.
1int pure_memcmp(const void *const b1_, const void *const b2_, size_t len)
2 {
3 const unsigned char *b1 = (const unsigned char *) b1_;
4 const unsigned char *b2 = (const unsigned char *) b2_;
5 size_t i;
6 unsigned char d = (unsigned char) 0 U;
7 for (i = 0 U; i < len; i++)
8 {
9 d |= b1[i] ^ b2[i];
10 }
11 return (int)((1 &((d - 1) >> 8)) - 1);
12}
13
14int pure_strcmp(const char *const s1, const char *const s2)
15{
16 return pure_memcmp(s1, s2, strlen(s1) + 1 U);
17}
Esse bug recebeu a identificação CVE-2020-9365. Leia o relatório.
Conclusão e próximos passos
Agora esperamos que você tenha um entendimento básico de como são as vulnerabilidades de C/CC++, onde elas residem e que forma têm. À primeira vista, alguns desses exploits podem parecer incomuns. No entanto, com uma melhor compreensão delas, é possível saber mais sobre o funcionamento interno de um software e ajudar a evitar bugs críticos.
Conforme mostrado na explicação acima sobre o estouro de número inteiro, essas vulnerabilidades podem estar conectadas entre si, gerando um ponto fraco que permite explorações maliciosas.
Agora que lançamos C/C++ para o Snyk Open Source, compartilharemos mais conteúdos sobre tópicos que demonstram como encontrar, explorar e corrigir vulnerabilidades em C e C++.
Proteja suas dependências de código aberto
As ferramentas da Snyk com foco no desenvolvedor oferecem solicitações de pull de correção em um clique para dependências vulneráveis de código aberto e suas dependências transitivas.