Cómo realizar pruebas de fuzzing a un servidor con American Fuzzy Lop
American Fuzzy Lop (AFL) es una herramienta de código abierto de realización de pruebas de fuzzing que está equipada con asistencia de cobertura de código y que fue desarrollada por Michał Zalewski, de Google. En resumen, esta herramienta suministra entradas de datos elaboradas con inteligencia a un programa que pone en práctica casos límite y encuentra errores en un programa de destino.
En esta entrada de blog, voy a explicar cómo utilizar el modo persistente experimental de American Fuzzy Lop (AFL) para abrir las puertas de un servidor sin tener que hacer modificaciones significativas en su código base. He utilizado esta técnica en Fastly para ampliar la realización de pruebas en algunos de los servidores de los que dependemos y en otros con los que estamos experimentando.
A lo largo de esta entrada de blog, voy a utilizar el servidor de código abierto Knot DNS con una configuración básica a modo de ejemplo práctico, pero la técnica también es aplicable a otros servidores y procesos de larga duración.
El problema: entrada de archivos y tiempo de inicio
American Fuzzy Lop transmite entradas a través de una interfaz de archivos, pero por lo general los servidores toman lecturas de sockets u otras interfaces preparadas para la red. Además, por defecto, AFL vuelve a ejecutar el programa de destino cada vez que ponga en marcha una prueba. Dado que los servidores suelen precisar un segundo o más para iniciarse, esto puede obstaculizar notablemente el avance de AFL en la exploración del programa y la detección de errores.
En el pasado, los testers han tenido que poner en práctica herramientas de ejecución de pruebas cuyo desarrollo no ha sido insignificante o que prueban únicamente un subconjunto de la lógica de gestión de solicitudes del servidor (consulta la herramienta existente de ejecución de pruebas de AFL en Knot DNS para acceder a un ejemplo). Ninguna de estas soluciones es ideal: la primera posiblemente requiera mucho tiempo (y además precise altas dosis de profundo entendimiento e intuición respecto del código fuente) y la segunda restringe la cobertura del código de tu prueba.
La solución: el modo persistente
Aquí encontrarás más información sobre el modo persistente de American Fuzzy Lop (en fase experimental). Por medio de algunas modificaciones secundarias en el código fuente, permite al tester controlar:
Cuándo AFL bifurca la aplicación de destino;
Cuándo AFL suministra nuevas entradas a la aplicación;
Este modo resuelve el problema de esperar a que el programa se inicie; el tester puede entonces suministrar entradas sujetas a fuzzing al programa de destino sin tener que reiniciarlo. El inconveniente más importante en este caso es que el tester deberá procurar reiniciar el estado de la aplicación con cada iteración del fuzzing. Si no se tiene el cuidado de reiniciar el estado, aparecerán errores en tu herramienta de ejecución de pruebas en lugar de en el destino.
Perspectiva clave: combinación modo persistente y servidores
En este punto, las buenas noticias quizás sean evidentes: multitud de servidores se esmeran por restablecer el estado de la aplicación cada vez que se procesa una solicitud para ti; de modo que la "parte difícil" que conlleva el uso del modo persistente de American Fuzzy Lop ya está cubierta. En líneas generales, los subprocesos de servidor tienen este aspecto:
while (go):
req = get_request()
process(req)
A fin de integrar el modo persistente de AFL, lo único que tienes que hacer es modificar el programa de modo que haga esto:
while (go)
put_request(read(file)) // AFL
req = get_request()
process(req)
notify_fuzzer() // AFL
Sin embargo, resulta que (según mi experiencia) se necesita mucho menos tiempo para identificar y modificar el código de destino anterior que para idear cómo escribir una herramienta de ejecución de pruebas personalizada que lleve aparejada la simulación de funciones de API o la extracción de lógica de análisis a un programa independiente.
Aplicación de la técnica a Knot DNS
Detección del bucle de procesamiento
Knot DNS utiliza sockets para comunicarse, así que hice búsquedas en el código fuente de select
y encontré el bucle que lee y procesa paquetes UDP. A continuación te muestro el fragmento pertinente de código fuente (he añadido comentarios del tipo "**** AFL: .. ****" con el fin de indicar los lugares donde se necesitan cambios para que el fuzzing en el modo persistente de AFL sea compatible):
// **** AFL: Declara e inicializa variables ****
...
/* Loop until all data is read. */
for (;;) {
/* Check handler state. */
if (unlikely(*iostate & ServerReload)) {
*iostate &= ~ServerReload;
udp.thread_id = handler->thread_id[thr_id];
rcu_read_lock();
forget_ifaces(ref, &fds, maxfd);
ref = handler->server->ifaces;
track_ifaces(ref, &fds, &maxfd, &minfd);
rcu_read_unlock();
}
/* Cancellation point. */
if (dt_is_cancelled(thread)) {
break;
}
/* Wait for events. */
fd_set rfds;
FD_COPY(&fds, &rfds);
// **** AFL: Read from input file here ****
int nfds = select(maxfd + 1, &rfds, NULL, NULL, NULL);
if (nfds <= 0) {
if (errno == EINTR) continue;
break;
}
/* Bound sockets will be usually closely coupled. */
for (unsigned fd = minfd; fd <= maxfd; ++fd) {
if (FD_ISSET(fd, &rfds)) {
if ((rcvd = _udp_recv(fd, rq)) > 0) {
_udp_handle(&udp, rq);
/* Flush allocated memory. */
mp_flush(mm.ctx);
_udp_send(rq);
udp_pps_sample(rcvd, thr_id);
}
}
}
// **** AFL: Notify fuzzer that processing complete here ****
}
Nota: utilicé como destino select
para Knot DNS; sin embargo, si tu servidor de destino utiliza una API diferente para la gestión de tráfico de red, deberías poder buscar sus funciones recv
o read
análogas para lograr el mismo objetivo.
Correcciones de compatibilidad
Toda modificación que efectúo para que el modo persistente sea compatible la denomino "corrección de compatibilidad" (o "shim" en los ejemplos de código). La primera corrección de compatibilidad es muy aburrida. Declara e inicializa variables necesarias para el resto de las correcciones de compatibilidad:
#ifdef KNOT_AFL_PERSISTENT_SHIM /* For AFL persistent mode fuzzing shim */
/* Initialize variables for fuzzing */
size_t insize;
struct sockaddr_in servaddr;
int udp_socket;
char *env_dest_ip = getenv("KNOT_AFL_DEST_IP");
char *env_dest_port = getenv("KNOT_AFL_DEST_PORT");
int dest_port = env_dest_port ? strtol(env_dest_port, NULL, 10) : 9090;
char *dest_ip = env_dest_ip ? env_dest_ip : "127.0.0.1";
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr(dest_ip);
servaddr.sin_port = htons(dest_port);
char buf[5120];
#endif // #ifdef KNOT_AFL_PERSISTENT_SHIM
Nota: el código anterior lee la dirección IP y el puerto de destino a partir de variables de entorno con algunos valores predeterminados. Esto es importante si tienes previsto escalar una ejecución de American Fuzzy Lop, en cuyo caso probablemente te interese que cada instancia del servidor escuche en un puerto diferente.
La segunda corrección de compatibilidad lee entradas sometidas a fuzzing a partir de un archivo —stdin
en este caso— y las suministra a uno de los sockets a los que Knot DNS está escuchando.
#ifdef KNOT_AFL_PERSISTENT_SHIM /* For AFL persistent mode fuzzing shim */
/* Read fuzzed packet from stdin and send to socket */
if (getenv("KNOT_AFL_STDIN") || getenv("KNOT_AFL_CMIN") ||
getenv("AFL_PERSISTENT")) {
memset(buf, 0, 5120);
insize = read(0, buf, 5120);
udp_socket = ((iface_t*)HEAD(handler->server->ifaces->l))->fd[IO_UDP];
sendto(udp_socket, buf, insize,0, (struct sockaddr *)&servaddr,sizeof(servaddr));
}
#endif // #ifdef KNOT_AFL_PERSISTENT_SHIM
Suelo procurar que esta sección del código esté libre de estados de modo que no tenga que preocuparme por hacer limpieza. Si verdaderamente haces asignaciones aquí, es importante asegurarse de que queden limpias en la corrección de compatibilidad del posprocesamiento (consulta a continuación), procurando tener en cuenta cualquier caso de borde que pudiera ocurrir al procesar cualquier mensaje.
Recuerda que este código realmente exige cierta planificación. En este caso, utilizo una estructura de datos específica de Knot para captar el descriptor de archivo correspondiente a un socket a partir del cual el servidor va a leer. En otros casos, he tenido que utilizar FD_ISSET
y un bucle de una línea con el fin de encontrar un socket que haya pasado a select
en el conjunto readfds
. A pesar de todo, no suele ser difícil: si estás escribiendo código cerca de select
, el conjunto de descriptores de archivo no debe de estar muy lejos.
Observa, además, el uso de getenv
en este ejemplo. Esta corrección de compatibilidad únicamente se ejecuta si está establecido KNOT_AFL_STDIN
(sirve para pruebas de humo), KNOT_AFL_CMIN
(lo utilizo para ejecuciones de afl-cmin
) o AFL_PERSISTENT
(se establece en el modo persistente de AFL).
La tercera corrección de compatibilidad es bastante básica. Se limita a indicar a American Fuzzy Lop que se ha terminado una iteración de fuzzing (a través de un SIGSTOP
) y a salir, de modo que el próximo paquete de semillas pueda ser procesado si esta es una ejecución afl-cmin
, o a no hacer nada:
#ifdef KNOT_AFL_PERSISTENT_SHIM /* For AFL persistent mode fuzzing shim */
/* Signal AFL to fuzz input and continue execution */
if (getenv("AFL_PERSISTENT")) {
raise(SIGSTOP);
} else if (getenv("KNOT_AFL_CMIN")) {
exit(0);
}
#endif // #ifdef KNOT_AFL_PERSISTENT_SHIM
Utilicé macros de preprocesador de modo que todas las correcciones de compatibilidad anteriores sean incluidas con condiciones. Así, a pesar de que las correcciones ensucien un poco el código base, por lo menos no interferirán con otras compilaciones.
Fuzzing
Aunque en la documentación encontrarás más información sobre fuzzing con AFL y el uso del modo persistente, he incluido a continuación algunos ejemplos sobre cómo lograr que AFL se ejecute con esta herramienta de ejecución de pruebas.
Configuración y compilación de la aplicación
$ CC=~/afl-1.83b/afl-clang-fast CFLAGS='-DKNOT_AFL_PERSISTENT_SHIM' ./configure --disable-shared
$ make
Nota: tendrás que compilar afl-clang-fast
a partir del directorio llvm_mode
para que el modo persistente sea compatible; consulta el archivo README para obtener más información.
Minimización de tus casos de prueba
KNOTD_AFL_CMIN=1 ~/afl-1.83b/afl-cmin -i ~/knot-seeds -o ~/knot-seeds-cmin -- ~/knot-dns/src/knotd -c my_config.config
Inicio del fuzzing en modo persistente
AFL_PERSISTENT=1 ~/afl-1.83b/afl-fuzz -i ~/knot-seeds-cmin -o ~/my_output_dir ~/knot-dns/src/knotd -c my_config.config
El modo persistente de American Fuzzy Lop es alucinante, pero no tienes por qué creerme.
Si has logrado llegar hasta aquí, espero que ya te hayas hecho una idea de cómo aplicar el modo persistente de AFL a un servidor para incrementar notablemente su cobertura de código de fuzzing. A pesar de que he utilizado Knot DNS a modo de ejemplo, esta técnica debería ser aplicable a la mayoría de servidores o daemons que utilicen un patrón de bucle para leer y procesar. Si estudias un poco la situación, incluso los servidores que portan el estado más allá del nivel del protocolo deberían ser direccionables gracias al modo persistente.
Nota: ¿y dónde están los errores?
Aunque el equipo de Knot ya utilizaba AFL para someter a fuzzing un subconjunto de su lógica de servidor (ya le he enviado un parche del modo persistente al equipo), conviene observar que en cuestión de unas horas fui capaz de encontrar vulnerabilidades en otro servidor DNS conocido desde un PC de prestaciones básicas por medio de esta técnica (estamos trabajando en una solución bajo la coordinación del distribuidor inmediato, así que por el momento no voy a hablar de este asunto). Actualización del 4 de agosto de 2015: encontrarás información sobre la solución si te desplazas abajo.
A pesar de todo, a fin de ofrecer un ejemplo totalmente desprovisto de rigor científico de las ventajas que aporta el modo persistente de AFL para la realización de pruebas en cualquier proyecto, incluimos a continuación un resumen de la cobertura de código de lcov/genhtml
resultante de aplicar fuzzing a Knot DNS con su herramienta de ejecución de pruebas durante cinco minutos bajo AFL en un equipo virtual en un ordenador portátil:
Tasa de cobertura general:
líneas......: 24,5 % (711 de 2903 líneas)
funciones..: 28,1 % (88 de 313 funciones)
Para comparar el ejemplo anterior, incluimos seguidamente el resumen de cobertura de código obtenido de una ejecución de cinco minutos con la nueva herramienta de pruebas utilizando un archivo de configuración simplificado:
Tasa de cobertura general:
líneas...: 23,9 % (6638 de 27796 líneas)
funciones...: 32,9% (704 de 2139 funciones)
Aunque determinados aspectos de la cobertura de código indicada en la ejecución del modo persistente corresponden a lógica de inicialización, la cobertura de líneas y funciones lograda por las pruebas se incrementó notablemente gracias a la nueva herramienta de pruebas. (Nota: si te confunden los porcentajes, piensa que están escalados en función del tamaño del programa). Además, las líneas que abarca American Fuzzy Lop se están ejecutando con una frecuencia considerablemente mayor con la nueva herramienta de ejecución de pruebas. La pantalla de estado de AFL muestra lo siguiente cuando se da el experimento de cinco minutos con la herramienta existente de ejecución de pruebas:
total de ejecuciones: 518 k
velocidad de ejecución: 1610/seg
Además, muestra lo siguiente cuando existe una nueva herramienta de pruebas:
total de ejecuciones: 1,34 M
velocidad de ejecución: 1610/seg
Por tanto, aunque no vayas a encontrar una brecha de día cero en esta entrada de blog, espero que las posibles ventajas en cobertura de código y ejecuciones y la sencillez relativa de esta técnica te animen a intentarlo. ¡Gracias por leerme!
Actualización del 4 de agosto de 2015: esta vulnerabilidad ha sido divulgada tras la coordinación con el distribuidor (ISC). Utilicé la técnica descrita en esta entrada para localizar CVE-2015-5477, una vulnerabilidad crítica en Bind. Encontrarás más información sobre la vulnerabilidad aquí y aquí.