Ссылки на остальные статьи данного цикла:
От автора
Напоминаю, что статья потенциально может содержать ошибки и неточности, автор открыт к диалогу (и самообразованию), если Вам есть что подсказать, исправить и скорректировать - пишите!
Продолжаем рассматривать afl-fuzz
Время выполнения afl-fuzz основывается на единственном цикле do-while, который выполняет основную логику работы фаззера:
do { if (likely(!afl->old_seed_selection)) { // Если в очереди появились новые элементы (prev_queued_items < afl->queued_items) или требуется реинициализация таблицы // (afl->reinit_table), пересоздается alias-таблица, которая помогает приоритетно обрабатывать элементы очереди. if (unlikely(prev_queued_items < afl->queued_items || afl->reinit_table)) { prev_queued_items = afl->queued_items; create_alias_table(afl); } // C помощью функции select_next_queue_entry выбирается следующий элемент из очереди для обработки. // Проверяется, что индекс выбранного элемента `current_entry` находится в пределах доступных элементов в очереди. // Если нет, продолжается выбор. // После выбора элемент записывается в queue_cur. do { afl->current_entry = select_next_queue_entry(afl); } while (unlikely(afl->current_entry >= afl->queued_items)); afl->queue_cur = afl->queue_buf[afl->current_entry]; } //Основная часть фаззинга происходит в функции fuzz_one skipped_fuzz = fuzz_one(afl); ... // Старая стратегия выбора seed'ов, не будем на ней останавливаться, сейчас испольузется редко if (unlikely(!afl->stop_soon && exit_1)) { afl->stop_soon = 2; } if (unlikely(afl->old_seed_selection)) { while (++afl->current_entry < afl->queued_items && afl->queue_buf[afl->current_entry]->disabled) {}; if (unlikely(afl->current_entry >= afl->queued_items || afl->queue_buf[afl->current_entry] == NULL || afl->queue_buf[afl->current_entry]->disabled)) { afl->queue_cur = NULL; } else { afl->queue_cur = afl->queue_buf[afl->current_entry]; } } } while (skipped_fuzz && afl->queue_cur && !afl->stop_soon);
В приведенном выше фрагменте кода мы, по сути, настраиваем очередь для фаззинга.
Основная часть фаззинга происходит в функции fuzz_one. Это большая функция, которая выполняет множество операций, связанных с мутацией, и она действительно заслуживает отдельной статьи. В рамках этой статьи я пропущу детали и перейду к тому моменту, где входные данные записываются в forkserver и он запускается.
Входные данные подаются в функцию с названием common_fuzz_stuff. Вот соответствующий фрагмент кода:
u8 fault; if (unlikely(len = write_to_testcase(afl, (void **)&out_buf, len, 0)) == 0) { return 0; } fault = fuzz_run_target(afl, &afl->fsrv, afl->fsrv.exec_tmout);
Сначала вызывается функция write_to_testcase, которая, как следует из названия, записывает тестовый данные в входной файл. write_to_testcase обрабатывает множество особых случаев, но основная работа выполняется в функции afl_fsrv_write_to_testcase.
Ёе исходный код представлен ниже:
if (likely(fsrv->use_shmem_fuzz)) { // Если используется общая память для фаззинга, ограничиваем длину п��редаваемых данных. if (unlikely(len > MAX_FILE)) len = MAX_FILE; // Если длина данных больше максимального размера файла, устанавливаем длину на максимально возможное значение. // Записываем длину в общую память. *fsrv->shmem_fuzz_len = len; // Копируем данные из буфера в общую память. memcpy(fsrv->shmem_fuzz, buf, len); ... } else { // Если не используется общая память, работаем с файловым дескриптором. s32 fd = fsrv->out_fd; // Если не используется стандартный ввод и задан выходной файл, работаем с файлом. if (!fsrv->use_stdin && fsrv->out_file) { // Если не нужно удалять файл, обрабатываем его по обычному сценарию. if (unlikely(fsrv->no_unlink)) { ... } else { // Если нужно удалить файл перед записью, вызываем unlink, игнорируя возможные ошибки. unlink(fsrv->out_file); // Игнорируем ошибки удаления файла. // Создаем новый файл с флагами: запись, создание и исключение, если файл уже существует. fd = open(fsrv->out_file, O_WRONLY | O_CREAT | O_EXCL, DEFAULT_PERMISSION); } // Проверка, что файл был успешно открыт, иначе выводим ошибку. if (fd < 0) { PFATAL("Unable to create '%s'", fsrv->out_file); } } else if (unlikely(fd <= 0)) { // Если файловый дескриптор не задан, возникает ошибка. FATAL("Nowhere to write output to (neither out_fd nor out_file set (fd is %d))", fd); } else { // Если дескриптор валиден, устанавливаем его в начало файла. lseek(fd, 0, SEEK_SET); } // Пишем данные в файл или на выходной дескриптор. ck_write(fd, buf, len, fsrv->out_file); // Если используется stdin, обрабатываем файл как стандартный ввод. if (fsrv->use_stdin) { // Обрезаем файл до нужной длины. if (ftruncate(fd, len)) { PFATAL("ftruncate() failed"); } // Сдвигаем указатель на начало файла. lseek(fd, 0, SEEK_SET); } else { // Если не используется stdin, закрываем файл. close(fd); } }
Ссылка на этот кусок кода
Первое условие проверяет, используем ли мы фаззинг через общую память. Если да (то есть, если fsrv->use_shmem_fuzz != 0), то мы просто копируем входные данные в буфер общей памяти с помощью memcpy и записываем длину данных.
Если же используется ввод из файла, то мы попадаем в блок else.
Мы начинаем с получения файлового дескриптора (fd). Если не используется ввод из потока ввода, то заходим в следующий блок кода.
Внутри блока if происходит: удаление старого файла (unlink(fsrv->out_file)), создание нового файла с помощью функции open с флагами: запись, создание и исключение, если файл уже существует.
Если используется stdin, то мы просто устанавливаем указатель файла в начало с помощью lseek(fd, 0, SEEK_SET).
После того как файл обработан, данные записываются в файл с помощью ck_write.
Если используется stdin, то после записи данных в файл, мы также вызываем ftruncate(fd, len), чтобы обрезать файл до нужной длины. Это предотвращает наличие старых данных в файле после завершения работы программы.
Затем выполняется lseek(fd, 0, SEEK_SET), чтобы указатель на чтение был установлен на начало файла, что гарантирует, что дочерний процесс прочитает данные с самого начала.
На этом этапе мутированные данные записываются. Все, что нам нужно сделать — это перезапустить сервер форков. Это выполняется в функции fuzz_run_target. Эта функция на самом деле просто запускает таймер и вызывает afl_fsrv_run_target. Большая часть этой функции занимается обработкой альтернативных режимов работы AFL++, поэтому я привел ниже самый важный фрагмент кода данной функции.
fsrv_run_result_t __attribute__((hot)) afl_fsrv_run_target(afl_forkserver_t *fsrv, u32 timeout, volatile u8 *stop_soon_p) { s32 res; u32 exec_ms; u32 write_value = fsrv->last_run_timed_out; ... /* После этого memset, fsrv->trace_bits[] становятся эффективно volatile, поэтому мы должны предотвратить любые предыдущие операции, которые могут попасть в эту область. */ memset(fsrv->trace_bits, 0, fsrv->map_size); MEM_BARRIER(); //commit all writes /* у нас есть работающий сервер форков (или его подделка). Сначала сообщим, если предыдущий запуск завершился с тайм-аутом. */ if ((res = write(fsrv->fsrv_ctl_fd, &write_value, 4)) != 4) { if (*stop_soon_p) { return 0; } RPFATAL(res, "Unable to request new process from fork server (OOM?)"); } fsrv->last_run_timed_out = 0; if ((res = read(fsrv->fsrv_st_fd, &fsrv->child_pid, 4)) != 4) { if (*stop_soon_p) { return 0; } RPFATAL(res, "Unable to request new process from fork server (OOM?)"); } ... exec_ms = read_s32_timed(fsrv->fsrv_st_fd, &fsrv->child_status, timeout, stop_soon_p); if (exec_ms > timeout) { /* Если после тайм-аута не было ответа от сервера форков, мы убиваем дочерний процесс. Сервер форков должен сообщить нам об этом позже */ s32 tmp_pid = fsrv->child_pid; if (tmp_pid > 0) { kill(tmp_pid, fsrv->child_kill_signal); fsrv->child_pid = -1; } fsrv->last_run_timed_out = 1; if (read(fsrv->fsrv_st_fd, &fsrv->child_status, 4) < 4) { exec_ms = 0; } } if (!exec_ms) { if (*stop_soon_p) { return 0; } SAYF("\n" cLRD "[-] " cRST "Unable to communicate with fork server. Some possible reasons:\n\n" " - You've run out of memory. Use -m to increase the the memory " "limit\n" " to something higher than %llu.\n" " - The binary or one of the libraries it uses manages to " "create\n" " threads before the forkserver initializes.\n" " - The binary, at least in some circumstances, exits in a way " "that\n" " also kills the parent process - raise() could be the " "culprit.\n" " - If using persistent mode with QEMU, " "AFL_QEMU_PERSISTENT_ADDR " "is\n" " probably not valid (hint: add the base address in case of " "PIE)" "\n\n" "If all else fails you can disable the fork server via " "AFL_NO_FORKSRV=1.\n", fsrv->mem_limit); RPFATAL(res, "Unable to communicate with fork server"); } if (!WIFSTOPPED(fsrv->child_status)) { fsrv->child_pid = -1; } fsrv->total_execs++; /* Любые последующие операции с fsrv->trace_bits не должны быть перемещены компилятором ниже этой точки. После этого момента fsrv->trace_bits[] ведут себя нормально и не должны рассматриваться как volatile. */ MEM_BARRIER(); /* Сообщаем результат вызывающему коду. */ /* Был ли запуск неудачным? (а был ли мальчик...?)*/ if (unlikely(*(u32 *)fsrv->trace_bits == EXEC_FAIL_SIG)) { return FSRV_RUN_ERROR; } ... /* успех :) */ return FSRV_RUN_OK; }
Давайте пройдемся по этой функции построчно:
Мы начинаем с выполнения memset с нулем для fsrv->trace_bits (или карты покрытия). Это гарантирует, что карта покрытия будет обнулена и сброшена перед следующим запуском.
После этого мы записываем в дочерний процесс через ctl_pipe. Это будит дочерний процесс для следующего запуска.
Затем мы читаем PID дочернего процесса из канала статуса. Напоминаю, что сервер форков записывает PID дочернего процесса в канал статуса после форка.
Предполагая, что это было успешно, мы выполняем чтение с таймаутом для статуса дочернего процесса через read_s32_timed. read_s32_timed — это функция чтение с таймаутом. То есть, после истечения заданного времени таймаута, чтение завершится, независимо от того, было ли что-то прочитано. Это помогает убедиться, что afl-fuzz не ждет бесконечно, если дочерний процесс завис. Значение, считанное с помощью read_s32_timed, — это то же самое значение, которое передается из __afl_start_forkserver здесь.
Как только это возвращено, мы проверяем, истек ли таймаут. Если таймаут истек, выполняется код обработки ошибки. Затем выполняется следующий код:
if (!WIFSTOPPED(fsrv->child_status)) { fsrv->child_pid = -1; } Источник
Этот код проверяет, остановлен ли дочерний процесс. Если дочерний процесс не остановлен (то есть завершился), мы просто устанавливаем fsrv->child_pid = -1, что очищает PID для следующего запуска. Напоминаю, что дочерний процесс может быть остановлен только в режиме постоянного процесса, и если используется этот режим, то дочерний процесс будет перезапущен. Таким образом, если используется режим постоянного процесса, нет смысла устанавливать fsrv->child_pid = -1, поскольку child_pid будет повторно использован.
Этот цикл мутировать-записать-выполнить будет выполняться бесконечно, пока либо что-то не пойдет катастрофически не так, либо мы не скажем фаззеру остановиться.
Заключение
Статья сложная, кто прочитал - молодец, кто что-то понял - герой.
На русском языке крайне мало материалов на тему работы фаззера "изнутри", надеюсь, что Вы смогли что-то подчерпнуть для себя.
Что почитать?
Документация на оригинальный AFL (AFL++ основан на нем);
Благодарности
@Koltiradw - за рецензии, консультацию и ответы на тупые вопросы в четыре утра.
