Skip to main content

Los 5 principales riesgos de seguridad de C++

Escrito por
Headshot of Snyk Security Research Team

Snyk Security Research Team

feature security

16 de agosto de 2022

0 minutos de lectura

C++ ofrece muchas capacidades potentes a los desarrolladores, por eso se usa en muchas industrias y en muchos sistemas centrales. Sin embargo, a diferencia de algunos lenguajes de nivel superior que ofrecen menos control directo sobre los recursos, C++ presenta diversos problemas de seguridad que los desarrolladores deben tener muy en cuenta al escribir código para evitar introducir vulnerabilidades en sus proyectos.

Como desarrolladores, creamos aplicaciones pensando en los usuarios finales. Confían en nosotros sus datos, su tiempo y el acceso a sus dispositivos. Es nuestra responsabilidad garantizar que nuestra aplicación y los datos de nuestros usuarios estén siempre seguros y protegidos.

Este artículo analiza los cinco principales problemas de seguridad de C++ que afectan el desarrollo de código y ofrece consejos para mitigarlos.

1. Desbordamiento de búfer

Uno de los problemas de seguridad más comunes en C++ es el desbordamiento de búfer. Los desbordamientos de búfer se producen porque C++ no cuenta con funciones integradas para verificar los límites, que reduzcan el riesgo de sobrescribir la memoria. Escribir fuera de la memoria asignada puede provocar fallas en el programa y dañar los datos. Incluso puede permitir la ejecución de código malicioso. Por esta razón, el desbordamiento de búfer ocupó el primer lugar como la vulnerabilidad de software más peligrosa en la encuesta 2021 CWE Top 25 Most Dangerous Software Weaknesses.

El ataque de desbordamiento de búfer de 2019 contra la pila VOIP de la aplicación WhatsApp destaca el grave impacto de esta vulnerabilidad. En este ataque, el atacante aprovechó la vulnerabilidad de desbordamiento de búfer de WhatsApp para instalar spyware en los teléfonos de los usuarios objetivo.

Veamos cómo se ve en la práctica una vulnerabilidad de desbordamiento de búfer. En el siguiente fragmento de código, obtenemos la entrada del usuario mediante la función gets y verificamos si afectó la variable important_data.

#include <stdio.h>

int main(int argc, char **argv)
{
  volatile int important_data = 0;
  char user_input[10];

  gets(user_input);

  if(important_data != 0) {
    printf("Warning !!!, the 'important_data' was changed\n");
  } else {
    printf("the 'important_data' was not changed\n");
  }
}

Aunque este código puede parecer inofensivo, es vulnerable a un ataque de desbordamiento de búfer si el usuario ingresa una cadena más larga que el tamaño del arreglo user_input. La pila crece hacia una dirección menor, lo que significa que important_data está debajo de user_input en la memoria.

Hay varias maneras de evitar este problema; algunas dependen de nuestro compilador o de las funciones del sistema operativo y del kernel. Las principales herramientas que podemos usar para proteger nuestros datos son los canarios de pila, la aleatorización del diseño del espacio de direcciones (ASLR) y la prevención de ejecución de datos (DEP).

  • Los canarios de pila agregan un nuevo valor secreto seleccionado al azar a la pila cada vez que se inicia un programa. Este valor se verifica antes de que una función retorne.

  • ASLR impide que un atacante conozca el diseño de la memoria, lo que dificulta llevar a cabo un ataque de desbordamiento de búfer. Si el atacante no sabe dónde están los datos en la memoria, no sabrá qué búfer atacar.

  • DEP marca ciertas áreas de la memoria, como la pila, como no ejecutables.

Además de usar las funciones del sistema operativo y del compilador, debemos implementar buenas prácticas de programación, incluida la verificación de límites. Debemos evitar las funciones de la biblioteca estándar vulnerables a ataques de desbordamiento de búfer, como get, strcpy, strcat, scanf y printf, ya que no verifican los límites. En su lugar, debemos reemplazarlas por funciones seguras equivalentes, como fgets.

2. Desbordamiento y subdesbordamiento de enteros

Hay desbordamiento de enteros cuando el valor que queremos almacenar en una variable entera supera el valor máximo que puede contener. El subdesbordamiento de enteros ocurre cuando el valor es menor que el mínimo que puede contener. En ese caso, el valor da la vuelta.

La encuesta 2021 CWE Top 25 Most Dangerous Software Weaknesses clasificó esta vulnerabilidad como la 12.ª más peligrosa en el software. Además, el desbordamiento y el subdesbordamiento de enteros pueden provocar una vulnerabilidad de desbordamiento de búfer.

El siguiente código es un error real que se encontró en OpenSSH v3.3 y muestra cómo un error de desbordamiento de enteros puede provocar un ataque de desbordamiento de búfer:

nresp = packet_get_int();
if (nresp > 0) {
response = xmalloc(nresp*sizeof(char*));
for (i = 0; i < nresp; i++) 
    response[i] = packet_get_string(NULL);
}

Este código representa una vulnerabilidad de desbordamiento de enteros. Aunque el código verifica si el valor es cero, es posible que la asignación de memoria sea de cero si la entrada nresp equivale a 1073741824. Al multiplicar este valor por cuatro (el tamaño de un puntero a char), la variable se desborda y xmalloc(nresp*sizeof(char*)) asigna un búfer de tamaño 0.

Para mitigar esta vulnerabilidad, debemos comprobar los rangos de los valores cero o mínimo y máximo, a fin de protegernos contra el desbordamiento o la vuelta del valor de la variable.

3. Inicialización de punteros

Es fundamental inicializar los punteros. Podemos exponer muchos datos si usamos un puntero sin inicializarlo para apuntar a una ubicación de memoria o una función. Si el puntero sin inicializar apunta a una ubicación de memoria, puede hacer que el programa lea o escriba en una ubicación inesperada. Si apunta a una función, puede provocar la ejecución involuntaria de una función arbitraria.

Los punteros también son vulnerables a ataques de desreferencia de punteros nulos, que pueden causar problemas de confiabilidad del programa. Los ataques de desreferencia de punteros consisten en acceder a un puntero inicializado como nulo, lo que provoca un comportamiento indefinido (UB) en el programa. En la mayoría de los casos, el programa se bloqueará. Un atacante puede aprovecharse de esto al forzar la desreferencia de un puntero nulo. Si el puntero se inicializa de forma incorrecta, se generará un comportamiento inesperado e impredecible. Un atacante puede eludir algunas verificaciones de seguridad o revelar información de depuración que luego podría usar.

Veamos un ejemplo de desreferencia de puntero causada por una inicialización incorrecta:

void main(){
int *ptr;
if (nullptr != ptr)
 {
*ptr = 5;
 }
}

Como podemos ver, el puntero no se inicializa, lo que significa que se le asigna un valor aleatorio y la verificación se aprobará porque ese valor podría no ser nulo.

No debemos depender únicamente de las verificaciones o del manejo de excepciones de error para evitar los ataques de desreferencia de punteros. Si podemos evitarlo, conviene no usar punteros y usar referencias en su lugar. Si usamos punteros, debemos reemplazar los punteros sin procesar por punteros inteligentes.

Otra alternativa a los punteros es adaptar la técnica adquisición de recursos es inicialización (RAII) en nuestra implementación, que garantiza que cualquier función con acceso disponga del recurso.

4. Conversión de tipos incorrecta

Otra vulnerabilidad común es la conversión de tipos incorrecta. La mayoría de los problemas relacionados con la conversión de tipos se deben a la conversión de tipos con signo a tipos sin signo, que suele ocurrir durante las llamadas a funciones al pasar un tipo de parámetro incorrecto. Otra vulnerabilidad común es la conversión entre tipos de datos más largos, como de double a float y de long int a int, que provoca la pérdida de datos durante la conversión implícita.

Este es un ejemplo sencillo de cómo se ve una vulnerabilidad de conversión de tipos incorrecta:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string str;
    cout << "Please enter your string: \n";
    getline(cin, str);
    unsigned int len = str.length();
    if (len > -1)
    {
    cout << "string length is " << len << "which is bigger than -1 " <<std::endl;
    }else 
    {
    cout << "string length is " << len << " which is less than -1 " <<std::endl;
    }
    return 0;
}

Aunque parezca imposible llegar a la instrucción else, ya que la longitud de cualquier cadena de entrada será mayor que -1, este código no funciona y siempre llega a la instrucción else. Según el estándar de C++ y el concepto de promoción integral, si se comparan dos valores de tipos de datos diferentes, cambia la representación de los valores.

En nuestro caso, el valor de signed short int se convertirá al tipo más amplio, unsigned int. Esto convierte el valor -1 en un entero sin signo equivalente a 4294967295, lo que hace que el flujo del programa llegue a la instrucción else.

El mismo problema puede ocurrir si usas enteros sin signo para restar dos valores ingresados por el usuario, suponiendo que nunca ingresará primero un valor menor, lo que puede hacer que el resultado de la resta sea negativo.

Según la guía de estilo de C++ de Google, evitar las operaciones matemáticas con enteros sin signo puede mitigar la mayoría de los problemas de conversión de tipos (salvo cuando se representan campos de bits). También recomiendan evitar mezclar valores con y sin signo, y usar iteradores y contenedores en lugar de punteros y tamaños.

5. Vulnerabilidad de cadena de formato

Una vulnerabilidad de cadena de formato tiene dos componentes: la función de formato y la cadena de formato. Antes de analizar estas vulnerabilidades, repasemos qué hacen la función y la cadena de formato.

La función de formato convierte variables del lenguaje de programación a un formato legible para las personas. Algunos ejemplos de funciones de formato son printf y fprintf.

La cadena de formato es el argumento de la función de formato y contiene texto y parámetros de cadena de formato. Veamos un ejemplo:

printf ("This is a test text of number: %d ", 11);
  • "This is a test text of number: %d ", 11" es la cadena de formato.

  • "%d" es el parámetro de la cadena de formato que define el formato de conversión.

Los ataques de cadena de formato ocurren cuando no verificamos los parámetros que se pasan a la función de formato. Por ejemplo, supongamos que implementamos una aplicación como la siguiente:

#include  <stdio.h> 

int main(int argc, char **argv)
{
printf(argv[1]);
return 0;
}

Este código es vulnerable a un ataque de cadena de formato porque no verifica la entrada del usuario. Por lo tanto, el ataque se realiza al pasar un parámetro de cadena de formato y una entrada como esta:

"./program "Snyk %p %p %p %p %p" " 

Esta entrada puede obtener datos de la pila, ya que la salida del programa se parecerá a lo siguiente:

>> ./program "Snyk %p %p %p"
 Snyk 0x7ffcc40aafd8 0x7ffcc40aaff0 0x558581c18180%

Esta salida se debe a que printf interpreta %p como una referencia a un puntero void que intenta interpretar las direcciones de memoria.

Para evitar esta vulnerabilidad, debemos agregar un argumento de formato a nuestro código, como se muestra a continuación:

#include  <stdio.h> 

int main(int argc, char **argv)
{
// safe code
printf("%s\n",argv[1]);
return 0;
}

El fragmento de código anterior es seguro porque no interpretará la cadena. Por ejemplo, si intentáramos compilar y ejecutar el código, la salida sería la siguiente:

>> ./program "Snyk %p %p %p"
 Snyk %p %p %p

La salida solo contiene los caracteres que se pasan al programa, sin interpretarlos como una referencia al puntero void.

Una solución aún mejor es evitar el uso de printf siempre que sea posible, a menos que no tengas otra opción, y reemplazarlo por std::format y std::vformat (incorporados en C++ 20), ya que verifican que la entrada coincida con los tipos, ya sea en tiempo de ejecución o de compilación, y generan un format_error si no coinciden.

Reduce tus riesgos de seguridad en C++

En este artículo, analizamos los cinco principales riesgos de seguridad al trabajar con C++. Vimos cómo un atacante puede aprovechar estas vulnerabilidades para acceder a los datos de los usuarios u obtener información de nuestras aplicaciones, y cómo se manifiestan en el código. También destacamos estrategias para evitar que estas vulnerabilidades se conviertan en incidentes de seguridad graves.

A diferencia de los lenguajes de nivel superior que ofrecen menos control directo sobre los recursos, C++ presenta diversas vulnerabilidades que los desarrolladores deben conocer al escribir código para evitar introducirlas en sus proyectos. La explotación puede adoptar muchas formas y los actores maliciosos amplían continuamente sus estrategias de ataque. Para obtener más información sobre las vulnerabilidades en C++, consulta este blog del equipo de investigación de seguridad de Snyk y este artículo sobre las vulnerabilidades de directory traversal en C/C++.

Como desarrolladores, es nuestra responsabilidad implementar técnicas de seguridad para garantizar que nuestro software sea confiable y esté protegido contra los ataques que podrían exponer información de los usuarios. Con las estrategias adecuadas, podemos evitar todas las vulnerabilidades que analizamos en este artículo.

Comienza con Capture the Flag

Aprende a resolver desafíos de Capture the Flag con nuestro taller virtual 101 a pedido.