kernel-fuzzer
como funciona afl
vamos a instrumentar el ejercicio que hice para hackthebox para ver que parte se le agregan al binario y analizar que modificaciones le hace la instrumentacion de afl
compilamos nuestro challenge:
#include <iostream>
#include <fstream>
#include <string.h>
#include <unistd.h>
#define MAX_ANIMALS 10
using namespace std;
class Animal {
public:
string name;
Animal() {
cout << "Fabricando animalito " << endl;
}
virtual void talk(string) {};
};
class Cat : public Animal {
public:
void talk(string phrase) {
cout << " _ "<< string(phrase.length(), '_') << endl;
cout << "( \\ (" << phrase << ")" << endl;
cout << " ) ) " << "-," << string(phrase.length()-2, '-') << endl;
cout << "( ( .-''''-. A.-.A" << endl;
cout << " \\ \\/ \\/ , , \\" << endl;
cout << " \\ \\ =; t /=" << endl;
cout << " \\ |''''- ',--'" << endl;
cout << " / // | ||" << endl;
cout << " /_,)) |_,))" << endl;
}
};
class Toad : public Animal {
public:
void talk(string phrase) {
cout << " " << string(phrase.length(), '_') << endl;
cout << " (" << phrase << ")" << endl;
cout << " -," << string(phrase.length()-2, '-') << endl;
cout << " @..@" << endl;
cout << " (----)" << endl;
cout << "( >__< )" << endl;
cout << "^^ ~~ ^^" << endl;
}
};
class Dog : public Animal {
public:
void talk(string phrase) {
cout << " " << string(phrase.length(), '_') << endl;
cout << " (" << phrase << ")" << endl;
cout << " -," << string(phrase.length()-2, '-') << endl;
cout << " ___" << endl;
cout << "---'o \\ ,"<< endl;
cout << "\\,__ | ) ))" << endl;
cout << " \\;_/\\---------------------~//" << endl;
cout << " \\ )" << endl;
cout << " ( .____________________. (\\" << endl;
cout << " ) )) ) ))" << endl;
cout << " '-'' '_''" << endl;
}
};
class ParserHistory {
ifstream story;
Animal *character[MAX_ANIMALS];
unsigned int i=0;
public:
ParserHistory(char *fileName, bool verbose) {
char data[100], *textLine, *repeatedLine;
unsigned int tag;
unsigned short characterIndex, lengthMessage, howMany;
string animal;
this->story.open(fileName);
memset(data, 0, sizeof(data));
this->story.read(data, 11);
if (strncmp(data, "LINUXSTORY#", 11) == 0) {
if (verbose) {
cout << "Header detected" << endl;
}
do {
tag = 0;
this->story.read((char *)&tag, 3);
if ( this->i <= MAX_ANIMALS ) {
if ( tag == 0x544143 ) {
character[this->i] = new Cat();
if (verbose ) {
cout << "cat memory address: " << hex << character[this->i] << endl;
}
} else if ( tag == 0x474f44 ) {
character[this->i] = new Dog();
if (verbose) {
cout << "dog memory address: " << hex << character[this->i] << endl;
}
} else if ( tag == 0x414f54) {
character[this->i] = new Toad();
if (verbose) {
cout << "toad memory address: " << hex << character[this->i] << endl;
}
}
this->i++;
}
} while( tag != 0x444e45 );
do {
tag = 0;
characterIndex = 0;
//memset(textLine, 0, sizeof(textLine));
this->story.read((char *)&tag, 1);
if ( tag == 'T' ) {
this->story.read((char *)&characterIndex, 2);
if ( characterIndex < this->i ) {
this->story.read((char *)&lengthMessage, 2);
if (lengthMessage <= 1024) {
textLine = new char(lengthMessage);
if (verbose) {
cout << "string memory address: " << static_cast<void *>(textLine) << endl;
}
if (textLine != NULL) {
this->story.read(textLine, lengthMessage);
this->character[characterIndex]->talk(textLine);
}
}
}
} else if ( tag == 'F' ) {
this->story.read((char *)&characterIndex, 2);
if (verbose) {
cout << "tag freed" << endl;
cout << "memory deleted: " << hex << character[characterIndex] << endl;
}
delete this->character[characterIndex];
} else if (tag == 'C') {
this->story.read((char *)&characterIndex, 2);
if ( characterIndex < this->i ) {
this->story.read((char *)&howMany, 2);
this->story.read((char *)&lengthMessage, 2);
repeatedLine = new char(lengthMessage);
this->story.read(repeatedLine, lengthMessage);
for(unsigned int i=0; i <= howMany; i++) {
textLine = new char(lengthMessage);
if (verbose) {
cout << "string memory address: " << static_cast<void *>(textLine) << endl;
}
strncpy(textLine, repeatedLine, lengthMessage);
this->character[characterIndex]->talk(textLine);
}
}
} else {
if (verbose) {
cout << "invalid tag: " << tag << endl;
}
}
} while( tag != 0x00);
}
}
};
int main(int argc, char **argv) {
int opt;
bool verbose;
char *fileName;
ParserHistory *director;
verbose = false;
while((opt = getopt(argc, argv, "f:v")) != -1) {
switch(opt) {
case 'f':
cout << "[*] History file: " << optarg << endl;
fileName = optarg;
break;
case 'v':
verbose = true;
break;
default:
cout << "[-] Unrecogniced option: " << opt << endl;
break;
}
}
director = new ParserHistory(fileName, verbose);
return 0;
}
para compilarlo basta con:
afl-g++ -o orange-afl orange-afl.cpp
afl-cc 2.52b by <[email protected]>
afl-as 2.52b by <[email protected]>
[+] Instrumented 302 locations (64-bit, non-hardened mode, ratio 100%).
fuzzeando
para probar como se fuzzea, se le puede pegar desde un sample mas o menos armado, al toque encuentra los crashes
abriendo con ghidra
lo abrimos y al toque vemos que el main tiene incrustados un monton de calls a zonas nombradas __afl_maybe_log
estos calls a la funcion esa, se agregan por todo el binario, como podemos ver en la siguiente imagen:
el arranque del injerto solo verifica si la variable global __afl_area_ptr ya fue definida (es decir si ya paso una vez), en caso de que no este initialized, procede a cargarla saltado a __afl_setup
vamos a mirarlo como graph para darnos una idea de que es:
claramente es una serie de if encadenaditos con algun bucle dentro, haciendole zoom al primer basic block, vemos que estamos parados ahi
los primeros 3 basic blocks verifican si:
- __afl_area_ptr no esta inicializado
- __afl_setup_failure no esta en true
- __afl_global_area_ptr esta en NULL
ejecuta esta zona donde carga el estado de todos los registros (esto es para preservar el estado que tiene el proyecto en la zona donde se va a forkear)
ahora recien ahora hace espacio en el stack para alvergar algunas variables y llama a getenv("AFL_SHM_ENV"), se esta activa es como que le dice "NO INSTRUMENTES" y se va para el __afl_setup_abort
y solo para saber un poquito mas, nos vamos a ver que es la funcion __afl_setup_abort
pone en 1 (true?puede ser mas incrementado) __afl_setup_failure restaura los registros como cuando estaban en el main y salta a un violento __afl_return
la inversa del prologo raro (me parece que tiene algo que ver con los flags, si entro por ser mayor, por ser menor, altera los flags y cambian de valor)
si esta todo bien, y no pasa por esa zona oscura, aterriza por aca:
que es el algoritmo que explica el chabon en sus famosos tutoriales
cur_location = <COMPILE_TIME_RANDOM>;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;
en el chunk de assembler que pegue, esta hasta la asignacion final, fijense aca:
INC byte ptr [RDX+RCX*0x1]
RDX ahi tiene la base del unsigned int array __afl_area_ptr y RCX indexa como char (lo digo por el *0x1)
la zona de memoria compartida de afl tiene un size de 64k, es decir 65536 bytes, entonces probablemente cur_location y prev_location deben ser unsigned short int
cur_location es un numero random para identificar el basic bloc que se pasa por ECX como argumento del CALL:
en la anterior imagen se puede ver como se carga RCX con un valorcito para identificar la zona, en ese caso 0xd435
aca tenemos otro ejemplo:
aca podemos ver que en cada basic block que se va para adentro, anade el marcador ese
hay algunos que no le da importancia como por ejmplo los dos que no tienen marcador
pero si vamos un poco mas para adentro del parser, vemos que le metio a los demas
no sabemos a ciencia cierta como es que elige que basic blocks instrumenta y cuales no, pero no nos interesa para nuestro trabajo final
tambien podemos notar, que se pasan mas argumentos de los que creiamos pero sin embargo no le da mucho bola dentro de la funcoin, asi que evitaremos analizarlos
si tenemos AFL_SHM_ENV
si tenemos activa esa variable de entorno entonces transforma ese ascii que consulto a int con atoi y lo usa como shmid para definir el identificador de la memoria compartida
segundo y tercer argumento estan en NULL (por lso dos xor que hay), shmat tiene la siguiente definicion:
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
en ese caso, nuestra llamada es:
shmat(atoi(getenv("ALF_SHM_ENV")), NULL, 0);
el hecho de que shmaddr fuera NULL, the system choose a suitable (unused) page-aligned address to attach the segment. El flag 0 no se que caracteristica magica le aporta
todo esto se esta ejecutando en el caminito __afl_setup_first
luego de preguntar cual era el area que le asigno a la shared memory lo guarda en __afl_area_ptr y __afl_global_area_ptr
forkserver
envia un mensajito de 4 caracteres al canal de comunicacion que tiene abierto con el forkserver
no se como sabe que el file descriptor es 0xc7, no nos vamos a centrar en eso, pero en caso de que responda que no le llegaron bien los 4 caracteres, se va al resume
que solo cierra los descriptores fijos de comunicacion con el forkserver
como siempre hagamos de cuenta que salio todo bien y le llegaron los 4 bytes
ahora queda bloqueante, esperando que el server le envie 4 bytes
luego viene este bucle:
si el fork retorna error, se toma la flecha verde y se va con un exit
si retorna cero, significa que es el proceso duplicado, el hijo y esta parado en la misma zona pasando el CALL
cierra el canal donde writeaba y leia, restaura los registros, storea la posicion hitteada y sale
si es el padre, retorna el PID del proceso el fork entonces se va para aca
la parte que justo se corto es la porcion de codigo que envia el PID del hijo por el canal de comunicacion:
y se queda esperando hasta que termine el proceso con el waitpid, como el segundo argumento de waitpid no es NULL, entonces storeara un entero que avisa como fue que murio el proceso:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *wstatus, int options);
y por ultimo envia wstatus por el canal de comunicacion
con esto puede saber si el proceso faulteo o salio aireoso
como registro el handler 199 y 198?
una de las preguntas que me genera ese codigo, es como logro tener el 199 y 198 para el, veamos el codigo de afl para sacarnos esa duda
crea un pipe y despues duplica el descriptor que se le otorgo, asignandole el 199 y 198:
afl-fuzz.c
if (pipe(st_pipe) || pipe(ctl_pipe)) PFATAL("pipe() failed");
y mas abajo:
afl-fuzz.c
if (dup2(ctl_pipe[0], FORKSRV_FD) < 0) PFATAL("dup2() failed");
if (dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");
close(ctl_pipe[0]);
close(ctl_pipe[1]);
close(st_pipe[0]);
close(st_pipe[1]);
que hizo el cristiano este para coverage de kernel?
la pregunta que todos nos hacemos, como logro que este modo que solo funciona para coverage en procesos de user space, lograr usarlo satisfactoriamente para kernel space
levantando el kernel con virtme
luego de compilar un kernel, lo podemos levantar con virtme para correrlo virtualmente, esto nos trae la ventaja de que si crashea no vamos a perder nada, porque estamos en el virtual
sudo ./virtme-run --rw --pwd --kimg ../linux/arch/x86/boot/bzImage --memory 512M
le decimos eso y automagicamente levanta el kernel, nos da una shell de root y nos monta el disco dejandonos acceder a nuestro sistema real
dentro del kernel virtual, corremos
# ./fuzznetlink --dump --verbose
al toque nos da todos los bloques de memoria que se ejecutaron
como hizo eso?
primero habilito KCOV al momento de compilar el kernel en las zonas que me interesaban, en el tutorial el flaco se quiere centrar en sockets netlink, asi que habilito kernel coverage solo en la carpeta net:
find net -name Makefile \
| xargs -L1 -I {} bash -c 'echo "KCOV_INSTRUMENT := y" >> {}'
en la siguiente foto podemos ver un grep que muestra en que Makefiles se activo kcov:
esto nos da la ventaja de poder aislar parcialmente las zonas que nos interesan auditar
esto le debe agregar algo a esos basic blocks para que reporten, pero por ahora no le vamos a dar importancia a eso
como obtiene las direcciones ejecutadas en kernel
en el switch para agarrar los argumentos a lo getopt tiene el procesamiento de 'd':
fuzznetlink.c:84
case 'd':
state->dump++;
break;
y mas abajo vemos que si esta prendido este flag, imprime las direcciones en hexa como ya vimos:
fuzznetlink.c:242
if (state->dump) {
printf("0x%016lx%s\n", current_loc, "");
}
current_loc es la direccion que tiene la posta del basic block ejecutado, si vemos el bucle entero que procesa el TRACE
fuzznetlink.c:223
/* Read recorded %rip */
int i;
uint64_t afl_prev_loc = 0;
for (i = 0; i < kcov_len; i++) {
uint64_t current_loc = kcov_cover_buf[i + 1];
uint64_t hash = hsiphash_static(¤t_loc,
sizeof(unsigned long));
uint64_t mixed = (hash & 0xffff) ^ afl_prev_loc;
afl_prev_loc = (hash & 0xffff) >> 1;
uint8_t *s = &afl_area_ptr[mixed];
int r = __builtin_add_overflow(*s, 1, s);
if (r) {
/* Boxing. AFL is fine with overflows,
* but we can be better. Drop down to
* 128 on overflow. */
*s = 128;
}
if (state->dump) {
printf("0x%016lx%s\n", current_loc, "");
}
}
lo saca del kcov_cover_buf indexandolo desde 1 (eso parece raro)
kcov_cover_buf es un array de direcciones de 64 bits
fuzznetlink.c:113
struct kcov *kcov = NULL;
uint64_t *kcov_cover_buf = NULL;
if (state->no_kcov == 0) {
kcov = kcov_new();
kcov_cover_buf = kcov_cover(kcov);
}
me encanta la falsa orientacion a objetos que armo, divino, vamos a ver kcov_cover
kcov.c:89
uint64_t *kcov_cover(struct kcov *kcov) { return kcov->cover; }
lo unico que hay que saber de la falsa OOP es que siempre le pasa como 1er argumento un pseudo-this
kcov->cover fue cargado en el "constructor"
kcov.c:24
struct kcov *kcov_new(void)
{
int fd = open("/sys/kernel/debug/kcov", O_RDWR);
if (fd == -1) {
PFATAL("open(/sys/kernel/debug/kcov)");
}
/* Setup trace mode and trace size. */
int r = ioctl(fd, KCOV_INIT_TRACE, COVER_SIZE);
if (r != 0) {
PFATAL("ioctl(KCOV_INIT_TRACE)");
}
/* Mmap buffer shared between kernel- and user-space. */
unsigned long *cover = (unsigned long *)mmap(
NULL, COVER_SIZE * sizeof(unsigned long),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if ((void *)cover == MAP_FAILED) {
PFATAL("mmap(/sys/kernel/debug/kcov)");
}
struct kcov *kcov = calloc(1, sizeof(struct kcov));
kcov->fd = fd;
kcov->cover = cover;
return kcov;
}
cover es un buffer para almacenar
kcov.c:14
#define COVER_SIZE (64 << 10)
que no es mas que una forma cheta de poner 65536
fijense que esta MAP_SHARED y los flags, eso debe ser los permisos que necesita para que se le guarden los datos
todo apunta que la parte de carga del trace se hace por aca:
kcov.c:51
void kcov_enable(struct kcov *kcov)
{
/* reset counter */
__atomic_store_n(&kcov->cover[0], 0, __ATOMIC_RELAXED);
int r = ioctl(kcov->fd, KCOV_ENABLE, KCOV_TRACE_PC);
if (r != 0) {
PFATAL("ioctl(KCOV_ENABLE)");
}
/* Reset coverage. */
__atomic_store_n(&kcov->cover[0], 0, __ATOMIC_RELAXED);
__sync_synchronize();
}
__atomic_store_n y __sync_synchronize apuntan a que bloquean la zona de memoria mientras se carga con las direcciones
son funciones para shared memory y mutual exclusion, asi que no vamos a indagar en eso... vamos a tener en cuenta que nos cae la informacion solita
en la pagina de kcov tienen un sample casi igual, asi que vamos a tomar como que automagicamente funciona
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>
#define KCOV_INIT_TRACE _IOR('c', 1, unsigned long)
#define KCOV_ENABLE _IO('c', 100)
#define KCOV_DISABLE _IO('c', 101)
#define COVER_SIZE (64<<10)
#define KCOV_TRACE_PC 0
#define KCOV_TRACE_CMP 1
int main(int argc, char **argv)
{
int fd;
unsigned long *cover, n, i;
/* A single fd descriptor allows coverage collection on a single
* thread.
*/
fd = open("/sys/kernel/debug/kcov", O_RDWR);
if (fd == -1)
perror("open"), exit(1);
/* Setup trace mode and trace size. */
if (ioctl(fd, KCOV_INIT_TRACE, COVER_SIZE))
perror("ioctl"), exit(1);
/* Mmap buffer shared between kernel- and user-space. */
cover = (unsigned long*)mmap(NULL, COVER_SIZE * sizeof(unsigned long),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if ((void*)cover == MAP_FAILED)
perror("mmap"), exit(1);
/* Enable coverage collection on the current thread. */
if (ioctl(fd, KCOV_ENABLE, KCOV_TRACE_PC))
perror("ioctl"), exit(1);
/* Reset coverage from the tail of the ioctl() call. */
__atomic_store_n(&cover[0], 0, __ATOMIC_RELAXED);
/* That's the target syscal call. */
read(-1, NULL, 0);
/* Read number of PCs collected. */
n = __atomic_load_n(&cover[0], __ATOMIC_RELAXED);
for (i = 0; i < n; i++)
printf("0x%lx\n", cover[i + 1]);
/* Disable coverage collection for the current thread. After this call
* coverage can be enabled for a different thread.
*/
if (ioctl(fd, KCOV_DISABLE, 0))
perror("ioctl"), exit(1);
/* Free resources. */
if (munmap(cover, COVER_SIZE * sizeof(unsigned long)))
perror("munmap"), exit(1);
if (close(fd))
perror("close"), exit(1);
return 0;
}
KCOV nos tira las direcciones de los basic blocks ejecutados, si quisieramos podriamos saber la linea ejecutada pipeando a addr2line, para tener una salida similar a esta:
SyS\_read
\_\_fs/read\_write.c:562
fdget\_pos
\_\_fs/file.c:774
fget\_light
\_\_fs/file.c:746
fget\_light
\_\_fs/file.c:750
fget\_light
fs/file.c:760
fdget\_pos
\_\_fs/file.c:784
SyS\_read
fs/read\_write.c:562
pero para nuestros menesteres, nos valemos solo con el raw %rip
que da kcov?
las direcciones crudas en memoria del basic block que se ejecuto, si lo redireccionas a addr2line podes tener la linea de codigo del bloque que se ejecuto
$ ./fuzznetlink --dump --one-run < test_case.bin \
| addr2line -e ./linux/vmlinux
[-] Running outside of AFL
linux/net/socket.c:1514
linux/net/socket.c:1500
linux/net/socket.c:1502
linux/net/socket.c:1353
linux/net/socket.c:1355
como traduce la direccion de KCOV a AFL
para pasar de un lado a otro, obtiene la direccion, le genera un hash unico a esa direccion usando el algoritmo hsiphash_static
uint64_t current_loc = kcov_cover_buf[i + 1];
uint64_t hash = hsiphash_static(¤t_loc,
sizeof(unsigned long));
uint64_t mixed = (hash & 0xffff) ^ afl_prev_loc;
afl_prev_loc = (hash & 0xffff) >> 1;
cito al loco
But we achieved our goal - we set up a basic, yet still useful fuzzer against a kernel. Most importantly: the same machinery can be reused to fuzz other parts of Linux subsystems - from file systems to bpf verifier.
el argumento --one-run
hace una sola ejecucion del bucle, no forkea, con eso conseguimos poder tracearlo, util para tunear el fuzzer, una vez que con un archivito, llegaste a la zona que te interesa fuzzear
TIPAZOOO
que devuelve hsiphash_static
esto lo tengo que saber por simple curiosidad
me hice un clientito de la lib
para hacer esto, solo tire este codigo en donde estaba el codigo fuente del fuzznetlink original:
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <ctype.h>
#include "common.h"
int main(int argc, char **argv) {
uint64_t n, *p;
if (argc > 1 ) {
n = atoi(argv[1]);
p = &n;
printf("0x%x", hsiphash_static(p, sizeof(unsigned long)));
}
return 0;
}
como bien decia la documentacion por todos lados, es un algoritmo optimizado que le entran uint32 y escupe uint32, cada uno diferente al anterior por anda a saber que metodo
para compilarlo le tire un:
gcc -Wextra siphash.c -o checkhash checkhash.c -g
pero que onda, antes eran short int los valores que indexaban al buffer loco de afl, es por eso que el chabon les hace un casting feo, agarrando solo los 0xffff bytes
uint64_t mixed = (hash & 0xffff) ^ afl_prev_loc;
afl_prev_loc = (hash & 0xffff) >> 1;
que colisione el hash tenes que tener mucha mala suerte, pasara una vez cada muerte de obispo y de ultima en el peor de los casos te dara un falso positivo diciendo que encontro un crash
como le mete el input generado al chunk fuzzeado
/* Load input from AFL (stdin) */
char buf[512 * 1024];
memset(buf, 0, 32);
int buf_len = read(0, buf, sizeof(buf));
if (buf_len < 0) {
PFATAL("read(stdin)");
}
if (buf_len < 5) {
buf_len = 5;
}
if (state->verbose) {
fprintf(stderr, "[.] %d bytes on input\n", buf_len);
}
lee stdin, de ahi le viene el paquete crafteado, lo guarda en buf y de ahi lo deployea como parte de estructuras y fruta
struct sockaddr_nl sa = {
.nl_family = AF_NETLINK,
.nl_groups = (buf[1] << 24) | (buf[2] << 16) |
(buf[3] << 8) | buf[4],
};
struct iovec iov = {&buf[5], buf_len - 5};
struct sockaddr_nl sax = {
.nl_family = AF_NETLINK,
};
struct msghdr msg = {&sax, sizeof(sax), &iov, 1, NULL, 0, 0};
r = sendmsg(netlink_fd, &msg, 0);
if (r != -1) {
char buf[8192];
struct iovec iov = {buf, sizeof(buf)};
struct sockaddr_nl sa;
struct msghdr msg = {&sa, sizeof(sa), &iov, 1,
NULL, 0, 0};
recvmsg(netlink_fd, &msg, 0);
}