From 82326eba3c90be9c57fadc003231e8484c4449d4 Mon Sep 17 00:00:00 2001 From: PowerLi Date: Fri, 18 Jul 2025 12:58:18 +0800 Subject: [PATCH 1/2] add v20 cookies decryption --- browser_cookie3/__init__.py | 224 ++++++++++++++++++++++++++++++++++-- 1 file changed, 215 insertions(+), 9 deletions(-) diff --git a/browser_cookie3/__init__.py b/browser_cookie3/__init__.py index 855bf7c..609855b 100644 --- a/browser_cookie3/__init__.py +++ b/browser_cookie3/__init__.py @@ -13,6 +13,8 @@ import subprocess import sys import tempfile +import ctypes +import binascii from io import BytesIO from pathlib import Path from typing import Dict, List, Union @@ -29,16 +31,24 @@ shadowcopy = None +windows = None if sys.platform == 'win32': try: import shadowcopy except ImportError: pass + try: + import windows + import windows.security + import windows.crypto + import windows.generated_def as gdef + except ImportError: + windows = None # external dependencies import lz4.block -from Cryptodome.Cipher import AES +from Cryptodome.Cipher import AES, ChaCha20_Poly1305 from Cryptodome.Protocol.KDF import PBKDF2 from Cryptodome.Util.Padding import unpad @@ -68,6 +78,138 @@ def _windows_group_policy_path(): return os.path.join(user_data_dir, "Default", "Cookies") +def _is_admin(): + """Check if running as administrator on Windows""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except: + return False + + +@contextlib.contextmanager +def _impersonate_lsass(): + """Impersonate lsass.exe to get SYSTEM privilege""" + if not windows: + raise RuntimeError("Windows module not available") + + original_token = windows.current_thread.token + try: + windows.current_process.token.enable_privilege("SeDebugPrivilege") + proc = next(p for p in windows.system.processes if p.name == "lsass.exe") + lsass_token = proc.token + impersonation_token = lsass_token.duplicate( + type=gdef.TokenImpersonation, + impersonation_level=gdef.SecurityImpersonation + ) + windows.current_thread.token = impersonation_token + yield + finally: + windows.current_thread.token = original_token + + +def _parse_key_blob(blob_data: bytes) -> dict: + """Parse Chrome v20 key blob""" + buffer = BytesIO(blob_data) + parsed_data = {} + + header_len = struct.unpack(' bytes: + """Derive Chrome v20 master key""" + if parsed_data['flag'] == 1: + aes_key = bytes.fromhex("B31C6E241AC846728DA9C1FAC4936651CFFB944D143AB816276BCC6DA0284787") + cipher = AES.new(aes_key, AES.MODE_GCM, nonce=parsed_data['iv']) + elif parsed_data['flag'] == 2: + chacha20_key = bytes.fromhex("E98F37D7F4E1FA433D19304DC2258042090E2D1D7EEA7670D41F738D08729660") + cipher = ChaCha20_Poly1305.new(key=chacha20_key, nonce=parsed_data['iv']) + elif parsed_data['flag'] == 3: + xor_key = bytes.fromhex("CCF8A1CEC56605B8517552BA1A2D061C03A29E90274FB2FCF59BA4B75C392390") + with _impersonate_lsass(): + decrypted_aes_key = _decrypt_with_cng(parsed_data['encrypted_aes_key']) + xored_aes_key = _byte_xor(decrypted_aes_key, xor_key) + cipher = AES.new(xored_aes_key, AES.MODE_GCM, nonce=parsed_data['iv']) + + return cipher.decrypt_and_verify(parsed_data['ciphertext'], parsed_data['tag']) + + # Code adapted slightly from https://github.com/Arnie97/chrome-cookies def _crypt_unprotect_data( cipher_text=b'', entropy=b'', reserved=None, prompt_struct=None, is_key=False @@ -478,15 +620,45 @@ def __add_key_and_cookie_file(self, if key_file: with open(key_file, 'rb') as f: key_file_json = json.load(f) - key64 = key_file_json['os_crypt']['encrypted_key'].encode( - 'utf-8') - - # Decode Key, get rid of DPAPI prefix, unprotect data - keydpapi = base64.standard_b64decode(key64)[5:] - _, self.v10_key = _crypt_unprotect_data( - keydpapi, is_key=True) + + # Handle v10 encrypted_key + if 'encrypted_key' in key_file_json['os_crypt']: + key64 = key_file_json['os_crypt']['encrypted_key'].encode('utf-8') + # Decode Key, get rid of DPAPI prefix, unprotect data + keydpapi = base64.standard_b64decode(key64)[5:] + _, self.v10_key = _crypt_unprotect_data(keydpapi, is_key=True) + else: + self.v10_key = None + + # Handle v20 app_bound_encrypted_key + self.v20_key = None + if 'app_bound_encrypted_key' in key_file_json['os_crypt']: + try: + app_bound_encrypted_key = key_file_json['os_crypt']['app_bound_encrypted_key'] + if binascii.a2b_base64(app_bound_encrypted_key)[:4] == b"APPB": + key_blob_encrypted = binascii.a2b_base64(app_bound_encrypted_key)[4:] + + # Decrypt with SYSTEM DPAPI + if windows and _is_admin(): + try: + with _impersonate_lsass(): + key_blob_system_decrypted = windows.crypto.dpapi.unprotect(key_blob_encrypted) + + # Decrypt with user DPAPI + key_blob_user_decrypted = windows.crypto.dpapi.unprotect(key_blob_system_decrypted) + + # Parse key blob and derive v20 master key + parsed_data = _parse_key_blob(key_blob_user_decrypted) + self.v20_key = _derive_v20_master_key(parsed_data) + except Exception: + # Fallback to v10 key if v20 fails + pass + except Exception: + # Ignore v20 key errors and continue with v10 + pass else: self.v10_key = None + self.v20_key = None # get cookie file from APPDATA @@ -592,6 +764,15 @@ def _decrypt(self, value, encrypted_value, has_integrity_check_for_cookie_domain """Decrypt encoded cookies""" if sys.platform == 'win32': + # Check for v20 cookies first + if encrypted_value[:3] == b'v20': + if hasattr(self, 'v20_key') and self.v20_key: + try: + return self._decrypt_v20_cookie(encrypted_value, has_integrity_check_for_cookie_domain) + except Exception: + # Fallback to other methods if v20 decryption fails + pass + try: return self._decrypt_windows_chromium(value, encrypted_value) @@ -617,9 +798,17 @@ def _decrypt(self, value, encrypted_value, has_integrity_check_for_cookie_domain data = data[32:] return data.decode() - if value or (encrypted_value[:3] not in [b'v11', b'v10']): + if value or (encrypted_value[:3] not in [b'v11', b'v10', b'v20']): return value + # Check for v20 cookies on non-Windows platforms (though unlikely) + if encrypted_value[:3] == b'v20': + if hasattr(self, 'v20_key') and self.v20_key: + try: + return self._decrypt_v20_cookie(encrypted_value, has_integrity_check_for_cookie_domain) + except Exception: + pass + # Encrypted cookies should be prefixed with 'v10' on mac, # 'v10' or 'v11' on Linux. Choose key based on this prefix. # Reference in chromium code: `OSCryptImpl::DecryptString` in @@ -645,6 +834,23 @@ def _decrypt(self, value, encrypted_value, has_integrity_check_for_cookie_domain pass raise BrowserCookieError('Unable to get key for cookie decryption') + def _decrypt_v20_cookie(self, encrypted_value, has_integrity_check_for_cookie_domain=False): + """Decrypt Chrome v20 cookies using AES256GCM""" + # [flag|iv|ciphertext|tag] encrypted_value + # [3bytes|12bytes|variable|16bytes] + cookie_iv = encrypted_value[3:3+12] + encrypted_cookie = encrypted_value[3+12:-16] + cookie_tag = encrypted_value[-16:] + + cookie_cipher = AES.new(self.v20_key, AES.MODE_GCM, nonce=cookie_iv) + decrypted_cookie = cookie_cipher.decrypt_and_verify(encrypted_cookie, cookie_tag) + + # Skip the first 32 bytes (domain hash) if present + if has_integrity_check_for_cookie_domain or len(decrypted_cookie) > 32: + decrypted_cookie = decrypted_cookie[32:] + + return decrypted_cookie.decode('utf-8') + class Chrome(ChromiumBased): """Class for Google Chrome""" From 76dd61d296456300cc3584c8e59df7bb44551f57 Mon Sep 17 00:00:00 2001 From: PowerLi Date: Fri, 18 Jul 2025 13:08:00 +0800 Subject: [PATCH 2/2] update Readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d6770f7..a1839a9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This is a python3 fork of [Richard Penman's Browser Cookie](https://github.com/richardpenman/browsercookie) +Cookies v20 decryption is based on [https://github.com/runassu/chrome_v20_decryption](https://github.com/runassu/chrome_v20_decryption) + # Browser Cookie * ***What does it do?*** Loads cookies used by your web browser into a cookiejar object.