Sanitizers y Valgrind: detección de errores en runtime

Cuando programas en C, el compilador se encarga de la sintaxis y de la estructura, pero no de la validez lógica de tus accesos a memoria. Si intentas escribir fuera de un array o accedes a un puntero que acabas de liberar, el compilador lo verá como algo legal; el error se manifestará como un comportamiento indefinido (UB) o un fallo de segmentación (segfault) mucho después de que ocurriera el problema real. Para evitar estos “Heisenbugs”, utilizamos herramientas de instrumentación en tiempo de ejecución.

Estas herramientas detectan errores que el compilador ignora por diseño para no penalizar el rendimiento. AddressSanitizer (ASan) funciona mediante la instrumentación del código, añadiendo chequeos en cada instrucción que accede a memoria y utilizando una memoria de sombra (shadow memory) para marcar zonas de memoria como “venenosas” (poisoned). Por otro lado, UndefinedBehaviorSanitizer (UBSan) inserta comprobaciones lógicas antes de operaciones críticas, como una división por cero o un desbordamiento de enteros. Si prefieres no recompilar, Valgrind ofrece una alternativa mediante la simulación de una máquina virtual que rastrea cada byte, aunque con un coste de rendimiento mucho mayor (hasta 20x más lento).

Debes usar estas herramientas durante el desarrollo y en tus procesos de Integración Continua (CI), pero nunca en producción, ya que el overhead de ejecución y el consumo de memoria pueden degradar severamente el rendimiento. Si ignoras estas alertas o no las integras en tu flujo de trabajo, tu programa seguirá ejecutándose con una memoria corrupta, lo que derivará en comportamientos impredecibles que serán casi imposibles de depurar en sistemas de producción.

#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

/* 
 * Escenario de fallo: una función que contiene múltiples errores 
 * de lógica de memoria y aritmética.
 */
void ejecutar_escenario_de_error() {
    // 1. Comportamiento indefinido: Signed Integer Overflow
    // Intentar exceder el límite máximo de un entero con signo es UB.
    int valor_max = INT_MAX;
    int desbordamiento = valor_max + 10; 
    printf("Valor desbordado: %d\n", desbordamiento);

    // 2. Heap Buffer Overflow
    // Reservamos espacio para 5 enteros, pero el bucle escribe en el índice 5.
    int *búfer = malloc(5 * sizeof(int));
    if (!búfer) return;

    for (int i = 0; i <= 5; i++) {
        búfer[i] = i * 10; // Error: El índice 5 está fuera de los límites [0..4]
    }

    // 3. Use-after-free
    // Liberamos la memoria, pero intentamos acceder a ella inmediatamente después.
    free(búfer);
    printf("Acceso tras free: %d\n", búfer[0]);

    // 4. Memory Leak
    // Si el programa terminara aquí sin liberar 'búfer' (si no hubiéramos hecho el free anterior),
    // Valgrind lo detectaría como una fuga de memoria.
}

int main(void) {
    printf("Iniciando ejecución de pruebas...\n");
    ejecutar_escenario_de_error();
    return 0;
}

Para analizar lo que ocurre, fíjate en cómo ejecutar_escenario_de_error interactúa con la memoria. Cuando compiles con -fsanitize=address,undefined, el compilador modificará el código de la función ejecutar_escenario_de_error.

En el primer paso, al realizar valor_max + 10, UBSan detectará que el resultado excede INT_MAX e imprimirá un error en la consola informando del desbordamiento. Si el programa continuara, llegaríamos al bucle for. Aquí, ASan ha “envenenado” las zonas de memoria inmediatamente anteriores y posteriores al bloque reservado por malloc. En el momento en que el índice i llega a 5, el puntero apunta a esa zona envenenada, y ASan detiene la ejecución inmediatamente con un reporte detallado de heap-buffer-overflow.

Si logras de alguna manera saltar ese error, el free(búfer) marcará esa dirección de memoria como inválida en la memoria de sombra. Al intentar ejecutar printf con búfer[0], estarás realizando un use-after-free, un error que ASan detectará al ver que el puntero apunta a memoria que ya ha sido devuelta al sistema.

El error más difícil de ver sin herramientas es el desbordamiento por desbordamiento de entero. Si el cálculo de un tamaño para malloc resultara en un número erróneo debido a un desbordamiento, podrías estar reservando una cantidad minúscula de memoria y luego intentando escribir un array enorme en ella.

El error frecuente

Un error clásico es calcular el tamaño de un bloque de memoria multiplicando el número de elementos por su tamaño sin verificar posibles desbordamientos:

// ERROR: Si count es muy grande, count * sizeof(int) puede desbordar
// y resultar en un valor pequeño, provocando un heap-buffer-overflow.
void error_de_calculo(size_t count) {
    int *arr = malloc(count * sizeof(int)); 
    if (arr) {
        for (size_t i = 0; i < count; i++) {
            arr[i] = 0; // Aquí ocurre el desbordamiento
        }
        free(arr);
    }
}

Este tipo de error es especialmente peligroso porque malloc puede tener éxito con un tamaño pequeño, y el fallo solo ocurrirá durante el bucle, corrompiendo el heap silenciosamente. ASan es capaz de detectar esto si el desbordamiento ocurre en los bordes de la memoria asignada, pero Valgrind es incluso más robusto para rastrear la procedencia de la asignación original con --track-origins=yes.

116

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio