← Gestión de Memoria Bloque 2 · La pila y el heap · Tema 9 de 52

Tema 9

Errores de memoria

Los errores de memoria son los más difíciles de encontrar: el programa puede funcionar durante horas y fallar en un caso límite, o comportarse de forma distinta en cada ejecución. En este tema verás los cuatro errores clásicos (fuga, uso después de free, doble free, desbordamiento), otros relacionados (memoria no inicializada, punteros colgantes), programas que los reproducen y las dos herramientas que los detectan: Valgrind y AddressSanitizer.

1. Por qué son los peores errores

C en C, el programador gestiona la memoria manualmente. El asignador (malloc/free) es rápido porque no verifica cada acceso: confía en que el programador respete los límites y la vida de los bloques. Cuando no es así, el comportamiento es indefinido: el programa puede funcionar, corromper datos silenciosamente o fallar en una máquina distinta.

Estas son las propiedades que hacen que los errores de memoria sean tan difíciles:

Por eso las herramientas de detección (Valgrind, AddressSanitizer) son indispensables: convierten errores silenciosos en mensajes claros con la línea exacta del código.

2. Fuga de memoria (memory leak)

Una fuga ocurre cuando se pide memoria con malloc/calloc/realloc y nunca se libera, aunque ya no se use. El heap crece sin límite hasta agotar la RAM.

fuga.c
#include <stdlib.h>

void procesar(void)
{
    char *buf = malloc(1024);
    /* usar buf ... */
    /* ¡falta free(buf)! */
}

int main(void)
{
    for (int i = 0; i < 1000000; i++) procesar();
    return 0;
}

En este ejemplo, cada llamada a procesar pierde 1 KiB. Tras un millón de llamadas, se han perdido ~1 GiB. El programa puede seguir "funcionando" mientras haya RAM, pero consume cada vez más.

Las fugas son especialmente graves en servidores y demonios que corren durante días o semanas.

3. Uso después de free (use-after-free)

Se accede a un bloque después de haberlo liberado. El bloque puede haberse reutilizado para otro propósito, por lo que se leen o escriben datos de otro objeto.

uaf.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char *p = malloc(16);
    strcpy(p, "hola");
    free(p);
    printf("%s\n", p);   /* uso después de free */
    p[0] = 'H';          /* escritura después de free */
    return 0;
}

El programa puede imprimir "hola" (porque la memoria no se borra) o basura, o fallar. Escribir después de free puede corromper el heap si el bloque ya se reutilizó.

4. Doble free

Se libera el mismo bloque dos veces. El asignador mantiene los bloques libres en listas; liberar dos veces corrompe esas listas, lo que puede causar fallos posteriores o ser explotable para ataques.

doublefree.c
#include <stdlib.h>

int main(void)
{
    char *p = malloc(16);
    free(p);
    free(p);   /* doble free */
    return 0;
}

glibc suele detectar el doble free y abortar con "double free or corruption", pero no siempre: depende del estado del heap.

5. Desbordamiento de búfer (buffer overflow)

Se escribe más allá del final (o antes del principio) de un bloque. Corrompe los metadatos del heap o los datos adyacentes.

overflow.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char *p = malloc(8);
    strcpy(p, "esto es mucho más largo que 8 bytes");  /* desbordamiento */
    printf("%s\n", p);
    free(p);
    return 0;
}

El desbordamiento de búfer es una de las vulnerabilidades más explotadas de la historia: permite sobrescribir la dirección de retorno o los metadatos del heap para ejecutar código arbitrario.

6. Memoria no inicializada

Se lee memoria que nunca se escribió. Con malloc, el contenido es basura; con variables locales no inicializadas, también. El programa usa valores arbitrarios, que pueden ser secretos de otras partes del programa (fuga de información).

uninit.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int x;                       /* no inicializada */
    char *p = malloc(16);        /* no inicializada */
    printf("x = %d\n", x);
    printf("p[0] = %d\n", p[0]);
    return 0;
}

7. Punteros colgantes y salvajes

La buena práctica es inicializar los punteros a NULL y ponerlos a NULL después de free.

8. Experimentos que reproducen cada error

Compila y ejecuta cada programa para ver el comportamiento (indefinido, pero útil para entender):

terminal · reproducir errores
$ gcc -std=c17 -Wall -Wextra -O1 -o fuga fuga.c && ./fuga
(puede consumir mucha RAM; Ctrl+C para parar)
$ gcc -std=c17 -Wall -Wextra -O1 -o uaf uaf.c && ./uaf
hola
Segmentation fault
$ gcc -std=c17 -Wall -Wextra -O1 -o doublefree doublefree.c && ./doublefree
double free or corruption (out)
$ gcc -std=c17 -Wall -Wextra -O1 -o overflow overflow.c && ./overflow
esto es mucho más largo que 8 bytes
free(): invalid next size (normal)
$ gcc -std=c17 -Wall -Wextra -O1 -o uninit uninit.c && ./uninit
x = 32767
p[0] = 0

Los resultados varían entre ejecuciones y máquinas. Esa no determinación es precisamente el problema.

9. Detección con Valgrind

Valgrind es una herramienta que ejecuta el programa en una máquina virtual y detecta errores de memoria y fugas. Se usa así:

terminal · Valgrind
$ valgrind --leak-check=full --show-leak-kinds=all ./uaf
==1234== Invalid read of size 1
==1234==    at 0x...: main (uaf.c:9)
==1234==  Address 0x... is 0 bytes inside a block of size 16 free'd
==1234==    at 0x...: free (vg_replace_malloc.c:...)
==1234==    by 0x...: main (uaf.c:8)
==1234==
==1234== HEAP SUMMARY:
==1234==     in use at exit: 0 bytes in 0 blocks
==1234==   total heap usage: 1 allocs, 1 frees, 16 bytes allocated
==1234==
==1234== All heap blocks were freed -- no leaks are possible

Valgrind reporta el tipo de error, la línea exacta, la traza de llamadas y el estado del heap al salir. Es lento (10-50x más lento) pero muy completo.

10. Detección con AddressSanitizer

AddressSanitizer (ASan) es un sanitizer del compilador que instrumenta el código para detectar errores de memoria con bajo overhead (~2x). Se activa al compilar:

terminal · AddressSanitizer
$ gcc -std=c17 -g -fsanitize=address -o uaf-asan uaf.c
$ ./uaf-asan
=================================================================
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x... at pc 0x...
READ of size 1 at 0x... thread T0
    #0 0x... in main uaf.c:9
    #1 0x... in __libc_start_main

0x... is located 0 bytes inside of 16-byte region [0x...,0x...)
freed by thread T0 here:
    #0 0x... in free
    #1 0x... in main uaf.c:8
previously allocated by thread T0 here:
    #0 0x... in malloc
    #1 0x... in main uaf.c:7
=================================================================

ASan muestra la traza de asignación, liberación y uso, con números de línea. Es más rápido que Valgrind y se integra en el ciclo de desarrollo.

11. Otras herramientas

HerramientaQué detectaCómo se activa
MemorySanitizer (MSan)Lecturas de memoria no inicializada.-fsanitize=memory
UndefinedBehaviorSanitizer (UBSan)Comportamientos indefinidos (desbordamientos de enteros, etc.).-fsanitize=undefined
ThreadSanitizer (TSan)Carreras de datos entre hilos.-fsanitize=thread
GDBInspección interactiva, breakpoints, backtrace.gdb ./prog

Estos sanitizers no se pueden combinar entre sí (ASan + TSan no), pero se pueden usar por separado en distintas compilaciones.

12. Probar con un simulador

Este simulador presenta un escenario de código y te pide clasificar el error. Selecciona el tipo de error que crees que contiene el fragmento.

Simulador · clasificar errores de memoria


        

13. Mirar en Linux

terminal · herramientas de detección
$ valgrind --leak-check=full ./prog
$ gcc -fsanitize=address -g prog.c -o prog-asan && ./prog-asan
$ gcc -fsanitize=memory -g prog.c -o prog-msan && ./prog-msan
$ gcc -fsanitize=undefined -g prog.c -o prog-ubsan && ./prog-ubsan
$ gdb ./prog

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué es una fuga de memoria?

Pedir memoria y no liberarla cuando ya no se usa, haciendo que el heap crezca sin límite.

2. ¿Qué es un use-after-free?

Acceder a un bloque después de haberlo liberado. El bloque puede haberse reutilizado, causando corrupción o fugas de información.

3. ¿Por qué el doble free es peligroso?

Corrompe las listas de bloques libres del asignador, lo que puede causar fallos posteriores o ser explotable para ataques.

4. ¿Qué es un desbordamiento de búfer?

Escribir más allá del final (o antes del principio) de un bloque, corrompiendo metadatos o datos adyacentes.

5. ¿Qué herramienta detecta memoria no inicializada?

MemorySanitizer (MSan) con -fsanitize=memory, y Valgrind con --track-origins=yes.

6. ¿Qué ventaja tiene AddressSanitizer sobre Valgrind?

Es mucho más rápido (~2x vs 10-50x) y se integra en el ciclo de compilación, con trazas precisas.

7. ¿Qué es un puntero colgante?

Un puntero que apunta a memoria ya liberada. Usarlo es un use-after-free.

8. ¿Por qué hay que poner un puntero a NULL tras free?

Para evitar que se use accidentalmente (use-after-free) y para que free(NULL) sea seguro si se libera de nuevo.

Para practicar

  1. Escribe un programa con una fuga deliberada y ejecútalo con Valgrind (--leak-check=full). ¿Qué reporta?
  2. Compila un programa con use-after-free con -fsanitize=address y observa la traza.
  3. Escribe un programa con un desbordamiento de búfer en el heap y ejecútalo con ASan.
  4. Crea un programa con memoria no inicializada y ejecútalo con MSan.
  5. Compara el tiempo de ejecución de un programa con y sin ASan para 1.000.000 de asignaciones.
  6. Usa GDB para inspeccionar un programa que crashea por doble free. ¿Qué muestra bt?
  7. Corrige los cuatro programas de error (fuga, uaf, doublefree, overflow) y verifica con Valgrind que ya no hay errores.