diff --git a/config.example.json b/config.example.json index abbd8fa..df34721 100644 --- a/config.example.json +++ b/config.example.json @@ -2,5 +2,8 @@ "db_dir": "D:\\xwechat_files\\your_wxid\\db_storage", "keys_file": "all_keys.json", "decrypted_dir": "decrypted", - "wechat_process": "Weixin.exe" + "wechat_process": "Weixin.exe", + "wxwork_db_dir": "", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_process": "WXWork.exe" } diff --git a/config.py b/config.py index 4099795..a6c9960 100644 --- a/config.py +++ b/config.py @@ -41,6 +41,9 @@ _DEFAULT = { "decrypted_dir": "decrypted", "decoded_image_dir": "decoded_images", "wechat_process": _DEFAULT_PROCESS, + "wxwork_db_dir": "", + "wxwork_keys_file": "wxwork_keys.json", + "wxwork_process": "WXWork.exe", } @@ -213,8 +216,8 @@ def load_config(): # 将相对路径转为绝对路径 base = _app_base_dir() - for key in ("keys_file", "decrypted_dir", "decoded_image_dir"): - if key in cfg and not os.path.isabs(cfg[key]): + for key in ("keys_file", "decrypted_dir", "decoded_image_dir", "wxwork_keys_file"): + if key in cfg and cfg[key] and not os.path.isabs(cfg[key]): cfg[key] = os.path.join(base, cfg[key]) # 自动推导微信数据根目录(db_dir 的上级目录) diff --git a/export_messages.py b/export_messages.py index 0deb581..bf92e07 100644 --- a/export_messages.py +++ b/export_messages.py @@ -1,5 +1,5 @@ """导出微信消息记录到 CSV / HTML / JSON -目录结构: export//messages.csv|html|json +目录结构: /export//messages.csv|html|json """ import sqlite3 import glob @@ -18,9 +18,12 @@ import zstandard as zstd if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") -MSG_DB_DIR = r"decrypted\message" -CONTACT_DB_PATH = r"decrypted\contact\contact.db" -OUTPUT_DIR = "export" +from config import load_config + +_cfg = load_config() +MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message") +CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") +OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "export") MSG_TYPES = { 1: "文本", diff --git a/find_wxwork_keys.py b/find_wxwork_keys.py new file mode 100644 index 0000000..c99c4db --- /dev/null +++ b/find_wxwork_keys.py @@ -0,0 +1,455 @@ +""" +从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key + +企业微信使用 WCDB/SQLCipher 加密,但与个人微信参数不同: +- 个人微信: AES-256-CBC, 32字节密钥, HMAC-SHA512 +- 企业微信: AES-128-CBC, 16字节密钥, 每页根据page index重新派生IV/key + +密钥在内存中的缓存格式仍为 x'',但 key 为16字节(32 hex chars) +""" +import ctypes +import ctypes.wintypes as wt +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 + +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" +SQLITE_HEADER_HEX = b"SQLite format 3\x00".hex() + +PAGE_SZ = 4096 +SALT_SZ = 16 + +# 企业微信可能的加密参数组合 (按可能性排序) +# (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): + """尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)""" + 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 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 + if len(candidates) == 1: + return candidates[0] + + if not sys.stdin.isatty(): + 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): + """过滤掉未加密的数据库(salt 等于 SQLite header 的)""" + filtered_files = [ + entry for entry in db_files if entry[3] != SQLITE_HEADER_HEX + ] + filtered_salts = { + s: dbs for s, dbs in salt_to_dbs.items() if s != SQLITE_HEADER_HEX + } + removed = len(db_files) - len(filtered_files) + if removed: + print(f"[*] 跳过 {removed} 个未加密数据库") + 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 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 + with open(out_file, 'w', encoding='utf-8') as f: + json.dump(result, f, indent=2, ensure_ascii=False) + print_fn(f"\n密钥保存到: {out_file}") + + 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() + + # 宽松正则:匹配 32+ hex chars (16字节key起) + hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'") + key_map = {} + remaining_salts = set(salt_to_dbs.keys()) + all_hex_matches = 0 + t0 = time.time() + + for pid, mem_kb in pids: + h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid) + if not h: + print(f"[WARN] 无法打开进程 PID={pid},跳过") + continue + + try: + regions = enum_regions(h) + total_bytes = sum(s for _, s in regions) + total_mb = total_bytes / 1024 / 1024 + print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)") + + scanned_bytes = 0 + for reg_idx, (base, size) in enumerate(regions): + data = read_mem(h, base, size) + scanned_bytes += size + if not data: + continue + + all_hex_matches += scan_memory_for_wxwork_keys( + data, hex_re, db_files, salt_to_dbs, + key_map, remaining_salts, base, pid, print, + ) + + if (reg_idx + 1) % 200 == 0: + elapsed = time.time() - t0 + progress = scanned_bytes / total_bytes * 100 if total_bytes else 100 + print( + f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, " + f"{all_hex_matches} hex patterns, {elapsed:.1f}s" + ) + finally: + kernel32.CloseHandle(h) + + if not remaining_salts: + print(f"\n[+] 所有密钥已找到,跳过剩余进程") + break + + elapsed = time.time() - t0 + print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex模式") + + cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print) + save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print) + + +if __name__ == '__main__': + try: + main() + except RuntimeError as e: + print(f"\n[ERROR] {e}") + sys.exit(1) diff --git a/voice_to_mp3.py b/voice_to_mp3.py index ffb2ad8..e8d7ebe 100644 --- a/voice_to_mp3.py +++ b/voice_to_mp3.py @@ -10,10 +10,12 @@ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") import pilk +from config import load_config -DB_PATH = r"decrypted\message\media_0.db" -CONTACT_DB_PATH = r"decrypted\contact\contact.db" -OUTPUT_DIR = "data" +_cfg = load_config() +DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db") +CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") +OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "voice") def silk_to_mp3(voice_data, output_path): """将微信 SILK 语音数据转换为 MP3"""