专栏名称: Seebug漏洞平台
Seebug,原 Sebug 漏洞平台,洞悉漏洞,让你掌握第一手漏洞情报!
目录
相关文章推荐
普象工业设计小站  ·  亚洲顶流表情包女孩20岁了,最新近照惊艳曝光 ... ·  20 小时前  
普象工业设计小站  ·  视频退出键在哪里?!水果版定格动画,越看越魔性! ·  昨天  
普象工业设计小站  ·  笑得停不下来!艺术家给小动物们P上长长的小手 ... ·  2 天前  
51好读  ›  专栏  ›  Seebug漏洞平台

Exim CVE-2020-28018 漏洞分析

Seebug漏洞平台  · 公众号  ·  · 2021-06-03 20:59

正文


作者: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.listRUN sed -i "s/security.ubuntu.com/mirrors.ustc.edu.cn/g" /etc/apt/sources.listRUN apt update
RUN mkdir /root/exim4
COPY *.deb /root/exim4/COPY *.ddeb /root/exim4/
RUN dpkg -i /root/exim4/*.deb || apt --fix-broken install -yRUN 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.confRUN sed -i "1i\MAIN_TLS_ENABLE = yes" /etc/exim4/exim4.conf.templateCOPY exim.crt /etc/exim4/exim.crtCOPY exim.key /etc/exim4/exim.keyCOPY exim_start /exim_startRUN update-exim4.conf && chmod +x /exim_start
CMD ["/exim_start"]
其中 crt key 的生成脚本如下:
#!/bin/sh -e
if [ -n "$EX4DEBUG" ]; then echo "now debugging $0 $@" set -xfi
DIR=/etc/exim4CERT=$DIR/exim.crtKEY=$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 1fi
# valid for three yearsDAYS=1095
if [ "$1" != "--force" ] && [ -f $CERT ] && [ -f $KEY ]; then echo "[*] $CERT and $KEY exists!" echo " Use \"$0 --force\" to force generation!" exit 0fi
if [ "$1" = "--force" ]; then shiftfi
#SSLEAY=/tmp/exim.ssleay.$$.cnfSSLEAY="$(tempfile -m600 -pexi)"
cat > $SSLEAY <RANDFILE = $HOME/.rnd[ req ]default_bits = 1024default_keyfile = exim.keydistinguished_name = req_distinguished_name[ req_distinguished_name ]countryName = Country Code (2 letters)countryName_default = UScountryName_min = 2countryName_max = 2stateOrProvinceName = State or Province Name (full name)localityName = Locality Name (eg, city)organizationName = Organization Name (eg, company; recommended)organizationName_max = 64organizationalUnitName = Organizational Unit Name (eg, section)organizationalUnitName_max = 64commonName = Server name (eg. ssl.domain.tld; required!!!)commonName_max = 64emailAddress = Email AddressemailAddress_max = 40EOM
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 $DHrm -f $SSLEAY
chown root:Debian-exim $KEY $CERT $DHchmod 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/bashwget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1.orig.tar.xzwget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1-1ubuntu1.5.debian.tar.xzwget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/exim4/4.90.1-1ubuntu1.5/exim4_4.90.1-1ubuntu1.5.dscdpkg-source --no-check -x exim4_4.90.1-1ubuntu1.5.dsc# /etc/apt/source.list 里面记得加上deb-srcapt-get build-dep exim4apt-get install --no-install-recommends devscripts cd exim4-4.90.1perl -i -pe 's/^\s*#\s*OPENSSL\s*:=\s*1/OPENSSL:=1/' debian/rulesdch -l +openssl 'rebuild with openssl'debian/rules binary

漏洞分析


  • 漏洞点

漏洞点位于 tls-openssl.c 文件的 tls_write 函数。
inttls_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 onlyone stream does it, in one context (i.e. no store reset). Currently it is usedfor 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 函数。
intsmtp_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 FROM RCPT 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: \nNO
6.关闭TLS信道,切换回明文信道
7.OP\n
8.使用EHLO或者REST调用smtp_reset
9.STARTTLS
10.NOOP


最关键的在5,6,8步,下面堆这三步进行解释:
5.. 必须要让RCPT执行成功,所以可以发送 RCPT TO: ,处理完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: * n + "NO"
2.先发送MAIL FROM: <>\n,在发送RCPT TO: * 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.90 exim4.92 中这个函数的区别:
#define STRING_SPRINTF_BUFFER_SIZE (8192 * 4)
# exim 4.90uschar *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;#elsegstring * gp = string_get(STRING_SPRINTF_BUFFER_SIZE);#endifgstring * 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_UTILITYreturn string_copy(gp->s);#elsegstring_reset_unused(gp);return gp->s;#endif}
我最开始测试的就是 exim4.92 ,默认是没有定义 COMPILE_UTILITY 。所以在这个版本中,每调用一次 sprintf_smpt 就得 store_get_3(0x8000) ,分配赋值之后,根据具体长度,调整 next_yield yield_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:
目的是要把 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 \"
然后会调用 string_catn corked 指向的地址写入上述的字符串。
因为用了堆喷,所以我觉得任意读和任意写的代码是可以通杀的。
最后的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:






请到「今天看啥」查看全文