engineering

Fuzzing Guiado por LLM: Más Cobertura, Menos Crashes Tontos

Hubo un martes en el que perdimos siete horas persiguiendo un SIGSEGV que parecía bonito. Estábamos fuzzeando un parser de JWT particular de un cliente, y AFL++ nos había escupido un crash reproducible casi al instante. Olía a hallazgo. Lo aislamos, minimizamos con afl-tmin, escribimos el PoC, lo enviamos al canal interno con la confianza de quien ya redacta mentalmente el CVE.

El crash era un strncpy sobre un buffer vacío porque el header del JWT era literalmente la cadena { seguida de bytes binarios. El parser, antes de llegar a algo interesante, hacía base64url_decode y reventaba en una ruta que jamás sería alcanzada en producción: el balanceador de delante exige Content-Type y al menos un punto en el token. Era un bug, sí. Era inútil, también.

Esa tarde decidimos que ya valía.

Por qué los fuzzers grises se atragantan con gramáticas

Las estrategias de mutación de AFL (bit flipping, splicing, arithmetic) son grammar-blind: la mayoría de inputs mutados no superan el parser. Wang et al. (Superion, ICSE 2019) ya señalaban que «AFL gasta mucha cantidad de tiempo lidiando con corrección sintáctica y solo encuentra errores de parsing».

Para JSON, ASN.1 o cualquier formato con estado, la cobertura efectiva se estanca. Aparecen muchos crashes, pero la mayoría son del prólogo: validadores, decodificadores base64, comprobaciones de longitud. Lo interesante (lógica de verificación de firma, manejo de kid, ataques tipo algorithm confusion) queda detrás de una muralla que los bit flips no atraviesan.

El LLM como motor de mutación semántica

Xia et al. en Fuzz4All (ICSE 2024)[1] lo formalizan: el LLM «implícitamente aprende sintaxis, semántica y restricciones de API válidas». Es un generador probabilístico de inputs plausibles.

Deng et al. con TitanFuzz (ISSTA 2023)[2] demostraron 30-50% más cobertura sobre TensorFlow/PyTorch. Meng et al. con ChatAFL (NDSS 2024)[3] llevaron el principio a protocolos de red: 47.6% más transiciones de estado, 9 CVEs nuevos. Yang et al. con WhiteFox (OOPSLA 2024)[4] añadieron un agente que lee el código fuente para derivar requisitos del input.

El patrón común: el LLM no fuzzea, propone candidatos. El fuzzer tradicional sigue siendo el motor coverage-guided.

Nuestra arquitectura: AFL++ con plug-in semántico

Gwaihir CLI envuelve AFL++ y añade dos cosas: un custom mutator que delega un porcentaje de mutaciones a un proveedor semántico, y Beorn que aporta gramáticas conocidas, samples históricos y CVEs previos del mismo formato.

  1. Gwaihir analiza el target. Si Beorn reconoce el formato, inyecta una gramática seed.
  2. AFL++ arranca con corpus inicial generado por LLM desde la spec.
  3. El custom mutator engancha afl_custom_fuzz. Cada N iteraciones pide al LLM una mutación semántica.
  4. Los inputs que aumentan cobertura se reinyectan al corpus.
// gwaihir_mutator.c — fragmento ilustrativo
size_t afl_custom_fuzz(void *data, uint8_t *buf, size_t buf_size,
                       uint8_t **out_buf, uint8_t *add_buf,
                       size_t add_buf_size, size_t max_size) {
  gwaihir_ctx_t *ctx = (gwaihir_ctx_t *)data;
  ctx->counter++;
  if (ctx->counter % ctx->llm_every == 0) {
    return gwaihir_llm_mutate(ctx, buf, buf_size, out_buf, max_size);
  }
  return afl_havoc_mutate(ctx, buf, buf_size, out_buf, max_size);
}

El caso del parser JWT

Campaña de 24 horas sobre el mismo binario:

  • AFL++ vainilla: 12.3% cobertura, 47 unique crashes, 2 explotables.
  • AFL++ + dictionary tokens: 19.8% cobertura, 31 crashes, 3 explotables.
  • AFL++ + Gwaihir/Beorn: 41.7% cobertura, 18 unique crashes, 7 explotables.

Menos crashes totales, pero más densos. Tres de los siete acabaron siendo bugs reales en la lógica de kid y en el manejo de algoritmos asimétricos.

Fuzz4All frente a nuestro enfoque

Fuzz4All es más ambicioso: el LLM es el bucle de generación. Versatilidad enorme (98 bugs en GCC/Clang/Z3/OpenJDK) pero dos limitaciones: sin coverage feedback tantea a ciegas, y cada mutación es una llamada al modelo. Nuestro enfoque es más modesto y más barato: AFL++ hace el 98% a velocidad de bit flip, y el LLM solo entra para empujar cobertura más allá del parser.

Trade-offs

Coste de tokens. 24h × 1 llamada/50 ejec × 5.000 ejec/s = ~8.6M llamadas. Inviable sin caching agresivo y modelos locales. Usamos modelo cuantizado en GPU local para el 95% y reservamos modelo grande para mutaciones de rescate.

Latencia. Una llamada al LLM, incluso local, son decenas/cientos de ms. AFL++ es síncrono. Cola asíncrona donde el LLM va llenando un pool de mutaciones pre-generadas.

Underspecification. Prompt vago → LLM alucina inputs aburridos. Prompt estrecho → replica casos conocidos. Iteramos sobre los prompts casi tanto como sobre el código.

Lo que nos llevamos

El fuzzing guiado por LLM no es reemplazo de AFL++. Es un complemento que sube el techo de cobertura cuando el target tiene parsing complejo. No persigue más crashes; persigue mejores crashes. Para el equipo, eso ha significado dedicar menos horas a triajes inútiles y más a bugs que importan al cliente.

Bibliografía