Ataques de estouro de buffer em C++: um guia prático
Snyk Security Research Team
28 de julho de 2022
0 minutos de leituraUm estouro de buffer é um tipo de erro em tempo de execução que permite que um programa escreva além do fim de um buffer ou array — daí o nome estouro — e corrompa a memória adjacente. Como a maioria dos bugs, um estouro de buffer não se manifesta em todas as execuções do programa. Em vez disso, a vulnerabilidade é acionada em determinadas circunstâncias, como quando há uma entrada inesperada do usuário.
Um ataque de estouro de buffer explora uma vulnerabilidade desse tipo — geralmente por meio de um agente mal-intencionado que busca obter acesso ou informações. Neste artigo, explicamos como ocorre um estouro de buffer e mostramos como proteger seu código C++ contra esses ataques.
Exemplo de ataque de estouro de buffer
Para entender como ocorre um estouro de buffer, vamos analisar o código a seguir. Ele faz uma verificação simples de senha e está vulnerável a um ataque de estouro de buffer:
O trecho de código solicita que o usuário digite uma senha (linha 14). Em seguida, compara essa entrada com a senha armazenada (linha 23), carregada anteriormente (linha 12). Se forem iguais, o usuário recebe acesso.
Na prática, leríamos a senha de um arquivo usando std::fscanf, mas, para simplificar este exemplo, vamos usar uma constante de string. Além disso, o ideal seria armazenar uma versão com salt e hash da senha, em vez da original. Mas, para manter a simplicidade, usaremos uma senha em texto simples.
Um mecanismo como esse poderia ser usado para desbloquear a versão shareware — ou de avaliação — de um aplicativo, ou para conceder ao usuário acesso a informações e recursos do aplicativo mediante a digitação da senha de um administrador.
Vamos executar o programa e ver o que acontece:
Como esperado, digitar a senha correta nos dá acesso. Por outro lado, uma senha incorreta impede o acesso:
Até aqui, tudo funciona como deveria. No entanto, digitar a senha correta não é a única maneira de acessar esse aplicativo. Vamos ver o que acontece se digitarmos “sunshinesunshine”:
Isso não era esperado. Digitamos algo bem diferente da senha correta, “rictro”, e ainda assim recebemos acesso.
A explicação é que executamos com sucesso um ataque de estouro de buffer contra o programa em questão. Para entender como isso aconteceu, vamos remover os comentários das instruções de depuração nas linhas 18–21 e executar o aplicativo novamente.
Primeiro, vamos executá-lo com a entrada correta:
A saída não tem nada de relevante.
Vamos tentar uma senha incorreta:
Mais uma vez, o aplicativo se comporta como esperado.
Mas observe o que acontece quando digitamos nossa senha especial, “sunshinesunshine”.
A senha não tem mais o valor “rictro” — agora contém “sunshine”.
Usamos dois arrays de 8 bytes: password armazena a senha do aplicativo e input armazena a entrada do usuário. O compilador usado neste exemplo (GCC 10.3) posiciona password oito bytes depois de input (0x7ffc5581e4b0 - 0x7ffc5581e4a8 = 8), então os arrays ficam lado a lado na memória. Vale lembrar que outro compilador pode gerar resultados diferentes.
Senhas com menos de oito caracteres geram blocos de memória parecidos com este:

Mas, se digitarmos “sunshinesunshine”, a memória ficará assim:

O terminador nulo \0 é gravado além do fim de password, sobrescrevendo o que estiver na pilha naquele momento.
Isso acontece porque std::cin (linha 15) não verifica os limites. Ele lê o que vem do console até encontrar uma nova linha — isto é, até o usuário pressionar Enter — sem garantir que o buffer de destino seja grande o bastante para armazenar a entrada.
Como comparamos apenas os oito primeiros caracteres de password e input usando std::strncmp (linha 23), para evitar ler além do fim de qualquer um dos arrays, obtemos uma correspondência que não deveria existir.
Como evitar estouros de buffer em C++
Em comparação com outras linguagens de programação de alto nível, C++ é especialmente vulnerável a estouros de buffer, pois boa parte do seu ecossistema — incluindo trechos da biblioteca padrão de C++ — ainda usa ponteiros brutos. Podemos usar buffers gerenciados, como std::vector ou std::string, no nosso próprio código. Mas perdemos a verificação de limites assim que interagimos com APIs no estilo C que exigem que passemos vector::data ou string::c_str.
Adote boas práticas de programação em C++
A melhor maneira de evitar estouros de buffer é usar APIs que não sejam vulneráveis. Em C++, isso significa usar buffers e strings gerenciados em vez de arrays e ponteiros brutos.
Prefira | Evite |
|---|---|
|
|
|
|
|
|
|
|
Podemos usar std::string para corrigir nosso aplicativo de exemplo.
Vamos analisar a versão corrigida. Observe as alterações nas linhas 4, 10 e 24:
std::string sobrecarrega o operador de extração (>>), permitindo que ele leia dados dos fluxos com segurança. Para isso, consulta o fluxo para saber o tamanho da entrada, aloca memória suficiente e só então faz a leitura. Assim, não há como estourar o buffer, por maior que seja a entrada. No pior dos casos, podemos ficar sem memória.
Se você precisar usar APIs no estilo C, procure usar as versões “seguras”, quando disponíveis. Elas verificam os limites, aceitam um parâmetro adicional size e não permitem ler ou gravar além dele.
Prefira | Evite |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Sanitize endereços para evitar estouros de buffer
Além de boas práticas de programação, há ferramentas automatizadas que ajudam a detectar estouros de buffer. Uma das mais conhecidas é o AddressSanitizer (ASan). Ele é compatível com todos os principais compiladores, incluindo Visual Studio (v16.9 e posteriores), GCC (v4.8 e posteriores), Clang (v3.1 e posteriores) e Xcode (v7.0 e posteriores). No Visual Studio, a sanitização de endereços pode ser ativada com a opção /fsanitize=address; no GCC/Clang, com -fsanitize=address.
O ASan desempenha duas funções:
Ele adiciona alguns bytes de “memória envenenada” ao redor de todos os objetos na pilha e de todas as alocações no heap, substituindo
mallocpor uma versão modificada.O compilador injeta código no aplicativo para detectar tentativas de acesso à memória envenenada.
Vimos anteriormente como os arrays input e password ficam organizados na memória quando compilados com o GCC 10.3. Se ativarmos a sanitização de endereços, essa organização poderá mudar para algo parecido com isto:

Os símbolos ✗ representam a memória envenenada adicionada ao redor dos nossos arrays.
O código injetado pelo compilador contém uma lógica que detecta tentativas de acessar a memória envenenada. Veja um trecho de código simples antes da transformação:
Depois de processado pelo ASan, o código fica assim:
Para determinar se o aplicativo tenta acessar alguma região de memória envenenada — por meio da implementação da função is_poisoned —, o ASan usa a “memória sombra”. Trata-se de uma região de memória separada que armazena metadados sobre a memória real do aplicativo.
Na prática, o algoritmo leva em conta mais fatores do que caberia neste artigo, mas esta implementação nativa simples demonstra a ideia.
Imagine um bitset simples em que cada byte acessado pelo aplicativo tem um bit correspondente na memória sombra, indicando se aquele byte está envenenado.
Podemos encontrar uma declaração simples como esta:
Para implementar a memória sombra, a declaração poderia ser transformada em algo assim:
O usuário declara um char array de 6 bytes na pilha. Em vez disso, o compilador cria um array de 8 bytes e retorna um ponteiro para o meio, adicionando 1 byte de preenchimento em cada lado. Em seguida, grava o padrão de bits 10000001 em bitset shadow_memory, usando o endereço temp como índice. Isso indica que os 6 bytes contidos em password estão limpos, enquanto os dois bytes ao redor estão envenenados.
Agora, o compilador pode determinar facilmente se estamos tentando acessar uma região de memória envenenada. Veja o trecho a seguir:
Agora que você entende os conceitos básicos da sanitização de endereços, vamos ver o que acontece quando inserimos a entrada mal-intencionada “sunshinesunshine” no nosso aplicativo original vulnerável — desta vez, com a sanitização de endereços ativada:
Observe duas coisas:
O espaço entre os arrays
inputepasswordaumentou de 8 para 32 bytes (0x7ffc5581e4c8-0x7ffc5581e4a8= 32) para dar lugar à memória envenenada.O compilador detecta uma corrupção de memória no endereço
0x7ffc5581e4b0, exatamente 8 bytes depois deinput, que corresponde ao primeiro byte envenenado encontrado.
Proteja seus projetos em C++
Neste artigo, abordamos os conceitos básicos dos ataques de estouro de buffer em C++ e as melhores maneiras de proteger seus projetos. Em resumo, um estouro de buffer é uma vulnerabilidade que permite que um programa escreva além do fim de um buffer, causando corrupção de memória — algo que pode ser explorado para obter acesso a aplicativos ou informações restritas.
Entre as linguagens de alto nível, C++ é especialmente vulnerável a estouros de buffer porque muitas APIs ainda usam ponteiros brutos e não verificam os limites. É possível reduzir esse risco usando buffers e strings gerenciados em vez de APIs no estilo C ou, quando for necessário usar uma API desse tipo, recorrendo às versões seguras, que aceitam um parâmetro size adicional. Também é possível evitar ataques de estouro de buffer com ferramentas de sanitização de endereços que detectam defeitos e acessos além dos limites da memória.
Para saber mais sobre segurança em C++, confira nossa introdução descomplicada às vulnerabilidades em C/C++ e conheça as vulnerabilidades de travessia de diretório em C/C++.
Comece a resolver desafios de capture the flag
Aprenda a resolver desafios de capture the flag assistindo sob demanda ao nosso workshop virtual introdutório.



