M4cCrypt0
back to overview

chrome_dpapi_decrypt.py: Offline Chrome Password Recovery via DPAPI

Companion tool to the Management Wants a Word writeup. Given a user's login password, their SID, and three files pulled from a dead-box acquisition, it walks the DPAPI chain and prints every saved Chrome credential.

text
login password ─▶ DPAPI master key ─▶ Chrome AES key (Local State) ─▶ AES-256-GCM decrypt (Login Data)

Pure Python — no dm-crypt, no AF_ALG, no kernel extension. It runs identically on macOS and in a Linux forensics VM, which matters when your analysis box isn't the acquisition host.

Use

bash
pip install impacket pycryptodome

python3 chrome_dpapi_decrypt.py \
    --password   <login-pass> \
    --sid        <SID> \
    --masterkey  "<KAPE>/Users/<user>/AppData/Roaming/Microsoft/Protect/<SID>/<GUID>" \
    --localstate "<KAPE>/.../Chrome/User Data/Local State" \
    --logindata  "<KAPE>/.../Chrome/User Data/Default/Login Data"
[ TIP ]

Finding the inputs

The <login-pass> comes from cracking the local NT hash (impacket-secretsdumpjohn -format=NT). The master-key <GUID> is the file in the Protect folder that is not named Preferred. The <SID> is the folder name that wraps it.

The two traps

[WARN]

deriveKeysFromUser returns three keys

impacket.dpapi.deriveKeysFromUser() hands back three candidate keys — SHA1-, MD4/NTLM- and legacy-derived — not two. The tool loops over all of them until the master key decrypts. Assume two and you'll fail on accounts that don't use the "obvious" derivation.

[WARN]

v10 vs legacy blobs

Modern Chrome password blobs start v10/v11 (AES-256-GCM, key from Local State). Older values are DPAPI-per-value and need a different path — the tool flags those instead of silently mangling them.

Source

chrome_dpapi_decrypt.py
#!/usr/bin/env python3
"""
chrome_dpapi_decrypt.py — recover Chrome-saved passwords from an offline
Windows (KAPE) acquisition, on macOS/Linux. Pure Python, no dm-crypt/kext.

DPAPI chain:
  user login password ─▶ DPAPI master key ─▶ Chrome AES key (Local State)
  ─▶ AES-256-GCM decrypt of Login Data (v10 blobs)

Deps:  pip install impacket pycryptodome
Usage:
  python3 chrome_dpapi_decrypt.py \
      --password <login-pass> \
      --sid <SID> \
      --masterkey "<KAPE>/Users/<user>/AppData/Roaming/Microsoft/Protect/<SID>/<GUID>" \
      --localstate "<KAPE>/.../Chrome For Testing/User Data/Local State" \
      --logindata "<KAPE>/.../Chrome For Testing/User Data/Default/Login Data"

Tip: find the master key GUID (skip the 'Preferred' file):
  ls "<KAPE>/Users/<user>/AppData/Roaming/Microsoft/Protect/<SID>/"
"""
import argparse, json, base64, sqlite3, tempfile, shutil, sys
from impacket.dpapi import MasterKeyFile, MasterKey, DPAPI_BLOB, deriveKeysFromUser
from Crypto.Cipher import AES

def decrypt_masterkey(mk_path, sid, password):
    blob = open(mk_path, 'rb').read()
    mkf = MasterKeyFile(blob); rest = blob[len(mkf):]
    mk = MasterKey(rest[:mkf['MasterKeyLen']])
    keys = deriveKeysFromUser(sid, password)   # SHA1-, MD4(NTLM)- and legacy-based
    for k in keys:
        dec = mk.decrypt(k)
        if dec:
            return dec
    raise SystemExit("[!] Master key decrypt failed — wrong password or SID?")

def chrome_aes_key(local_state_path, master_key):
    enc = base64.b64decode(json.load(open(local_state_path))['os_crypt']['encrypted_key'])
    blob = DPAPI_BLOB(enc[5:])                     # strip the 'DPAPI' prefix
    return blob.decrypt(master_key)

def dump_logins(login_data_path, aes_key):
    tmp = tempfile.mktemp(suffix='.db'); shutil.copy(login_data_path, tmp)
    con = sqlite3.connect(tmp)
    rows = []
    for url, user, pw in con.execute(
            'SELECT origin_url, username_value, password_value FROM logins'):
        if pw[:3] in (b'v10', b'v11'):             # AES-256-GCM
            nonce, ct, tag = pw[3:15], pw[15:-16], pw[-16:]
            plain = AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(ct, tag)
            rows.append((url, user, plain.decode('utf-8', 'replace')))
        else:
            rows.append((url, user, '<legacy/DPAPI-per-value — not handled>'))
    return rows

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--password', required=True)
    ap.add_argument('--sid', required=True)
    ap.add_argument('--masterkey', required=True)
    ap.add_argument('--localstate', required=True)
    ap.add_argument('--logindata', required=True)
    a = ap.parse_args()

    mk = decrypt_masterkey(a.masterkey, a.sid, a.password)
    print(f"[+] Master key decrypted ({len(mk)} bytes)")
    aes = chrome_aes_key(a.localstate, mk)
    print(f"[+] Chrome AES key: {aes.hex()}")
    print("[+] Saved credentials:")
    for url, user, pw in dump_logins(a.logindata, aes):
        print(f"    URL : {url}\n    USER: {user}\n    PASS: {pw}\n    ---")

if __name__ == '__main__':
    main()
[FAIL]

Scope

Only run this against systems and images you're authorized to analyse. Credential recovery on someone else's machine is exactly the crime the blue side is paid to catch.