Seminario web destacado: MISRA C++ 2023: todo lo que necesita saber | Vea ahora

Cómo gestionar eficazmente los punteros en C para evitar abusos

Foto de cabeza de Ricardo Camacho, Director de Cumplimiento de Seguridad y Seguridad
1 de diciembre de 2023
4 min leer

Aunque los punteros le permiten usar su creatividad para resolver un problema en particular, tienen varias limitaciones que se encuentran entre las más difíciles de manejar para los programadores. Continúe leyendo para aprender cómo utilizar Insure++ para identificar automáticamente problemas relacionados con el puntero.

Los punteros son al mismo tiempo la fuerza y ​​el talón de Aquiles de programación en C y C++. Mientras que los consejos le permiten ser muy creativo y flexible en la forma de abordar la solución de un problema en particular. Sin embargo, es fácil introducir defectos en el código sin darse cuenta. Los problemas con los punteros se encuentran entre los más difíciles que encuentran los programadores de C. Hace una generación, las técnicas de fuerza bruta, como insertar declaraciones impresas en el código, eran a menudo la mejor estrategia para intentar encontrar estos problemas.

Herramientas para detectar el abuso de punteros en C

Insure++: una herramienta para comprobaciones dinámicas de asignación de memoria

Hoy en día, las herramientas de detección de errores de memoria como Asegurar ++ puede detectar problemas relacionados con el puntero automáticamente a medida que se ejecuta el código, lo que ahorra mucho tiempo y dolores de cabeza. Insure++ encuentra problemas en las siguientes categorías:

  • Operaciones con punteros NULL
  • Operaciones con punteros no inicializados
  • Operaciones sobre punteros que no apuntan a datos válidos
  • Operaciones que intentan comparar o relacionar punteros que no apuntan al mismo objeto de datos
  • Llamadas a funciones a través de punteros de función que no apuntan a funciones
  • Muchas otras causas de posible comportamiento indefinido o comportamiento definido por la implementación

Insure++ utiliza un analizador de código de última generación, junto con cientos de heurísticas, para analizar el código de la aplicación, durante el cual informa sobre varias posibles infracciones estáticas. Mientras analiza el código, escribe un nuevo archivo de código fuente con la instrumentación adecuada insertada en los puntos problemáticos, como la desreferencia del puntero, la salida del alcance, etc. El archivo fuente resultante se compila automáticamente y todos los archivos de código objeto resultantes se vinculan a un nuevo programa ejecutable.

Identificación de la dirección de una variable: mejores prácticas

En muchos casos, al intentar resolver problemas de administración de memoria y descubrir si una variable está dañada, necesitará saber la dirección de esa variable y probablemente descubrirá que no se trata solo de esa variable sino también de otras. El uso de un depurador suele considerarse una práctica recomendada cuando se trata de identificar problemas en el código, incluida la comprensión de los valores de las variables y las direcciones de memoria.

Los depuradores proporcionan un poderoso conjunto de herramientas para inspeccionar y manipular la ejecución de su programa. Sin embargo, es importante tener en cuenta que el uso de un depurador no es excluyente de otras técnicas de depuración. Las declaraciones impresas y la inspección manual pueden seguir siendo herramientas valiosas, especialmente en situaciones en las que utilizar un depurador puede resultar poco práctico.

Un enfoque simple que he usado durante muchos años es simplemente usar la dirección del operador '&' en una declaración printf. Tome el siguiente ejemplo de código.

int myVariable = 77;
printf("Address of myVariable is: %p\n", (void *)&myVariable);

La expresión &myVariable devuelve la dirección de myVariable. El especificador de formato %p se utiliza con printf para imprimir la dirección en formato de puntero. La conversión (void*) se utiliza para coincidir con el especificador de formato %p. No olvide que la dirección de memoria real puede variar cada vez que se ejecuta el programa.

Ejemplo práctico: gestión de punteros en un programa "Hola mundo"

A continuación se muestra el código de un programa "Hola mundo" que utiliza asignación de memoria dinámica.

    
/*
 * File: hello.c
 */
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
    char *string, *string_so_far;
    int i, length;     length = 0;
    for(i=0; i<argc; i++) {
        length += strlen(argv[i])+1;
        string = malloc(length+1);
 
        /*  * Copy the string built so far. */
        if(string_so_far != (char *)0)
            strcpy(string, string_so_far);
        else *string = '\0';
        strcat(string, argv[i]);
        if(i < argc-1) strcat(string, " ");
        string_so_far = string;
    }
    printf("You entered: %s\n", string_so_far);
    return (0);
}
    

La idea básica de este programa es que realizamos un seguimiento del tamaño de cadena actual en la longitud variable. A medida que se procesa cada nuevo argumento, agregamos su longitud a la variable de longitud y asignamos un bloque de memoria del nuevo tamaño. Observe que el código tiene cuidado de incluir el carácter NULL final al calcular la longitud de la cadena (línea 14) y también el espacio entre cadenas. Ambos son errores fáciles de cometer. Es un ejercicio interesante ver qué tan rápido se puede encontrar un error de este tipo con una herramienta de detección de errores de memoria como Parasoft Insure++.

El código copia el argumento en el búfer o lo agrega, dependiendo de si esta es la primera pasada del ciclo o no. Finalmente, el puntero string_so_far apunta a la nueva cadena más larga.

Si compila y ejecuta este programa bajo Insure ++, verá errores de "puntero no inicializado" reportados para el código "strcpy (string, string_so_far)". Esto se debe a que la variable string_so_far no se ha establecido en nada antes del primer viaje a través del bucle de argumentos. En una pequeña muestra de código como esta, este problema es obvio, pero incluso si el error está oculto en un montón de cientos de miles de líneas de código y es mucho más sutil que el error anterior, Insure ++ lo encontrará cada vez.

Informes e información de Insure++

Insure++ informa cualquier problema encontrado. Los informes de Insure++ contienen información detallada como el tipo de error, el archivo fuente y el número de línea, el contenido real de la línea del código fuente y las expresiones que causaron el problema, con informes que incluyen:

  • El tipo de error, por ejemplo, EXPR_UNRELATED_PTRCMP
  • El archivo fuente y el número de línea, por ejemplo, foo.cc:42
  • El contenido de la línea del código fuente real, por ejemplo, "mientras (p < g) {"
  • La expresión que causó el problema, por ejemplo, "p < g"
  • Información sobre todos los punteros y bloques de memoria involucrados en el error:
    • Valores de puntero
    • Bloques de memoria apuntados (si los hay) y cualquier desplazamiento
    • Información de asignación de bloques:
      • Seguimiento de pila si se asigna dinámicamente
      • Ubicación de la declaración del bloque (archivo fuente y número de línea), si está asignado en la pila o globalmente
      • Seguimiento de pila de desasignación del bloque, si corresponde
    • Seguimiento de pila que muestra cómo llegó el programa a la ubicación del error

Próximos pasos: cómo mantener seguros sus punteros en C

La cobertura de prueba es fundamental para mantener seguros los punteros en C y C++. El análisis estático y el análisis dinámico desempeñan papeles importantes.

La importancia de la cobertura de las pruebas en los punteros

Cuando se trata de trabajar con punteros en C o C++, la cobertura de las pruebas es importante porque introducen riesgos. El riesgo puede presentarse de manera sutil y, a veces, muy difícil de rastrear, lo que implica altos costos laborales.

Es esencial emplear un conjunto completo de métodos de prueba para cubrir una lista de problemas y escenarios punteros. Por ejemplo, querrás utilizar una herramienta como Insure++ para ayudar a los desarrolladores a encontrar errores erráticos de programación y acceso a la memoria, como corrupción del montón, subprocesos no autorizados, pérdidas de memoria, matrices fuera de límites y punteros no válidos.

Análisis estático

Una manera poderosa de comenzar es a través de usando análisis estático o detección de errores en tiempo de compilación, que detecta posibles problemas relacionados con los punteros durante la fase de compilación. Problemas como la pérdida de precisión del puntero, falta de coincidencia en la especificación de formato o tipo de argumento, variables no utilizadas, código inactivo, división por cero y más.

Análisis dinámico

Pruebas de análisis dinámico es otro enfoque potente y complementario al análisis estático. La detección de errores en tiempo de ejecución ayuda a identificar memorias de montón y de pila corruptas, además de todo tipo de pérdidas de memoria, asignación de memoria, errores libres o discrepancias y errores de matriz fuera de límites.

Para asegurarse de haber probado todo el código, el análisis de cobertura de código permite la identificación visual de qué secciones de código se han ejecutado y cuáles no. Dejar código sin probar en su aplicación puede costarle noches de insomnio o puede resultar perjudicial más tarde.

Agregue análisis estático a su caja de herramientas de pruebas de seguridad