wandres.dev
CAZADOR · Análisis estático y fuzzing

Fuzzing con libFuzzer: el harness y el corpus que crece solo

Escribir un objetivo de fuzzing correcto, entender el bucle de retroalimentación por cobertura que convierte bytes aleatorios en entradas estructuradas, y cultivar un corpus que sobrevive al fuzzer que lo generó.

⏱ 18 min

Un fuzzer ingenuo que genere bytes al azar no llegará jamás más allá de la primera comprobación de cabecera de tu parser: la probabilidad de acertar cuatro bytes mágicos por azar es de uno entre cuatro mil millones. libFuzzer no genera al azar; genera, mide qué código nuevo tocó cada entrada y conserva las que abrieron territorio. Esa única idea —realimentación por cobertura— es lo que convierte una lotería imposible en una búsqueda que atraviesa parsers completos en minutos.

🎯 Al terminar esta lección sabrás
  • Escribir un harness que cumpla el contrato de LLVMFuzzerTestOneInput.
  • Entender la instrumentación de cobertura y el bucle de mutación y selección.
  • Cultivar, minimizar y versionar el corpus como artefacto de primera clase.
  • Reproducir, reducir y convertir cada crash en un test de regresión.

El harness y su contrato

El punto de entrada es una sola función. Todo lo demás es infraestructura que libFuzzer aporta enlazándose como main:

#include <stdint.h>
#include <stddef.h>
#include "parser.h"

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    struct doc *d = doc_parsear(data, size);
    if (d) doc_liberar(d);
    return 0;                 /* cualquier valor distinto de 0 esta reservado */
}
clang -std=c23 -g -O1 -fsanitize=fuzzer,address,undefined \
      -fno-omit-frame-pointer harness.c parser.c -o fuzz_parser
./fuzz_parser corpus/

El contrato tiene cuatro cláusulas y romper cualquiera arruina la campaña.

Determinista: la misma entrada debe producir la misma ejecución. Si el harness lee la hora, un fichero externo o rand, el crash que encuentres no se reproducirá y el motor tomará decisiones de cobertura basadas en ruido.

Sin estado global persistente: el proceso ejecuta millones de iteraciones sin reiniciarse. Cualquier estado que sobreviva entre llamadas contamina las siguientes y hace que los crashes dependan del orden de las entradas.

Sin fugas ni exit: cada iteración debe liberar lo que reserva —con ASan activo, LeakSanitizer comprobará periódicamente— y jamás llamar a exit ni abort por una entrada inválida. Un parser que aborta ante datos malformados no es fuzzeable hasta que esa salida se convierte en código de error.

Rápido: el objetivo son diez mil o cien mil ejecuciones por segundo y por núcleo. Todo lo que hagas fuera del código bajo prueba —abrir sockets, escribir en disco, imprimir— divide directamente el número de entradas exploradas.

Si necesitas inicializar algo una sola vez, existe un gancho previo:

int LLVMFuzzerInitialize(int *argc, char ***argv) {
    biblioteca_init();        /* se ejecuta una vez, antes del bucle */
    return 0;
}

El bucle de retroalimentación

Al compilar con -fsanitize=fuzzer, Clang inserta SanitizerCoverage: un contador en cada arista del grafo de flujo de control, más instrumentación de comparaciones. El motor mantiene una tabla de cobertura acumulada y, tras cada ejecución, la compara con la anterior.

flowchart LR
A[Corpus en memoria] --> B[Elegir una entrada]
B --> C[Mutar bytes]
C --> D[Ejecutar el harness]
D --> E[Hubo cobertura nueva]
E -->|Si| F[Anadir al corpus]
E -->|No| G[Descartar]
F --> A
G --> A
D --> H[Crash o sanitizer]
H --> I[Escribir artefacto y parar]
style F fill:#a6e3a1,color:#11111b
style I fill:#f38ba8,color:#11111b

Lo que hace este bucle poderoso no es la mutación —es tonta: voltear bits, insertar bloques, cruzar dos entradas— sino el criterio de conservación. Cada entrada que descubre una arista nueva se queda en el corpus y se convierte en base de mutaciones futuras. La búsqueda escala por la estructura del programa: primero encuentra la longitud mínima, luego el byte mágico, luego el campo de versión, y cada logro queda cristalizado como semilla para el siguiente.

La instrumentación de comparaciones es la que rompe los muros duros. Ante una comparación entera o una llamada a memcmp, libFuzzer recibe los dos operandos y los guarda en una tabla; si una entrada compara sus bytes contra la constante 0x89504E47, esa constante entra en el diccionario automático y aparecerá en mutaciones posteriores. Sin ella, un if de cuatro bytes es infranqueable; con ella, cae en segundos.

# opciones que gobiernan la campana
./fuzz_parser corpus/ -max_len=4096 -rss_limit_mb=4096 \
              -timeout=5 -jobs=8 -workers=8 -print_final_stats=1

Vigila -max_len: sin límite, libFuzzer crece las entradas y cada ejecución se vuelve más lenta. Un parser de configuración rara vez necesita más de unos kilobytes, y acotar la longitud multiplica las ejecuciones por segundo.

El corpus como artefacto

El corpus es el activo que produce una campaña de fuzzing, y sobrevive al fuzzer: es un conjunto de entradas que, en conjunto, ejercita el máximo de código con el mínimo de ficheros. Se cultiva, se poda y se versiona.

# 1. sembrar: entradas validas reales, no ficheros vacios
cp tests/ejemplos/*.cfg corpus/

# 2. minimizar el corpus: conserva solo lo que aporta cobertura
mkdir corpus_min
./fuzz_parser -merge=1 corpus_min/ corpus/

# 3. reducir cada entrada individual manteniendo su cobertura
./fuzz_parser -minimize_crash=1 crash-3f9a1c

# 4. correr el corpus sin fuzzear: regresion determinista en cada commit
./fuzz_parser corpus_min/ -runs=0

El paso cuatro es el que convierte todo esto en ingeniería sostenible. -runs=0 ejecuta cada fichero del corpus exactamente una vez y termina: es rápido, determinista y perfecto para la integración continua. La campaña larga descubre; el corpus minimizado se queda para siempre como suite de regresión que impide que un bug arreglado vuelva.

Una semilla buena vale más que horas de cómputo. Sembrar con ficheros válidos reales —los ejemplos de tu documentación, las entradas de tus tests— ahorra al motor todo el trabajo de reinventar la sintaxis desde cero.

Reproducir sin engañarse

Cuando algo revienta, libFuzzer escribe la entrada culpable en el directorio actual con un nombre como crash-3f9a1c y termina. Ese fichero es el reproductor, y ejecutarlo es tan simple como pasarlo como argumento:

./fuzz_parser artefactos/crash-3f9a1c        # una sola ejecucion, con el informe
./fuzz_parser corpus/ -artifact_prefix=artefactos/ -fork=1 -ignore_crashes=1

El modo -fork=1 merece atención en campañas largas. En el modo normal, el primer crash detiene todo y pierdes las horas restantes; con fork, un proceso supervisor lanza hijos, recoge los artefactos y sigue, de modo que una noche de fuzzing puede devolver diez bugs distintos en lugar del primero.

Tres clases de hallazgo no son crashes y aun así son bugs que conviene no silenciar por reflejo. El timeout que dispara -timeout=5 casi siempre revela una complejidad cuadrática o un bucle que no avanza ante entrada malformada: en un servicio expuesto, eso es una denegación de servicio. El consumo de memoria que corta -rss_limit_mb suele venir de un campo de longitud que se cree sin validar y se pasa directo a malloc. Y las fugas que denuncia LeakSanitizer señalan rutas de error que olvidan liberar, exactamente las que ningún test recorre.

El engaño más común, en cambio, es el harness que rechaza casi todo. Si tu objetivo empieza descartando las entradas que no cumplen un formato, el motor gastará el noventa y nueve por ciento de sus ejecuciones en el return inmediato. La solución no es filtrar, sino construir la entrada a partir de los bytes:

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 2) return 0;

    uint8_t op = data[0] % N_OPERACIONES;   /* el primer byte elige la API */
    size_t n = data[1];                     /* el segundo, un parametro */

    ejecutar_operacion(op, n, data + 2, size - 2);
    return 0;
}

Ese patrón —consumir los primeros bytes como decisiones y el resto como carga— convierte el fuzzer en un explorador de secuencias de llamadas, no solo de datos, y es como se encuentran los bugs de máquina de estados: abrir dos veces, liberar antes de cerrar, escribir tras el reinicio.

El fuzzer no adivina: escala una colina que tú construiste

La intuición popular sobre el fuzzing —bombardear el programa con basura hasta que reviente— describe la técnica de 1990 y es exactamente lo contrario de lo que hace libFuzzer. Lo que ocurre bajo el capó es un algoritmo evolutivo cuya función de aptitud es el conjunto de aristas alcanzadas del grafo de flujo de control de tu propio programa. Es decir: el fuzzer no sabe nada de tu formato, ni de tu gramática, ni de tu dominio; lo único que sabe es qué ramas se tomaron, y eso le basta porque tu código es la especificación implícita del formato. Cada if que escribiste es un escalón de una colina, y el motor la escala con una lotería sesgada donde cada acierto se conserva para siempre. De ahí se sigue algo con consecuencias directas de diseño: la fuzzeabilidad es una propiedad de la arquitectura, no de la campaña. Un parser que valida en muchos pasos pequeños y explícitos ofrece una colina suave y cae en minutos. Uno que comprueba un checksum de treinta y dos bits en la primera línea presenta un acantilado vertical que ninguna cantidad de cómputo escala, y la solución no es más CPU sino desactivar el checksum en el binario de fuzzing. Escribir código fuzzeable —funciones puras sobre búferes, sin estado global, sin salidas abruptas, con las barreras criptográficas aisladas tras un flag de compilación— es la misma disciplina que hace el código testeable, y por eso la primera campaña de fuzzing de un proyecto suele descubrir menos sobre sus bugs que sobre su diseño.

⚔️ Del harness al corpus versionado
  1. Escribe un harness para una función tuya que consuma un búfer de bytes y compílalo con -fsanitize=fuzzer,address,undefined. Déjalo correr cinco minutos y observa cómo crece la cobertura en la columna cov del informe.
  2. Introduce a propósito una comprobación de cuatro bytes mágicos al principio del parser. Comprueba que el fuzzer la atraviesa, y luego recompila con -fsanitize-coverage=trace-pc-guard sin la instrumentación de comparaciones para ver cómo se atasca.
  3. Rompe el contrato: añade un estado global que se conserve entre iteraciones y comprueba que un crash encontrado deja de reproducirse al pasarle solo su fichero.
  4. Siembra el corpus con los ejemplos de tus tests, lánzalo diez minutos, minimízalo con -merge=1 y anota cuántos ficheros quedan frente a cuántos entraron.
  5. Añade a tu proyecto un objetivo de compilación que ejecute el corpus minimizado con -runs=0 y haz que falle el build si algún fichero revienta.