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

Cobertura y fuzzing continuo en integración continua

Medir con gcov y llvm-cov, leer la cobertura del corpus para saber dónde se atasca el fuzzer, montar fuzzing continuo que no rompa la CI y aplicar un procedimiento fijo a cada hallazgo.

⏱ 18 min

Una campaña de fuzzing que lleva un mes sin encontrar nada admite dos lecturas opuestas: o el código es sólido, o el fuzzer nunca llegó más allá de la función de validación de entrada. Sin medir cobertura no hay forma de distinguirlas, y esa ambigüedad es el fallo más caro de toda la disciplina. La cobertura no es una métrica de vanidad para el informe trimestral: es el instrumento que convierte una campaña ciega en un experimento con resultado interpretable.

🎯 Al terminar esta lección sabrás
  • Instrumentar y leer cobertura con gcov y con llvm-cov, y entender qué mide cada nivel.
  • Usar la cobertura del corpus para diagnosticar dónde se atasca el fuzzer.
  • Montar fuzzing continuo con corpus persistente sin volver la CI inestable.
  • Aplicar un procedimiento de triaje reproducible a cada crash.

Medir: gcov y llvm-cov

Son dos ecosistemas incompatibles entre sí y con flujos distintos. El de GCC escribe contadores junto a los objetos:

gcc -std=c23 -O0 -g --coverage prog.c -o prog   # arcs mas test-coverage
./prog                                          # genera prog.gcda
gcov prog.c                                     # informe por linea

# informes agregados y legibles
gcovr --html-details -o cobertura/index.html --exclude 'tests/'
lcov --capture --directory . --output-file cov.info && genhtml cov.info -o html/

El de LLVM usa un formato de perfil binario con regiones de código en lugar de líneas, lo que da resultados bastante más precisos en expresiones compuestas:

clang -std=c23 -O0 -g -fprofile-instr-generate -fcoverage-mapping prog.c -o prog
LLVM_PROFILE_FILE=prog.profraw ./prog
llvm-profdata merge -sparse prog.profraw -o prog.profdata
llvm-cov show ./prog -instr-profile=prog.profdata -format=html -output-dir=cov/
llvm-cov report ./prog -instr-profile=prog.profdata

Los niveles de cobertura no son intercambiables y confundirlos produce falsa confianza:

📏

Línea y región

Qué se ejecutó. La región de LLVM distingue las dos mitades de un operador && en la misma línea; la línea de gcov no.

🌿

Rama

Si cada condición tomó los dos valores. Requiere --branch-probabilities en gcov o -show-branches en llvm-cov.

🧬

MC/DC

Si cada condición atómica determinó por sí sola el resultado. Obligatoria en aviónica y disponible en Clang moderno con -fcoverage-mcdc.

Un detalle que arruina muchos informes: mide con -O0. Con optimizaciones, el compilador funde bloques, hace inlining y elimina código muerto, y el mapa de cobertura deja de corresponderse con lo que leíste en el editor.

La cobertura que importa es la del corpus

Esta es la técnica central de la lección, y la que casi nadie aplica. No midas la cobertura de tus tests: mide la del corpus que produjo el fuzzer, ejecutándolo sobre un binario instrumentado para cobertura y sin fuzzer.

# binario gemelo: mismo codigo, instrumentado para cobertura
clang -std=c23 -O0 -g -fprofile-instr-generate -fcoverage-mapping \
      main_corpus.c parser.c -o parser_cov

# ejecutar cada entrada del corpus acumulando perfiles
for f in corpus_min/*; do
  LLVM_PROFILE_FILE="perfiles/$(basename "$f").profraw" ./parser_cov "$f"
done

llvm-profdata merge -sparse perfiles/*.profraw -o corpus.profdata
llvm-cov show ./parser_cov -instr-profile=corpus.profdata \
         -format=html -output-dir=cov_corpus/

Dos precisiones sobre ese binario gemelo. Debe compilarse desde exactamente el mismo código que el objetivo de fuzzing, pero sin -fsanitize=fuzzer: la instrumentación de cobertura del fuzzer y la de llvm-cov son mecanismos distintos y no se leen igual, así que se construyen por separado. Y necesita un main propio que lea el fichero de la línea de órdenes y llame al mismo punto de entrada que el harness, para garantizar que mides el camino real y no una aproximación:

int main(int argc, char **argv) {
    for (int i = 1; i < argc; i++) {
        long n; uint8_t *buf = leer_fichero(argv[i], &n);
        LLVMFuzzerTestOneInput(buf, (size_t)n);
        free(buf);
    }
    return 0;
}

El informe resultante se lee de una manera muy concreta: buscas las funciones en rojo. Cada bloque de código que el corpus no toca tras horas de campaña es un diagnóstico, y hay exactamente cuatro causas posibles.

flowchart TD
A[Region roja tras la campana] --> B[Es codigo alcanzable]
B -->|No| C[Codigo muerto: borralo]
B -->|Si| D[Que barrera lo protege]
D --> E[Constante o token: anade diccionario]
D --> F[Checksum o firma: desactiva bajo flag de fuzzing]
D --> G[Estructura profunda: siembra mejores semillas]
style C fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111b

Ese bucle —medir, diagnosticar la barrera, atacarla, volver a fuzzear— es el que separa una campaña que progresa de una que quema CPU. Y da además una respuesta honesta a la pregunta del principio: si el corpus cubre el ochenta y cinco por ciento de las regiones del parser y lleva un mes sin crashes, la primera lectura es defendible. Si cubre el nueve por ciento, no hay nada que celebrar.

Fuzzing continuo sin romper la CI

El error clásico es lanzar el fuzzer en cada pull request y esperar. El fuzzing es no determinista y no acotado en tiempo; la CI exige lo contrario. La solución es partirlo en dos trabajos con contratos distintos:

# regresion: rapido, determinista, bloqueante en cada pull request
- name: Regresion del corpus
  run: |
    clang -std=c23 -g -O1 -fsanitize=fuzzer,address,undefined \
          harness.c parser.c -o fuzz_parser
    ./fuzz_parser corpus/ -runs=0        # ejecuta el corpus y termina
    ./fuzz_parser crashes_conocidos/ -runs=0

# campana: programada, larga, no bloqueante, con corpus persistente
- name: Fuzzing nocturno
  run: ./fuzz_parser corpus/ -max_total_time=3600 -jobs=4

Las tres reglas que hacen esto sostenible:

El corpus persiste entre ejecuciones. Se guarda como artefacto o en un bucket y se restaura al empezar. Un fuzzer que arranca desde cero cada noche repite el mismo trabajo eternamente y nunca llega al código profundo.

La campaña larga nunca bloquea un merge. Notifica, abre incidencia, adjunta el reproductor. Si bloquease, el equipo desactivaría el trabajo en dos semanas.

Cada crash arreglado deja su entrada en el repositorio. El fichero mínimo entra en crashes_conocidos/ y se ejecuta en la regresión rápida para siempre. Es la única defensa real contra la reintroducción del mismo bug.

Para proyectos de código abierto con superficie de ataque relevante, existe la infraestructura ya montada: OSS-Fuzz ejecuta campañas continuas gratis sobre cientos de proyectos, y ClusterFuzzLite trae el mismo motor —construcción, deduplicación, bisección del commit culpable, corpus gestionado— a tu propia CI con un fichero de configuración.

Qué hacer con cada hallazgo

Un crash no es una tarea, es la entrada de un procedimiento de seis pasos que conviene ejecutar siempre en el mismo orden:

  1. Reproducir aislado. El fichero solo, fuera del fuzzer, con los sanitizers activos. Si no reproduce, el harness viola el determinismo y ese es el bug que hay que arreglar primero.
  2. Minimizar. -minimize_crash=1 o afl-tmin hasta la entrada más pequeña que conserva el fallo. Un reproductor de once bytes se depura; uno de cuatro kilobytes se archiva y se olvida.
  3. Clasificar. Lee el informe del sanitizer, no solo la línea del crash: escritura fuera de límites, uso tras liberar y desbordamiento de enteros tienen severidades y arreglos completamente distintos.
  4. Deduplicar. Agrupa por las tres o cuatro tramas superiores de la pila. Un solo bug produce con frecuencia cientos de ficheros en el directorio de crashes.
  5. Arreglar y verificar. Con el reproductor en la suite, para que falle antes del parche y pase después.
  6. Preguntar por la clase. Este es el paso que más valor genera y el que más se salta: si el bug fue un memcpy con una longitud no validada, busca en todo el árbol los demás memcpy con longitud procedente de la entrada. El fuzzer encontró un ejemplar; el arreglo debe cubrir la especie.
La cobertura es el único testigo del fuzzing

Hay una asimetría epistemológica en el fuzzing que conviene enunciar sin rodeos: un crash es una demostración positiva —existe una entrada que rompe el programa, y aquí está—, mientras que la ausencia de crashes no demuestra absolutamente nada por sí sola. Es una no-observación, y las no-observaciones solo informan cuando conoces el poder estadístico del experimento que las produjo. La cobertura del corpus es ese poder estadístico. Sin ella, “llevamos un mes fuzzeando sin hallazgos” es una frase vacía que puede significar código robusto o un harness que devuelve error en la línea tres; con ella, la frase se convierte en un enunciado acotado y falsable: hemos ejercitado el ochenta y siete por ciento de las regiones de este módulo con doce mil millones de entradas y estas cuatro funciones siguen sin tocarse. Eso ya es ingeniería, porque es una afirmación que otro puede verificar y que señala su propio límite. El corolario práctico gobierna la asignación de recursos en todo programa de fuzzing maduro: cuando la cobertura se estanca, más CPU no compra nada. Lo que compra progreso es leer el informe, encontrar la barrera concreta y traducirla en un diccionario, una semilla o un flag de compilación. Y hay una última consecuencia cultural. El corpus, no el fuzzer, es el activo duradero de todo esto: sobrevive a la campaña, a la versión de la herramienta y al ingeniero que la montó, y cada entrada que contiene es un camino del programa que alguien ya recorrió y que jamás volverá a romperse en silencio. Los proyectos que versionan su corpus acumulan garantías; los que lo pierden en cada ejecución de la CI llevan años reempezando desde cero sin saberlo.

⚔️ Cierra el bucle: medir, diagnosticar, atacar
  1. Compila tu parser con --coverage y con -fprofile-instr-generate -fcoverage-mapping, ejecuta la misma entrada en ambos y compara los informes de gcov y llvm-cov en una línea que contenga un operador &&.
  2. Mide la cobertura de tu suite de tests. Luego mide la del corpus que produjo el fuzzer y compara ambas: casi siempre son conjuntos distintos, no uno contenido en el otro.
  3. Localiza la función más grande que el corpus no toca en absoluto y diagnostica cuál de las cuatro causas del diagrama la protege.
  4. Ataca esa barrera: escribe el diccionario, la semilla o el flag que la desactiva bajo compilación de fuzzing, y vuelve a medir tras media hora de campaña.
  5. Añade a tu CI un trabajo bloqueante que ejecute el corpus con -runs=0 y otro programado que fuzzee una hora restaurando el corpus del día anterior.
  6. Toma un crash antiguo ya arreglado, minimízalo y añádelo al directorio de regresión. Revierte el parche y comprueba que la CI se pone roja.