Хакер - Круче кучи! Разбираем в подробностях проблемы heap allocation
Содержание статьи
- Основы GDB
- Структура чанков
- Арена
- Флаги
- Bins
- Тестовая программа
- Практика
- Fast bin Dup
- Что еще почитать про кучу
Некоторые уязвимости возникают из‑за ошибок с управлением памятью, выделенной на куче. Механизм эксплуатации этих уязвимостей сложнее, чем обычное переполнение на стеке, поэтому не все умеют с ними работать. Даже курс Cracking the perimeter (OSCE) не заходил дальше тривиальной перезаписи SEH. В этой статье я расскажу и на практике покажу механику работы кучи.
Практиковаться мы будем на реализации кучи ptmalloc2, которая сейчас используется по умолчанию в glibc, поэтому нам понадобятся машина с Linux и необходимый софт. Установим отладчик GDB, GCC (можно сразу поставить весь пакет build-essential) и отладочную версию библиотеки libc, позволяющую видеть подробную информацию о куче. Также поставим pwngdb и его зависимость — peda, чтобы получить удобные команды vmmap
, hexdump
, heapinfo
.
sudo apt install gdb build-essential libc6-dbg
git clone https://github.com/scwuaptx/Pwngdb.git ~/Pwngdb
git clone https://github.com/longld/peda.git ~/peda
ОСНОВЫ GDB
Для изучения работы наших тестовых программ понадобится знание базовых команд GDB:
r[un]
— запустить файл;b[reak] *0x1234
— поставить точку останова на адресе0x1234
;b[reak] 123
— поставить точку останова на строке123
текущего исходного файла;b[reak] basic.c:123
— поставить точку останова на строке123
исходного файлаbasic.c
;c[ontinue]
— продолжить выполнение;s[tep]
— выполнить одну ассемблерную инструкцию;n[ext]
— выполнить одну строчку исходного файла;x/10xg 0x1234
— распечатать десять 8-байтных слов по адресу0x1234
;p[rint] a
— распечатать значение переменнойa
;p[rint] *((mchunkptr)0x555555756680)
— взять содержимое памяти по адресу0x555555756680
как типmchunkptr
, задереференсить его и распечатать;where
— показать, на какой строчке исходного кода находится выполнение программы.
vmmap
— вывести карту памяти;hexdump
— показать содержимое памяти по адресу в видеhexdump
;heapinfo
— посмотреть информацию о куче.
СТРУКТУРА ЧАНКОВ
Когда программа запрашивает буфер для данных (например, размером в 10 байт) с помощью malloc
, на самом деле выделяется больше памяти, так как для хранения метаданных необходимо дополнительное пространство. Такой кусок памяти, содержащий метаданные, называют чанком (chunk).
Структура чанка, используемая в ptmalloc2, приведена ниже. Из нее можно понять, что перед указателем на выделенный буфер памяти, который возвращается пользователю (mem
), располагаются еще два поля: размер чанка и размер предыдущего чанка.
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
. (malloc_usable_size() bytes) .
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Сам чанк имеет такую структуру:
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk, if it is free. */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links — used only if this chunk is free. */
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links — used only if this chunk is free. */
struct malloc_chunk* bk_nextsize;
typedef struct malloc_chunk* mchunkptr;
Чтобы получить из указателя на чанк (служебную структуру) указатель на буфер памяти, который можно использовать, к первому прибавляют значение 2*SIZE_SZ
. SIZE_SZ
. Для архитектуры x64 оно равно 8, а для x86 — 4. То есть на x64 user_mem = chunk + 16
. И наоборот, чтобы из указателя, который вернул malloc
, получить указатель на чанк, необходимо вычесть 2*SIZE_SZ
из него. За это отвечают следующие макросы:
#define chunk2mem(p) ((void*)((char*)(p) + 2*SIZE_SZ))
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))
Важный момент: поле mchunk_prev_size
в следующем чанке используется для хранения пользовательских данных предыдущего чанка.
Арена
Старые менеджеры кучи использовали одну кучу на весь процесс и синхронизировали доступ к ней разных потоков с помощью мьютексов. Как несложно догадаться, положительно на производительности это не сказывалось. Ptmalloc2 использует арены — области памяти для того, чтобы каждый поток мог там хранить свою кучу.
Но поскольку на практике потоков в приложении может быть слишком много, максимальное количество создаваемых арен вычисляется по следующей формуле (n
— количество процессоров):
#define NARENAS_FROM_NCORES(n) ((n) * (**sizeof** (**long**) == 4 ? 2 : 8))
Пока количество арен меньше максимального, менеджер кучи создает новую арену на каждый новый поток. После этого, увы, нескольким потокам придется делить между собой одну арену.
Первая созданная менеджером кучи арена называется основной (main). Однопоточное приложение использует только основную арену.
Флаги
Остановимся подробнее на флагах чанка. Поле размера предыдущего чанка (mchunk_size
), кроме собственно размера, хранит три флага: A
, M
, P
. Это возможно за счет выравнивания размера чанка. Так как размер чанка всегда кратен либо 8, либо 16 байтам, последние 3 бита размера не несут смысловой нагрузки, и их можно использовать для хранения флагов.
A
(NON_MAIN_ARENA): 0 — чанк был выделен из основной арены и основной кучи; 1 — чанк принадлежит одной из второстепенных арен. Когда приложение создает дополнительные потоки, каждый из них получает свою арену (грубо говоря, свою кучу). В чанках, выделяемых на этих аренах, установлен битA
;M
(IS_MMAPPED): 1 — чанк получен с помощью вызоваmmap
. Остальные флаги игнорируются, потому что данные чанки не располагаются в арене и к ним не примыкают другие чанки;P
(PREV_INUSE): 0 — предыдущий чанк не используется. Перед полемmchunk_size
располагается значение размера предыдущего чанка; 1 — предыдущий чанк используется. Перед полемmchunk_size
располагаются пользовательские данные.
Bins
Для повышения быстродействия чанки используют повторно (и именно эту особенность учитывают при эксплуатации кучи). Ранее использованные и освобожденные чанки складывают в бины (bins). В нашей реализации кучи существует пять типов бинов:
Small bins
- Чанки в каждом из small bin хранятся в двусвязном списке.
- Вставка освобожденных чанков в этот список производится с начала (head), удаление — с конца (tail, FIFO). Для ведения этого списка используются указатели fd и bk (см. структуру чанка).
- Таких бинов 62 штуки. Каждый из small bins хранит чанки только одного размера: 16, 24, ..., 504 байт для x86 и 1008 байт для x64.
- Если в small bin попадают два соседних чанка, то они объединяются и отправляются в unsorted bin.
Large bins
- Чанки в каждом из large bin также хранятся в двусвязном списке.
- Чанки в каждом из бинов имеют диапазон размеров.
- Чанки сортируются следующим образом: самый большой чанк находится в head списка, а самый маленький — в tail. Для этого используются указатели
fd_nextsize
иbk_nextsize
. - Вставки и удаления происходят на любой позиции.
- Таких бинов 63 штуки, в них хранятся чанки размером от 512 байт для x86 и от 1024 байт для x64.
Unsorted bin
Вместо того чтобы складывать только что освобожденные чанки в подходящий bin, менеджер кучи соединяет их с соседями и складывает их в unsorted bin. При следующем вызове malloc
каждый чанк из unsorted bin проверяется: подходит он по размеру или нет. Если подходит, то malloc
использует его. В противном случае чанк помещается в подходящий bin: small или large.
Fast bins
- Созданы для оптимизации освобождения и аллокации чанков маленького размера.
- Хранят чанки фиксированного размера. Максимальный размер чанка для x86 88 байт, для x64 176 байт.
- Чанки хранятся в односвязном списке (LIFO).
- У чанков в fast bins не снимается флаг
P
. Поэтому соседние освобожденные чанки не сливаются. Это сделано для увеличения скорости освобождения и выделения чанков небольшого размера.
Tcache bins
- У каждого потока есть 64 tcache bin. Это сделано для еще большей оптимизации выделения небольших чанков. Они используются всегда, когда количество потоков превышает максимально допустимое число арен: в этом случае каждый поток может сначала использовать свой кеш, а не ждать, пока освободится мьютекс синхронизации доступа к куче.
- Чанки хранятся в односвязном списке.
- В каждом bin — максимум семь чанков с одинаковым размером (12–516 на x86 и 24–1032 на x64).
- Чанки при освобождении не сливаются и не освобождаются «по‑настоящему» (флаг
P
не снимается). - При запросе памяти подходящий чанк сначала ищется в tcache, а потом в остальных bin.
ТЕСТОВАЯ ПРОГРАММА
Напишем простую тестовую программу для демонстрации:
puts("Basic allocation example.\n");
strcpy(a, "AAAAAAAAAAAAAAA"); // A * 15
memcpy(b, "BBBBBBBBBBBBBBBBBBBBBBBB", 24); // B * 23
char* c = malloc(496); // was 0x200
char* c = malloc(496); // was 0x200
Скомпилируем ее с отладочной информацией с помощью такой команды:
ПРАКТИКА
Здесь и далее мы будем рассматривать платформу x64. Откроем нашу тестовую программу в GDB и поставим бряк на строчке 8: char* a = malloc(0x10):
с помощью команды b basic.c:8
.
Запустим выполнение программы командой r
и посмотрим, что в ней происходит (команда heapinfo
).
После начала исполнения кода пользователя куча инициализируется, и мы можем увидеть, по какому адресу расположен верхний (top) чанк кучи: 0x5555555596a0
.
INFO
Top chunk — чанк, находящийся на вершине кучи. Флаг P
данного чанка всегда выставлен.
С помощью команды vmmap
мы можем посмотреть карту памяти процесса и увидеть, где находится куча.
Теперь мы знаем, что нас не обманывают и верхний чанк кучи действительно расположен в сегменте кучи.
Исполним одну строчку программы char* a = malloc(0x10)
с помощью команды n
. Мы увидим, что верхний чанк кучи сместился вперед на 0x20 байт, а доступный размер кучи уменьшился соответственно. Почему на 0x20, если мы запросили 0x10 байт? Оставшиеся 16 байт ушли на метаданные, расположенные перед указателем a
: mchunk_prev_size
и mchunk_size
.
Также мы можем увидеть, что флаг P
в поле mchunk_size
равен 1, то есть предыдущий чанк занят. С помощью команды p *((mchunkptr)(a-16))
мы можем распечатать поля чанка как структуры.
Даже если мы запросим буфер размером 1 байт, на куче будет выделен чанк размером 0x20 байт, а пользователю вернется указатель на буфер размером 0x18 байт.
Выполним строчку char* b = malloc(0x12)
. Менеджер кучи выделит чанк размером 0x20 байт. Пользователь все так же сможет использовать буфер размером 0x18.
Если мы запишем в наш чанк буфер размером 24 байта, то увидим, что последние 8 байт буфера «залезут» на следующий чанк, но метаданные перезаписаны не будут.
В результате выполнения строчки char* c = malloc(496)
выделяется чанк размером 496 + 16 = 0x200 байт.
Теперь перейдем к изучению освобожденных чанков. Для начала выполним строчку 18 free(a);
. Поскольку размер чанка a
меньше 1032 байт, освобожденный чанк попадет в tcache.
Как мы уже знаем, чанки в tcache не освобождаются «по‑настоящему», и поэтому у следующего чанка все еще выставлен флаг P
.
Освободим еще один чанк. Теперь в список чанков в tcache добавился еще один.
По смещению 0x55555555*96d0* мы видим указатель на следующий чанк в нашем бине, который относится к tcache. Если распечатать структуру чанка по 0x55555555*96c0, то окажется, что поле fd
(указатель на следующий свободный чанк в бине) равно 0x5555555596b0* — именно этот чанк мы освободили строчкой ранее. Значения указателей fd_nextsize
и bk_nextsize
неважны для этого чанка, так как они используются только для чанков, находящихся в large bin.
После того как мы освобождаем чанк размером 0x200, он попадает в другой бин (тот, который соответствует его размеру).
После выполнения строчки 21 свободный чанк определяется в unsorted bin.
Строчка 23 освобождает еще один чанк размером 0x500. Теперь в unsorted bin находятся два чанка, и мы можем посмотреть, как эти чанки хранятся в двусвязном списке.
Мы видим, что чанк 0x55555555a300 находится в начале списка. Поле fd
указывает на следующий чанк — 0x5555555598e0.
FAST BIN DUP
Теперь, вооружившись этими знаниями, разберем простейшую атаку fastbin duplication. Суть такова: если в приложении есть double-free, то мы можем заставить malloc
возвращать одни и те же чанки из fastbin. Эту технику используют, чтобы получить примитив write-what-where (пример с 0ctf).
Разберем пример этой атаки из репозитория how2heap.
INFO
Я разберу код для libc 2.31, потому что именно эта версия используется в Ubuntu 20.04. Ты можешь выбрать другой, но проследи, чтобы версия libc совпадала с используемой твоей программой.
В первую очередь забивают tcache (как ты помнишь, в tcache хранится максимум семь чанков одного размера).
Делается это по одной простой причине: в реализации tcache в libc 2.31 присутствует защита от данной атаки. Когда чанк кладется в tcache, в него сохраняется указатель на сам tcache.
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
tcache_put (mchunkptr chunk, size_t tc_idx)
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
/* Mark this chunk as "in the tcache" so the test in _int_free will
А функция free
перед тем, как положить освобождаемый чанк в tcache, проверяет, не сохранен ли уже в этом чанке указатель на tcache. В таком случае триггерится проверка на double free. К слову, в актуальной версии glibc эта проверка улучшена и вместо указателя на tcache в качестве ключа используется случайное число.
if (__glibc_unlikely (e->key == tcache))
LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
for (tmp = tcache->entries[tc_idx];
malloc_printerr ("free(): double free detected in tcache 2");
/* If we get here, it was a coincidence. We’ve wasted a
few cycles, but don’t abort. */
Далее выделяются три буфера. Для выделения используется calloc
, так как он не использует tcache. Знаю, в реальной жизни calloc
встречается реже malloc
, но для учебных целей сойдет.
Затем последовательно освобождаются a
и b
.
Пока что ничего криминального, наша куча выглядит вот так.
В fastbin[0]
находятся два чанка, b→fd
указывает на a
.
Но если выполнить free(a)
еще раз, мы увидим, что чанк a
во второй раз добавится в fastbin[0]
.
И теперь, если мы выделим еще три буфера с помощью calloc
, нам вернутся три одинаковых указателя, потому что все они берутся из fastbin[0]
!
Разберемся, почему это сработало. Чанк old
в сниппете ниже — это старый чанк, находящийся на вершине fastbin, а p
— это освобождаемый чанк. И если мы постараемся освободить чанк, находящийся на вершине fastbin, два раза (old == p
), программа напишет нам «double free or corruption (fasttop)» и закроется.
unsigned int idx = fastbin_index(size);
/* Atomically link P to its fastbin: P->FD = *FB; *FB = P; */
/* Check that the top of the bin is not the record we are going to
if (__builtin_expect (old == p, 0))
malloc_printerr ("double free or corruption (fasttop)");
Именно поэтому мы не можем успешно выполнить free(a)
два раза подряд. Но если поместить на вершину fastbin чанк b
, то free
успешно перезапишет указатель fd
чанка а
(p->fd = old2 = old;
на сниппете выше) и добавит его в fastbin!
ЧТО ЕЩЕ ПОЧИТАТЬ ПРО КУЧУ
Если после прочитанного тебя заинтересовала тема кучи, могу порекомендовать ознакомиться с другими классическими атаками из how2heap, открыв в соседней вкладке malloc.c
из исследуемой тобой версии glibc.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei