wandres.dev
SEMIDIÓS · Undefined Behavior

Como el optimizador te castiga

Casos reales en los que el compilador borro la comprobacion de seguridad que si estaba escrita: el silogismo del optimizador, el fallo del kernel de Linux, los bucles que nunca terminan y el viaje en el tiempo del comportamiento indefinido.

⏱ 18 min

Hasta aquí el comportamiento indefinido parecía un problema de resultados raros. No lo es. Su forma más peligrosa es la contraria: el programa da resultados perfectamente normales durante años, hasta que una versión nueva del compilador aplica un paso de optimización más agresivo y elimina la comprobación de seguridad que sí escribiste. Esta lección son cuatro autopsias de código real donde ocurrió exactamente eso.

🎯 Al terminar esta lección sabrás
  • Reconstruir el silogismo con el que el optimizador justifica cada eliminación.
  • Analizar la vulnerabilidad del kernel de Linux en que desapareció una comprobación de nulo.
  • Ver cómo las comprobaciones de desbordamiento escritas a mano se pliegan a una constante.
  • Entender la propagación hacia atrás y por qué el efecto precede a la causa.

El silogismo del optimizador

El compilador no persigue a nadie. Ejecuta un razonamiento de tres pasos que es lógicamente impecable y cuya premisa mayor se la concede la propia norma.

flowchart TD
A[Premisa mayor: un programa conforme nunca ejecuta comportamiento indefinido] --> C
B[Premisa menor: esta rama solo se alcanza si hubo comportamiento indefinido] --> C
C[Conclusion: la rama es inalcanzable y puede eliminarse]
C --> D[Se borra la comprobacion escrita por el programador]
D --> E[El codigo generado no se parece al codigo fuente]
style A fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b

La premisa mayor no es una interpretación abusiva: es lo que significa que la norma no imponga requisitos. Si el compilador estuviese obligado a generar código correcto también para las entradas que provocan comportamiento indefinido, entonces ese comportamiento estaría definido. La premisa menor la aporta el análisis de flujo, que es cada año más listo. Y la conclusión, que en cualquier otro contexto llamaríamos eliminación de código muerto, aquí borra precisamente las líneas defensivas.

Conviene nombrarlo bien porque cambia la actitud: el optimizador no rompe tu código. Deduce a partir de premisas que tú firmaste al escribir la construcción indefinida.

Caso uno: la comprobación de nulo que desapareció

El ejemplo canónico vive en el kernel de Linux, en el controlador tun, y produjo una escalada de privilegios explotable. El patrón, reducido a su esqueleto, era este:

static unsigned int tun_chr_poll(struct file *file, poll_table *wait)
{
    struct tun_file *tfile = file->private_data;
    struct tun_struct *tun = __tun_get(tfile);
    struct sock *sk = tun->sk;          /* 1. dereferencia de tun */

    if (!tun)                            /* 2. comprobacion de nulidad */
        return POLLERR;
    /* ... uso de sk ... */
}

El razonamiento del compilador es el silogismo aplicado al pie de la letra. La línea 1 dereferencia tun. Si tun fuese nulo, esa dereferencia sería comportamiento indefinido. Un programa conforme no ejecuta comportamiento indefinido, luego tun no es nulo en la línea 1. Como tun no se modifica entre las dos líneas, tampoco es nulo en la línea 2, luego la condición es siempre falsa, luego el if completo es código muerto y se elimina.

El resultado no fue un fallo: fue un acceso a la dirección cero que en el kernel, con la página cero mapeable por el usuario en aquella configuración, se convirtió en ejecución de código arbitrario. La comprobación estaba escrita, había pasado la revisión de código y no existía en el binario.

⚠️
El orden importa más de lo que parece

La corrección fue mover la dereferencia detrás de la comprobación, no añadir una comprobación nueva. Regla operativa: nunca dereferencies antes de validar, ni siquiera para inicializar una variable que aún no usas. Una asignación aparentemente inocente al principio de la función es una afirmación de no nulidad que el optimizador propaga a todo el cuerpo. GCC y Clang ofrecen -fno-delete-null-pointer-checks como red para código que no puedes reauditar; el kernel la lleva activada desde entonces.

Caso dos: la comprobación de desbordamiento que se pliega a una constante

El segundo patrón afecta a código de validación escrito con la mejor intención. Estas cuatro funciones son intentos razonables de detectar un desbordamiento con signo, y las cuatro son eliminadas o reducidas a una constante:

/* 1. se pliega a return 1: el optimizador sabe que x+1 nunca es menor que x */
int comprobar_a(int x) { return x + 1 > x; }

/* 2. la rama de error se borra entera */
int comprobar_b(int x, int y) {
    int s = x + y;
    if ((x > 0 && y > 0 && s < 0) || (x < 0 && y < 0 && s > 0))
        return -1;                  /* inalcanzable segun el optimizador */
    return s;
}

/* 3. el bucle se convierte en aritmetica de 64 bits o se declara infinito */
void recorrer(int n) {
    for (int i = 0; i <= n; i++)    /* asume que i nunca envuelve */
        trabajo(i);
}

/* 4. UB si len es grande: el producto desborda antes de compararse */
void *reservar(int n, int len) {
    if (n * len > LIMITE) return NULL;
    return malloc(n * len);
}

El caso tres merece detenerse. La norma dice que un bucle cuya condición de control no es una expresión constante y que no realiza operaciones observables puede ser asumido terminante por la implementación. Combinado con la ausencia de envolvimiento en la aritmética con signo, el compilador deduce que i alcanza n y sale, lo que le permite ensanchar el contador a la palabra nativa y vectorizar. Si n es INT_MAX, el bucle real jamás termina y el generado hace cualquier otra cosa.

Los cuatro se arreglan con la misma disciplina: no provoques la operación para después inspeccionar su resultado. Comprueba antes, o calcula en un tipo que no pueda desbordar, o usa stdckdint.h.

#include <stdckdint.h>
#include <stdlib.h>

void *reservar_bien(size_t n, size_t len) {
    size_t total;
    if (ckd_mul(&total, n, len) || total > LIMITE) return NULL;
    return malloc(total);
}

Caso tres: el viaje en el tiempo

La consecuencia más contraintuitiva, y la que separa a quien ha entendido el asunto de quien cree haberlo entendido, es que el comportamiento indefinido no respeta la flecha del tiempo. Como la norma no impone requisitos sobre la ejecución completa —no sobre la ejecución a partir de cierto punto—, un compilador conforme puede mover efectos observables anteriores a la construcción indefinida, o eliminarlos.

#include <stdio.h>

int dividir(int a, int b) {
    printf("voy a dividir\n");   /* efecto observable, ANTES de la division */
    return a / b;                /* UB si b es cero */
}

Si llamas a esta función con b igual a cero, no tienes derecho a que el mensaje aparezca. El compilador puede razonar que la única forma de alcanzar la división con b a cero es un programa no conforme, propagar esa conclusión hacia atrás, y decidir que la llamada entera es inalcanzable —eliminando la impresión— o reordenar la salida por almacenamiento intermedio y morir antes del vaciado. Esto tiene consecuencias prácticas inmediatas: la última línea que imprime tu programa no marca dónde estaba cuando falló.

Un segundo efecto de la misma familia es la propagación por funciones ya integradas. Cuando el optimizador integra una función que en alguna rama tiene comportamiento indefinido, las conclusiones extraídas de esa rama viajan al cuerpo del llamador y pueden borrar comprobaciones escritas a cientos de líneas de distancia, en otro archivo, incorporadas por optimización en tiempo de enlace.

⚠️

No hay UB local

Un solo punto indefinido alcanzable autoriza conclusiones sobre todo el programa, no solo sobre esa línea.

Efectos hacia atrás

La salida previa a la construcción indefinida no está garantizada. Depurar por trazas induce a error sistemáticamente.

🔗

Cruza archivos

Con optimización en tiempo de enlace, la deducción atraviesa unidades de traducción y bibliotecas estáticas.

El compilador no traduce texto: demuestra teoremas

Aquí está el cambio de modelo mental que corona todo el nivel. La imagen ingenua del compilador es la de un traductor que convierte cada sentencia de C en un puñado de instrucciones, de modo que el binario es una versión literal del fuente en otro idioma. Esa imagen fue razonablemente cierta hasta los años ochenta y hoy es falsa hasta el punto de resultar peligrosa. Un compilador moderno construye una representación intermedia con semántica formal, deriva de ella un conjunto de hechos —este valor no es nulo, este rango está acotado, este bucle termina, estos punteros no se solapan— y genera el código a partir de los hechos, no del texto. El binario no es una traducción de tu programa: es la conclusión de una demostración cuyas premisas son la norma más lo que tu código afirma implícitamente. Y de ahí se sigue lo único que importa: una premisa falsa no produce una conclusión un poco equivocada, sino que hace que cualquier conclusión sea derivable. Es el principio de explosión de la lógica clásica, ejecutándose sobre tu servidor de producción. Por eso el comportamiento indefinido no se negocia, no se mide, no se acepta como riesgo calculado y no se justifica porque el binario de hoy salga bien. Programar en C con dominio significa asegurarse de que todo lo que tu código afirma implícitamente sea verdad, y aceptar que las herramientas que verifican esas afirmaciones —UBSan, los avisos, el análisis estático— no son opcionales sino la condición para poder confiar en -O2.

⚔️ Provoca las tres eliminaciones
  1. Compila comprobar_a con -O2 y examina el ensamblador con objdump -d o el explorador de compiladores. Comprueba que devuelve la constante uno. Repite con -fwrapv y observa la diferencia.
  2. Reproduce el patrón del kernel: dereferencia un puntero y compruébalo después. Verifica con -O2 que la comprobación desaparece y que con -fno-delete-null-pointer-checks vuelve.
  3. Escribe el bucle del caso tres, llámalo con INT_MAX y compara la ejecución a -O0 y a -O2. Explica qué hipótesis usó el optimizador.
  4. Ejecuta dividir con el divisor a cero bajo distintos niveles de optimización y comprueba si el mensaje aparece siempre. Razona por qué depurar con trazas puede engañarte.
  5. Reescribe reservar con ckd_mul y demuestra con un caso concreto que la versión original aceptaba un tamaño que desbordaba.