1. El catálogo de errores
C no comprueba nada en tiempo de ejecución: un puntero es una dirección, y el programa puede leer o escribir donde esa dirección diga. Si la dirección no corresponde a un objeto vivo y del tamaño correcto, el comportamiento es indefinido, el concepto con el que empezó el tema 1. Los errores de memoria son la forma más importante de comportamiento indefinido, y comparten cuatro rasgos que los hacen difíciles:
- El síntoma aparece lejos de la causa. Un desborde en una función pisa un dato que otra función usa mucho después; el programa falla en la segunda, y el error está en la primera.
- Dependen del estado del administrador de memoria, de las direcciones que sorteó el ASLR (tema 27) y de las optimizaciones: el mismo programa falla un día, funciona otro, y deja de fallar al agregar un
printf. - Las pruebas chicas los esconden. Como vimos en el tema 28,
malloc(3)entrega 24 bytes utilizables: escribir 4 bytes pasa desapercibido. - Son explotables. Escribir fuera de un bloque o usar uno liberado le permite, a quien controla la entrada, modificar datos o el flujo del programa. Por eso son una de las clases de vulnerabilidad más frecuentes y graves en el software escrito en C y C++.
La tabla resume los siete errores del tema, y qué herramienta detecta cada uno. Las dos últimas columnas se aprenden con los programas de las secciones siguientes:
| Error | Qué ocurre | gcc al compilar | glibc al ejecutar | ASan | Valgrind |
|---|---|---|---|---|---|
| Fuga | Un bloque se queda sin liberar y sin dueño. | No | No | Sí, al terminar | Sí |
| Uso después de liberar | Se lee o escribe un bloque ya devuelto. | Casos simples | No | Sí | Sí |
| Doble liberación | free sobre un bloque ya liberado. | Casos simples | A veces | Sí | Sí |
| Liberación inválida | free de algo que no vino de malloc. | Casos simples | Casi siempre | Sí | Sí |
| Desborde en el montículo | Se escribe o lee más allá de un bloque. | Con tamaños constantes | Tarde, por casualidad | Sí, exacto | Sí, exacto |
| Desborde en la pila o en un global | Se sale de un arreglo local o global. | Pocos casos | El canario, al volver | Sí | No |
| Lectura sin inicializar | Se usa un valor indeterminado. | Algunos casos | No | No | Sí |
Ninguna columna es suficiente sola, y la primera, la del compilador, es la que más conviene tener siempre encendida: cuesta nada y avisa antes de ejecutar. Son los avisos que fuimos viendo: -Wuse-after-free, -Wfree-nonheap-object, -Wstringop-overflow, -Wmaybe-uninitialized, -Wreturn-local-addr, -Walloc-size-larger-than. Cuando uno aparece, no es ruido: casi siempre es uno de los errores de esta tabla.
2. Las herramientas para encontrarlos
Las dos herramientas que usaremos resuelven el problema de fondo con un mismo principio: vigilar cada acceso a la memoria, algo que ni el procesador ni el lenguaje hacen. Difieren en cómo lo hacen.
AddressSanitizer (ASan) es parte de gcc y de clang. Se pide al compilar, y el compilador inserta una comprobación antes de cada acceso a memoria:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -fno-omit-frame-pointer -o prog prog.c
$ ./prog
-fsanitize=address activa ASan, con LeakSanitizer (el detector de fugas) incluido en Linux; -fsanitize=undefined suma UBSan, que detecta otros comportamientos indefinidos como el desbordamiento de enteros y las lecturas desalineadas del tema 18; -g es lo que permite ver nombres de funciones y números de línea en los informes; y -fno-omit-frame-pointer mejora las trazas. Se usa el ejecutable como siempre: si todo está bien, no imprime nada.
ASan funciona con tres ideas, y entender cómo permite interpretar sus informes:
- Memoria sombra. Reserva una región auxiliar donde, por cada 8 bytes de memoria del programa, guarda 1 byte de estado:
00si los 8 son válidos,01a07si solo los primeros 1 a 7 lo son, y un código negativo si ninguno lo es. - Zonas rojas (redzones). Alrededor de cada bloque de
malloc, y también de cada variable local y global, deja franjas de bytes marcadas como inválidas. Un acceso que se pase del final, aunque sea por un byte, cae en la franja y se detecta. - Cuarentena. Un bloque liberado no se reutiliza de inmediato: queda marcado como inválido durante un tiempo. Cualquier acceso a él, antes de que otro pedido lo recicle, se detecta.
glibc ven.El precio es una ejecución unas dos veces más lenta y varias veces más memoria, y la obligación de recompilar. Por eso ASan se usa en desarrollo y en las pruebas, no en el programa que se entrega. Su ventaja principal es que ve también los arreglos de la pila y las variables globales.
Valgrind es un programa aparte, que ejecuta el ejecutable sin modificarlo, como si corriera en un procesador simulado, y comprueba cada acceso con su propia contabilidad: qué bytes están reservados y cuáles tienen un valor definido. Se usa así:
$ valgrind --leak-check=full --track-origins=yes ./prog
Se compila con -g para tener los números de línea (no hace falta ninguna otra opción), y se instala con sudo apt install valgrind. Las salidas de este tema son de Valgrind 3.26.0. No hace falta recompilar, lo que lo hace útil sobre programas ya construidos, pero es entre 10 y 50 veces más lento. Y como ve solo bloques del montículo y valores definidos, no conoce los límites de un arreglo en la pila ni de una variable global. La comparación:
| AddressSanitizer | Valgrind (Memcheck) | |
|---|---|---|
| Cómo se usa | Se compila con -fsanitize=address. | Se antepone valgrind al ejecutable. |
| Recompilar | Sí. | No (con -g, mejores informes). |
| Costo | Aproximadamente 2× en tiempo. | De 10× a 50× en tiempo. |
| Errores del montículo y fugas | Sí. | Sí. |
| Desbordes de arreglos de la pila y globales | Sí. | No. |
| Lectura sin inicializar | No. | Sí. |
| Se puede combinar con el otro | No: un ejecutable compilado con ASan no se ejecuta bajo Valgrind. | |
La práctica habitual es usar ASan en el desarrollo diario, por rápido, y Valgrind de vez en cuando como segunda opinión, sobre todo para las lecturas sin inicializar. Ambos cambian ligeramente el comportamiento del programa (reciclan los bloques de otra manera, rellenan la memoria nueva), un detalle que aparecerá en los ejemplos.
3. Fugas de memoria
Una fuga (memory leak) ocurre cuando un bloque reservado deja de ser alcanzable sin haberse liberado: el programa perdió la última dirección que lo apuntaba, y nadie puede ya usarlo ni liberarlo. Este programa atiende pedidos, y por cada uno reserva un buffer de 1 KiB que olvida liberar:
/* campo() es la de bss.c del tema 27: lee un campo de /proc/self/status */
/* atiende un pedido y se olvida de liberar su buffer */
static int atender(int pedido)
{
char *buffer = malloc(1024);
if (buffer == NULL)
return -1;
snprintf(buffer, 1024, "respuesta al pedido %d", pedido);
return (int) strlen(buffer);
}
int main(void)
{
printf("pedidos VmRSS\n");
for (int i = 0; i <= 200000; i++) {
atender(i);
if (i % 50000 == 0)
printf("%7d %7ld KiB\n", i, campo("VmRSS:"));
}
return 0;
}
$ ./fuga_rss
pedidos VmRSS
0 1536 KiB
50000 52480 KiB
100000 103296 KiB
150000 154112 KiB
200000 204800 KiB
Cada pedido deja 1 KiB perdido, y el programa crece sin parar: 200 000 pedidos, 200 MB. En un programa de línea de órdenes que termina enseguida, una fuga no se nota, porque al salir el sistema recupera toda la memoria. El daño aparece en los programas que viven mucho: un servidor que filtra 1 KiB por conexión se queda sin memoria al cabo de millones de conexiones, un día cualquiera, y el OOM killer del tema 28 se lo lleva.
No todas las fugas son iguales. Valgrind las clasifica en cuatro categorías según lo que quede apuntando al bloque al terminar el programa, y este programa tiene un ejemplo de cada una:
#include <stdio.h>
#include <stdlib.h>
struct nodo {
struct nodo *siguiente;
char datos[32];
};
static char *alcanzable; /* punteros globales: siguen apuntando al terminar */
static char *interior;
int main(void)
{
alcanzable = malloc(100); /* 1. todavía alcanzable */
char *perdido = malloc(200);
perdido = NULL; /* 2. perdido: la única dirección se sobrescribió */
struct nodo *lista = malloc(sizeof *lista);
lista->siguiente = malloc(sizeof *lista->siguiente);
lista->siguiente->siguiente = NULL;
lista = NULL; /* 3. se pierde la cabeza, y con ella el segundo nodo */
interior = malloc(300) + 100; /* 4. solo queda un puntero al interior del bloque */
printf("fin del programa\n");
return 0;
}
$ valgrind --leak-check=full --show-leak-kinds=all ./fugas
fin del programa
HEAP SUMMARY:
in use at exit: 680 bytes in 5 blocks
total heap usage: 6 allocs, 1 frees, 4,776 bytes allocated
40 bytes in 1 blocks are indirectly lost in loss record 1 of 5
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x40011B3: main (fugas.c:20)
80 (40 direct, 40 indirect) bytes in 1 blocks are definitely lost in loss record 2 of 5
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x40011A5: main (fugas.c:19)
100 bytes in 1 blocks are still reachable in loss record 3 of 5
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x400117E: main (fugas.c:14)
200 bytes in 1 blocks are definitely lost in loss record 4 of 5
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x400118F: main (fugas.c:16)
300 bytes in 1 blocks are possibly lost in loss record 5 of 5
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x40011DD: main (fugas.c:24)
LEAK SUMMARY:
definitely lost: 240 bytes in 2 blocks
indirectly lost: 40 bytes in 1 blocks
possibly lost: 300 bytes in 1 blocks
still reachable: 100 bytes in 1 blocks
suppressed: 0 bytes in 0 blocks
$ ./fugas_asan # compilado con -fsanitize=address
fin del programa
==419==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 200 byte(s) in 1 object(s) allocated from:
#0 0x… in malloc …
#1 0x… in main /home/ana/c/tema29/fugas.c:16
Direct leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x… in malloc …
#1 0x… in main /home/ana/c/tema29/fugas.c:19
Indirect leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x… in malloc …
#1 0x… in main /home/ana/c/tema29/fugas.c:20
SUMMARY: AddressSanitizer: 280 byte(s) leaked in 3 allocation(s).
| Categoría | Significa | En fugas.c | ¿Es una fuga? |
|---|---|---|---|
| definitely lost | Ningún puntero apunta al bloque: no hay forma de liberarlo. | El bloque de 200 bytes y la cabeza de la lista. | Sí. La que hay que corregir primero. |
| indirectly lost | El bloque solo es alcanzable desde otro bloque perdido. | El segundo nodo, apuntado desde la cabeza perdida. | Sí: desaparece al arreglar el bloque que lo contiene. |
| possibly lost | Solo queda un puntero al interior del bloque, no a su comienzo. | El bloque de 300, con interior apuntando a +100. | Probablemente; a veces es intencional, y free igual exige la dirección original. |
| still reachable | Al terminar, una variable todavía apunta al bloque; solo faltó liberarlo. | El de 100 bytes, apuntado por alcanzable. | No es una fuga en sentido estricto: no crece. Es descuido, pero rara vez un error. |
LeakSanitizer reporta solo lo perdido, con informes directos e indirectos, y trata como alcanzable un bloque con un puntero a su interior, por eso no mencionó los 300 bytes ni los 100 de la variable global. En los dos informes, el dato que más sirve es la traza de dónde se reservó cada bloque perdido, con el nombre de la función y la línea: de ahí se parte para encontrar quién debió liberarlo. Una fuga no se «ve» en el momento de producirse, porque el error es lo que no ocurre, la llamada a free que faltó; las herramientas la descubren al terminar el programa, comparando lo que quedó reservado con lo alcanzable. Por eso es importante que el programa de prueba termine normalmente y recorra los caminos de limpieza.
Una advertencia: ninguna herramienta distingue una fuga de un crecimiento intencional. Una caché que crece sin límite, o una lista a la que se agregan elementos y nunca se quitan, mantiene todo alcanzable y no se informa, aunque consuma toda la memoria de la misma forma. Medir el uso de memoria a lo largo del tiempo, como hicimos con VmRSS, sigue siendo necesario.
4. Uso después de liberar
Después de free(p), la dirección que guarda p es un puntero colgante: apunta a un bloque que ya no es del programa. Usarlo, para leer o para escribir, es el error de uso después de liberar (use-after-free). Es traicionero porque a menudo no tiene efecto visible, y cuando lo tiene, es porque el bloque ya fue reciclado, como mostró el tema 28. Este programa lo provoca a propósito:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *a = malloc(10 * sizeof *a);
if (a == NULL)
return 1;
a[0] = 111;
free(a); /* a ya no es nuestro... */
int *b = malloc(10 * sizeof *b); /* ...y otro pedido del mismo tamaño lo recicla */
if (b == NULL)
return 1;
b[0] = 222;
printf("a = %p, b = %p\n", (void *) a, (void *) b);
printf("a[0] = %d (a fue liberado; esta lectura es un error)\n", a[0]);
a[0] = 999; /* escritura por el puntero viejo */
printf("b[0] = %d (nadie escribió b[0] después de 222)\n", b[0]);
free(b);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -g -o uaf uaf.c
uaf.c: In function ‘main’:
uaf.c:20:10: warning: pointer ‘a’ may be used after ‘free’ [-Wuse-after-free]
20 | a[0] = 999; /* escritura por el puntero viejo */
| ~~~~~^~~~~
uaf.c:11:5: note: call to ‘free’ here
11 | free(a); /* a ya no es nuestro... */
| ^~~~~~~
…
$ ./uaf
a = 0x59361acfb010, b = 0x59361acfb010
a[0] = 222 (a fue liberado; esta lectura es un error)
b[0] = 999 (nadie escribió b[0] después de 222)
$ ./uaf_asan
=================================================================
==557==ERROR: AddressSanitizer: heap-use-after-free on address 0x711b218e0010 at pc 0x60e172a8440f bp 0x7ffe56066f80 sp 0x7ffe56066f70
READ of size 4 at 0x711b218e0010 thread T0
#0 0x60e172a8440e in main /home/ana/c/tema29/uaf.c:19
#1 0x74db2282a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
…
0x711b218e0010 is located 0 bytes inside of 40-byte region [0x711b218e0010,0x711b218e0038)
freed by thread T0 here:
#0 0x74db2352a3ff in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:51
#1 0x60e172a84308 in main /home/ana/c/tema29/uaf.c:11
…
previously allocated by thread T0 here:
#0 0x74db2352b60f in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:67
#1 0x60e172a8427e in main /home/ana/c/tema29/uaf.c:6
…
SUMMARY: AddressSanitizer: heap-use-after-free /home/ana/c/tema29/uaf.c:19 in main
Shadow bytes around the buggy address:
…
=>0x711b218e0000: fa fa[fd]fd fd fd fd fa fa fa 00 00 00 00 00 fa
$ valgrind ./uaf
Invalid read of size 4
at 0x4001213: main (uaf.c:19)
Address 0x4a91040 is 0 bytes inside a block of size 40 free'd
at 0x48538BF: free (vg_replace_malloc.c:990)
by 0x40011C9: main (uaf.c:11)
Block was alloc'd at
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x400119E: main (uaf.c:6)
Invalid write of size 4
at 0x400122F: main (uaf.c:20)
…
a = 0x4a91040, b = 0x4a910b0
a[0] = 111 (a fue liberado; esta lectura es un error)
b[0] = 222 (nadie escribió b[0] después de 222)
Sin herramientas, el programa imprime algo que parece una locura y que es perfectamente lógico. a y b son la misma dirección: el administrador entregó a b el bloque que acababa de recibir, como vimos en el tema 28. Leer a[0] devolvió el valor de b, 222, y escribir por a cambió b[0] sin que nadie lo tocara: dos «dueños» del mismo bloque. Este mecanismo es el origen de las vulnerabilidades por uso después de liberar: si el bloque reciclado guarda datos que controla un atacante, y el código viejo los usa creyendo que son los suyos, el atacante decide qué hace el programa. gcc ya avisó con -Wuse-after-free al compilar, porque aquí el flujo es sencillo; en un programa real, el free y el uso están en funciones distintas, y el compilador no los conecta.
Los dos detectores encuentran el error en la lectura de la línea 19, y su informe dice tres cosas: dónde se usó el bloque, dónde se liberó y dónde se reservó. Esas tres trazas son lo que permite entender un uso después de liberar sin adivinar. Valgrind, además, cambió el comportamiento del programa: no recicla el bloque (a y b son direcciones distintas, 111 y 222 quedan separados), porque mantiene los bloques liberados en una cola para detectar justamente esto. ASan hace algo parecido con su cuarentena. Hay además un segundo efecto: aun sin reciclar el bloque, el administrador escribe sus propios datos en los primeros 16 bytes de un bloque liberado, como vimos con gdb en el tema 28, de modo que leer un bloque liberado puede devolver basura aunque nadie más lo haya pedido. Con otra secuencia de pedidos, este mismo programa habría mostrado otros valores: un error cuyo resultado cambia con el estado de la memoria.
Un caso más sutil y muy frecuente es el puntero al interior de un bloque que realloc mueve:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *v = malloc(2 * sizeof *v);
if (v == NULL)
return 1;
v[0] = 10;
v[1] = 20;
int *primero = &v[0]; /* un puntero al interior del bloque */
int *tmp = realloc(v, 1000 * sizeof *v);
if (tmp == NULL) {
free(v);
return 1;
}
v = tmp;
v[2] = 30;
printf("v = %p, primero = %p\n", (void *) v, (void *) primero);
printf("*primero = %d\n", *primero); /* ¿sigue valiendo? */
free(v);
return 0;
}
$ ./crecer_mal
v = 0x5c9fdb80f010, primero = 0x5c9fdb80f010
*primero = 10
$ ./crecer_mal_asan
==562==ERROR: AddressSanitizer: heap-use-after-free on address 0x7aebe9ae0010 at pc 0x57f8c7e05518 …
READ of size 4 at 0x7aebe9ae0010 thread T0
#0 0x57f8c7e05517 in main /home/ana/c/tema29/crecer_mal.c:22
…
0x7aebe9ae0010 is located 0 bytes inside of 8-byte region [0x7aebe9ae0010,0x7aebe9ae0018)
freed by thread T0 here:
#0 0x7ecbeb72a6cf in realloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:81
#1 0x57f8c7e053e3 in main /home/ana/c/tema29/crecer_mal.c:14
…
En la ejecución normal, realloc pudo extender el bloque en el mismo lugar: primero sigue valiendo y el programa imprime 10. El error está ahí, pero no se manifiesta; cuando el arreglo crezca más, o con otro estado del montículo, realloc moverá el bloque, y primero apuntará a memoria liberada. ASan lo encuentra en la primera ejecución, porque su realloc siempre mueve el bloque y libera el viejo. Es una de sus virtudes: convierte errores que dependen de la suerte en errores que se producen siempre. La regla del tema 28 se aplica sin excepciones: después de un realloc se vuelven a calcular los punteros interiores, o se usan índices en lugar de punteros.
Hay otros dos orígenes comunes del puntero colgante. Uno es devolver la dirección de una variable local, el error del tema 12: gcc lo avisa con -Wreturn-local-addr, y hasta reemplaza la dirección por NULL al optimizar, así que el programa suele terminar con Segmentation fault en lugar de leer basura. El otro es guardar un puntero a un elemento de una estructura de datos que cambia, una lista o un arreglo que crece, y usarlo después de que el elemento se quitó. La defensa general es la de la sección 10: un dueño por bloque, y no conservar punteros a datos cuyo dueño pueda liberarlos.
5. Doble liberación
Llamar a free dos veces sobre el mismo bloque es un caso particular de uso después de liberar: el administrador modifica su contabilidad sobre un bloque que ya no tiene en uso. glibc detecta varios de estos casos, y los mensajes cambian con la situación:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
if (argc != 2)
return 2;
if (strcmp(argv[1], "simple") == 0) {
void *p = malloc(32);
free(p);
free(p);
printf("seguimos\n");
return 0;
}
/* la segunda liberación llega cuando otro pedido ya recicló el bloque */
char *p = malloc(32);
free(p);
char *q = malloc(32); /* q es el mismo bloque que tenía p */
free(p); /* "doble liberación": en realidad libera q */
char *r = malloc(32); /* r recibe el mismo bloque otra vez */
printf("p = %p, q = %p, r = %p\n", (void *) p, (void *) q, (void *) r);
strcpy(q, "datos de q");
strcpy(r, "datos de r");
printf("q contiene: \"%s\"\n", q);
printf("r contiene: \"%s\"\n", r);
return 0;
}
$ ./doble simple
free(): double free detected in tcache 2
Aborted
$ ./doble reciclado
p = 0x57544eafc010, q = 0x57544eafc010, r = 0x57544eafc010
q contiene: "datos de r"
r contiene: "datos de r"
$ ./doble_asan reciclado
==441==ERROR: AddressSanitizer: attempting double-free on 0x6f6f7eee0040 in thread T0:
#0 0x733f80b2a3ff in free …
#1 0x601bbf5d341d in main /home/ana/c/tema29/doble.c:24
0x6f6f7eee0040 is located 0 bytes inside of 32-byte region [0x6f6f7eee0040,0x6f6f7eee0060)
freed by thread T0 here:
#1 0x601bbf5d3403 in main /home/ana/c/tema29/doble.c:22
…
$ valgrind ./doble simple
Invalid free() / delete / delete[] / realloc()
at 0x48538BF: free (vg_replace_malloc.c:990)
by 0x4001279: main (doble.c:14)
Address 0x4a91040 is 0 bytes inside a block of size 32 free'd
at 0x48538BF: free (vg_replace_malloc.c:990)
by 0x400126A: main (doble.c:13)
Block was alloc'd at …
seguimos
ERROR SUMMARY: 1 errors from 1 contexts
El caso simple es el que glibc atrapa: el bloque liberado lleva una marca, la clave al azar que vimos en el tema 28, y encontrarla al liberar otra vez delata el error. Pero esa defensa es una heurística, y el caso reciclado la evade sin esfuerzo: entre las dos liberaciones, otro pedido recibió el mismo bloque, de modo que la segunda free(p) es, para el administrador, una liberación legítima del bloque que ahora es de q. El programa no aborta. Pero las consecuencias son las de la sección anterior: q sigue creyéndose dueño de un bloque que el administrador ya consideró libre, y el siguiente pedido, r, recibe el mismo bloque. Los tres punteros apuntan a lo mismo, y lo que el programa escribe por q es pisado por r: «q contiene "datos de r"». Dos dueños, sin ningún mensaje.
Por eso los mensajes de glibc no son una red de seguridad: double free detected in tcache 2, double free or corruption (fasttop), double free or corruption (!prev) y free(): invalid pointer son comprobaciones oportunistas, y varios de esos mensajes (como el double free or corruption (out) de la sección siguiente) se emiten también ante errores que no son una doble liberación. ASan y Valgrind sí lo detectan siempre, porque llevan la cuenta exacta de qué bloques están vivos. Incluso en el caso reciclado: como mantienen el bloque liberado fuera de circulación, q es un bloque distinto de p, y la segunda free(p) se reconoce como lo que es. Los dos nombran el hecho (attempting double-free, Invalid free()) y muestran quién liberó primero, con la línea 22 del ejemplo. La defensa en el código es la de siempre: un dueño por bloque, y asignar NULL al puntero después de liberarlo, para que una segunda liberación sea inofensiva. Con una excepción importante: eso solo anula esa variable. Si la dirección estaba en dos lugares, la otra copia sigue colgante, y el problema es de diseño: había dos dueños.
6. Desbordes
Un desborde (buffer overflow) es escribir, o leer, más allá del final de un bloque o de un arreglo; su variante simétrica, antes del comienzo, se llama a veces underflow. Ya los vimos en la pila (temas 1, 12, 21), con strcpy y scanf. En el montículo ocurren igual, y suelen ser de un byte de más: un <= en lugar de un < en un bucle, o el + 1 del terminador que se olvida. Este programa tiene los dos casos que importan:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
if (argc != 2)
return 2;
char *a = malloc(10);
char *b = malloc(10);
if (a == NULL || b == NULL)
return 1;
strcpy(b, "bbbbbbbbb");
if (strcmp(argv[1], "uno") == 0) {
for (int i = 0; i <= 10; i++) /* un elemento de más: <= en vez de < */
a[i] = 'a';
printf("escribí 11 bytes en un bloque de 10: sin ningún síntoma\n");
} else if (strcmp(argv[1], "mucho") == 0) {
memset(a, 'a', 40); /* pisa el encabezado del bloque siguiente */
printf("escribí 40 bytes en un bloque de 10: todavía sin síntomas\n");
}
printf("b = \"%s\"\n", b);
free(a);
printf("free(a) terminó\n");
free(b);
printf("free(b) terminó\n");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -g -o desborde_heap desborde_heap.c
desborde_heap.c: In function ‘main’:
desborde_heap.c:21:9: warning: ‘memset’ writing 40 bytes into a region of size 10 overflows the destination [-Wstringop-overflow=]
…
$ ./desborde_heap uno
escribí 11 bytes en un bloque de 10: sin ningún síntoma
b = "bbbbbbbbb"
free(a) terminó
free(b) terminó
$ ./desborde_heap mucho
escribí 40 bytes en un bloque de 10: todavía sin síntomas
b = "aaaaaaaab"
free(a) terminó
double free or corruption (out)
Aborted
$ ./desborde_heap_asan uno
==572==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x704beb2e001a at pc 0x635c962b250e …
WRITE of size 1 at 0x704beb2e001a thread T0
#0 0x635c962b250d in main /home/ana/c/tema29/desborde_heap.c:18
…
0x704beb2e001a is located 0 bytes after 10-byte region [0x704beb2e0010,0x704beb2e001a)
allocated by thread T0 here:
#0 0x742becf2b60f in malloc …
#1 0x635c962b2376 in main /home/ana/c/tema29/desborde_heap.c:9
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/ana/c/tema29/desborde_heap.c:18 in main
Shadow bytes around the buggy address:
…
=>0x704beb2e0000: fa fa 00[02]fa fa 00 02 fa fa fa fa fa fa fa fa
$ valgrind ./desborde_heap uno
Invalid write of size 1
at 0x400128E: main (desborde_heap.c:18)
Address 0x4a9104a is 0 bytes after a block of size 10 alloc'd
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x4001215: main (desborde_heap.c:9)
…
Los dos casos ilustran los dos problemas de los desbordes:
- Un byte de más se esconde. En
unoel programa escribe 11 bytes en un bloque de 10, y termina sin un solo síntoma:malloc(10)entrega, en realidad, 24 bytes utilizables (tema 28), así que el undécimo cae en el espacio de relleno del propio bloque, sin pisar nada. Es el error que pasa todas las pruebas, hasta que cambia un tamaño o una plataforma. ASan y Valgrind, en cambio, miden el pedido exacto de 10: el undécimo byte cae en la zona roja, y ambos informan 0 bytes after a block of size 10 con la línea exacta. - El síntoma de un desborde grande aparece después, en otro lugar. En
mucho,memsetescribe 40 bytes: pisa el encabezado del bloque siguiente, el deb(que muestra"aaaaaaaab"en lugar de"bbbbbbbbb"), yfree(a)funciona; el aborto ocurre enfree(b), con un mensaje,double free or corruption (out), que ni habla de desborde ni apunta a la línea que causó el daño. Sin una herramienta, el que lee ese mensaje busca una doble liberación que no existe. ASan, en cambio, lo señala en elmemsetde la línea 21.
Para los arreglos de la pila y las variables globales, la diferencia entre las dos herramientas es mayor. Este programa escribe un elemento después del final de un arreglo local, o de uno global:
#include <stdio.h>
#include <string.h>
static int global[10];
int main(int argc, char *argv[])
{
int local[10];
volatile int *p;
if (argc != 2)
return 2;
memset(local, 0, sizeof local);
if (strcmp(argv[1], "pila") == 0)
p = &local[0] + 10; /* un elemento después del final del arreglo local */
else
p = &global[0] + 10; /* ...y del global */
*p = 7;
printf("escritura fuera del arreglo (%s): sin ningún síntoma\n", argv[1]);
return 0;
}
$ ./fuera_asan pila
==576==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7a0a488d0058 at pc 0x55eb91e28545 …
WRITE of size 4 at 0x7a0a488d0058 thread T0
#0 0x55eb91e28544 in main /home/ana/c/tema29/fuera.c:18
…
Address 0x7a0a488d0058 is located in stack of thread T0 at offset 88 in frame
#0 0x55eb91e282f8 in main /home/ana/c/tema29/fuera.c:7
This frame has 1 object(s):
[48, 88) 'local' (line 8) <== Memory access at offset 88 overflows this variable
$ ./fuera_asan global
==578==ERROR: AddressSanitizer: global-buffer-overflow on address 0x62828a50a6a8 at pc 0x62828a507545 …
WRITE of size 4 at 0x62828a50a6a8 thread T0
#0 0x62828a507544 in main /home/ana/c/tema29/fuera.c:18
0x62828a50a6a8 is located 0 bytes after global variable 'global' defined in 'fuera.c:4:12' (0x62828a50a680) of size 40
$ valgrind ./fuera pila
escritura fuera del arreglo (pila): sin ningún síntoma
…
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
ASan, que insertó zonas rojas alrededor de cada variable al compilar, nombra la variable ('local', 'global') y su tamaño. Valgrind informa cero errores: para él, la pila y las variables globales son memoria válida de principio a fin, sin límites internos que vigilar. Es la razón de la elección: para los errores del montículo, cualquiera de las dos; para la pila y los globales, solo ASan.
7. Lectura sin inicializar
El quinto error es el más callado: usar el valor de una variable o de un bloque antes de haberle escrito algo. Como vimos en el tema 28, malloc devuelve memoria sin inicializar, que a menudo contiene ceros (si es nueva) o restos de un bloque anterior (si es reciclada). Este programa suma cuatro enteros, pero solo inicializó el primero:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *v = malloc(4 * sizeof *v); /* nunca se inicializa */
int suma = 0;
if (v == NULL)
return 1;
v[0] = 5; /* solo el primero tiene valor */
for (int i = 0; i < 4; i++)
suma += v[i];
if (suma > 5)
printf("suma = %d: hay algo más que el 5\n", suma);
else
printf("suma = %d\n", suma);
free(v);
return 0;
}
$ ./sin_iniciar
suma = 5
$ valgrind --track-origins=yes ./sin_iniciar
Conditional jump or move depends on uninitialised value(s)
at 0x40011F5: main (sin_iniciar.c:14)
Uninitialised value was created by a heap allocation
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x400119E: main (sin_iniciar.c:6)
Use of uninitialised value of size 8
at 0x48D0062: _itoa_word (_fitoa_word.c:38)
by 0x48DB61C: __printf_buffer (vfprintf-process-arg.c:155)
…
Uninitialised value was created by a heap allocation
at 0x4850858: malloc (vg_replace_malloc.c:447)
by 0x400119E: main (sin_iniciar.c:6)
…
$ ./sin_iniciar_asan
sin_iniciar.c:13:14: runtime error: signed integer overflow: -1094795581 + -1094795586 cannot be represented in type 'int'
suma = 1010580543: hay algo más que el 5
La ejecución normal imprime suma = 5, la respuesta que «se esperaba», porque el bloque era nuevo y llegó en cero: el programa funciona por casualidad. Valgrind rastrea qué bytes de la memoria están definidos y cuáles no, y protesta en el primer lugar donde el programa decide algo con un valor indefinido, aquí el if de la línea 14, no en la suma de la línea 13; sumar un valor indefinido no es un error por sí mismo, sino usarlo para decidir o para mostrar algo. Con --track-origins=yes informa también dónde nació el valor: «created by a heap allocation» en la línea 6. Sin esa opción solo diría que hay un valor indefinido, y habría que buscar de dónde viene.
ASan no detecta lecturas sin inicializar (para eso existe MemorySanitizer, pero solo en clang), pero cambió el resultado de otra forma: rellena toda la memoria nueva con el byte 0xbe, lo que vuelve visible el error. La suma dio un número enorme, y UBSan, del mismo ejecutable, avisó de un desbordamiento de enteros. Es un efecto secundario útil: un programa que dependía de que «malloc da ceros» cambia de resultado en cuanto se compila con ASan.
glibc ofrece el mismo truco sin recompilar: la variable de entorno MALLOC_PERTURB_ rellena con un valor fijo la memoria que entrega malloc y la que recibe free:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char *p = malloc(16); /* sin inicializar */
if (p == NULL)
return 1;
printf("primer byte sin inicializar: %u\n", p[0]);
free(p);
return 0;
}
$ ./perturb
primer byte sin inicializar: 0
$ MALLOC_PERTURB_=165 ./perturb
primer byte sin inicializar: 90
Con MALLOC_PERTURB_=165 (0xa5), los bloques nuevos llegan llenos de 90 (0x5a, el complemento) y los liberados quedan llenos de 165. Un programa cuyo resultado depende de ese relleno tiene una lectura sin inicializar, o un uso después de liberar. Es una prueba gratuita, que se puede correr sobre cualquier ejecutable, sin Valgrind ni ASan.
8. Cómo leer un informe
Los informes de las dos herramientas siguen un orden fijo, y aprender a leerlos ahorra más tiempo que cualquier otra cosa de este tema. El de ASan, tomado del uaf de la sección 4:
| Parte del informe | Qué dice | Cómo se usa |
|---|---|---|
ERROR: AddressSanitizer: heap-use-after-free on address … | El tipo de error y la dirección que lo provocó. | El tipo ya orienta la causa (tabla siguiente). |
READ of size 4 at … y la traza #0 … #1 … | Si fue lectura o escritura, cuántos bytes, y la pila de llamadas en el momento del acceso. | La primera línea del programa propio, uaf.c:19, es donde está el uso incorrecto. |
is located 0 bytes inside of 40-byte region | Dónde cae la dirección respecto del bloque: dentro, después, antes. | «N bytes after» indica un desborde; «inside» de un bloque liberado, un uso colgante. |
freed by thread T0 here: | La pila de llamadas de la liberación (solo en errores con un bloque liberado). | Quién devolvió el bloque: ahí hay que revisar el dueño. |
previously allocated by thread T0 here: | La pila de llamadas de la reserva. | Quién pidió el bloque, y con qué tamaño. |
SUMMARY: … in main | Una línea de resumen, con el archivo y la función. | Lo que se copia en un mensaje de error o un reporte. |
Shadow bytes around the buggy address: | La memoria sombra alrededor, con la dirección señalada entre corchetes y la leyenda de códigos. | Rara vez hace falta; confirma el tipo de región (fd es «liberado», fa es «zona roja»). |
El de Valgrind tiene la misma lógica con otras palabras: Invalid read/write of size N (el acceso y su traza); Address … is N bytes after / inside a block of size M alloc'd / free'd (dónde cae); y los bloques at / by de la liberación y la reserva, que se leen de arriba hacia abajo, de la función más interna a la más externa. El nombre de las funciones internas de libc en las trazas (strlen, _itoa_word, __printf_buffer) no es el error: es por donde pasó el acceso. Se busca hacia abajo la primera línea con un archivo propio.
Los nombres de error de ASan que conviene reconocer:
| Mensaje | Qué significa |
|---|---|
heap-buffer-overflow | Acceso fuera de los límites de un bloque de malloc, hacia adelante o hacia atrás (secciones 6 y 9). |
stack-buffer-overflow | Acceso fuera de un arreglo local; el informe nombra la variable. |
global-buffer-overflow | Acceso fuera de una variable global o static. |
heap-use-after-free | Acceso a un bloque ya liberado (sección 4), incluidos los movidos por realloc. |
attempting double-free | Segundo free sobre el mismo bloque (sección 5). |
attempting free on address which was not malloc()-ed | free de una variable local, una literal o un puntero al interior. |
SEGV on unknown address | Acceso a una dirección sin región, como NULL (tema 27). |
LeakSanitizer: detected memory leaks | Fugas, al terminar el programa (sección 3). |
ASan se detiene en el primer error por omisión: es lo que permite una explicación limpia y evita informar errores en cascada. Si se quiere seguir, se compila con -fsanitize-recover=address y se ejecuta con ASAN_OPTIONS=halt_on_error=0; pero el primer error es el que importa, y los demás pueden ser consecuencia suya.
9. Un caso completo: la agenda
Para ver cómo se usa todo junto, este programa mantiene una agenda de nombres en un arreglo dinámico que crece con realloc, y tiene tres errores. Conviene leerlo antes de seguir, y tratar de encontrarlos:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct agenda {
char **nombres;
size_t cantidad;
size_t capacidad;
};
static int agregar(struct agenda *a, const char *nombre)
{
if (a->cantidad == a->capacidad) {
size_t nueva = a->capacidad ? a->capacidad * 2 : 2;
char **tmp = realloc(a->nombres, nueva * sizeof *tmp);
if (tmp == NULL)
return -1;
a->nombres = tmp;
a->capacidad = nueva;
}
char *copia = malloc(strlen(nombre));
if (copia == NULL)
return -1;
strcpy(copia, nombre);
a->nombres[a->cantidad++] = copia;
return 0;
}
static void quitar(struct agenda *a, size_t i)
{
free(a->nombres[i]);
}
static void mostrar(const struct agenda *a)
{
for (size_t i = 0; i < a->cantidad; i++)
printf(" %zu: %s\n", i, a->nombres[i]);
}
int main(void)
{
struct agenda a = {NULL, 0, 0};
agregar(&a, "Ana");
agregar(&a, "Luis");
agregar(&a, "Beatriz");
printf("agenda con %zu nombres:\n", a.cantidad);
mostrar(&a);
quitar(&a, 1);
printf("después de quitar el 1:\n");
mostrar(&a);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -g -o agenda agenda.c && ./agenda
agenda con 3 nombres:
0: Ana
1: Luis
2: Beatriz
después de quitar el 1:
0: Ana
1: �9
2: Beatriz
Compila sin un solo aviso y la primera parte sale bien. Después de quitar el nombre 1, la agenda muestra basura en esa posición. Eso es todo lo que se ve: ningún aborto, ningún mensaje, código de salida 0. Pasemos el programa por ASan, uno a la vez, corrigiendo cada hallazgo:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -fno-omit-frame-pointer -o agenda_asan agenda.c
$ ./agenda_asan
==442==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x71a8918e0033 at pc 0x7588934bd2a9 …
WRITE of size 4 at 0x71a8918e0033 thread T0
#0 0x7588934bd2a8 in strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:575
#1 0x5d6cdaf3f705 in agregar /home/ana/c/tema29/agenda.c:25
#2 0x5d6cdaf3fc91 in main /home/ana/c/tema29/agenda.c:45
…
0x71a8918e0033 is located 0 bytes after 3-byte region [0x71a8918e0030,0x71a8918e0033)
allocated by thread T0 here:
#0 0x75889352b60f in malloc …
#1 0x5d6cdaf3f6b1 in agregar /home/ana/c/tema29/agenda.c:22
Error 1. El informe es claro: un strcpy de la línea 25 escribió 4 bytes y el bloque, reservado en la línea 22, mide 3 bytes: «Ana» tiene 3 caracteres y falta el lugar del terminador. Sin ASan, glibc entregó 24 bytes utilizables y no se enteró. La corrección es el + 1 que ya conocemos del tema 21: malloc(strlen(nombre) + 1). Se corrige y se vuelve a ejecutar:
$ ./agenda1_asan
==444==ERROR: AddressSanitizer: heap-use-after-free on address 0x77f612ee0050 at pc 0x7bd614ad0cae …
READ of size 2 at 0x77f612ee0050 thread T0
#0 0x7bd614ad0cad in printf_common …
#3 0x604527b96acf in mostrar /home/ana/c/tema29/agenda1.c:38
#4 0x604527b96d39 in main /home/ana/c/tema29/agenda1.c:53
…
0x77f612ee0050 is located 0 bytes inside of 5-byte region [0x77f612ee0050,0x77f612ee0055)
freed by thread T0 here:
#0 0x7bd614b2a3ff in free …
#1 0x604527b969b3 in quitar /home/ana/c/tema29/agenda1.c:32
#2 0x604527b96d1e in main /home/ana/c/tema29/agenda1.c:51
previously allocated by thread T0 here:
#1 0x604527b966b5 in agregar /home/ana/c/tema29/agenda1.c:22
#2 0x604527b96cab in main /home/ana/c/tema29/agenda1.c:46
Error 2. Ahora el informe es un uso después de liberar. Las tres trazas cuentan la historia completa: mostrar (línea 38) lee un nombre de 5 bytes, «Luis» más el terminador, que quitar liberó en la línea 32, y que agregar reservó en la línea 22. Es la causa de la basura que se veía: quitar libera el nombre pero deja el puntero en la lista y la cantidad sin tocar, de modo que mostrar lo recorre como si siguiera vivo. La corrección es quitar también la entrada: correr las siguientes una posición con memmove (tema 21) y restar uno a la cantidad:
static void quitar(struct agenda *a, size_t i)
{
free(a->nombres[i]);
memmove(&a->nombres[i], &a->nombres[i + 1],
(a->cantidad - i - 1) * sizeof a->nombres[0]);
a->cantidad--;
}
$ ./agenda2_asan
agenda con 3 nombres:
0: Ana
1: Luis
2: Beatriz
después de quitar el 1:
0: Ana
1: Beatriz
=================================================================
==446==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 32 byte(s) in 1 object(s) allocated from:
#0 0x75977fb2a6cf in realloc …
#1 0x569975fbf5c1 in agregar /home/ana/c/tema29/agenda2.c:15
Indirect leak of 8 byte(s) in 1 object(s) allocated from:
#1 0x569975fbf6d5 in agregar /home/ana/c/tema29/agenda2.c:22
Indirect leak of 4 byte(s) in 1 object(s) allocated from:
#1 0x569975fbf6d5 in agregar /home/ana/c/tema29/agenda2.c:22
SUMMARY: AddressSanitizer: 44 byte(s) leaked in 3 allocation(s).
Error 3. La salida del programa ahora es correcta, y al terminar LeakSanitizer informa la fuga: el arreglo de punteros (32 bytes, realloc de la línea 15) es una pérdida directa, y los dos nombres que quedan, «Ana» y «Beatriz», 4 y 8 bytes, son indirectas, alcanzables solo desde el arreglo perdido. Así se lee una fuga de una estructura con punteros propios, y se corrige con la función hermana de las que construyen la agenda, la que libera primero lo que cuelga y después el contenedor, como vimos en el tema 28:
static void vaciar(struct agenda *a)
{
for (size_t i = 0; i < a->cantidad; i++)
free(a->nombres[i]);
free(a->nombres);
a->nombres = NULL;
a->cantidad = a->capacidad = 0;
}
/* ...y al final de main, antes del return: */
vaciar(&a);
$ ./agenda3_asan; echo $?
agenda con 3 nombres:
0: Ana
1: Luis
2: Beatriz
después de quitar el 1:
0: Ana
1: Beatriz
0
$ valgrind --leak-check=full ./agenda3
…
HEAP SUMMARY:
in use at exit: 0 bytes in 0 blocks
total heap usage: 6 allocs, 6 frees, 4,161 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
El criterio de «terminado» es este: seis reservas, seis liberaciones, cero errores, con las dos herramientas. Hay varias lecciones en el proceso. Los tres errores estaban en un programa de 60 líneas que compilaba sin avisos y que «casi» funcionaba; ninguno se habría encontrado por inspección rápida sin un motivo para sospechar. Cada informe señaló la línea exacta y explicó su causa en un par de líneas. Cada corrección destapó el siguiente problema, porque ASan se detiene en el primero. Y Valgrind, sobre el programa original, encuentra los tres a la vez, porque sigue después de los errores: tres escrituras inválidas de un byte en strcpy, las lecturas de los nombres liberados, y una pérdida de 42 bytes (32 directos y 10 indirectos), lo que sirve para ver el panorama completo antes de empezar a corregir.
10. Cómo evitarlos
Las herramientas encuentran los errores, pero el esfuerzo más rentable es no cometerlos. Los hábitos que más errores evitan, casi todos vistos en este bloque:
- Cada bloque tiene un dueño, y su documentación dice quién (tema 28). Quien reserva, o quien recibe la responsabilidad, libera. Si no se puede decir quién libera un bloque, hay un error de diseño.
- Crear y destruir van en pares, en el mismo módulo:
agregaryvaciar,contador_crearycontador_destruir. Se escribe la función de destrucción junto con la de creación, no al final. - Un solo lugar de limpieza por función, con el
goto limpiardel tema 9, para que todos los caminos de error liberen lo mismo. - Asignar
NULLdespués defreecuando el puntero seguirá a la vista, y borrar de la estructura las entradas que se liberan, como corrigióquitar. - Tamaños calculados con cuidado:
sizeof *p,callocoreallocarraypara las multiplicaciones,strlen + 1ostrduppara las cadenas, ysnprintfcon comprobación del recorte (tema 22) en lugar destrcpy. - No guardar punteros a datos que otro puede mover o liberar. Cuando un arreglo crece, se usan índices; un puntero al interior no sobrevive a un
realloc. - Inicializar siempre:
calloc,= {0}, o escribir antes de leer. - Compilar con
-Wall -Wextra, sin ignorar ningún aviso, y correr las pruebas bajo ASan y Valgrind en cada cambio importante. Un error encontrado el mismo día en que se escribió cuesta minutos; encontrado por un usuario, semanas. - Reducir la cantidad de reservas. Un arreglo de estructuras en un solo bloque tiene un dueño, un
freey ninguna lista de punteros que mantener, frente a un bloque por elemento (tema 28, sección 12). Un diseño con menos dueños tiene menos errores.
Hay herramientas del sistema que reducen el daño cuando algo se escapa: el protector de pila y _FORTIFY_SOURCE del tema 4, el ASLR y la memoria W^X del tema 27, y las comprobaciones de glibc de este tema. Dificultan la explotación, pero no corrigen el error, y no se pueden tomar como garantía. Hay también lenguajes que eliminan clases enteras de estos errores desde el diseño, como Rust, que comprueba el dueño de cada dato al compilar; los programas de este curso, en C, deben conseguir lo mismo con disciplina y herramientas.
Finalmente, una pregunta de método: ¿cuándo se usan las herramientas? Siempre que se escribe código con memoria dinámica, antes de dar por terminado un cambio. ASan en las pruebas diarias; Valgrind de vez en cuando, sobre todo para las lecturas sin inicializar y para comprobar programas que no se pueden recompilar. Y siempre con casos que recorran los caminos de error, que son los que menos se ejecutan: con ulimit -v para forzar los fallos de malloc (tema 28), y entradas fuera de lo normal, vacías, enormes, mal formadas.
11. Errores frecuentes
| Error | Qué pasa | Corrección |
|---|---|---|
| Concluir que el programa está bien porque «funciona» | Un desborde de un byte o un puntero colgante pueden no tener efecto visible durante años. | Ejecutar bajo ASan y Valgrind. |
Ignorar los avisos -Wuse-after-free o -Wstringop-overflow | Casi siempre señalan un error real. | Tratar cada aviso como un error. |
| Medir las fugas solo con el final del programa | Un crecimiento intencional o una caché sin límite no se informan. | Observar VmRSS durante la ejecución. |
Creer que glibc detecta los errores de free | Sus controles son heurísticas: la doble liberación reciclada pasa sin avisar. | No depender de ellos; usar las herramientas. |
Interpretar double free or corruption como una doble liberación | También lo produce un desborde que pisó un encabezado. | Buscar el desborde con ASan. |
Usar un puntero interior después de realloc | Funciona hasta que el bloque se mueve. | Recalcular o usar índices. |
| Liberar el elemento y dejarlo en la estructura | La estructura tiene un puntero colgante. | Quitar la entrada al liberar. |
| Liberar el contenedor antes que lo que cuelga | Se pierden los bloques internos. | Primero los internos. |
| Esperar que ASan encuentre lecturas sin inicializar | No lo hace; solo rellena la memoria con 0xbe. | Valgrind con --track-origins=yes, o MALLOC_PERTURB_. |
| Esperar que Valgrind encuentre desbordes de arreglos locales o globales | No los ve. | Usar ASan. |
| Ejecutar un binario de ASan bajo Valgrind | Las dos herramientas interfieren. | Un binario normal para Valgrind, uno con ASan para ASan. |
| Dejar ASan en el programa que se entrega | Más lento, más memoria, y es una herramienta de depuración. | Solo en desarrollo y pruebas. |
Compilar sin -g para depurar | Los informes no traen líneas ni nombres. | -g siempre. |
| Probar solo el camino feliz | Los errores de limpieza están en las ramas de error. | Forzar fallos con ulimit -v y entradas hostiles. |
12. Resumen
- Los errores de memoria son comportamiento indefinido: C no comprueba límites ni vida de los bloques. Sus síntomas aparecen lejos de la causa, dependen del estado de la memoria, se esconden en pruebas chicas y son explotables.
- Una fuga es un bloque sin liberar y sin dueño. Hace crecer a los programas de larga vida. Valgrind la clasifica en definitely, indirectly, possibly lost y still reachable; LeakSanitizer informa las pérdidas directas e indirectas al terminar el programa.
- El uso después de liberar usa un puntero colgante; el bloque suele estar reciclado y compartido con otro dueño. Incluye usar punteros interiores después de un
realloc. Los informes dicen dónde se usó, dónde se liberó y dónde se reservó. - La doble liberación la detectan ASan y Valgrind siempre;
glibcsolo a veces, y con mensajes que no siempre dicen la causa. Si otro pedido recicló el bloque, la segundafreelibera al nuevo dueño sin ningún aviso. - Un desborde de un byte pasa desapercibido porque
mallocredondea; uno grande pisa el encabezado del bloque siguiente y falla después, en otra parte. ASan ve también los arreglos de la pila y los globales; Valgrind, no. - La lectura sin inicializar solo la ve Valgrind (con
--track-origins=yesdice dónde nació el valor). ASan rellena con0xbeyMALLOC_PERTURB_con un valor a elección: ambos cambian el resultado de un programa que dependía de ceros. - ASan se activa con
-fsanitize=address,undefined, usa memoria sombra, zonas rojas y cuarentena, y cuesta unas 2× en velocidad; Valgrind ejecuta el binario sin modificar, 10× a 50× más lento. Se usan con-gy no se combinan. - Un informe se lee en orden: el tipo de error, la traza del acceso (primera línea propia), dónde cae la dirección respecto del bloque, y las trazas de la liberación y la reserva.
- Se corrige un hallazgo a la vez, porque ASan se detiene en el primero. El criterio de programa limpio: tantas liberaciones como reservas y cero errores, con las dos herramientas.
- Prevención: un dueño por bloque, crear y destruir en pares, una sola salida de limpieza, tamaños calculados con cuidado, inicializar siempre, no guardar punteros que puedan quedar colgantes, y herramientas en cada cambio.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Por qué un programa con un desborde de un byte en un malloc(10) puede pasar todas las pruebas?
Porque malloc(10) entrega en realidad 24 bytes utilizables en glibc, y el undécimo byte cae en ese espacio de relleno sin pisar nada. El error existe, pero no tiene efecto visible. ASan y Valgrind miden el pedido exacto y lo detectan.
2. ¿Cuál es la diferencia entre definitely lost y still reachable en Valgrind?
Definitely lost es un bloque al que no apunta ningún puntero: no hay forma de liberarlo, es una fuga que crece si se repite. Still reachable es un bloque que, al terminar el programa, todavía tiene un puntero apuntándole, normalmente una variable global; no se liberó, pero no se perdió, y no crece con las repeticiones.
3. ¿Por qué las fugas no se detectan hasta que termina el programa?
Porque una fuga es una omisión, la llamada a free que no se hizo, y nada la delata mientras el programa corre. Las herramientas, al final, comparan los bloques que quedaron reservados con los que todavía son alcanzables desde variables y registros. Un programa que no termina nunca, o que termina por una señal, no recorre ese análisis.
4. En uaf.c, ¿por qué la lectura de a[0] devolvió 222 y no 111?
Porque free(a) devolvió el bloque al administrador y el siguiente malloc del mismo tamaño le entregó a b exactamente el mismo bloque. a y b eran la misma dirección: lo que se escribió por b se lee por a. Bajo Valgrind se devuelve 111, porque no recicla el bloque de inmediato.
5. ¿Por qué ASan encuentra siempre el error de crecer_mal.c y la ejecución normal no?
Con el administrador de glibc, realloc puede extender el bloque en el mismo lugar, y entonces el puntero interior sigue siendo válido por casualidad. El realloc de ASan siempre reserva un bloque nuevo, copia y libera el viejo, de modo que el puntero interior siempre queda colgante y el error se manifiesta en cada ejecución.
6. ¿Por qué los mensajes de glibc sobre free no son una protección confiable?
Porque son comprobaciones oportunistas sobre la contabilidad interna: detectan algunos patrones, como la marca de un bloque ya liberado, pero no todos. Una segunda liberación que llega cuando otro pedido ya recicló el bloque parece, para el administrador, una liberación válida, y pasa sin aviso. Además, varios mensajes se emiten ante errores distintos del que nombran.
7. ¿Qué es la memoria sombra de AddressSanitizer?
Una región auxiliar donde cada byte describe el estado de 8 bytes de la memoria del programa: 00 si todos son válidos, 01 a 07 si solo los primeros lo son, y códigos negativos para las zonas rojas, la memoria liberada y otros casos. El código que inserta el compilador consulta la sombra antes de cada acceso.
8. ¿Por qué Valgrind no encuentra el desborde del arreglo local de fuera.c y ASan sí?
Porque Valgrind controla la memoria del montículo y qué bytes están definidos, pero para él la pila es una región válida de punta a punta: no sabe dónde empieza y termina cada variable local. ASan, en cambio, modifica el código al compilar y coloca zonas rojas alrededor de cada arreglo local y global.
9. ¿Qué herramienta detecta la lectura de un valor sin inicializar, y cómo se pide que diga de dónde viene?
Valgrind, con Memcheck. Con --track-origins=yes añade a cada informe dónde se creó el valor indefinido, por ejemplo «created by a heap allocation» con la traza de malloc. Protesta cuando el valor se usa para decidir o para mostrar algo, no cuando solo se copia o suma.
10. En el informe de ASan de un uso después de liberar, ¿qué indican las secciones «freed by» y «previously allocated by»?
«freed by thread T0 here» es la pila de llamadas del free que devolvió el bloque, o del realloc que lo movió. «previously allocated by thread T0 here» es la pila del malloc que lo creó. Junto con la traza del acceso, dan las tres posiciones de la historia del bloque: dónde nació, dónde murió y dónde se usó indebidamente.
11. ¿Por qué ASan se detiene en el primer error, y cómo se hace para ver más?
Porque después del primer error el estado del programa ya es incierto, y los siguientes pueden ser consecuencia suya; así el informe es limpio y explica una sola causa. Para seguir se compila con -fsanitize-recover=address y se ejecuta con ASAN_OPTIONS=halt_on_error=0. Lo normal es corregir el primero y repetir.
12. ¿Qué tres errores tenía la agenda, y cómo se corrigió cada uno?
(1) malloc(strlen(nombre)) sin el lugar del \0: un desborde de un byte en strcpy; se corrigió con + 1. (2) quitar liberaba el nombre pero lo dejaba en la lista: un uso después de liberar en mostrar; se corrigió quitando la entrada con memmove y restando uno a la cantidad. (3) Nadie liberaba la agenda al final: una fuga del arreglo y de los nombres; se corrigió con una función vaciar.
13. ¿Cómo se prueba, sin un sistema real sin memoria, que un programa limpia correctamente cuando malloc falla?
Se limita el espacio de direcciones del proceso con ulimit -v, por ejemplo (ulimit -v 200000; ./programa), con lo que malloc devuelve NULL de verdad, y se ejecuta el programa bajo ASan o Valgrind para comprobar que las ramas de error liberan todo lo reservado hasta ese punto, sin fugas ni accesos inválidos.
Para practicar
- Compila
fugas.ccon-fsanitize=addressy mira cuáles de las cuatro reservas informa LeakSanitizer. Prueba conASAN_OPTIONS=detect_leaks=0y vuelve a ejecutar. Después corrige el programa para que Valgrind diga «All heap blocks were freed». - Escribe un programa que acumule mensajes en una lista y se olvide de liberar la lista al terminar. Mide con
VmRSSel crecimiento si se repite la operación 100 000 veces, y verifica con Valgrind que la fuga aparece como definitely lost con la línea correcta. - Modifica
doble.cpara que la segunda liberación llegue después de dos pedidos intermedios de tamaños distintos. ¿Cambia el resultado deglibc? Compruébalo y explica por qué. - Escribe la versión de
desborde_heap.cque usastrcpypara copiar «hola mundo» en unmalloc(10). Compila con-O2y con-O0, y observa qué dicegcc, qué diceglibc(_FORTIFY_SOURCE, tema 4) y qué dicen ASan y Valgrind. - Corre
sin_iniciar.cconMALLOC_PERTURB_=165y con distintos valores, y convalgrind --track-origins=no. ¿Qué información se pierde sin el origen? - Escribe un programa que acceda a un arreglo local con un índice negativo (underflow) y observa el informe de ASan. ¿Qué dice la parte «is located N bytes before»?
- Toma el módulo
contadordel tema 28 y quítale a propósito elfree(c->nombre)decontador_destruir. Compruébalo con LeakSanitizer y con Valgrind, y compara el nivel de detalle de las dos trazas. - Escribe una versión de
agenda.ccon un cuarto error de tu elección, que ni ASan ni Valgrind detecten (por ejemplo, una lógica incorrecta que no viole la memoria), y explica qué clase de errores quedan fuera de estas herramientas.