Skip to main content

Ataques de desbordamiento de búfer en C++: una guía práctica

Escrito por
Headshot of Snyk Security Research Team

Snyk Security Research Team

hero buffer overflow

28 de julio de 2022

0 minutos de lectura

Un desbordamiento de búfer es un tipo de error en tiempo de ejecución que permite que un programa escriba más allá del final de un búfer o arreglo —de ahí el nombre «desbordamiento»— y corrompa la memoria adyacente. Como ocurre con la mayoría de los errores, un desbordamiento de búfer no se manifiesta cada vez que se ejecuta el programa. En cambio, la vulnerabilidad se activa en ciertas circunstancias, como cuando se recibe una entrada inesperada del usuario.

Un ataque de desbordamiento de búfer consiste en explotar una vulnerabilidad de desbordamiento de búfer —por lo general, por parte de un actor malicioso que busca obtener acceso o información. En esta publicación, explicaremos cómo se produce un desbordamiento de búfer y te mostraremos cómo proteger tu código C++ contra estos ataques.

Ejemplo de un ataque de desbordamiento de búfer

Para entender cómo se produce un desbordamiento de búfer, veamos el siguiente código, que realiza una simple comprobación de contraseña y es vulnerable a un ataque de desbordamiento de búfer:

#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;
}

El fragmento de código le pide al usuario que ingrese una contraseña (línea 14). Luego compara esta entrada con la contraseña almacenada (línea 23), que se había cargado previamente (línea 12). Si coinciden, se le concede acceso al usuario.

En la práctica, leeríamos la contraseña de un archivo mediante std::fscanf, pero para mantener este ejemplo sencillo, la leeremos de una constante de cadena. Además, lo ideal sería almacenar una versión cifrada con sal y con hash de la contraseña en vez de la original, pero, por simplicidad, usaremos una contraseña en texto plano.

Un mecanismo como este podría usarse para desbloquear la versión shareware —o de prueba— de una aplicación, o para conceder al usuario acceso a información y funciones dentro de la aplicación al ingresar la contraseña de un administrador.

Ejecutemos el programa y veamos qué sucede:

Enter password: rictro
Access granted

"Como esperábamos, al ingresar la contraseña correcta obtenemos acceso. En cambio, si ingresamos una contraseña incorrecta, se nos niega el acceso:

Enter password: hello
Access denied

Hasta ahora, todo funciona correctamente. Sin embargo, ingresar la contraseña correcta no es la única forma de obtener acceso a esta aplicación. Veamos qué sucede si ingresamos «sunshinesunshine» en su lugar:

Enter password: sunshinesunshine
Access granted

Eso no era lo esperado. Ingresamos algo muy distinto de la contraseña correcta, «rictro», y aun así obtuvimos acceso.

La explicación es que ejecutamos correctamente un ataque de desbordamiento de búfer contra el programa en cuestión. Para entender cómo sucedió, descomentemos las impresiones de depuración de las líneas 18–21 y volvamos a ejecutar la aplicación.

Primero, ejecutémosla con la entrada correcta:

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

El resultado no tiene nada de particular.

Probemos con una contraseña incorrecta:

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

Una vez más, la aplicación se comporta como se esperaba.

Sin embargo, observa qué sucede cuando ingresamos nuestra contraseña especial, «sunshinesunshine».

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

La contraseña ya no tiene el valor «rictro»; ahora contiene «sunshine».

Usamos dos arreglos de 8 bytes: password almacena la contraseña de la aplicación y input almacena la entrada del usuario. El compilador que usamos en este ejemplo (GCC 10.3) coloca password ocho bytes después de input (0x7ffc5581e4b0 - 0x7ffc5581e4a8 = 8), por lo que los arreglos están juntos en la memoria. Ten en cuenta que otro compilador podría producir resultados distintos.

Las contraseñas de menos de ocho caracteres generan bloques de memoria similares a este:

Tabla que muestra los campos de entrada y contraseña, con “hello\0” en la fila de entrada y “rictro\0” en la fila de contraseña.

Sin embargo, si ingresamos «sunshinesunshine», la memoria se ve así:

Tabla que ilustra un desbordamiento de búfer: el valor de entrada «sunshine» se extiende al campo de contraseña adyacente.

El terminador nulo \0 se escribe más allá del final de password, sobrescribiendo lo que haya en ese momento en la pila.

Esto sucede porque std::cin (línea 15) no realiza ninguna comprobación de límites. Lee desde la consola hasta encontrar una nueva línea —es decir, hasta que el usuario presiona Enter— sin verificar que el búfer receptor tenga suficiente espacio para la entrada del usuario.

Como solo comparamos los primeros ocho caracteres de password y input con std::strncmp (línea 23) para evitar leer más allá del final de cualquiera de los arreglos, obtenemos una coincidencia que no debería existir.

Cómo evitar desbordamientos de búfer en C++

En comparación con otros lenguajes de programación de alto nivel, C++ es especialmente vulnerable a los desbordamientos de búfer porque gran parte de su ecosistema, incluida una parte de la biblioteca estándar de C++, todavía usa punteros sin procesar. Podemos usar búferes administrados como std::vector o std::string en nuestro propio código, pero perdemos la capacidad de comprobar los límites en cuanto interactuamos con API de estilo C que nos obligan a pasar vector::data o string::c_str.

Aplica las prácticas recomendadas de programación en C++

La mejor manera de evitar desbordamientos de búfer es usar API que no sean vulnerables. En C++, esto significa usar búferes y cadenas administrados en lugar de arreglos y punteros sin procesar.

Prefiere

Evita

std::string

char*

std::vector

int array[15]

std::cin

scanf

<iostream>

<cstdio>

Podemos usar std::string para corregir nuestra aplicación de ejemplo.

Veamos la versión corregida. Observa los cambios en las líneas 4, 10 y 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 sobrecarga el operador de extracción (>>), lo que le permite leer de forma segura desde los flujos. Para ello, consulta el tamaño de la entrada en el flujo, asigna suficiente memoria y solo entonces lee del flujo. Así, es imposible que desbordemos el búfer, sin importar cuánto mida la entrada. En el peor de los casos, podríamos quedarnos sin memoria.

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

Si tienes que usar API de estilo C, intenta usar sus alternativas «seguras» cuando estén disponibles. Se trata de versiones que comprueban los límites, aceptan un parámetro adicional size y se niegan a leer o escribir más allá de ese límite.

Prefiere

Evita

scanf_s

scanf

fscanf_s

fscanf

sscanf_s

sscanf

strncmp

strcmp

strncpy

strcpy

strncat

strcat

Sanitiza las direcciones para evitar desbordamientos de búfer

Además de las buenas prácticas de programación, existen herramientas automatizadas que pueden ayudar a detectar desbordamientos de búfer. AddressSanitizer (ASan) es una de las más populares. Es compatible con los principales compiladores, incluidos Visual Studio (v16.9 y versiones posteriores), GCC (v4.8 y versiones posteriores), Clang (v3.1 y versiones posteriores) y Xcode (v7.0 y versiones posteriores). La sanitización de direcciones se puede activar con la opción /fsanitize=address en Visual Studio y la opción -fsanitize=address en GCC/Clang.

ASan cumple dos funciones:

  • Agrega unos cuantos bytes de «memoria envenenada» alrededor de todos los objetos de la pila y las asignaciones del heap, reemplazando malloc por una versión modificada.

  • El compilador inserta código en tu aplicación para detectar si intenta acceder a alguna zona de memoria envenenada.

Ya vimos cómo se organizan en memoria los arreglos input y password al compilar con GCC 10.3. Si activamos la sanitización de direcciones, la distribución podría cambiar y parecerse a la siguiente:

Tabla que muestra campos de entrada y contraseña con celdas de caracteres, incluidos “xhello\0” y “x x r i c t r o\0 x”.

Los símbolos ✗ representan la memoria envenenada que se colocó alrededor de nuestros arreglos.

El código que inserta el compilador contiene lógica para detectar si intentamos acceder a memoria envenenada. Considera un fragmento de código sencillo antes de la transformación:

array[i] = 5;

Después de que ASan lo procesa, el código queda así:

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

Para determinar si la aplicación intenta acceder a memoria envenenada —mediante la implementación de la función is_poisoned—, ASan usa «memoria sombra». Se trata de una región de memoria independiente que almacena metadatos sobre la memoria real de la aplicación.

En la práctica, el algoritmo tiene en cuenta más factores de los que podemos abordar en este artículo, pero esta sencilla implementación nativa ilustra la idea.

Imagina un conjunto de bits sencillo en el que cada byte al que accede la aplicación tiene un bit correspondiente en la memoria sombra que indica si ese byte está envenenado.

Podríamos ver una declaración sencilla como esta:

char password[6];

Para implementar la memoria sombra, la declaración podría transformarse en algo así:

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

El usuario declara un char array de 6 bytes en la pila. En cambio, el compilador crea un arreglo de 8 bytes y devuelve un puntero a la posición central, con 1 byte de relleno a cada lado. Luego escribe el patrón de bits 10000001 en bitset shadow_memory, usando la dirección temp como índice, para indicar que los 6 bytes de password están limpios, mientras que los dos bytes circundantes están envenenados.

Ahora el compilador puede determinar fácilmente si intentamos acceder a memoria envenenada. Considera el siguiente fragmento:

*pointer = 5;

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

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

"Ahora que tienes una comprensión básica de la sanitización de direcciones, veamos qué sucede si ingresamos el dato malicioso «sunshinesunshine» en nuestra aplicación original y vulnerable, esta vez con la sanitización de direcciones activada:

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

Observa dos cosas:

  • La distancia entre los arreglos input y password aumentó de 8 a 32 bytes (0x7ffc5581e4c8 - 0x7ffc5581e4a8 = 32) para dejar espacio para la memoria envenenada.

  • El compilador detecta una corrupción de memoria en la dirección 0x7ffc5581e4b0, exactamente 8 bytes después de input, donde se encuentra el primer byte envenenado que encuentra.

Protege tus proyectos de C++

En esta publicación, repasamos los conceptos básicos de los ataques de desbordamiento de búfer en C++ y cómo proteger mejor tus proyectos. En resumen, un desbordamiento de búfer es un tipo de vulnerabilidad que permite que un programa escriba más allá del final de un búfer y provoque una corrupción de memoria, que luego se puede explotar para obtener acceso a aplicaciones o información restringidas.

Entre los lenguajes de alto nivel, C++ es especialmente vulnerable a los desbordamientos de búfer porque muchas API todavía usan punteros sin procesar y no comprueban los límites. Esto se puede mitigar usando búferes y cadenas administrados en lugar de API de estilo C o, si es necesario usar una API de estilo C, recurriendo a sus versiones seguras, que aceptan un parámetro size adicional. También es posible evitar los ataques de desbordamiento de búfer con herramientas que activan la sanitización de direcciones para detectar defectos o desbordamientos de memoria.

Para obtener más información sobre la seguridad en C++, consulta nuestra introducción sencilla a las vulnerabilidades de C/C++ y descubre las vulnerabilidades de recorrido de directorios en C/C++.

Empieza con los desafíos de Capture the Flag

Aprende a resolver desafíos de captura la bandera con nuestro taller virtual introductorio a pedido.