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

В этой статье мы познакомим вас с концепцией арен, покажем, как мы можем добавить арены в нашу реализацию, и продемонстрируем атаку в стиле «дома силы», чтобы перехватить казнь.

Что такое арена?

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

Давайте взглянем на нашу базовую структуру арены.

struct mmalloc_state {
	binptr sortedbins;
	binptr fastbins[NFASTBINS];
	chunkptr top;
	struct mmalloc_state *next;
};

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

Верхний кусок

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

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

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

Верхний фрагмент в mmalloc()

Чтобы применить эту новую стратегию распределения, были созданы три функции для взаимодействия с верхним фрагментом; create_topchunk(), split_topchunk() и extend_heap(). Каждая из этих функций вызывается функцией req_space(), которую мы ранее использовали в качестве функции для расширения нашей кучи с помощью sbrk(). Новый req_space() теперь действует как оболочка для этих новых функций и определяется следующим образом.

struct chunk_data *req_space(size_t size) {
    struct chunk_data *chunk = NULL;
    if(!main_arena->top) {
        main_arena->top = create_topchunk(TOP_SZ);
    }
    if(main_arena->top->size > (size + CHUNK_SZ)) {
        chunk = split_topchunk(size);
    } else {
        extend_heap(size);
        chunk = split_topchunk(size);
    }
    return chunk;
}

Если первое условие оценивается как NULL, это означает, что верхний блок на главной арене не был создан и вызывается create_topchunk().

struct chunk_data *create_topchunk(size_t size) {
	struct chunk_data *top;
	top = sbrk(0);
	void *req = sbrk(size);
	assert((void *)top == req);
	if(req == (void *)-1) {
    	return NULL;
	}
	top->size = (size - ALLOC_SZ);
	top->fd = NULL;
	return top;
}

Функция create_topchunk() выполняет первоначальный вызов sbrk() с верхним размером по умолчанию, который в нашем случае определен как таковой.

#define TOP_SZ 32000

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

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

struct chunk_data *split_topchunk(size_t size) {
	struct chunk_data *chunk;
	size_t top_sz = main_arena->top->size;
	chunk = main_arena->top;
	chunk->size = size;
	main_arena->top = (void *)chunk + (size + ALLOC_SZ);
	main_arena->top->size = top_sz - (size + ALLOC_SZ);
	main_arena->top->fd = NULL;
	return chunk;
}

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

Последнее условие в req_space() указывает, что верхний фрагмент был инициализирован, но в нем недостаточно места для удовлетворения запроса на выделение. Если это условие выполнено, вызывается extend_heap().

int extend_heap(size_t size) {
	void *top = sbrk(0);
	void *req = sbrk((size + ALLOC_SZ));
	assert(top == req);
	if(req == (void *)-1) {
    	return -1;
	}
	main_arena->top->size += (size + ALLOC_SZ);
	return 0;
}

Функция extend_heap() аналогична тому, как req_space() работала в нашей предыдущей реализации, выполняя вызовы sbrk() для расширения кучи. Как только куча была расширена, req_space() делает еще один вызов split_chunk(), чтобы использовать это вновь добавленное пространство кучи.

Распределение в действии

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

int main(int argc, char *argv[]) {
	void *test, *test2;
	test = mmalloc(32);
	memset(test, 0x41, 32);
	
	test2 = mmalloc(32);
	memset(test2, 0x42, 32);
	
	return 0;
}

Далее мы загрузим этот образец программы с помощью GDB и установим точку останова после каждого вызова memset(). После нашей первой точки останова куча выглядит следующим образом.

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

Атака Дома Силы

Теперь, когда мы понимаем, как инициализируется и используется наш верхний блок, давайте посмотрим, как мы можем использовать присущий ему недостаток. Атака Дом Силы была описана в знаменитой статье Malloc Maleficarum: https://dl.packetstormsecurity.net/papers/attack/MallocMaleficarum.txt. верхний фрагмент, злоумышленник может выделить фрагмент, расположенный за пределами кучи, и перезаписать произвольное место в памяти. Хотя реализация GLIBC функции malloc() ведет себя немного по-другому и имеет некоторые проверки размера, наша реализация намного проще и упрощает проведение этой атаки.

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

Цель

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

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

test = mmalloc(32);
memset(test, 0x41, 32);
test2 = mmalloc(32);
memset(test2, 0x42, 32);
test3 = mmalloc(32);
memset(test3, 0xFF, 48); //overwrite 16 bytes past the end of test3

Если мы посмотрим на кучу в этот момент, мы увидим три выделенных фрагмента, а также верхний фрагмент с перезаписанными полями prev_size и size.

Теперь, когда мы изменили размер верхнего фрагмента на равный 0xFFFFFFFFFFFFFFFF (-1 со знаком или 18446744073709551615 без знака), нам нужно найти запись в таблице GOT, которую мы хотим перезаписать, и вычислить смещение между этой записью и верхним фрагментом. Взглянув на GOT в GDB, мы можем увидеть, какие у нас есть варианты.

Для нашего конкретного варианта использования запись для memset() является лучшим вариантом для цели. Мы не можем перезаписать mmalloc(), так как нам нужно сделать еще одно выделение после того, как мы перезапишем запись GOT, чтобы завершить эту атаку. Мы также не можем перезаписать print_chunks() или print_top() в этом случае из-за поведения mmalloc() и split_topchunk ().

Это связано с тем, что split_topchunk() установит поле размера вновь выделенного фрагмента во время своей работы, а mmalloc() вернет указатель на 16 байт после начала chunk, чтобы компенсировать поля заголовка. Оглядываясь назад на GOT, мы видим, что каждая запись отделена друг от друга всего 8 байтами. Это означает, что когда split_topchunk() устанавливает поле размера фрагмента, который мы выделяем, он фактически перезаписывает предыдущую запись в GOT. Так, например, если мы попытаемся перезаписать запись для print_chunks(), мы в конечном итоге перезапишем запись для mmalloc(), а если мы попытаемся перезаписать print_top( ) в конечном итоге мы перезаписываем запись для printf() (на которую опирается print_top()).

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

(0x5555555573A8 - 0x555555558090) - 32 = 0xFFFFFFFFFFFFF2F8

Давайте добавим еще один вызов mmalloc() с этим размером, затем дополнительный вызов mmalloc(), который вернет адрес, который мы хотим перезаписать.

test4 = mmalloc(0xFFFFFFFFFFFFF2F8);
functest = mmalloc(64);

Если мы посмотрим на GOT после этих вызовов mmalloc(), мы увидим, как запись перед memset() в GOT перезаписывается, как упоминалось ранее.

Кроме того, если мы посмотрим на адрес нашей переменной functest, мы увидим, что она указывает на адрес memset() в GOT.

Теперь мы можем записать адрес в это место и выполнить memset(), чтобы перенаправить выполнение на функцию по нашему выбору. В этом случае мы запишем адрес для местоположения print_top(), который, как видно из нашего предыдущего просмотра GOT (по крайней мере, до его перезаписи), равен 0x7ffff7fc3299.

strcpy(functest, "\x99\x32\xfc\xf7\xff\x7f");
memset(functest, 0x41, 1);

Теперь при выполнении вызова memset() будет выполняться функция print_top(). Мы можем подтвердить это, установив другую точку останова для print_top() и продолжив выполнение в GDB.

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

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