5 principais riscos de segurança em C++
Snyk Security Research Team
16 de agosto de 2022
0 minutos de leituraC++ oferece muitos recursos poderosos para os desenvolvedores, por isso é usado em diversos setores e em muitos sistemas essenciais. Mas, ao contrário de algumas linguagens de nível mais alto, que oferecem menos controle direto sobre os recursos, C++ apresenta vários riscos de segurança que os desenvolvedores precisam conhecer bem ao escrever código para evitar introduzir vulnerabilidades nos projetos.
Como desenvolvedores, criamos aplicações pensando nos usuários finais. Eles confiam a nós seus dados, seu tempo e o acesso aos dispositivos. É nossa responsabilidade garantir que nossa aplicação — e os dados dos usuários — estejam sempre seguros e protegidos.
Este artigo aborda as cinco principais preocupações de segurança em C++ que afetam o desenvolvimento de código e oferece dicas para reduzi-las.
1. Estouro de buffer
Um dos problemas de segurança mais comuns em C++ é o estouro de buffer. Isso acontece porque a linguagem não tem recursos integrados para verificar limites, que reduziriam o risco de sobrescrever a memória. Escrever fora da memória alocada pode causar falhas no programa e corromper dados. Também pode levar à execução de código malicioso. Por isso, o estouro de buffer foi classificado como a vulnerabilidade de software mais perigosa na pesquisa 2021 CWE Top 25 Most Dangerous Software Weaknesses.
O ataque de estouro de buffer de 2019 à pilha VOIP do aplicativo WhatsApp demonstra o grave impacto dessa vulnerabilidade. Nesse ataque, o invasor explorou uma vulnerabilidade de estouro de buffer no WhatsApp para instalar spyware nos celulares de usuários específicos.
Vamos ver como uma vulnerabilidade de estouro de buffer aparece na prática. No trecho de código a seguir, recebemos dados do usuário usando a função gets e verificamos se eles afetaram a variável important_data.
Embora esse código pareça inofensivo, ele está vulnerável a um ataque de estouro de buffer se o usuário inserir uma string maior que o tamanho do array user_input. A pilha cresce em direção a um endereço menor, ou seja, important_data fica abaixo de user_input na memória.
Há várias maneiras de evitar esse problema, algumas delas dependem de recursos do compilador, do sistema operacional ou do kernel. As principais ferramentas que podemos usar para proteger nossos dados são stack canaries, randomização do layout do espaço de endereços (ASLR) e prevenção de execução de dados (DEP).
Stack canaries adicionam à pilha um novo valor secreto escolhido aleatoriamente sempre que um programa é iniciado. Esse valor é verificado antes de uma função retornar.
ASLR impede que o invasor saiba como a memória está organizada, dificultando um ataque de estouro de buffer. Se não souber onde os dados estão na memória, ele também não saberá qual buffer atacar.
DEP marca determinadas áreas da memória, como a pilha, como não executáveis.
Além de usar recursos do sistema operacional e do compilador, precisamos adotar boas práticas de programação, incluindo a verificação de limites. Devemos evitar funções da biblioteca padrão vulneráveis a ataques de estouro de buffer, como get, strcpy, strcat, scanf e printf, pois elas não verificam os limites. Em vez disso, devemos substituí-las por funções equivalentes e seguras, como fgets.
2. Estouro e underflow de inteiros
Ocorre estouro de inteiro quando o valor que queremos armazenar em uma variável inteira excede o valor máximo que ela pode comportar. O underflow de inteiro acontece quando esse valor é menor que o mínimo que ela pode comportar. Nesse caso, o valor dá a volta.
A pesquisa 2021 CWE Top 25 Most Dangerous Software Weaknesses classificou essa como a 12ª vulnerabilidade de software mais perigosa. Além disso, o estouro e o underflow de inteiros podem levar a uma vulnerabilidade de estouro de buffer.
O código a seguir é um bug real encontrado no OpenSSH v3.3 e mostra como um bug de estouro de inteiro pode causar um ataque de estouro de buffer:
Esse código representa uma vulnerabilidade de estouro de inteiro. Embora o código verifique se o valor é zero, pode ocorrer uma alocação de memória com tamanho zero se a entrada nresp for igual a 1073741824. Multiplicar esse valor por quatro (o tamanho de um ponteiro para char) causa o estouro da variável, e xmalloc(nresp*sizeof(char*)) aloca um buffer de tamanho 0.
Para reduzir essa vulnerabilidade, devemos verificar os intervalos para o valor zero, o mínimo e o máximo, protegendo a variável contra estouro ou retorno circular do valor.
3. Inicialização de ponteiros
A inicialização de ponteiros é fundamental. Um ponteiro que não foi inicializado para apontar para um endereço de memória ou uma função pode expor muitos dados. Se um ponteiro não inicializado apontar para um endereço de memória, o programa poderá ler ou gravar em um local inesperado. Se apontar para uma função, poderá provocar a execução não intencional de uma função arbitrária.
Ponteiros também são vulneráveis a ataques de desreferência de ponteiro nulo, que podem afetar a confiabilidade do programa. Esse tipo de ataque consiste em acessar um ponteiro inicializado como nulo, causando comportamento indefinido (UB) no programa. Na maioria dos casos, isso provoca uma falha. Um invasor pode explorar isso forçando a desreferência de um ponteiro nulo. Uma inicialização incorreta do ponteiro cria um comportamento inesperado e imprevisível. O invasor pode contornar algumas verificações de segurança ou revelar informações de depuração que poderá usar posteriormente.
Veja um exemplo de desreferência de ponteiro causada por uma inicialização incorreta:
Como podemos ver, o ponteiro não foi inicializado, o que significa que recebe um valor aleatório. Como esse valor pode não ser nulo, a verificação será aprovada.
Não devemos depender apenas das verificações ou do tratamento de exceções para evitar ataques de desreferência de ponteiros. Devemos considerar não usar ponteiros quando for possível evitá-los e usar referências. Se precisarmos de ponteiros, devemos substituir os ponteiros brutos por smart pointers.
Outra alternativa aos ponteiros é adotar a técnica Resource Acquisition Is Initialization (RAII) na implementação, que garante que um recurso esteja disponível para qualquer função que tenha acesso a ele.
4. Conversão incorreta de tipos
Outra vulnerabilidade comum é a conversão incorreta de tipos. A maioria dos problemas relacionados a conversões de tipos ocorre ao converter valores com sinal em valores sem sinal, geralmente durante chamadas de função que passam um tipo de parâmetro incorreto. Outro problema comum é converter tipos de dados maiores, como double para float e long int para int, o que causa perda de dados durante a conversão implícita.
Veja um exemplo simples de vulnerabilidade causada por uma conversão incorreta de tipos:
Embora pareça impossível chegar à instrução else, já que o tamanho de qualquer string de entrada será maior que -1, esse código não funciona e sempre executa a instrução else. De acordo com o padrão C++ e o conceito de promoção de tipos integrais, quando dois valores de tipos de dados diferentes são comparados, sua representação é alterada.
No nosso caso, o valor de signed short int será convertido para o tipo maior, unsigned int. Isso converterá o valor -1 em um inteiro sem sinal igual a 4294967295, fazendo o fluxo do programa seguir para a instrução else.
O mesmo problema pode ocorrer se você usar inteiros sem sinal para subtrair dois valores inseridos pelo usuário, supondo que ele nunca informará primeiro um valor menor, o que pode fazer com que o resultado da subtração seja negativo.
De acordo com o Google C++ Style Guide, evitar cálculos com inteiros sem sinal pode reduzir a maioria dos problemas de conversão de tipos (com exceção da representação de bitfields). O guia também recomenda evitar misturar tipos com e sem sinal e usar iteradores e contêineres em vez de ponteiros e tamanhos.
5. Vulnerabilidade de string de formato
Uma vulnerabilidade de string de formato envolve dois componentes: a função de formatação e a string de formato. Antes de explorar esse tipo de vulnerabilidade, vamos revisar a função de formatação e a string de formato.
A função de formatação converte variáveis da linguagem de programação em um formato legível por humanos. printf e fprintf são exemplos de funções de formatação.
A string de formato é o argumento da função de formatação e contém texto e parâmetros de formatação. Veja um exemplo:
"This is a test text of number: %d ", 11"é a string de formato."%d"é o parâmetro da string de formato que define o formato da conversão.
Ataques de string de formato acontecem quando não verificamos os parâmetros passados à função de formatação. Por exemplo, suponha que implementamos uma aplicação como a seguinte:
Esse código é vulnerável a ataques de string de formato porque não verifica a entrada do usuário. O ataque acontece quando se passa um parâmetro de string de formato e uma entrada como esta:
Essa entrada pode obter dados da pilha, pois a saída do programa será algo parecido com isto:
Essa saída ocorre porque printf trata %p como uma referência a um ponteiro void e tenta interpretar os endereços de memória.
Para evitar essa vulnerabilidade, precisamos adicionar um argumento de formato ao código, como mostrado abaixo:
O trecho de código acima é seguro porque não interpreta a string. Por exemplo, se compilássemos e executássemos o código, a saída seria:
A saída contém apenas os caracteres passados ao programa, sem interpretá-los como uma referência ao ponteiro void.
Uma solução ainda melhor é evitar usar printf sempre que possível, a menos que seja necessário, e substituí-lo por std::format e std::vformat (introduzidos no C++ 20). Eles verificam se a entrada corresponde aos tipos, em tempo de execução ou durante a compilação, e geram um format_error quando há incompatibilidade.
Reduza os riscos de segurança em C++
Neste artigo, exploramos os cinco principais riscos de segurança relacionados ao uso de C++. Vimos como um invasor pode explorar essas vulnerabilidades para acessar dados de usuários ou obter informações de nossas aplicações, além de analisar como elas se manifestam no código. Também apresentamos estratégias para evitar que essas vulnerabilidades se transformem em violações de segurança.
Ao contrário das linguagens de nível mais alto, que oferecem menos controle direto sobre os recursos, C++ apresenta várias vulnerabilidades que os desenvolvedores precisam conhecer ao escrever código para evitar introduzi-las nos projetos. As formas de exploração são diversas, e agentes maliciosos estão sempre ampliando suas estratégias de ataque. Para saber mais sobre vulnerabilidades em C++, confira este artigo da equipe de pesquisa de segurança da Snyk e também este artigo sobre vulnerabilidades de directory traversal em C/C++.
Como desenvolvedores, é nossa responsabilidade implementar técnicas de segurança para garantir que nosso software seja confiável e imune a ataques que possam expor informações dos usuários. Com as estratégias certas, é possível evitar todas as vulnerabilidades que exploramos neste artigo.
Comece a jogar Capture the Flag
Aprenda a resolver desafios de Capture the Flag assistindo à gravação sob demanda do nosso workshop virtual introdutório.
