Разбираемся в устройстве AFL++. Часть 2
Ссылки на остальные статьи данного цикла:
От автора
Напоминаю, что статья потенциально может содержать ошибки и неточности, автор открыт к диалогу (и самообразованию), если Вам есть что подсказать, исправить и скорректировать — пишите!
Инициализация и запуск forkserver
Прежде чем обсуждать процесс инициализации, нам нужно рассмотреть одну из особенностей ELF
-файлов, известную как init array
.
Когда исполняемый бинарный файл запускается, функция main
не вызывается напрямую. Вместо этого выполнение всегда начинается с интерпретатора (обычно это ld.so
). Интерпретатор выполняет множество ключевых операций по настройке структуры процессов, таких как динамическая линковка. Однако менее известной особенностью интерпретатора является его способность запускать функции инициализации. Эти функции инициализации находятся в секции, называемой .init_array
. Эта секция содержит массив функций, которые выполняются последовательно, прямо перед вызовом main
. Это наглядно можно увидеть в коде glibc и в почитать в документации
Функции в секции .init_array
зависят от того, был ли бинарный файл скомпилирован с использованием инструментаций PCGUARD
или LTO
(однако, как мы убедились выше, в большинстве случаев они остаются одинаковыми).
В процессе обсуждения функций из .init_array
нам встретятся различные переменные, переменные окружения и макросы. Для удобства привожу их определения ниже:
Обычные переменные:
__afl_area_ptr
— Указатель на карту покрытия. По умолчанию это__afl_area_initial
. Размер__afl_area_initial
равенMAP_INITIAL_SIZE
.__afl_final_loc
— Последний индекс в__afl_area_ptr
, к которому был осуществлен доступ при помощи инструментации. По сути это конец массива карты покрытий.__afl_map_addr
— Адрес, по которому карта покрытия будет отображена через mmap. Эта переменная существует только в случае, еслиAFL_LLVM_MAP_ADDR
установлен в режимеLTO
. В противном случае её значение равно 0.__afl_map_size
— Размер карты покрытия.__afl_area_initial
— указатель на массив, который является картой покрытия и используется в общей (разделяемой) памяти между инстансамиafl-fuzz
.
Переменные окружения:
__AFL_SHM_ID
(алиас наSHM_ENV_VAR
) — Идентификатор общей памяти для карты покрытия.__AFL_SHM_FUZZ_ID
(алиасSHM_FUZZ_ENV_VAR
) — Идентификатор общей памяти для фаззинга через общую память.AFL_MAP_SIZE
— Используется для задания размера буфера общей памяти, выделяемогоafl-fuzz
.
Макросы:
MAP_SIZE
— Пользовательское значение, котороеafl-fuzz
будет использовать для задания размера карты покрытия общей памяти. изначально зависит от системыMAP_INITIAL_SIZE
— Размер__afl_area_initial
.
Теперь начнем с PCGUARD
. Функции инициализации выглядят следующим образом:
init_func_table
Все эти функции (за исключением sancov.module_ctor_trace_pc_guard
) можно найти в исходниках
Итак, далее мы рассмотрим функции все функции из скриншота выше.
__afl_auto_first
— она по сути ничего не делает, только устанавливает__afl_already_initialized_first = 1
, чтобы показать, что был запущен процесс инициализации и проверяет наличие LLVM инструментации.__afl_auto_second
— сравнивает__afl_final_loc
иMAP_INITIAL_SIZE
. Если первый параметр меньше второго, то выделяет доп.память и обновляет все указатели, связанные с картой покрытияsancov.module_ctor_trace_pc_guard
— просто обернутый вызов функции__sanitizer_cov_trace_pc_guard_init
.cov_trace_pc_guard
__afl-auto_eraly
— обертка для вызова__afl_map_shm
.__early_forkserver
— обертка для вызова функции__afl_auto_init
.__afl_auto_init
-
Далее разберем каждую функцию подробнее:
Рассмотрим функцию __sanitizer_cov_trace_pc_guard_init
более подробно:
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
u32 inst_ratio = 100;
char *x;
_is_sancov = 1;
...
if (start == stop || *start) return;
...
if (__afl_final_loc < 4) __afl_final_loc = 4; // we skip the first 5 entries
*(start++) = ++__afl_final_loc; //start at 4
while (start < stop) {
if (likely(inst_ratio == 100) || R(100) < inst_ratio)
*start = ++__afl_final_loc;
else
*start = 0; // write to map[0]
start++;
}
...
Эта функция принимает два параметра: start
и stop
, которые обозначают начало и конец массива меток защиты (guards, как показано в приведённом выше примере). Эти метки фактически составляют карту покрытия — массив состояний покрытия ребер кода.
Суть работы функции заключается в следующем: она последовательно проходит через всю карту покрытия, заполняя её индексами и одновременно увеличивая значение переменной __afl_final_loc
. В результате выполнения __afl_final_loc
принимает значение последнего индекса, соответствующего концу массива карты покрытия.
Напомню, что метки защиты (guards
) привязаны к определённым участкам кода. Эти метки:
Хранятся в массиве, который фактически является частью карты покрытия.
Выступают в роли уникальных идентификаторов для различных участков кода.
В тестовом примере из начала статьи такими метками выступали dword_91A0
и dword_919C
.
Пара слов об этой строке кода:
if (likely(inst_ratio == 100) || R(100) < inst_ratio)
Функция likely
— это макрос или встроенная функция, обычно предоставляемая компилятором. Она помогает оптимизировать выполнение кода, указывая компилятору, что определённое условие с высокой вероятностью истинно. В данном случае:
likely(inst_ratio == 100)
Указывает, что в большинстве случаев переменная inst_ratio
будет равна 100
. Это помогает компилятору оптимизировать ветвление для данного условия
R(100)
— это макрос или функция, которая возвращает случайное число от 0 до 99 (включительно).
Если inst_ratio == 100
, все метки защиты активируются.
Если inst_ratio < 100
, метки активируются случайным образом с вероятностью, равной inst_ratio
.
Следующая функция — __afl_auto_early
. Эта функция вызывает в себе функцию __afl_map_shm
.
Для дальнейшего обсуждения работы этой функции необходимо пару слов сказать про разделяемую память.
Разделяемая память — это механизм в Linux, который позволяет создавать области памяти, доступные для нескольких процессов. Каждая область разделяемой памяти имеет собственный уникальный идентификатор (ID), который ее идентифицирует. Чтобы два процесса могли получить доступ к одной и той же разделяемой памяти, им нужно только сопоставить память с одинаковым ID. Для более подробной информации о разделяемой памяти рекомендуется обратиться к странице руководства
По сути, afl-fuzz
создает экземпляр разделяемой памяти, а целевой процесс сопоставляет дескриптор этой памяти с картой покрытия. В результате afl-fuzz
может видеть любые изменения, которые дочерний процесс вносит в карту покрытия.
Функция вышла достаточно объемной, поэтому было решение объяснить её работу через комментарии.
Из функции также вырезаны отладочные сообщения, режим CMPlog и режим частичной инструментации кода, а также обработка некоторых ошибок. Полный код функции доступен тут
static void __afl_map_shm(void) {
// Если разделяемая память уже инициализирована, выходим
if (__afl_already_initialized_shm) return;
__afl_already_initialized_shm = 1;
// Если указатель карты покрытия не установлен, используем заглушку
if (!__afl_area_ptr) { __afl_area_ptr = __afl_area_ptr_dummy; }
// Получаем идентификатор разделяемой памяти из переменной окружения
char *id_str = getenv(SHM_ENV_VAR);
// Проверяем финальное местоположение карты покрытия
if (__afl_final_loc) { // !<----------- максмальный размер массива карты покрытия
__afl_map_size = ++__afl_final_loc; // Увеличиваем, так как отсчет начинается с 0
// Если размер больше допустимого MAP_SIZE, предупреждаем пользователя
if (__afl_final_loc > MAP_SIZE) {
char *ptr;
u32 val = 0;
if ((ptr = getenv("AFL_MAP_SIZE")) != NULL) { val = atoi(ptr); }
if (val < __afl_final_loc) {
if (__afl_final_loc > MAP_INITIAL_SIZE && !getenv("AFL_QUIET")) {
fprintf(stderr,
"Warning: AFL++ tools might need to set AFL_MAP_SIZE to %u "
"to be able to run this instrumented program if this "
"crashes!\n",
__afl_final_loc);
}
}
}
}
// Проверяем, запущено ли приложение под управлением afl-fuzz
if (__afl_sharedmem_fuzzing && (!id_str || !getenv(SHM_FUZZ_ENV_VAR) ||
fcntl(FORKSRV_FD, F_GETFD) == -1 ||
fcntl(FORKSRV_FD + 1, F_GETFD) == -1)) {
__afl_sharedmem_fuzzing = 0;
}
// Если ID разделяемой памяти отсутствует, инициализируем значения по умолчанию
if (!id_str) {
...
}
// Если приложение запущено под AFL++, привязываем карту покрытия
if (id_str) {
if (__afl_area_ptr && __afl_area_ptr != __afl_area_initial &&
__afl_area_ptr != __afl_area_ptr_dummy) {
if (__afl_map_addr) {
munmap((void *)__afl_map_addr, __afl_final_loc); // Освобождаем предыдущую память
} else {
free(__afl_area_ptr); // Освобождаем старую память
}
__afl_area_ptr = __afl_area_ptr_dummy; // Сбрасываем указатель
}
// Преобразуем строковый ID в числовой
u32 shm_id = atoi(id_str);
// Проверяем, соответствует ли размер карты
if (__afl_map_size && __afl_map_size > MAP_SIZE) {
u8 *map_env = (u8 *)getenv("AFL_MAP_SIZE");
if (!map_env || atoi((char *)map_env) < MAP_SIZE) {
fprintf(stderr, "FS_ERROR_MAP_SIZE\n");
send_forkserver_error(FS_ERROR_MAP_SIZE);
_exit(1);
}
}
//-----------------Здесь происходит вся магия---------------
// Привязываем разделяемую память
__afl_area_ptr = (u8 *)shmat(shm_id, (void *)__afl_map_addr, 0);
// Обрабатываем ошибки привязки
if (!__afl_area_ptr || __afl_area_ptr == (void *)-1) {
if (__afl_map_addr)
send_forkserver_error(FS_ERROR_MAP_ADDR);
else
send_forkserver_error(FS_ERROR_SHMAT);
perror("shmat for map");
_exit(1);
}
// Записываем что-либо в карту, чтобы AFL не прекращал работу
__afl_area_ptr[0] = 1;
...
}
// Сохраняем резервный указатель на карту
__afl_area_ptr_backup = __afl_area_ptr;
...
}
```
Видно, что основная работа с разделяемой памятью происходит в функции shmat. Подробнее можно прочитать здесь
Выполнения данной функции приводит к тому, что __afl_area_ptr
настроен и располагается в разделяемой (общей) памяти и доступен для всех потомков процесса afl-fuzz
. Это так же нужно для работы параллельного фаззинга.
Теперь перейдем к функции — __early_forkserver
. Эта функция зачастую ничего не делает, поэтому пропустим её.
Последняя функция — __afl_auto_init
— эта функция вызывает функцию __afl_manual_init();
, а она в свою очередь вызывает __afl_start_forkserver();
. Её мы и рассмотрим подробнее:
Итак, функция __afl_start_forkserver();
. Исходники можно посмотреть тут, в рамках статьи же рассмотрим эту функцию тезисно, аналогично функции __afl_auto_early
:
Не обходится тут и без небольших шуток от разработчиков.
//Проверяет, используется ли старый режим форксервера (без новых функций). Если __afl_old_forkserver равно 0, выполняется инициализация нового форксервера
if (!__afl_old_forkserver) {
//Отправляет сообщение родителю через канал. Если отправка не удалась, форксервер завершает выполнение, предполагая, что он не используется.
if (write(FORKSRV_FD + 1, msg, 4) != 4) { return; }
//Ожидает ответа от родителя. Если чтение завершилось ошибкой, программа аварийно завершается
if (read(FORKSRV_FD, reply, 4) != 4) { _exit(1); }
if (tmp != status2) {
write_error("wrong forkserver message from AFL++ tool");
_exit(1);
}
...
//Устанавливает опции для форксервера
status = FS_NEW_OPT_MAPSIZE; //Всегда включён, указывает на размер карты покрытия.
if (__afl_sharedmem_fuzzing) { status |= FS_NEW_OPT_SHDMEM_FUZZ; } //Включается, если используется совместная память для фаззинга.
if (__afl_dictionary_len && __afl_dictionary) { status |= FS_NEW_OPT_AUTODICT; } //Включается, если доступен автословарь
//Отправляет опции форксерверу. Завершает выполнение при ошибке записи
if (write(FORKSRV_FD + 1, msg, 4) != 4) { _exit(1); }
//Отправляет размер карты покрытия
status = __afl_map_size;
if (write(FORKSRV_FD + 1, msg, 4) != 4) { _exit(1); }
// Далее идет работа с автословарем, опустим
...
//Завершает рукопожатие, отправляя сообщение с версией протокола
status = version;
if (write(FORKSRV_FD + 1, msg, 4) != 4) { _exit(1); }
//Устанавливает флаг, сигнализирующий, что форксервер подключён к AFL.
__afl_connected = 1;
// Если используется совместная память, выполняется её настройка
if (__afl_sharedmem_fuzzing) { __afl_map_shm_fuzz(); }
while(1) { ...}
}
Внутри цикла while(1)
происходит основная работа фаззера. Расписывать построчно его действия не имеет смысла, поэтому тезизсно опишу логику работы программы внутри этого цикла:
Как мы помним — AFL++
создает дочерний процесс, через который контролирует запуск уже своих дочерних процессов с помощью функции fork
.
(это необходимо для повышения скорости фаззинга — дочерний процесс главного процесса
AFL++
создает свой fork непосредственно перед запуском исследуемой программы, а поскольку фаззер постоянно перезапускается, таким образом можно не тратить время на новый запуск и инициализацию процесса — возвращаемся к исходному состоянию программы перед запуском → запускаем → выполняем → завершаем, собираем покрытие → убиваем fork и запускаем новый fork с исходным состоянием)
Непосредственно здесь он получает (через каналы) информацию от родительского процесса с тем, а что именно сейчас необходимо делать — если процесс приостановлен (это корректно для режима persistant mode
, о котором ниже), то запускает его. Если процесс завершен (целевая функция успешно отработала), то создаем новый fork
дочернего процесса. После этого передаем PID созданного fork’a дочернего процесса в родительский и ожидаем остановки или завершения этого процесса.
Словом, здесь происходит общение между процессами AFL++ с отслеживанием состояния fork’a — процесса, который непосредственно и запускает целевую программу.
Итак, наш фаззер настроен, запущен и нормально работает. Но есть техники, которые позволяют существенно ускорить работу фаззера. Об одной из них ниже.
AFL++ Persistent Mode
Persistent Mode — это особый режим работы фаззера. Когда фаззер работает в обычном режиме, ему нужно каждый раз запускать fork целевой программы, что может занимать много времени, особенно для сложных или длительных процессов. В persistent mode
программа запускается один раз, и фаззер использует уже запущенный экземпляр программы для выполнения множества тестов без необходимости перезапуска программы, что значительно ускоряет процесс фаззига. Настоятельно рекомендую почитать документацию на этот механизм.
Рассмотрим следующий фрагмент кода с простейшим примером использования persistant mode
:
#include "what_you_need_for_your_target.h"
#ifndef __AFL_FUZZ_TESTCASE_LEN
ssize_t fuzz_len;
#define __AFL_FUZZ_TESTCASE_LEN fuzz_len
unsigned char fuzz_buf[1024000];
#define __AFL_FUZZ_TESTCASE_BUF fuzz_buf
#define __AFL_FUZZ_INIT() void sync(void);
#define __AFL_LOOP(x) ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0)
#define __AFL_INIT() sync()
#endif
__AFL_FUZZ_INIT();
main() {
// anything else here, e.g. command line arguments, initialization, etc.
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; // must be after __AFL_INIT
// and before __AFL_LOOP!
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN; // don't use the macro directly in a
// call!
if (len < 8) continue; // check for a required/useful minimum input length
/* Setup function call, e.g. struct target *tmp = libtarget_init() */
/* Call function to be fuzzed, e.g.: */
target_function(buf, len);
/* Reset state. e.g. libtarget_free(tmp) */
}
return 0;
}
Наглядно видно, что макросы AFL++ разворачиваются в достаточно простые команды, которые создают статический массив для входных данных fuzz_buf
, считывают в него данные функцией read
и записывают длину полученного массива в fuzz_len
.
Фактически архитектурно работа фаззера здесь представлена также, как и в обычном режиме фаззера — подаются данные (обычно из потока ввода) на вход программы, программа запускается, выполняется, собирается обратная связь. Здесь же все тоже самое, но данные подаются напрямую на функцию цель, а fork программы не завершается после выполнения программы, а выполняется в цикле __AFL_LOOP
.
Запуск фаззера в persistant mode
так же влияет на процесс работы forkserver'a
:
if (unlikely(waitpid(child_pid, &status, is_persistent ? WUNTRACED : 0) < 0)) {
write_error("waitpid");
_exit(1);
}
Если фаззинг запушен в режиме persistant mode
, то мы ждем, пока дочерний процесс не остановится (WUNTRACED)
, а не завершится. Причина этого кроется в макросе __AFL_LOOP
, который расширяется в функцию __afl_persistent_loop
. Исходник функции
Идея этой функции в том, что существует статическая переменная cycle_cnt
, которая отвечает за количество оставшихся циклов. В конце каждого цикла (кроме случая, когда cycle_cnt = 0
, в этом случае происходит остановка), посылается сигнал SIGSTOP
. Это вызывает выполнение условия waitpid
, что позволяет afl-fuzz
выполнить свои мутации и обновить карту покрытия. Как только управление возвращается в форксервер, бинарник возобновляется с помощью сигнала SIGCONT
— еще раз внимательно смотрим код функции __afl_start_forkserver
.
_AFL_FUZZ_TESTCASE_BUF
— это фактический буфер, куда подаются входные наборы данных. Этот буфер указывает на область общей памяти. Эта область памяти обновляется между циклами.
__AFL_FUZZ_TESTCASE_LEN
— это фактическая длина этого буфера.
Инициализация фаззинга начинается с функции __afl_map_shm_fuzz
. Эта функция вызывается из __afl_start_forkserver
всякий раз, когда запускается фаззинг с использованием общей (разделяемой) памяти. Для этого используется переменная __afl_sharedmem_fuzzing
, которая устанавливается в 1 всякий раз, когда используется общая память. Эта переменная так же поднимается в 1, когда используется макрос __AFL_FUZZ_INIT()
__afl_map_shm_fuzz
работает очень похоже на __afl_map_shm
, которую мы рассматривали выше. Она получает идентификатор shmem
из переменной окружения __AFL_SHM_FUZZ_ID
— как я писал выше это всего лишь обертка для SHM_FUZZ_ENV_VAR
. Затем она запускает функцию shmat
для сопоставления области разделяемой памяти с процессом.
После сопоставления области мы устанавливаем __afl_fuzz_len
и __afl_fuzz_ptr
(это define
для __AFL_FUZZ_TESTCASE_LEN
и __AFL_FUZZ_TESTCASE_BUF
соответственно) следующим образом:
__afl_fuzz_len = (u32 *)map;
__afl_fuzz_ptr = map + sizeof(u32);
Важно! Обратите внимание, что __afl_fuzz_len
на самом деле является типом данных u32*
, поскольку первые 4 байта карты памяти на самом деле являются размером данных.
Пара слов про санитайзеры кода
AFL++, несмотря на инструментацию кода, не умеет определять какие-либо ошибки кода, поэтому очень важно использовать санитайзеры. Тестируя программу без вы рискуете не отследить огромный объем ошибок.
Существует множество типов санитайзеров, каждый из которых предназначен для обнаружения различных видов ошибок при работе программы.
Особенность работы санитайзеров напрашивается на отдельную статью, благо Google опубликовал отличный вики-ресурс, который описывает каждый санитайзер и его реализацию — искать тут.
Конец
В следующей части подробно поговорим о инициализацииafl-fuzz