← Programación en C para Sistemas Operativos Bloque 7 · Memoria dinámica · Tema 29 de 42

Tema 29

Errores de memoria

El tema anterior terminó con una advertencia: en C, pedir memoria es fácil y devolverla bien es responsabilidad de cada línea del programa. Este tema es el catálogo de lo que sale mal, y de cómo se encuentra. Los errores de memoria tienen mala fama por buenas razones: el compilador casi nunca los ve, el programa a menudo parece funcionar, el síntoma aparece lejos de la causa, y los mismos errores son los que más vulnerabilidades graves han producido en la historia del software. Veremos los cinco más importantes, la fuga, el uso después de liberar, la doble liberación, el desborde y la lectura de memoria sin inicializar; las dos herramientas con las que se cazan, AddressSanitizer y Valgrind; cómo leer sus informes; y un caso completo, de un programa con tres errores, que se depura paso a paso hasta dejarlo limpio.

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:

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:

ErrorQué ocurregcc al compilarglibc al ejecutarASanValgrind
FugaUn bloque se queda sin liberar y sin dueño.NoNoSí, al terminarSí
Uso después de liberarSe lee o escribe un bloque ya devuelto.Casos simplesNoSíSí
Doble liberaciónfree sobre un bloque ya liberado.Casos simplesA vecesSíSí
Liberación inválidafree de algo que no vino de malloc.Casos simplesCasi siempreSíSí
Desborde en el montículoSe escribe o lee más allá de un bloque.Con tamaños constantesTarde, por casualidadSí, exactoSí, exacto
Desborde en la pila o en un globalSe sale de un arreglo local o global.Pocos casosEl canario, al volverSíNo
Lectura sin inicializarSe usa un valor indeterminado.Algunos casosNoNoSí

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:

terminal · compilar con AddressSanitizer
$ 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:

Un bloque de malloc(10) visto por AddressSanitizer: dos bytes de sombra marcan la zona roja izquierda con fa; los primeros 8 bytes del bloque tienen sombra 00, que significa los 8 bytes válidos; los 2 bytes siguientes tienen sombra 02, que significa solo los primeros 2 de 8 son válidos; y después viene la zona roja derecha con fa. Un acceso al byte 10 cae en el medio del byte de sombra 02 y se detecta malloc(10) vista por AddressSanitizer memoria del programa (cada cuadro, 8 bytes) zona roja 8 válidos 2 válidos zona roja memoria sombra (un byte por cuadro) fa 00 02 fa 00 = los 8 son válidos 02 = solo los 2 primeros fa = zona roja del montículo Un acceso al byte 10 mira la sombra «02»: solo 0 y 1 son válidos en ese cuadro, así que el byte 10 no. Es el «=> … 00[02]fa fa» del informe de la sección 6, con el byte señalado entre corchetes.
La memoria sombra es lo que permite que ASan detecte un desborde de un solo byte, algo que ni el procesador ni 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í:

terminal · ejecutar con Valgrind
$ 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:

AddressSanitizerValgrind (Memcheck)
Cómo se usaSe compila con -fsanitize=address.Se antepone valgrind al ejecutable.
RecompilarSí.No (con -g, mejores informes).
CostoAproximadamente 2× en tiempo.De 10× a 50× en tiempo.
Errores del montículo y fugasSí.Sí.
Desbordes de arreglos de la pila y globalesSí.No.
Lectura sin inicializarNo.Sí.
Se puede combinar con el otroNo: 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:

fuga_rss.c
/* 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;
}
terminal
$ ./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:

fugas.c
#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;
}
terminal · gcc -std=c17 -Wall -Wextra -g -o fugas fugas.c
$ 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íaSignificaEn fugas.c¿Es una fuga?
definitely lostNingú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 lostEl 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 lostSolo 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 reachableAl 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:

uaf.c
#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;
}
terminal
$ 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:

crecer_mal.c
#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;
}
terminal
$ ./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:

doble.c
#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;
}
terminal
$ ./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:

desborde_heap.c
#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;
}
terminal
$ 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:

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:

fuera.c
#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;
}
terminal
$ ./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:

sin_iniciar.c
#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;
}
terminal
$ ./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:

perturb.c
#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;
}
terminal
$ ./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 informeQué diceCó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 regionDó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 mainUna 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:

MensajeQué significa
heap-buffer-overflowAcceso fuera de los límites de un bloque de malloc, hacia adelante o hacia atrás (secciones 6 y 9).
stack-buffer-overflowAcceso fuera de un arreglo local; el informe nombra la variable.
global-buffer-overflowAcceso fuera de una variable global o static.
heap-use-after-freeAcceso a un bloque ya liberado (sección 4), incluidos los movidos por realloc.
attempting double-freeSegundo free sobre el mismo bloque (sección 5).
attempting free on address which was not malloc()-edfree de una variable local, una literal o un puntero al interior.
SEGV on unknown addressAcceso a una dirección sin región, como NULL (tema 27).
LeakSanitizer: detected memory leaksFugas, 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:

agenda.c
#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;
}
terminal · compila sin avisos y casi funciona
$ 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:

terminal · primera ejecución con AddressSanitizer
$ 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:

terminal · con el + 1
$ ./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:

quitar corregida
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--;
}
terminal · con las dos correcciones
$ ./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:

vaciar, y la llamada al final de main
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);
terminal · agenda3.c, con las tres correcciones, bajo las dos herramientas
$ ./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:

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

ErrorQué pasaCorrecció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-overflowCasi siempre señalan un error real.Tratar cada aviso como un error.
Medir las fugas solo con el final del programaUn crecimiento intencional o una caché sin límite no se informan.Observar VmRSS durante la ejecución.
Creer que glibc detecta los errores de freeSus 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ónTambién lo produce un desborde que pisó un encabezado.Buscar el desborde con ASan.
Usar un puntero interior después de reallocFunciona hasta que el bloque se mueve.Recalcular o usar índices.
Liberar el elemento y dejarlo en la estructuraLa estructura tiene un puntero colgante.Quitar la entrada al liberar.
Liberar el contenedor antes que lo que cuelgaSe pierden los bloques internos.Primero los internos.
Esperar que ASan encuentre lecturas sin inicializarNo 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 globalesNo los ve.Usar ASan.
Ejecutar un binario de ASan bajo ValgrindLas dos herramientas interfieren.Un binario normal para Valgrind, uno con ASan para ASan.
Dejar ASan en el programa que se entregaMás lento, más memoria, y es una herramienta de depuración.Solo en desarrollo y pruebas.
Compilar sin -g para depurarLos informes no traen líneas ni nombres.-g siempre.
Probar solo el camino felizLos errores de limpieza están en las ramas de error.Forzar fallos con ulimit -v y entradas hostiles.

12. Resumen

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

  1. Compila fugas.c con -fsanitize=address y mira cuáles de las cuatro reservas informa LeakSanitizer. Prueba con ASAN_OPTIONS=detect_leaks=0 y vuelve a ejecutar. Después corrige el programa para que Valgrind diga «All heap blocks were freed».
  2. Escribe un programa que acumule mensajes en una lista y se olvide de liberar la lista al terminar. Mide con VmRSS el 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.
  3. Modifica doble.c para que la segunda liberación llegue después de dos pedidos intermedios de tamaños distintos. ¿Cambia el resultado de glibc? Compruébalo y explica por qué.
  4. Escribe la versión de desborde_heap.c que usa strcpy para copiar «hola mundo» en un malloc(10). Compila con -O2 y con -O0, y observa qué dice gcc, qué dice glibc (_FORTIFY_SOURCE, tema 4) y qué dicen ASan y Valgrind.
  5. Corre sin_iniciar.c con MALLOC_PERTURB_=165 y con distintos valores, y con valgrind --track-origins=no. ¿Qué información se pierde sin el origen?
  6. 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»?
  7. Toma el módulo contador del tema 28 y quítale a propósito el free(c->nombre) de contador_destruir. Compruébalo con LeakSanitizer y con Valgrind, y compara el nivel de detalle de las dos trazas.
  8. Escribe una versión de agenda.c con 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.