Sanitizers en C++: AddressSanitizer, UBSan y ThreadSanitizer para cazar bugs

Un depurador te dice qué ocurre cuando ejecutas el programa paso a paso. Los sanitizers instrumentan el código en tiempo de compilación para detectar errores que no siempre producen un crash inmediato: acceso a memoria liberada, lectura de un byte más allá del final de un buffer, uso de variable no inicializada, o dos hilos modificando la misma variable sin sincronización.

Esos errores pueden existir durante meses en el código sin manifestarse, hasta que lo hacen en producción con datos específicos. Los sanitizers los sacan a la luz en desarrollo.

AddressSanitizer (ASan)

ASan detecta errores de memoria: desbordamientos de heap y stack, use-after-free, use-after-return, double-free y leaks.

# Compilar con ASan
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 programa.cpp -o programa
clang++ -fsanitize=address -fno-omit-frame-pointer -g -O1 programa.cpp -o programa

El flag -fno-omit-frame-pointer es importante para que los stack traces sean útiles. -O1 en lugar de -O0 da mejor detección sin eliminar demasiado contexto.

Ejemplo: heap buffer overflow

#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    return v[5];  // acceso fuera de rango: UB sin ASan, error con ASan
}

ASan genera un informe con la dirección exacta del acceso, el tamaño del buffer y el stack trace de dónde ocurrió el acceso y dónde se asignó la memoria. Con -g aparecen los números de línea.

Ejemplo: use-after-free

int* crear() {
    int* p = new int(42);
    delete p;
    return p;  // peligro: p apunta a memoria liberada
}

int main() {
    int* p = crear();
    return *p;  // use-after-free: ASan lo detecta
}

Variables de entorno de ASan

# Activar detección de leaks (por defecto en Linux)
ASAN_OPTIONS=detect_leaks=1 ./programa

# Ver el informe incluso si el programa termina normalmente
ASAN_OPTIONS=leak_check_at_exit=1 ./programa

# Parar en el primer error (útil con gdb)
ASAN_OPTIONS=abort_on_error=1 ./programa

UBSan: comportamiento indefinido

UBSan detecta comportamiento indefinido (UB): desbordamiento de enteros con signo, desreferencia de puntero nulo, shift fuera de rango, conversiones de puntero inválidas y muchos más.

g++ -fsanitize=undefined -g programa.cpp -o programa

Ejemplos de UB que UBSan detecta

#include <climits>

int main() {
    int x = INT_MAX;
    int y = x + 1;  // overflow de entero con signo: UB
                    // UBSan: runtime error: signed integer overflow

    int* p = nullptr;
    *p = 42;        // desreferencia de nullptr: UB
                    // UBSan: runtime error: null pointer dereference

    int arr[3] = {1, 2, 3};
    int v = arr[5]; // out of bounds: UB (diferente de ASan, que lo detecta en heap)
}

Seleccionar checks específicos

# Solo overflow de enteros
-fsanitize=signed-integer-overflow,unsigned-integer-overflow

# Solo null pointer
-fsanitize=null

# Todos los checks de UBSan juntos
-fsanitize=undefined

# Hacer que UBSan aborte en lugar de imprimir y continuar
-fsanitize=undefined -fno-sanitize-recover=all

ThreadSanitizer (TSan)

TSan detecta data races: dos hilos acceden a la misma variable, al menos uno de ellos escribe, y no hay sincronización entre los accesos.

g++ -fsanitize=thread -g -O1 programa.cpp -o programa -lpthread

TSan no es compatible con ASan. Solo uno de los dos puede estar activo a la vez.

Ejemplo: data race

#include <thread>

int contador = 0;

void incrementar() {
    for (int i = 0; i < 100000; ++i)
        ++contador;  // data race: sin mutex
}

int main() {
    std::thread t1(incrementar);
    std::thread t2(incrementar);
    t1.join();
    t2.join();
    // El resultado es impredecible, TSan lo detecta
}

TSan produce un informe indicando qué hilos accedieron a la variable, en qué línea, y el stack trace de cada acceso. Sin TSan, el bug puede no manifestarse durante días o aparecer solo bajo carga alta.

ASan + UBSan juntos

g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 programa.cpp -o programa

Se pueden combinar ASan y UBSan sin problema. TSan es el que no se puede mezclar con los otros dos.

Integración con CMake

# CMakeLists.txt
option(ENABLE_ASAN   "Activar AddressSanitizer" OFF)
option(ENABLE_UBSAN  "Activar UBSan"            OFF)
option(ENABLE_TSAN   "Activar ThreadSanitizer"   OFF)

function(target_enable_sanitizers target)
    if(ENABLE_ASAN)
        target_compile_options(${target} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
        target_link_options(${target} PRIVATE -fsanitize=address)
    endif()
    if(ENABLE_UBSAN)
        target_compile_options(${target} PRIVATE -fsanitize=undefined -fno-sanitize-recover=all)
        target_link_options(${target} PRIVATE -fsanitize=undefined)
    endif()
    if(ENABLE_TSAN)
        target_compile_options(${target} PRIVATE -fsanitize=thread)
        target_link_options(${target} PRIVATE -fsanitize=thread)
    endif()
endfunction()

add_executable(mi_app src/main.cpp)
target_enable_sanitizers(mi_app)
# Activar desde línea de comandos o preset:
cmake -DENABLE_ASAN=ON -DENABLE_UBSAN=ON --preset debug
cmake --build --preset debug

Rendimiento y uso en CI

Los sanitizers añaden overhead significativo: ASan ralentiza la ejecución entre 2x y 10x. Por eso no se usan en builds de producción, sino en tests automáticos en CI. La estrategia habitual es tener un preset de CI que active ASan+UBSan y ejecutar la suite de tests completa con él.

El artículo sobre CMake moderno tiene más detalles sobre cómo estructurar los presets para desarrollo, release y CI. Y si te interesa cómo Rust aborda la seguridad de memoria sin runtime checks, la guía de Rust 2024 edition lo explica desde el lado del compilador.

Imagen: Pexels / Godfrey Atima

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP