Hace ya algunos años, en el mundo del desarrollo de software se popularizó una práctica que comúnmente conocemos como testing y que básicamente consiste en realizar un conjunto de pruebas de software sobre nuestros desarrollos. Es decir, una especie de control de calidad pero aplicado a nuestro ámbito.
Dentro de este conjunto de pruebas, de ese mar de conceptos que puede englobar la palabra testing, los desarrolladores nos solemos centrar en un subconjunto específico de esas pruebas. Ya sean unitarios (con el consiguiente debate acerca de “¿qué es una unidad?") o de integración, lo habitual es que sean los propios desarrolladores los que, de un modo u otro, terminen por escribir más código que represente un conjunto de pruebas automatizadas de forma que, en un futuro, podamos garantizar, en cierto modo, que el comportamiento de nuestras aplicaciones no se romperá durante los nuevos desarrollos.
Por desgracia, ni en nuestros sueños más húmedos (cuándo logramos una cobertura del 100%), nuestro software es infranqueable, y siempre terminamos por ver nuevas tareas marcadas como “bug” en nuestros sprints. Eso cuándo no nos toca intervenir a las 3 de la mañana estando de guardia. Al fin y al cabo, somos humanos, ¿no?
La condición humana nos lleva a cometer errores, sin embargo, también nos abre la posibilidad de ayudarnos de las máquinas que nos rodean (y que nosotros mismos hemos creado) para intentar reducir ese porcentaje de error. Y aquí es donde entra en juego el término en el que queremos basar nuestro artículo de hoy: el fuzzing.
El objetivo de este artículo pues, es ver cómo podemos relacionar (o aplicar) este término con nuestro querido lenguaje de programación: Go. Sin embargo, lo primero será entender qué es exactamente eso del fuzzing y cómo nos puede ayudar a mejorar la robustez de nuestros desarrollos.
¿Qué es el fuzzing?
Bien, pues el fuzzing es una técnica de pruebas de software automatizada que consiste en proporcionar datos inválidos, inesperados o aleatorios como datos de entrada de un programa. Además, la idea es que éstas acciones se lleven a cabo bajo supervisión, de modo que seamos capaces de detectar posibles panics (o excepciones), aserciones de código fallidas, bucles infinitos o potenciales memory leaks.
Con este tipo de pruebas de software nos será posible probar continuamente nuestro código con nuevos casos con los que intentaremos asercionar todos los aspectos del software (o programa) en cuestión, en lugar de usar un pequeño conjunto predefinido de entradas creadas manualmente (como habíamos hecho hasta ahora con las pruebas unitarias “de toda la vida”).
Finalmente, y de una manera un poco más formal, podríamos definir el fuzzing como el siguiente algoritmo (a alto nivel):
empezamos con un corpus de posibles entradas para nuestro programa
continuamente (for) {
escogemos una de las entradas de nuestro corpus de forma aleatoria
aplicamos un conjunto de modificaciones a esa entrada para mutarla
ejecutamos nuestro programa con la entrada mutada y medimos la cobertura
si la nueva entrada hace aumentar la cobertura, entonces la añadimos al corpus
}
dónde ese corpus de posibles entradas al que hacemos referencia, consistirá, como veremos más adelante, en un directorio con distintos ficheros que contendran los datos que el fuzzer (el componente encargado de ejecutar los fuzz tests) cargará e inyectará como datos de entrada de nuestros tests.
El formato de estos datos variará acorde al tipo de aplicación sobre la que queramos aplicar fuzzing.
¿Por qué fuzzing?
Una vez aprendido, en términos generales, en qué consiste el fuzzing, veamos cuáles son las razones por las que deberíamos
tener en cuenta esta práctica a la hora de hacer nuestros desarrollos. Lo primero, y probablemente la razón de más peso
de todas sea su efectividad, la cuál no podemos poner en duda: la librería go-fuzz
(de la que hablaremos más adelante) ha permitido detectar más de 200 bugs
en la librería estándar de Go. Pero no solo en Go, sino que la técnica de fuzzing está relacionada con datos tan
remarcables como haber permitido detectar más de 15000 bugs en Chrome.
De hecho, el porcentaje de casos en los que el fuzzing nos permite detectar nuevos bugs cuándo es aplicada por primera
vez en un proyecto es altísimo.
Otro aspecto fundamental a la hora de decidir si llevar a cabo (o no) esta técnica es su coste, el cuál se suele considerar muy bajo en comparación con el valor que esta nos aporta: el coste de agregar un nuevo fuzz test es comparable al de añadir una nueva prueba unitaria, sin embargo, el fuzz test nos va a proporcionar una cobertura comparable a cientos de pruebas unitarias.
Además, otro ámbito donde la técnica del fuzzing es fundamental es en el de la seguridad. Pues si bien los lenguajes de más alto nivel suelen ser considerados más seguros, nada impide que nuestros programas puedan ser explotados debido a errores lógicos, condiciones de carrera y corrupciones de memoria en algunos casos. De hecho, el fuzzing es una técnica utilizada habitualmente por aquellos que quieren atacar nuestros sistemas para encontrar vulnerabilidades, de forma que, si no la usamos antes nosotros para encontrar errores en nuestros desarrollos, otros podrán hacerlo y aprovecharse de ello.
Por último, es importante recordar que esta técnica no busca sustituir a otras formas de testing, revisiones de código (code reviews) o análisis estáticos, sino que consiste en un complemento a éstas con el que encontrar errores que las pruebas clásicas no pueden detectar. Esto nos va a permitir encontrar errores de estabilidad, lógica e incluso de seguridad que, de otro modo, serían difíciles de detectar, especialmente a medida que el sistema bajo prueba se vuelve más complejo.
Por esa razón, este tipo de técnicas también deben integrarse en nuestro ciclo de desarrollo:
- los desarrolladores deben agregar fuzz tests (o fuzzers) junto con los nuevos cambios de código.
- los fuzz tests (o fuzzers) deberían funcionar continuamente (como los tests unitarios o de integración).
- el fuzzing debería proporcionar pruebas de regresión instantánea para nuevos cambios.
Convencidos ya de ello, veamos cómo podemos empezar a hacer fuzzing con nuestras aplicaciones o librerías desarrolladas en Go.
Fuzzing en Go
Como ya avanzamos anteriormente, en Go (para sorpresa de todos) el fuzzing está estandarizado mediamente una herramienta
externa: la librería go-fuzz
de Dmitry Vyukov.
Esta librería está basada en el algoritmo American Fuzzy Lop (AFL) sobre el cuál podéis
encontrar más información en su README, y que básicamente se trata de un fuzzer
que funciona por fuerza bruta junto con un algoritmo genético.
Para empezar a usarla, lo único que tendremos que hacer es implementar la siguiente función:
func Fuzz(data []byte) int
dónde el argumento de entrada data
estará autogenerado por la propia go-fuzz
y el retorno de la función deberá
respetar las siguientes indicaciones:
1
si el fuzzer debería darle prioridad a esa entrada en futuras ejecuciones.-1
si esa entrada no debería añadirse al corpus incluso aunque haya aumentado la cobertura.0
en cualquier otro caso.
Un ejemplo de implementación bastante útil para entender mejor cómo funciona es el que nos proporcionan en la propia
documentación de la aplicación, diseñado para testear la función png.Decode
del paquete "image/png"
. Veamos:
func Fuzz(data []byte) int {
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
if img != nil {
panic("img != nil on error")
}
return 0
}
var w bytes.Buffer
err = png.Encode(&w, img)
if err != nil {
panic(err)
}
return 1
}
Como podéis ver, lo que hace la función definida es:
-
Lanzar un
panic
en aquellos casos dónde nos encontramos con situaciones no deseadas:- La imagen devuelta no es
nil
en caso de error. - La imagen decodeada “correctamente” (supuestamente), después no puede ser encodeada de nuevo.
- La imagen devuelta no es
-
Devolver un
0
en los casos de error que ya controlamos. -
Devolver un
1
en los casos en los que la ejecución finaliza satisfactoriamente.
Una vez implementada nuestra función Fuzz(data []byte) int
, el siguiente paso sería definir ese corpus de entradas
del que hablábamos anteriormente. Lo ideal será que los archivos en el corpus sean lo más pequeños y diversos posible.
Por ejemplo, para el caso de la documentación, que se trata de probar un paquete de decodificación de imágenes, podríamos
codificar varios mapas de bits pequeños (negro, ruido aleatorio, blanco con pocos píxeles no blancos) con diferentes niveles
de compresiones y usarlo como el corpus inicial. Es decir, tendríamos varias imágenes en nuestro directorio que nuestro
fuzzer se encargará de transformar y será las que posteriormente utilizará para evaluar los casos. Para ver más en detalle
cómo quedaría, aquí podéis encontrar el corpus que
se corresponde con el test que vimos anteriormente para la función png.Decode
del paquete "image/png"
.
Posteriormente, go-fuzz
deduplicará y minimizará las entradas.
Por lo tanto, agregar muchas entradas será bueno, pero es importante recordar que la clave es la diversidad.
Una vez definido nuestro corpus de entradas inicial en el directorio de trabajo de la aplicación (especificado con el flag -workdir
),
ya podremos ejecutar go-fuzz
para que haga su labor. Sin embargo, en este punto es importante recordar que go-fuzz
agregará entradas
propias al directorio del corpus que posteriormente deberemos subir a nuestro control de versiones si no queremos perder el trabajo
del fuzzer para futuras ejecuciones del mismo.
Una vez lanzada la aplicación, go-fuzz
generará y probará varias entradas en un bucle infinito. Y, mientras eso suceda,
el directorio de trabajo se usará para almacenar datos persistentes (como el corpus y los crashers -entradas defectuosas descubiertas-)
que nos permitan reanudar la ejecución en un futuro.
Caso práctico
Hasta ahora hemos visto, por encima, cómo funciona go-fuzz
. Sin embargo, la cierto es que este tipo de prácticas no
se interiorizan hasta que no las llevamos a cabo. Por eso, queremos detallar un pequeño ejemplo práctico paso a paso
sobre cómo realizar fuzzing sobre un parser de Markdown:
Descargar las dependencias
Lo primero de todo será descargar las dependencias necesarias para llevar a cabo dicha labor, ambas del repositorio anteriormente mencionado:
$ go get github.com/dvyukov/go-fuzz/go-fuzz
$ go get github.com/dvyukov/go-fuzz/go-fuzz-build
Clonando el repositorio
Una vez instaladas las herramientas necesarias, lo siguiente será clonar el repositorio que usaremos a modo de ejemplo para este caso práctico. En este caso, como dijimos anteriormente, será el proyecto gomarkdown/markdown, un parser de Markdown.
$ git clone https://github.com/gomarkdown/markdown.git
Si analizamos brevemente esta herramienta, veremos que una de las funcionalidades que nos da es la función Parse, que será nuestro sujeto para realizar los fuzz tests.
Creando nuestro fuzz test
Una vez ya tengamos el proyecto descargado, lo siguiente será crear el fuzz test en cuestión, que, por ejemplo, podría
tener un aspecto similar a este (fuzz.go
):
// +build gofuzz
package markdown
// Fuzz function to be used by https://github.com/dvyukov/go-fuzz
func Fuzz(data []byte) int {
Parse(data, nil)
return 0
}
Si os fijáis, básicamente hemos creado un pequeño fichero de ejemplo parecido al que vimos anteriormente, al que además
hemos añadido el flag de compilación gofuzz
, que básicamente determina que ese código solo se añada al binario de la
aplicación cuándo la compilación se realice para la aplicación go-fuzz
.
Definiendo nuestro corpus de entradas
El siguiente paso será definir nuestro corpus inicial de entradas, el cuál básicamente vamos a definir dentro del directorio
fuzz-workdir/corpus
y que puede estar basado en todos los datos de test que el propio proyecto ya tiene para los tests
convencionales. Es decir, como el proyecto que estamos utilizando como referencia ya tiene definidos unos ficheros con
datos de tests, podemos usar esos mismos ficheros como corpus
y dejar que el resto de magia la haga el fuzzer.
$ mkdir -p fuzz-workdir/corpus
$ cp testdata/*.text fuzz-workdir/corpus
Compilando nuestro fuzz test
Ahora ya estamos casi listos para ejecutar nuestro fuzz test, sin embargo, antes deberemos compilarlo, razón por la que
anteriormente nos descargamos la aplicación go-fuzz-build
. Esta nos permitirá crear un fichero .zip
que incluirá
tanto el binario del fuzz test como el corpus de entradas para poder realizar la ejecución del mismo:
$ go-fuzz-build github.com/gomarkdown/markdown
Ejecutar el fuzz test
Finalmente, solo nos quedará usar la herramienta go-fuzz
para iniciar la ejecución de nuestros tests y ver como
poco a poco se va incrementando el número de entradas y como de vez en cuándo vamos detectando algunos crashers:
$ go-fuzz -bin=./markdown-fuzz.zip -workdir=fuzz-workdir
2019/12/16 04:11:09 workers: 8, corpus: 23 (2s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 0, uptime: 3s
2019/12/16 04:11:12 workers: 8, corpus: 28 (0s ago), crashers: 0, restarts: 1/6814, execs: 40885 (6806/sec), cover: 753, uptime: 6s
2019/12/16 04:11:15 workers: 8, corpus: 29 (1s ago), crashers: 0, restarts: 1/7742, execs: 147116 (16333/sec), cover: 918, uptime: 9s
2019/12/16 04:11:18 workers: 8, corpus: 30 (2s ago), crashers: 0, restarts: 1/9145, execs: 256076 (21332/sec), cover: 939, uptime: 12s
¡Y hasta aquí! Podríamos dejar que nuestro fuzz test se ejecute tanto tiempo como deseemos. Entonces, la cuestión sería ver como vamos incrementando la cobertura y como van apareciendo nuevos crashers, que posteriormente deberemos ir corrigiendo.
Continuous Fuzzing
Como ya os podéis imaginar, esta tarea que, a modo de introducción, hemos realizado de forma puntual y aislada, puede ser ejecutada de forma contínua. De hecho, ese será el modo en el que nos va a aportar más valor, al igual que sucede con los tests unitarios o de integración.
En este caso, y de un modo similar al que ya vimos en los artículos sobre continuous profiling y linters en Go, también tenemos dos opciones:
- Integrar una herramienta adhoc en nuestras pipelines que se encargue de dicha labor.
- Delegar esa responsabilidad a un servicio externo (SaaS).
En este último caso, tenemos dos alternativas muy populares en la comunidad Go, sobre las cuáles no vamos a profundizar en este artículo, pero que sí os recomendamos encarecidamente que investiguéis y probéis:
La propuesta oficial
Como dijimos anteriormente, a día de hoy resulta sorprendente que este tipo de prácticas no estén más consolidadas y mejor integradas en el core de Go, siendo este un lenguaje de programación que destaca especialmente por esa faceta.
Sin embargo, es posible que esto deje de ser así en un tiempo, pues hace ya casi tres años que Brad Fitzpatrick, core member del equipo de Go en Google, abrió esta propuesta para integrar los fuzz tests en Go. Y en la cuál, a día de hoy, aún se sigue trabajando.
La idea básicamente consiste en extender el paquete testing
y la herramienta go test
para dar soporte a los fuzz tests
de un modo similar a como se hace actualmente con los tests y los benchmarks:
- Detectar las funciones que empiecen por
Fuzz
dentro de los ficheros_test.go
y que cumplan con la firma(*testing.F, data []byte)
. - Añadir flags (
-fuzz
,-fuzzdir
) para activar los fuzz tests mediante expresiones regulares y para poder determinar los directorios fuente para nuestros corpus (entre otros).
Si queréis encontrar información más extendida y detallada, o si queréis seguir de cerca los desarrollos relacionados con la mencionada propuesta, solo tenéis que seguir de cerca este documento.
Y hasta aquí por hoy. Y vosotros, ¿ya estáis haciendo fuzzing en vuestros proyectos?
Como siempre, estaremos encantados de recibir vuestro feedback en los comentarios del blog o vía Twitter.