Vulnerabilidades habituales
El bestiario de los bugs de memoria más explotados, cada uno reducido a su patrón esencial: use-after-free y doble liberación como fallos de la vida de un puntero, el desbordamiento de enteros que hace mentir a un tamaño, y la cadena de formato que convierte un dato en código. Reconocer el patrón es el primer paso para no escribirlo.
El desbordamiento de pila es el más famoso, pero no está solo. Hay una familia de bugs de memoria que reaparece década tras década en los avisos de seguridad, y cada uno tiene una forma reconocible: un patrón que, una vez que lo has visto, salta a la vista en el código. Este es el bestiario esencial. No lo estudias para memorizar exploits, sino para que tu ojo aprenda a detenerse en las construcciones que los engendran, porque la defensa más barata contra una vulnerabilidad es no escribirla.
- Reconocer el patrón del use-after-free y la doble liberación en la vida de un puntero.
- Entender cómo un desbordamiento de enteros corrompe un tamaño antes de una asignación.
- Identificar la cadena de formato y por qué convierte un dato controlado en código.
- Asociar cada vulnerabilidad con la disciplina concreta que la previene.
Use-after-free y doble liberación: la vida de un puntero
Todo puntero al heap tiene un ciclo de vida: nace con malloc, se usa, y muere con free. Las dos vulnerabilidades más explotadas hoy son fallos de ese ciclo: usar el puntero después de su muerte, o matarlo dos veces.
char *p = malloc(64);
free(p);
strcpy(p, entrada); // USE-AFTER-FREE: p ya no es tuyo
free(p); // DOUBLE-FREE: liberar lo ya liberado
Tras el free, el bloque vuelve al asignador, que puede reutilizarlo para la siguiente petición. Si escribes en p después, corrompes los datos de otra parte del programa —o las estructuras internas del propio asignador. El use-after-free es hoy la clase de vulnerabilidad más valiosa del mercado porque es explotable con precisión quirúrgica: el atacante induce una nueva asignación que caiga en el bloque liberado, coloca allí datos que controla, y logra que el código antiguo los interprete como si fueran el objeto original.
La doble liberación es su primo: liberar dos veces el mismo bloque enreda las listas internas del asignador de forma que una petición futura puede devolver un puntero que apunte a metadatos, dándole al atacante una escritura arbitraria. El patrón que las previene es único y disciplinado: anula el puntero al liberarlo.
free(p);
p = nullptr; // C23: un free posterior sobre nullptr es inofensivo
Un free(nullptr) está definido como una operación nula, así que anular el puntero neutraliza de golpe la doble liberación, y convierte cualquier use-after-free en un SIGSEGV limpio en lugar de una corrupción silenciosa.
Desbordamiento de enteros: cuando el tamaño miente
Este es el bug más traicionero porque no ocurre donde parece. El desbordamiento sucede en un cálculo de aritmética; el daño estalla más tarde, en la asignación de memoria que confió en ese cálculo.
void copiar(uint32_t n, const int *fuente) {
int *buf = malloc(n * sizeof(int)); // n * 4 puede envolver a un numero pequeno
for (uint32_t i = 0; i < n; i++)
buf[i] = fuente[i]; // se escribe segun n, no segun lo reservado
}
Si n vale 0x40000001, el producto n * sizeof(int) desborda los 32 bits y envuelve a un número diminuto: malloc reserva unos pocos bytes, pero el bucle escribe según n, miles de millones de veces. Un desbordamiento de heap masivo, nacido de una multiplicación que nadie miró. La aritmética sin signo de C envuelve por definición —no es comportamiento indefinido— así que ni siquiera UBSan lo señala por defecto. El patrón defensivo es la aritmética comprobada de C23:
#include <stdckdint.h>
size_t total;
if (ckd_mul(&total, n, sizeof(int))) // true si el producto desbordo
return; // rechaza la peticion venenosa
int *buf = malloc(total);
ckd_mul calcula en precisión infinita, guarda el resultado envuelto y devuelve si hubo desbordamiento, sin ningún comportamiento indefinido. Es la forma correcta de validar cualquier tamaño antes de pasarlo a malloc, y debería ser tu reflejo automático ante toda multiplicación que alimente una asignación.
Cadena de formato: cuando el dato se vuelve código
Las últimas dos vulnerabilidades comparten una raíz filosófica: en ambas, un dato que el atacante controla se interpreta como si fuera parte del programa.
Format string
Pasar datos del usuario como la cadena de formato de printf. Los especificadores %x filtran la pila y %n escribe en memoria: un dato se vuelve una instrucción.
Off-by-one
Escribir un byte de más, casi siempre el NUL terminador, justo fuera del búfer. Un solo byte basta para pisar un puntero de marco o una longitud.
Truncamiento con signo
Un tamaño con signo que llega negativo pasa una comprobación < max y luego se convierte a size_t gigante al llamar a memcpy.
Puntero no inicializado
Un puntero local sin inicializar contiene basura de la pila; usarlo antes de asignarle un valor es un salto a una dirección arbitraria.
La cadena de formato merece un vistazo de cerca porque su patrón es engañosamente inocente:
printf(entrada); // VULNERABLE: entrada es la cadena de formato
printf("%s", entrada); // correcto: entrada es solo un argumento
Si entrada contiene %x %x %x, printf los interpreta como especificadores y vuelca valores de la pila que no debería revelar. Si contiene %n, printf escribe el número de bytes emitidos en la dirección que tome como argumento —y con controlar el ancho de campo, el atacante convierte esa escritura en la primitiva de escritura arbitraria más limpia que existe. La regla es absoluta: la cadena de formato es siempre un literal del programador; los datos son argumentos. gcc -Wformat-security avisa de la violación.
Fuera de límites: leer también es una vulnerabilidad
Hay una intuición perezosa que conviene desmontar: la de que solo las escrituras fuera de límites son peligrosas, porque solo ellas corrompen. Una lectura fuera de límites parece inofensiva —al fin y al cabo, no cambia nada— y sin embargo es la clase de bug que hizo tambalearse a media internet.
char respuesta[64];
size_t pedido = leer_longitud_del_cliente(); // controlado por el atacante
memcpy(respuesta, buffer_interno, pedido); // pedido puede exceder lo valido
enviar(respuesta, pedido); // se filtra memoria contigua
El patrón es exactamente el de Heartbleed: un tamaño que el atacante controla, usado para copiar y devolver datos, sin comprobar contra el tamaño real del origen. La lectura no corrompe la pila del servidor, pero filtra lo que hubiera en la memoria contigua —claves, tokens, otros datos de sesión— directamente al atacante. Y esa fuga es precisamente el prerrequisito que derrota a ASLR: una dirección revelada es un mapa entregado. Por eso las lecturas fuera de límites son la moneda de cambio del hardening moderno, y por eso comprobar siempre el tamaño del origen, no solo el del destino, es tan importante como no desbordar al escribir.
El pariente menor pero omnipresente es el off-by-one: escribir o leer un byte de más, casi siempre el NUL terminador que se coloca justo fuera del búfer. Un solo byte parece inofensivo, pero si cae sobre el byte bajo de un puntero guardado o de una longitud, basta para desviar el programa. El patrón nace de confundir “capacidad” con “capacidad menos uno para el terminador”, y se previene reservando siempre espacio explícito para el NUL y usando funciones que lo cuenten dentro del límite.
char dst[8];
strncpy(dst, src, sizeof dst); // si src mide 8+, dst queda SIN terminador
dst[sizeof dst - 1] = '\0'; // el remate imprescindible que casi nadie pone
strncpy es una trampa clásica precisamente aquí: rellena hasta el límite pero no garantiza el terminador si la fuente llena el destino, dejando una cadena sin cerrar que el siguiente strlen leerá fuera de límites. El off-by-one no es un error de descuido tonto: es la consecuencia de que en C el terminador es un byte real que ocupa espacio y que tú tienes que contar, siempre, dentro de la capacidad. Cada función de cadena tiene su propia regla sobre quién pone el NUL, y conocerla no es opcional.
Da un paso atrás y mira el bestiario entero: hay un solo pecado bajo todos estos nombres. El desbordamiento de pila deja que un dato sobrescriba la dirección de retorno —control. El use-after-free deja que un dato nuevo se lea como el objeto viejo —control sobre qué representa la memoria. El desbordamiento de enteros deja que un dato del atacante decida cuánta memoria se reserva —control sobre la geometría. La cadena de formato deja que un dato se interprete como instrucciones de formateo —control sobre qué hace el programa. En cada caso, la línea que separa lo que el programa es de lo que el programa procesa se ha borrado, y el atacante ha cruzado desde el lado de los datos, donde debía quedarse, al lado del control, donde manda. Esta es la intuición unificadora que convierte una lista de bugs en un principio: la seguridad de memoria es la disciplina de mantener los datos del lado de los datos. Cada mitigación del compilador defiende una frontera concreta de esa línea; cada regla de C defensivo la refuerza desde el código. Cuando revises un programa buscando vulnerabilidades, no busques nombres del bestiario: busca el momento en que un valor que viene de fuera toca algo que decide el comportamiento —una dirección, un tamaño, un formato, la vida de un puntero. Ahí, y solo ahí, viven los exploits.
- Escribe el ejemplo de use-after-free, cázalo con
-fsanitize=addressy lee el informe; luego añadep = nullptrtras elfreey comprueba que el segundofreedeja de corromper. - Reproduce el desbordamiento de enteros con un
ngrande, obsérvalo con-fsanitize=address, y reescribe la reserva conckd_mulpara que rechace la petición venenosa. - Compila los dos
printfcon-Wformat-securityy explica por qué solo uno genera aviso; alimenta el vulnerable con%x %x %xy observa qué filtra. - Toma un programa tuyo y localiza cada punto donde un dato externo decide una dirección, un tamaño o un formato. Anota cuántos son: esa es tu superficie de ataque real.