Часть 2

В последней статье этой серии я создал простую версию malloc/free и продемонстрировал уязвимость, связанную с переполнением кучи. В следующей статье я решил добавить бины в свою реализацию и продемонстрировать быструю атаку бинов.

Что такое бин?

Бункеры, также известные как свободные списки, представляют собой массивы освобожденных фрагментов памяти. Бункеры позволяют нам перераспределять участки памяти быстрее и эффективнее. В нашей предыдущей реализации необходимо было перечислить весь список выделенных фрагментов, чтобы определить, доступен ли для повторного использования свободный фрагмент, соответствующий требованиям по размеру. Корзины хранят только освобожденные фрагменты и упорядочены по размеру, что значительно ускоряет процесс поиска и выбора.

Чтобы лучше понять бины, давайте взглянем на реализацию malloc в GLIBC (ptmalloc2). ptmalloc2 использует 5 различных типов ячеек; быстрый, несортированный, маленький, большой и tcache. Быстрые и малые бины похожи в том смысле, что каждый из соответствующих бинов хранит фрагмент фиксированного размера. Это означает, что каждая быстрая и маленькая корзина будет автоматически сортироваться, что ускоряет процесс добавления и удаления фрагментов из корзин. Основное различие между быстрыми и малыми бинами заключается в том, что объединение фрагментов, хранящихся в быстрых бинах, не происходит, тогда как чанки, хранящиеся в небольших бинах, могут быть объединены с соседними освобожденными чанками, что помогает уменьшить фрагментацию памяти. Я не буду подробно останавливаться на другом типе бункеров, так как он не нужен для нашей простой реализации, но для получения дополнительной информации ознакомьтесь с этой статьей: https://azeria-labs.com/heap-exploitation-part-2 -glibc-куча-свободные-бины/

Бункеры в mmalloc()

Для этой реализации мы собираемся создать массив для быстрых бинов, а также один отсортированный бин. Всего будет 8 быстрых бинов, соответствующих следующим размерам: 8, 16, 24, 32, 40, 48, 56, 64. Для всех запросов mmalloc() мы будем округлять размер до ближайшего кратного 8. Это гарантирует, что для любого запроса, меньшего или равного 64, будет соответствующий быстрый бин, к которому он может быть добавлен. Выравнивание по 8 байтам также пригодится, когда мы добавим возможность объединения фрагментов, но об этом мы поговорим в следующей статье.

Отсортированная корзина будет обрабатывать любые фрагменты, размер которых превышает 64, и будет отсортирована от наименьшего к наибольшему. Такая сортировка фрагментов позволит mmalloc() легко вернуть наименьший освобожденный фрагмент, соответствующий запрошенному размеру.

Чтобы упростить сортировку в отсортированной корзине, мы собираемся настроить заголовок, чтобы разрешить двусвязный список. Для этого я решил воспроизвести структуру заголовка malloc GLIBC. Эта скорректированная структура заголовка также пригодится при обсуждении атаки на fastbin. Давайте посмотрим на предыдущий заголовок в сравнении с откорректированным.

Как мы видим, в новом заголовке есть поле для перехода вперед (fd) и назад (bk). Эти два поля аналогичны полю next в старом заголовке, так как содержат указатели на предыдущий и следующий фрагменты в соответствующей ячейке освобожденных фрагментов. Поле size аналогично предыдущему заголовку в том смысле, что оно определяет размер доступной памяти в чанке, исключая размер самого заголовка. Одно большое различие между старым заголовком и новым заключается в том, как заголовок обрабатывается по-разному в зависимости от того, используется ли фрагмент или свободен. Когда фрагмент используется, доступная для использования память фактически начинается сразу после поля size. Это позволяет нам сэкономить место, которое в противном случае было бы занято неиспользуемыми полями fd и bk.

Как только блок освобождается, поля fd и bk заполняются соответствующим образом. Это поведение различается в зависимости от того, предназначен ли освобожденный блок для отсортированной корзины или быстрой корзины. Поскольку фрагменты, которые сохраняются в быстрых корзинах, имеют фиксированный размер, нет необходимости их сортировать и, следовательно, нет необходимости создавать двусвязный список. В целях повышения скорости мы будем хранить недавно освобожденные фрагменты, предназначенные для быстрых контейнеров, в виде односвязного списка, установив только указатель fd и просто удаляя фрагменты из верхней части этого списка по мере их повторного использования.

Давайте посмотрим, как все это выглядит в коде, начиная с нашей новой структуры заголовка чанка и создания наших бинов.

struct chunk_data {
    size_t prev_size;
    size_t size;
    struct chunk_data *fd;
    struct chunk_data *bk;
};
typedef struct chunk_data *binptr;
binptr sortedbins = NULL;
binptr fastbins[NFASTBINS] = {NULL};

Здесь мы можем увидеть изменения, внесенные в наш заголовок, чтобы включить поля prev_size, fd и bk и удалить неиспользуемые поля free, магическое и следующее. Затем мы создаем нашу отсортированную корзину и массив быстрых ячеек и инициализируем их значения равными NULL.

Чтобы увидеть, как фрагменты добавляются в эти корзины, мы можем взглянуть на исходный код для mfree().

struct chunk_data *ptr = get_chunk_ptr(chunk);
if(ptr->size <= 64) {
    fastbin_add(ptr);
} else {
    sortbin_add(ptr);
}

mfree() вызывает get_chunk_ptr(), чтобы получить в памяти адрес, указывающий на начало заголовка блока, затем оценивает его размер, чтобы определить, должен ли блок храниться в отсортированной корзине или в одной из быстрых корзин. Если фрагмент предназначен для быстрой корзины, то вызывается fastbin_add(), которая оценивает, заполнена ли уже соответствующая корзина. Если это так, то указатель fd нового фрагмента устанавливается на первый член быстрого бина, а заголовок быстрого бина устанавливается на адрес нового фрагмента. Это эффективно добавляет новый блок в верхнюю часть корзины.

if(fastbins[FASTBIN_IDX(chunk->size)]) {
    chunk->fd = fastbins[FASTBIN_IDX(chunk->size)];
    fastbins[FASTBIN_IDX(chunk->size)] = chunk;
} else {
    fastbins[FASTBIN_IDX(chunk->size)] = chunk;
    chunk->fd = NULL;
}

Макрос FASTBIN_IDX(x), показанный в предыдущем источнике, используется для простого поиска правильного индекса быстрого бина, который соответствует запрошенному размеру блока (т. е. размер блока 64 будет соответствовать 8-му индексу в этот массив) и объявляется следующим образом:

#define FASTBIN_IDX(x) ((x+7) >> 3) - 1

Процесс добавления фрагмента в отсортированную корзину несколько сложнее. По сути, отсортированный бин сначала проверяется, чтобы увидеть, был ли он заполнен или нет. Если он не заполнен, то фрагмент просто устанавливается в начало списка, а указатели fd и bk оба устанавливаются в NULL.

} else {
    sortedbins = chunk;
    chunk->bk = NULL;
    chunk->fd = NULL;
}

Если отсортированный бин уже заполнен, то просматриваем список освободившихся чанков, проверяя размер. Как только найдена запись, которая больше или равна запрошенному размеру, указатель bk этого фрагмента оценивается, чтобы определить, находится ли текущий фрагмент во главе списка или нет.

while(current) {
        last = current->bk;
        if((current->size >= chunk->size) && !(current->bk)) {
            chunk->bk = NULL;
            chunk->fd = current;
            current->bk = chunk;
            sortedbins = chunk;
            return 0;
        } else if((current->size >= chunk->size) && current->bk) {
            chunk->bk = last;
            chunk->fd = current;
            current->bk = chunk;
            last->fd = chunk;
            return 0;
        }
        last = current;
        current = current->fd;
    }

Если !(current-›bk) оценивается как true, мы можем сделать вывод, что текущий блок действительно является головным. В этот момент кусок, который добавляется в корзину, получает указатель bk, установленный в NULL, его указатель fd, установленный на текущий фрагмент, и bk. Указатель strong> текущего чанка устанавливается на вновь добавленный чанк. Это эффективно добавляет новый фрагмент в начало списка.

если второе условие if оценивается как истинное, мы можем сделать вывод, что новый фрагмент добавляется где-то в середине списка. В этом случае наша стратегия очень похожа на предыдущую, за исключением того, что мы устанавливаем указатель bk нового фрагмента и указатель fd последнего фрагмента в список.

Если оба утверждения if оцениваются как ложные, то фрагмент необходимо добавить в конец списка, что делается следующим образом.

last->fd = chunk;
chunk->bk = last;
chunk->fd = NULL;

Теперь, когда у нас есть представление о том, как фрагменты добавляются в соответствующие контейнеры, давайте посмотрим, как фрагменты выбираются для повторного использования при вызове mmalloc().

if(fastbins[FASTBIN_IDX(aligned_size)]) {
    chunk = reuse_fastchunk(FASTBIN_IDX(aligned_size));
} else if(sortedbins) {
    chunk = reuse_chunk(sortedbins, aligned_size);
}
if(!chunk) {
    chunk = req_space(aligned_size);
    if(!chunk) {
        return NULL;
    }
}

Здесь мы видим, что соответствующий индекс быстрого бина оценивается, чтобы увидеть, заполнен ли он. Если это так, то вызывается функция reuse_fastchunk(), чтобы удалить фрагмент из корзины и вернуть его для использования mmalloc(). Глядя на исходный код reuse_fastchunk(), мы видим, что он устанавливает текущий указатель chunk_data на заголовок соответствующего быстрого бина, а затем оценивает, соответствует ли fd указатель заполняется. Если это так, заголовок быстрой корзины устанавливается на этот указатель, в противном случае он устанавливается в NULL, что помечает список как пустой.

struct chunk_data *reuse_fastchunk(size_t size) {
    if(fastbins[size]) {
        struct chunk_data *current = fastbins[size];
        
        if(current->fd) {
            fastbins[size] = current->fd;
        } else {
            fastbins[size] = NULL;
        }
        return current;
    }
    return NULL;
}

Если соответствующий быстрый бин пуст или запрошенный размер фрагмента слишком велик, чтобы поместиться в быстрый бин, то отсортированный бин проверяется, чтобы увидеть, заполнен ли он. Если этот бин заполнен, то вызывается reuse_chunk() с указателем на отсортированный бин в качестве первого аргумента и запрошенного размера в качестве второго аргумента. Затем функция reuse_chunk() продолжает перебирать фрагменты в предоставленной корзине, пока не найдет тот, который может удовлетворить запрос, или дойдет до конца списка.

while(current && !(current->size >= size)) {
    current = current->fd;
}
if(current) {
    struct chunk_data *last = current->bk;
    if(last && current->fd) { 
        //If true, chunk is in middle of list
        
        last->fd = current->fd;
        current->fd->bk = last;
    } else if(!(last) && current->fd) { 
        //If true, chunk is at the start of list
        
        *bin = current->fd;
        current->bk = NULL;
    } else if(current && !(current->fd && current->bk)) {
        //If true, chunk is only member of list
        
        last->fd = NULL;
    } else {
        //If true, chunk is at the end of the list
        
        *bin = NULL;
    }
}

Если фрагмент, соответствующий размеру, найден, он оценивается по ряду условий, чтобы определить его место в списке. (предупреждение, что этот код уродлив и должен быть переписан). Я не буду вдаваться в подробности относительно этой функции, так как уязвимость, которую мы продемонстрируем, специфична для реализации быстрой корзины, но я включил некоторые комментарии в приведенный выше код для всех, кому любопытно.

Фастбин Атака

Итак, теперь, когда у нас есть хорошее представление о том, как наши корзины заполняются и используются для перевыпуска фрагментов, давайте поговорим о том, как мы можем использовать уязвимость использования после освобождения, чтобы использовать проблему с реализацией быстрой корзины.

Уязвимость использования после освобождения возникает, когда память неправильно управляется таким образом, что позволяет злоумышленнику ссылаться на область памяти, которая уже была освобождена. Подобно переполнению кучи, продемонстрированному в предыдущей статье, мы можем использовать эту уязвимость, чтобы перезаписать указатель fd в освобожденном фрагменте, чтобы повредить список свободных мест и предоставить злоумышленнику примитив записи чего угодно и где угодно.

Давайте посмотрим, как именно это работает. Если мы помним описание быстрых бинов ранее, мы знаем, что каждый фрагмент добавляется и удаляется сверху быстрого бина (LIFO) в зависимости от размера. По мере роста каждого быстрого списка указатель fd самого нового фрагмента указывает на предыдущий заголовок списка. По мере использования фрагментов в быстрой корзине и сокращения списка первый фрагмент в списке удаляется, а следующий фрагмент становится головным. Таким образом, чтобы воспользоваться этим поведением, нам нужно иметь возможность записывать в освобожденный фрагмент, который находится где-то над нижней частью списка, и нам нужно иметь возможность выделять достаточно фрагментов соответствующего размера, пока нам не будет предоставлен фрагмент, который живет по поврежденному адресу, который мы указали, когда писали в ранее упомянутый освобожденный фрагмент. На этом этапе нам нужно иметь возможность сделать запись в последний выделенный фрагмент, чтобы завершить атаку.

Чтобы лучше понять, как это работает, давайте создадим сценарий, в котором имеет место это конкретное поведение. Сначала мы повторно используем таблицу переходов, которую мы использовали в предыдущей статье, в качестве нашей цели.

print_func *jmp_table[2] = {
    good_print,
    bad_print
};

Далее мы выделяем три фрагмента одинакового размера, а затем освобождаем эти три фрагмента.

test = mmalloc(16);
test2 = mmalloc(16);
test3 = mmalloc(16);
mfree(test);
mfree(test2);
mfree(test3);

На данный момент наш быстрый бин для размера 16 должен иметь три фрагмента: память, выделенная для test3, затем test2, а затем test. Далее мы выполним запись в чанк во главе списка, который, как уже упоминалось, является test3.

strcpy(test3, "\x20\xe4\xff\xff\xff\x7f");

В этом случае адрес, который записывается в test3, представляет собой область стека, содержащую указатель на good_print() в нашей таблице переходов. Важно помнить структуру освобожденного фрагмента, чтобы понять, как работает эта часть.

Взглянув на разницу между освобожденным фрагментом и выделенным фрагментом, мы увидим, что область, в которую мы записываем, — это именно та область, которая содержит указатель fd освобожденного фрагмента. Таким образом, записывая адрес памяти в этот указатель, мы, по сути, перенаправляем быстрый бин так, чтобы он указывал на произвольную область памяти, которой мы сможем управлять.

Теперь, когда мы испортили указатель fd первого фрагмента в этом быстром бине, мы хотим выделить еще два фрагмента того же размера. Первый выделенный фрагмент можно отбросить, но второй фрагмент будет указывать на перезаписанный адрес. На этом этапе мы можем скопировать адрес функции bad_print() в эту область памяти, которая перезапишет указатель функции, хранящийся там в данный момент (good_print), и мы можем сделать вызов этой записи в таблице переходов следующим образом.

test4 = mmalloc(16);
functest = mmalloc(16);
strcpy(functest, "\xcf\x59\x55\x55\x55\x55");
jmp_table[0]();

Заворачивать

Надеемся, что эта статья дает хорошее базовое понимание того, как работают корзины и как мы можем использовать уязвимость для повреждения данных заголовков фрагментов, которые находятся в этих корзинах. Я наткнулся на так много замечательных ресурсов, когда писал это, и просто хотел поделиться некоторыми из них здесь.

https://0x00sec.org/t/heap-exploitation-fastbin-attack/3627

https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/

https://heap-exploitation.dhavalkapil.com/diving_into_glibc_heap/bins_chunks

https://sourceware.org/glibc/wiki/MallocInternals

https://developers.redhat.com/blog/2017/03/02/malloc-internals-and-you#tunings

https://6point6.co.uk/insights/common-software-vulnerabilities-part-ii-explaining-the-use-after-free/

https://github.com/shellphish/how2heap