""" 从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key 企业微信的本地数据库与个人微信不同。实测 Windows 版使用 wxSQLite3 AES-128-CBC 页面加密:16 字节 raw key,每页按 page index 派生 AES key 和 IV;页面没有 SQLCipher HMAC/reserve 区。 """ import ctypes import ctypes.wintypes as wt import bisect import functools import hashlib import hmac as hmac_mod import json import os import re import struct import subprocess import sys import time from key_scan_common import collect_db_files from wxwork_crypto import ( is_plain_sqlite_page, is_wxsqlite3_aes128_page1, verify_wxsqlite3_aes128_key, ) print = functools.partial(print, flush=True) # ── Windows 内存读取原语 ────────────────────────────────────────────── kernel32 = ctypes.windll.kernel32 MEM_COMMIT = 0x1000 READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80} class MBI(ctypes.Structure): _fields_ = [ ("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64), ("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD), ("RegionSize", ctypes.c_uint64), ("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD), ] def read_mem(h, addr, sz): buf = ctypes.create_string_buffer(sz) n = ctypes.c_size_t(0) if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)): return buf.raw[:n.value] return None def enum_regions(h): regs = [] addr = 0 mbi = MBI() while addr < 0x7FFFFFFFFFFF: if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0: break if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024: regs.append((mbi.BaseAddress, mbi.RegionSize)) nxt = mbi.BaseAddress + mbi.RegionSize if nxt <= addr: break addr = nxt return regs # ── 常量 ───────────────────────────────────────────────────────────── WXWORK_PROCESS = "WXWork.exe" PAGE_SZ = 4096 SALT_SZ = 16 # 旧版本/其他平台可能回落到 SQLCipher 参数,保留作兼容验证。 # (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz) VERIFY_CONFIGS = [ # WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能) (16, "sha512", 64, 2, 80), # WCDB with AES-128, HMAC-SHA256 (16, "sha256", 32, 2, 48), # SQLCipher 3 defaults with AES-128 (16, "sha512", 64, 4000, 80), (16, "sha256", 32, 4000, 48), # AES-256 回退 (与个人微信相同参数) (32, "sha512", 64, 2, 80), ] def verify_enc_key_wxwork(enc_key, db_page1): """尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)""" if len(enc_key) == 16 and verify_wxsqlite3_aes128_key(enc_key, db_page1): return True, "wxSQLite3 AES-128-CBC, per-page MD5 key/IV, no HMAC" key_sz = len(enc_key) for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS: if key_sz != cfg_key_sz: continue salt = db_page1[:SALT_SZ] mac_salt = bytes(b ^ 0x3A for b in salt) mac_key = hashlib.pbkdf2_hmac(hmac_hash, enc_key, mac_salt, iterations, dklen=cfg_key_sz) hmac_data = db_page1[SALT_SZ: PAGE_SZ - reserve_sz + 16] stored_hmac = db_page1[PAGE_SZ - hmac_sz: PAGE_SZ] hash_fn = getattr(hashlib, hmac_hash) hm = hmac_mod.new(mac_key, hmac_data, hash_fn) hm.update(struct.pack("= 5: pid = int(p[1]) mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0') pids.append((pid, mem)) if not pids: raise RuntimeError(f"{WXWORK_PROCESS} 未运行") pids.sort(key=lambda x: x[1], reverse=True) for pid, mem in pids: print(f"[+] {WXWORK_PROCESS} PID={pid} ({mem // 1024}MB)") return pids # ── WXWork 数据目录自动检测 ────────────────────────────────────────── def _wxwork_data_dir_mtime(data_dir): """返回企业微信 Data 目录最近活跃时间,用于多账号自动选择。""" latest = 0 for root, dirs, files in os.walk(data_dir): dirs[:] = [d for d in dirs if d not in ("-journal",)] for name in files: if not name.endswith((".db", ".db-wal", ".db-shm")): continue path = os.path.join(root, name) try: latest = max(latest, os.path.getmtime(path)) except OSError: pass try: latest = max(latest, os.path.getmtime(data_dir)) except OSError: pass return latest def _is_noninteractive_mode(): return ( os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1" or os.environ.get("WXWORK_AUTO_SELECT_DB") == "1" or os.environ.get("WECHAT_DECRYPT_GUI") == "1" or not sys.stdin.isatty() ) def auto_detect_wxwork_db_dir(): """扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录""" docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork") if not os.path.isdir(docs): return None candidates = [] for name in os.listdir(docs): data_dir = os.path.join(docs, name, "Data") if not os.path.isdir(data_dir): continue has_encrypted = False for fname in os.listdir(data_dir): if not fname.endswith(".db"): continue fpath = os.path.join(data_dir, fname) if os.path.getsize(fpath) < PAGE_SZ: continue with open(fpath, "rb") as f: header = f.read(16) if header != b"SQLite format 3\x00": has_encrypted = True break if has_encrypted: candidates.append(data_dir) if not candidates: return None candidates.sort(key=_wxwork_data_dir_mtime, reverse=True) if len(candidates) == 1: return candidates[0] if _is_noninteractive_mode(): selected = candidates[0] print("[!] 检测到多个企业微信数据目录,非交互模式下自动选择最近活跃目录:") for i, c in enumerate(candidates, 1): marker = " *" if c == selected else " " print(f" {marker} {i}. {c}") return candidates[0] print("[!] 检测到多个企业微信数据目录:") for i, c in enumerate(candidates, 1): print(f" {i}. {c}") print(" 0. 跳过,稍后手动配置") try: while True: choice = input(f"请选择 [0-{len(candidates)}]: ").strip() if choice == "0": return None if choice.isdigit() and 1 <= int(choice) <= len(candidates): return candidates[int(choice) - 1] print(" 无效输入,请重新选择") except (EOFError, KeyboardInterrupt): print() return None def filter_encrypted_dbs(db_files, salt_to_dbs): """过滤掉未加密的数据库。""" filtered_files = [ entry for entry in db_files if not is_plain_sqlite_page(entry[4]) ] filtered_salts = { s: dbs for s, dbs in salt_to_dbs.items() if any(entry[3] == s and not is_plain_sqlite_page(entry[4]) for entry in db_files) } removed = len(db_files) - len(filtered_files) if removed: print(f"[*] 跳过 {removed} 个未加密数据库") wxsqlite3_count = sum(1 for entry in filtered_files if is_wxsqlite3_aes128_page1(entry[4])) if wxsqlite3_count: print(f"[*] 检测到 {wxsqlite3_count} 个 wxSQLite3 AES-128 格式数据库") return filtered_files, filtered_salts # ── 企业微信内存扫描 ───────────────────────────────────────────────── def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map, remaining_salts, base_addr, pid, print_fn): """扫描内存,匹配 hex 模式并用企业微信参数验证密钥。 企业微信 key=16字节(32 hex), salt=16字节(32 hex) 可能的缓存格式: - x'<32hex_key><32hex_salt>' = 64 hex total - x'<32hex_key>' = 32 hex (key only) - x'<64hex_key><32hex_salt>' = 96 hex (AES-256 回退) """ matches = 0 for m in hex_re.finditer(data): hex_str = m.group(1).decode() addr = base_addr + m.start() matches += 1 hex_len = len(hex_str) # 尝试不同的解释方式 candidates = [] if hex_len == 32: # 纯 16字节 key candidates.append((hex_str, None)) elif hex_len == 64: # 优先: 32hex key + 32hex salt (WeCom AES-128) candidates.append((hex_str[:32], hex_str[32:])) # 回退: 64hex = 32字节 key (personal WeChat AES-256) candidates.append((hex_str, None)) elif hex_len == 96: # 优先: 64hex key + 32hex salt (personal WeChat AES-256) candidates.append((hex_str[:64], hex_str[64:])) # 也尝试: 32hex key + ... + 32hex salt candidates.append((hex_str[:32], hex_str[-32:])) elif hex_len > 96 and hex_len % 2 == 0: candidates.append((hex_str[:64], hex_str[-32:])) candidates.append((hex_str[:32], hex_str[-32:])) for enc_key_hex, salt_hex in candidates: if len(enc_key_hex) not in (32, 64): continue enc_key = bytes.fromhex(enc_key_hex) if salt_hex and salt_hex in remaining_salts: # salt 匹配已知数据库 for rel, path, sz, s, page1 in db_files: if s == salt_hex: ok, desc = verify_enc_key_wxwork(enc_key, page1) if ok: key_map[salt_hex] = enc_key_hex remaining_salts.discard(salt_hex) dbs = salt_to_dbs[salt_hex] print_fn(f"\n [FOUND] salt={salt_hex}") print_fn(f" enc_key={enc_key_hex}") print_fn(f" params: {desc}") print_fn(f" PID={pid} 地址: 0x{addr:016X}") print_fn(f" 数据库: {', '.join(dbs)}") break elif not salt_hex and remaining_salts: # 没有 salt,暴力尝试所有未匹配的数据库 for rel, path, sz, salt_hex_db, page1 in db_files: if salt_hex_db in remaining_salts: ok, desc = verify_enc_key_wxwork(enc_key, page1) if ok: key_map[salt_hex_db] = enc_key_hex remaining_salts.discard(salt_hex_db) dbs = salt_to_dbs[salt_hex_db] print_fn(f"\n [FOUND] salt={salt_hex_db}") print_fn(f" enc_key={enc_key_hex}") print_fn(f" params: {desc}") print_fn(f" PID={pid} 地址: 0x{addr:016X}") print_fn(f" 数据库: {', '.join(dbs)}") break if not remaining_salts: break return matches def _find_region(memory_regions, starts, addr, length=4): idx = bisect.bisect_right(starts, addr) - 1 if idx < 0: return None base, end, data = memory_regions[idx] if base <= addr and addr + length <= end: return base, end, data return None def _read_u32(memory_regions, starts, addr): region = _find_region(memory_regions, starts, addr, 4) if not region: return None base, _end, data = region return struct.unpack_from("= 0 and off < max_off: if time.time() - t0 > max_seconds: print_fn( f"[WARN] 结构体扫描超时: checked={checked}, " f"ptr_hits={ptr_hits}, chain_hits={chain_hits}, key_tests={key_tests}" ) return key_tests # The AES-128 decrypt branch checks two non-zero flags at +0 and +4. flag0, flag4 = struct.unpack_from("= 6: key_tests += 1 if _record_candidate_key( enc_key, db_files, salt_to_dbs, key_map, remaining_salts, pid, cipher_addr, f"wxSQLite3 AES-128-CBC, page_size={page_size}", print_fn, ): if not remaining_salts: return key_tests checked += 1 off += 4 print_fn( f"[*] 结构体扫描完成: checked={checked}, ptr_hits={ptr_hits}, " f"chain_hits={chain_hits}, key_tests={key_tests}" ) return key_tests def cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print_fn): """用已找到的 key 交叉验证未匹配的 salt。""" missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys()) if not missing_salts or not key_map: return print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...") for salt_hex in list(missing_salts): for rel, path, sz, s, page1 in db_files: if s == salt_hex: for known_salt, known_key_hex in key_map.items(): enc_key = bytes.fromhex(known_key_hex) ok, desc = verify_enc_key_wxwork(enc_key, page1) if ok: key_map[salt_hex] = known_key_hex print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}") missing_salts.discard(salt_hex) break def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn): """输出扫描结果并保存 JSON。""" print_fn(f"\n{'=' * 60}") print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥") result = {} for rel, path, sz, salt_hex, page1 in db_files: if salt_hex in key_map: result[rel] = { "enc_key": key_map[salt_hex], "salt": salt_hex, "size_mb": round(sz / 1024 / 1024, 1) } print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)") else: print_fn(f" MISSING: {rel} (salt={salt_hex})") if not result: print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)") raise RuntimeError("未能从任何企业微信进程中提取到密钥") result["_db_dir"] = db_dir # 写文件含明文 raw key,先 atomic write 到 tmp 再 rename,中途 chmod 0600 # 限本用户读写。Windows 上 os.chmod 主要控制只读位,严格 ACL 需 win32security # 这里至少避免世界可读的最差情况。 tmp_file = out_file + ".tmp" with open(tmp_file, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2, ensure_ascii=False) try: os.chmod(tmp_file, 0o600) except OSError: pass # Windows 上某些场景 chmod 可能失败,不阻塞主流程 os.replace(tmp_file, out_file) print_fn(f"\n密钥保存到: {out_file} (权限已收紧为 0600)") missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map] if missing: print_fn(f"\n未找到密钥的数据库:") for rel in missing: print_fn(f" {rel}") # ── 配置加载 ───────────────────────────────────────────────────────── def _load_wxwork_config(): """从 config.json 加载企业微信配置,必要时自动检测""" from config import _config_file_path, _app_base_dir config_file = _config_file_path() cfg = {} if os.path.exists(config_file): try: with open(config_file, encoding="utf-8") as f: cfg = json.load(f) except json.JSONDecodeError: cfg = {} db_dir = cfg.get("wxwork_db_dir", "") if not db_dir or not os.path.isdir(db_dir): detected = auto_detect_wxwork_db_dir() if detected: print(f"[+] 自动检测到企业微信数据目录: {detected}") cfg["wxwork_db_dir"] = detected with open(config_file, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=4, ensure_ascii=False) print(f"[+] 已保存到: {config_file}") db_dir = detected else: print("[!] 未能自动检测企业微信数据目录") print(f" 请在 {config_file} 中设置 wxwork_db_dir 字段") print(" 路径格式: C:\\Users\\<用户>\\Documents\\WXWork\\\\Data") sys.exit(1) keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json") base = _app_base_dir() if not os.path.isabs(keys_file): keys_file = os.path.join(base, keys_file) return {"wxwork_db_dir": db_dir, "wxwork_keys_file": keys_file} # ── 主流程 ─────────────────────────────────────────────────────────── def main(): cfg = _load_wxwork_config() db_dir = cfg["wxwork_db_dir"] out_file = cfg["wxwork_keys_file"] print("=" * 60) print(" 提取所有企业微信数据库密钥") print("=" * 60) # 1. 收集所有DB文件及其salt db_files, salt_to_dbs = collect_db_files(db_dir) db_files, salt_to_dbs = filter_encrypted_dbs(db_files, salt_to_dbs) print(f"\n找到 {len(db_files)} 个加密数据库, {len(salt_to_dbs)} 个不同的salt") for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True): print(f" salt {salt_hex}: {', '.join(dbs)}") # 2. 打开所有企业微信进程 pids = get_wxwork_pids() # Some versions do not keep the key as SQL literal x'...'. Bare ASCII # hex scanning is much slower, so keep it behind an explicit switch. hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'") scan_bare_hex = "--scan-bare-hex" in sys.argv bare_hex_re = re.compile(b"(?