Skip to main content

Ataques de estouro de buffer em C++: um guia prático

Escrito por
Headshot of Snyk Security Research Team

Snyk Security Research Team

hero buffer overflow

28 de julho de 2022

0 minutos de leitura

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

#include <cstdio>
#include <cstring>
#include <iostream>

const char *PASSWORD_FILE = "rictro";

int main()
{
  char input[8];
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  // std::cout << "Address of input: " << &input << "\n";
  // std::cout << "Address of password: " << &password << "\n";
  // std::cout << "Input: " << input << "\n";
  // std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input, 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

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:

Enter password: rictro
Access granted

Como esperado, digitar a senha correta nos dá acesso. Por outro lado, uma senha incorreta impede o acesso:

Enter password: hello
Access denied

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

Enter password: sunshinesunshine
Access granted

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:

Enter password: rictro
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: rictro
Password: rictro
Access granted

A saída não tem nada de relevante.

Vamos tentar uma senha incorreta:

Enter password: hello
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: hello
Password: rictro
Access denied

Mais uma vez, o aplicativo se comporta como esperado.

Mas observe o que acontece quando digitamos nossa senha especial, “sunshinesunshine”.

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: sunshinesunshine
Password: sunshine
Access granted

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:

Tabela com campos de entrada e senha. A linha de entrada mostra “hello\0” e a linha de senha mostra “rictro\0”.

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

Tabela que ilustra um estouro de buffer: o valor de entrada “sunshine” se estende até o campo de senha adjacente.

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

std::string

char*

std::vector

int array[15]

std::cin

scanf

<iostream>

<cstdio>

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:

#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>

const char *PASSWORD_FILE = "rictro";

int main()
{
  std::string input;
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  std::cout << "Address of input: " << &input << "\n";
  std::cout << "Address of password: " << &password << "\n";
  std::cout << "Input: " << input << "\n";
  std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input.c_str(), 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

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.

Enter password: hellooo…ooo
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
Input: hellooo…ooo
Password: rictro
Access denied

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

scanf_s

scanf

fscanf_s

fscanf

sscanf_s

sscanf

strncmp

strcmp

strncpy

strcpy

strncat

strcat

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 malloc por 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:

Tabela que mostra campos de entrada e senha com células de caracteres, incluindo “xhello\0” e “x x r i c t r o\0 x”.

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:

array[i] = 5;

Depois de processado pelo ASan, o código fica assim:

if (is_poisoned(&array[i]))
{
  print_error();
  std::abort();
}
array[i] = 5;

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:

char password[6];

Para implementar a memória sombra, a declaração poderia ser transformada em algo assim:

char temp[8];
char* password = &temp[1];
shadow_memory[&temp...&temp+7] = 0b10000001;

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:

*pointer = 5;

After the compiler injects code, the snippet now looks like this:

if (shadow_memory[pointer] == 1)
{
  print_error();
  std::abort();
}
*pointer = 5;

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:

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
=============================
==15857==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc5581e4b0

Observe duas coisas:

  • O espaço entre os arrays input e password aumentou 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 de input, 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.

Leia mais

blog feature snyk container custom base recommendations
Blog

Conheça o Evo Agentic AppSec: segurança que acompanha o ritmo do desenvolvimento com IA

O desenvolvimento impulsionado por IA está avançando mais rápido do que os fluxos de trabalho de segurança tradicionais. O AppSec agentivo da Snyk usa agentes de segurança para encontrar, corrigir e verificar vulnerabilidades continuamente, enquanto as pessoas definem os critérios.

blog feature ai violet
Blog

VulnBump: agentes de IA conseguem corrigir dependências vulneráveis sem quebrar o build?

O VulnBump testa agentes de IA em 307 atualizações reais de dependências. Uma correção só é considerada válida se eliminar a vulnerabilidade e mantiver o build e os testes funcionando.

Blog

Modelos de ponta encontraram as vulnerabilidades. Só o atacante encontrou as cadeias.

A análise estática encontrou as falhas, mas só os testes de ataque em aplicações ativas provaram como elas poderiam ser encadeadas para causar invasões. Uma comparação entre Evo COS, Claude Security e Claude Code Security.