diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index 0b356ce..0000000 --- a/USAGE.md +++ /dev/null @@ -1,234 +0,0 @@ -# Usage Examples / 使用案例 - -WeChat MCP Server 注册到 Claude Code 后,可以直接在对话中查询微信数据。以下是真实使用截图(敏感信息已打码)。 - -## 1. 查看最近会话 - -``` -> 帮我看看微信最近的会话 -``` - -Claude 自动调用 `get_recent_sessions`: - -``` -最近 10 个会话: - -[02-28 16:26] A]██交流群 [群] (7条未读) - 文本: 阿██: 打开就是那么大的一个 - -[02-28 16:26] ██俱乐部 [群] (105条未读) - 文本: S██: 死了就换另外一个 - -[02-28 16:26] ██Sol██社区 [群] (3条未读) - 文本: 洋██: 加密币已成中登资产,还没避险属性 - -[02-28 16:25] ██健身群 [群] (18条未读) - 文本: 风██: 不强求,多的就留着。刚需就洗了。 - -[02-28 16:24] ██社保 (24条未读) - 链接/文件: @年后换工作的你,社保怎么处理?看这里! - -[02-28 16:22] 文件传输助手 - 图片: (无内容) -``` - -## 2. 查看群聊记录 - -``` -> 帮我看看██群在聊啥 -``` - -Claude 调用 `get_chat_history`,然后自动分析总结: - -``` -██群今日话题: - -1. AI工具讨论 (上午) - 群友对微信消息监听工具感兴趣,问了用什么模型、token贵不贵。 - -2. A股炒股 (下午) - ██想让朋友帮操作A股做T,群友一致劝阻——"赚了分他,亏了算你的" - -3. 基金持仓 (傍晚) - ██手痒又买了有色基金 - -活跃度排名: -| 排名 | 昵称 | 发言数 | -|------|------|--------| -| 1 | ██ | ~35条 | -| 2 | ██ | ~15条 | -| 3 | ██ | ~15条 | -``` - -## 3. 搜索消息 - -``` -> 搜一下谁提过"claude" -``` - -Claude 调用 `search_messages(keyword="claude")`: - -``` -搜索 "claude" 找到 20 条结果: - -[2026-02-28 10:25] [██AI群] ██: max套餐,就用opus模型 -[2026-02-28 10:31] [██AI群] ██: 我小龙虾也没用api接入的 -[2026-02-24 21:58] [██AI群] ██: Claude max 套餐给我干没了 -[2026-02-24 22:16] [██AI群] ██: 最简单的就是装claude -... -``` - -## 4. 时间范围 + 分页查看聊天记录 - -``` -> 帮我看一下██群 3 月 1 日到 3 月 7 日的聊天,先给我前 20 条 -``` - -Claude 可以调用: - -```python -get_chat_history( - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -下一页: - -```python -get_chat_history( - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=20, -) -``` - -## 5. 搜索指定联系人/群聊在某个时间段内的消息 - -``` -> 帮我搜一下██群这周谁提到过 Claude -``` - -Claude 可以调用统一接口: - -```python -search_messages( - keyword="Claude", - chat_name="██群", - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -## 6. 多个联系人/群聊联合搜索 - -``` -> 帮我看看联系人A、联系人B 和 ██项目群 这周谁提到过“项目” -``` - -Claude 可以调用统一接口: - -```python -search_messages( - keyword="项目", - chat_name=["联系人A", "联系人B", "██项目群"], - start_time="2026-03-01", - end_time="2026-03-07", - limit=20, - offset=0, -) -``` - -如果某些名字没匹配到联系人,或没有对应消息表,结果里会单独说明。 - -## 7. 搜索联系人 - -``` -> 帮我找一下姓张的联系人 -``` - -Claude 调用 `get_contacts(query="张")`: - -``` -找到 12 个联系人(搜索: 张): - -wxid_████ 备注: 张██ 昵称: ██ -wxid_████ 备注: 张██ 昵称: 小██ -████@chatroom 昵称: 张██的工作群 -... -``` - -## 8. 获取新消息 - -``` -> 有没有新消息 -``` - -Claude 调用 `get_new_messages()`: - -``` -当前 5 个未读会话: - -[16:26] ██交流群 [群] (7条未读): 打开就是那么大的一个 -[16:26] ██俱乐部 [群] (105条未读): 死了就换另外一个 -[16:25] ██健身群 [群] (18条未读): 不强求,多的就留着 -[16:24] ██社保 (24条未读): @年后换工作的你... -[16:22] ██群 [群] (19条未读): (图片) -``` - -## 9. 高级用法:群聊分析 - -Claude 可以获取大量消息后自动分析活跃度、话题分布、关键人物: - -``` -> 帮我分析一下██群最近一周的情况 -``` - -Claude 会调用 `get_chat_history(chat_name="██群", limit=500)` 获取消息,然后输出。消息很多时,也可以把 `limit` 设得更大,或配合 `offset` 分页读取: - -``` -## ██群最近一周分析 - -### 话题分布 -| 话题 | 占比 | -|----------|-------| -| AI工具 | ~40% | -| 币圈行情 | ~25% | -| 时事讨论 | ~20% | -| 闲聊 | ~15% | - -### 活跃度排名 -| 排名 | 昵称 | 发言数 | 角色 | -|------|------|--------|------------| -| 1 | ██ | ~90条 | 技术分享者 | -| 2 | ██ | ~55条 | 深度讨论 | -| 3 | ██ | ~25条 | 新闻搬运 | - -### 群氛围 -这个群正在从██交流群转型成AI工具交流群... -``` - ---- - -## Setup / 配置方法 - -```bash -# 1. 安装依赖 -pip install -r requirements.txt - -# 2. 注册到 Claude Code -claude mcp add wechat -- python C:\path\to\mcp_server.py - -# 3. 在 Claude Code 中直接对话 -claude -> 看看微信最近谁找我了 -``` - -前置条件:需要先运行 `find_all_keys.py` 提取密钥,并配置 `config.json`。详见 [README.md](README.md)。 diff --git a/decode_image.py b/decode_image.py deleted file mode 100644 index f9edbc4..0000000 --- a/decode_image.py +++ /dev/null @@ -1,464 +0,0 @@ -r""" -微信图片 .dat 文件解密模块 - -支持两种加密格式: - - 旧格式: 单字节 XOR 加密,key 通过对比文件头与已知图片 magic bytes 自动检测 - - V2 格式 (2025-08+): AES-128-ECB + XOR 混合加密,需要从微信进程内存提取 AES key - -V2 文件结构: - [6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding] - [aligned_aes_size bytes AES-ECB] [raw_data] [xor_size bytes XOR] - -文件路径格式: - D:\xwechat_files\\msg\attach\\\Img\[_t|_h].dat - -映射链: - message_*.db (local_id) → message_resource.db (packed_info 含 MD5) → .dat 文件 → 解密 -""" - -import os -import sys -import glob -import hashlib -import sqlite3 -import struct - -# V2 格式完整 magic (6 bytes) -V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测 -V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名 -V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key) - -# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性) -IMAGE_MAGIC = { - 'png': [0x89, 0x50, 0x4E, 0x47], - 'gif': [0x47, 0x49, 0x46, 0x38], - 'tif': [0x49, 0x49, 0x2A, 0x00], # little-endian TIFF - 'webp': [0x52, 0x49, 0x46, 0x46], # RIFF header - 'jpg': [0xFF, 0xD8, 0xFF], - # BMP 只有 2 字节 magic,容易假阳性,需要额外验证 -} - - -def is_v2_format(dat_path): - """检测是否是微信 V2 加密格式 (2025-08+)""" - try: - with open(dat_path, 'rb') as f: - magic = f.read(4) - return magic == V2_MAGIC - except (OSError, IOError): - return False - - -def detect_xor_key(dat_path): - """通过对比文件头和已知图片 magic bytes 自动检测 XOR key - - 返回 key (int) 或 None。V2 格式文件返回 None。 - """ - with open(dat_path, 'rb') as f: - header = f.read(16) - - if len(header) < 4: - return None - - # V2 新格式无法用 XOR 解密 - if header[:4] == V2_MAGIC: - return None - - # 先尝试 3+ 字节 magic 的格式(可靠匹配) - for fmt, magic in IMAGE_MAGIC.items(): - key = header[0] ^ magic[0] - match = True - for i in range(1, len(magic)): - if i >= len(header): - break - if (header[i] ^ key) != magic[i]: - match = False - break - if match: - return key - - # 最后尝试 BMP (2 字节 magic,需要额外验证) - bmp_magic = [0x42, 0x4D] - key = header[0] ^ bmp_magic[0] - if len(header) >= 2 and (header[1] ^ key) == bmp_magic[1]: - # 额外验证: XOR 解密后检查 BMP file size 和 offset 字段 - if len(header) >= 14: - dec = bytes(b ^ key for b in header[:14]) - bmp_size = struct.unpack_from('= 12 and header_bytes[8:12] == b'WEBP': - return 'webp' - if header_bytes[:4] == bytes([0x49, 0x49, 0x2A, 0x00]): - return 'tif' - return 'bin' - - -def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): - """解密 V2 格式 .dat 文件 (AES-ECB + XOR) - - Args: - dat_path: V2 .dat 文件路径 - out_path: 输出路径 (None 则自动命名) - aes_key: 16 字节 AES key (bytes 或 str) - xor_key: XOR key (int, 默认 0x88) - - Returns: - (output_path, format) 或 (None, None) - """ - if aes_key is None: - return None, None - - from Crypto.Cipher import AES - from Crypto.Util import Padding - - # 确保 key 是 16 字节 bytes - if isinstance(aes_key, str): - aes_key = aes_key.encode('ascii')[:16] - if len(aes_key) < 16: - return None, None - - with open(dat_path, 'rb') as f: - data = f.read() - - if len(data) < 15: - return None, None - - # 解析 header - sig = data[:6] - if sig not in (V2_MAGIC_FULL, V1_MAGIC_FULL): - return None, None - - aes_size, xor_size = struct.unpack_from('= aes_size,向上对齐到 16 - # 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式 - - offset = 15 - if offset + aligned_aes_size > len(data): - return None, None - - # AES-ECB 解密 - aes_data = data[offset:offset + aligned_aes_size] - try: - cipher = AES.new(aes_key[:16], AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - except (ValueError, KeyError): - return None, None - offset += aligned_aes_size - - # Raw 部分 (不加密) - raw_end = len(data) - xor_size - raw_data = data[offset:raw_end] if offset < raw_end else b'' - offset = raw_end - - # XOR 部分 - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) - - decrypted = dec_aes + raw_data + dec_xor - fmt = detect_image_format(decrypted[:16]) - - # wxgf (HEVC 裸流) 格式 - if decrypted[:4] == b'wxgf': - fmt = 'hevc' - - if out_path is None: - base = os.path.splitext(dat_path)[0] - for suffix in ('_t', '_h'): - if base.endswith(suffix): - base = base[:-len(suffix)] - break - out_path = f"{base}.{fmt}" - - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'wb') as f: - f.write(decrypted) - - return out_path, fmt - - -def xor_decrypt_file(dat_path, out_path=None, key=None): - """解密单个 .dat 文件,返回 (output_path, format)""" - if key is None: - key = detect_xor_key(dat_path) - if key is None: - return None, None - - with open(dat_path, 'rb') as f: - data = f.read() - - decrypted = bytes(b ^ key for b in data) - fmt = detect_image_format(decrypted[:16]) - - if out_path is None: - base = os.path.splitext(dat_path)[0] - # 去掉 _t, _h 后缀 - for suffix in ('_t', '_h'): - if base.endswith(suffix): - base = base[:-len(suffix)] - break - out_path = f"{base}.{fmt}" - - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'wb') as f: - f.write(decrypted) - - return out_path, fmt - - -def decrypt_dat_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): - """智能解密 .dat 文件 (自动检测格式) - - Args: - dat_path: .dat 文件路径 - out_path: 输出路径 - aes_key: V2 格式的 AES key (str 或 bytes, 16 字节) - xor_key: XOR key (int) - - Returns: - (output_path, format) 或 (None, None) - """ - with open(dat_path, 'rb') as f: - head = f.read(6) - - # V2 新格式 - if head == V2_MAGIC_FULL: - return v2_decrypt_file(dat_path, out_path, aes_key, xor_key) - - # V1 格式 (固定 AES key) - if head == V1_MAGIC_FULL: - return v2_decrypt_file(dat_path, out_path, b'cfcd208495d565ef', xor_key) - - # 旧 XOR 格式 - return xor_decrypt_file(dat_path, out_path) - - -def extract_md5_from_packed_info(blob): - """从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5 - - 格式: ... \\x12\\x22\\x0a\\x20 + 32 字节 ASCII hex MD5 ... - """ - if not blob or not isinstance(blob, bytes): - return None - - # 查找 protobuf 标记 - marker = b'\x12\x22\x0a\x20' - idx = blob.find(marker) - if idx >= 0 and idx + len(marker) + 32 <= len(blob): - md5_bytes = blob[idx + len(marker): idx + len(marker) + 32] - try: - md5_str = md5_bytes.decode('ascii') - # 验证是合法的 hex 字符串 - int(md5_str, 16) - return md5_str - except (UnicodeDecodeError, ValueError): - pass - - # 备用方案:扫描 32 字节连续 hex 字符 - hex_chars = set(b'0123456789abcdef') - i = 0 - while i <= len(blob) - 32: - if blob[i] in hex_chars: - candidate = blob[i:i+32] - if all(b in hex_chars for b in candidate): - try: - return candidate.decode('ascii') - except UnicodeDecodeError: - pass - i += 32 - else: - i += 1 - - return None - - -class ImageResolver: - """封装从 local_id 到图片文件的完整解析链""" - - def __init__(self, wechat_base_dir, decoded_image_dir, cache): - """ - Args: - wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\) - decoded_image_dir: 解密图片输出目录 - cache: DBCache 实例,用于解密 message_resource.db - """ - self.base_dir = wechat_base_dir - self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach") - self.out_dir = decoded_image_dir - self.cache = cache - - def get_image_md5(self, local_id): - """通过 local_id 查 message_resource.db 获取图片文件 MD5""" - path = self.cache.get("message/message_resource.db") - if not path: - return None - - conn = sqlite3.connect(path) - try: - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?", - (local_id,) - ).fetchone() - if row and row[0]: - return extract_md5_from_packed_info(row[0]) - except Exception: - pass - finally: - conn.close() - - return None - - def find_dat_files(self, username, file_md5): - """在 attach 目录下查找对应的 .dat 文件 - - 路径: attach///Img/[_t|_h].dat - """ - username_hash = hashlib.md5(username.encode()).hexdigest() - search_base = os.path.join(self.attach_dir, username_hash) - - if not os.path.isdir(search_base): - return [] - - # 在所有月份目录下搜索 - results = [] - pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat") - for p in glob.glob(pattern): - results.append(p) - - return sorted(results) - - def decode_image(self, username, local_id): - """完整流程:local_id → MD5 → .dat → 解密 - - Returns: - dict with keys: success, path, format, md5, error - """ - # 1. 获取 MD5 - file_md5 = self.get_image_md5(local_id) - if not file_md5: - return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'} - - # 2. 找 .dat 文件 - dat_files = self.find_dat_files(username, file_md5) - if not dat_files: - return {'success': False, 'error': f'找不到 .dat 文件 (MD5={file_md5})', 'md5': file_md5} - - # 优先选标准版(非 _t/_h),然后高清 _h,最后缩略图 _t - selected = dat_files[0] - for f in dat_files: - fname = os.path.basename(f) - if not fname.startswith(file_md5 + '_'): - selected = f - break - for f in dat_files: - if f.endswith('_h.dat'): - selected = f - break - - # 3. 解密 - out_name = f"{file_md5}" - out_path_base = os.path.join(self.out_dir, out_name) - - result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp") - if not result_path: - return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5} - - # 重命名为正确扩展名 - final_path = f"{out_path_base}.{fmt}" - if os.path.exists(final_path): - os.unlink(final_path) - os.rename(result_path, final_path) - - return { - 'success': True, - 'path': final_path, - 'format': fmt, - 'md5': file_md5, - 'source': selected, - 'size': os.path.getsize(final_path), - } - - def list_chat_images(self, db_path, table_name, username, limit=20): - """列出某个聊天中的所有图片消息""" - conn = sqlite3.connect(db_path) - try: - rows = conn.execute(f""" - SELECT local_id, create_time - FROM [{table_name}] - WHERE local_type = 3 - ORDER BY create_time DESC - LIMIT ? - """, (limit,)).fetchall() - except Exception as e: - conn.close() - return [] - conn.close() - - results = [] - for local_id, create_time in rows: - file_md5 = self.get_image_md5(local_id) - info = { - 'local_id': local_id, - 'create_time': create_time, - 'md5': file_md5, - } - if file_md5: - dat_files = self.find_dat_files(username, file_md5) - if dat_files: - info['dat_file'] = dat_files[0] - try: - info['size'] = os.path.getsize(dat_files[0]) - except OSError: - pass - results.append(info) - - return results - - -# ============ CLI 测试 ============ - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("用法: python decode_image.py [output_file]") - print(" 解密单个 .dat 文件") - sys.exit(1) - - dat_file = sys.argv[1] - out_file = sys.argv[2] if len(sys.argv) > 2 else None - - if not os.path.exists(dat_file): - print(f"文件不存在: {dat_file}") - sys.exit(1) - - result_path, fmt = decrypt_dat_file(dat_file, out_file) - if result_path: - size = os.path.getsize(result_path) - print(f"解密成功: {result_path}") - print(f"格式: {fmt}, 大小: {size:,} bytes") - else: - print("解密失败") - sys.exit(1) diff --git a/decrypt_db.py b/decrypt_db.py deleted file mode 100644 index ec1aad8..0000000 --- a/decrypt_db.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -WeChat 4.0 数据库解密器 - -使用从进程内存提取的per-DB enc_key解密SQLCipher 4加密的数据库 -参数: SQLCipher 4, AES-256-CBC, HMAC-SHA512, reserve=80, page_size=4096 -密钥来源: all_keys.json (由find_all_keys.py从内存提取) -""" -import hashlib, struct, os, sys, json -import hmac as hmac_mod -from Crypto.Cipher import AES - -import functools -print = functools.partial(print, flush=True) - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -IV_SZ = 16 -HMAC_SZ = 64 -RESERVE_SZ = 80 # IV(16) + HMAC(64) -SQLITE_HDR = b'SQLite format 3\x00' - -from config import load_config -from key_utils import get_key_info, strip_key_metadata -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -OUT_DIR = _cfg["decrypted_dir"] -KEYS_FILE = _cfg["keys_file"] - - -def derive_mac_key(enc_key, salt): - """从enc_key派生HMAC密钥""" - mac_salt = bytes(b ^ 0x3a for b in salt) - return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - - -def decrypt_page(enc_key, page_data, pgno): - """解密单个页面,输出4096字节的标准SQLite页面""" - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - # 保留 reserve=80, B-tree 基于 usable_size=4016 构建 - return bytes(page) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def decrypt_database(db_path, out_path, enc_key): - """解密整个数据库文件""" - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - - if file_size % PAGE_SZ != 0: - print(f" [WARN] 文件大小 {file_size} 不是 {PAGE_SZ} 的倍数") - total_pages += 1 - - with open(db_path, 'rb') as fin: - page1 = fin.read(PAGE_SZ) - - if len(page1) < PAGE_SZ: - print(f" [ERROR] 文件太小") - return False - - # 提取salt并派生mac_key, 验证page 1 - salt = page1[:SALT_SZ] - mac_key = derive_mac_key(enc_key, salt) - p1_hmac_data = page1[SALT_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - p1_stored_hmac = page1[PAGE_SZ - HMAC_SZ : PAGE_SZ] - hm = hmac_mod.new(mac_key, p1_hmac_data, hashlib.sha512) - hm.update(struct.pack(' 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - - decrypted = decrypt_page(enc_key, page, pgno) - fout.write(decrypted) - - if pgno == 1: - if decrypted[:16] != SQLITE_HDR: - print(f" [WARN] 解密后header不匹配!") - - if pgno % 10000 == 0: - print(f" 进度: {pgno}/{total_pages} ({100*pgno/total_pages:.1f}%)") - - return True - - -def main(): - print("=" * 60) - print(" WeChat 4.0 数据库解密器") - print("=" * 60) - - # 加载密钥 - if not os.path.exists(KEYS_FILE): - print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}") - print("请先运行 find_all_keys.py") - sys.exit(1) - - with open(KEYS_FILE, encoding="utf-8") as f: - keys = json.load(f) - - keys = strip_key_metadata(keys) - print(f"\n加载 {len(keys)} 个数据库密钥") - print(f"输出目录: {OUT_DIR}") - os.makedirs(OUT_DIR, exist_ok=True) - - # 收集所有DB文件 - db_files = [] - for root, dirs, files in os.walk(DB_DIR): - for f in files: - if f.endswith('.db') and not f.endswith('-wal') and not f.endswith('-shm'): - path = os.path.join(root, f) - rel = os.path.relpath(path, DB_DIR) - sz = os.path.getsize(path) - db_files.append((rel, path, sz)) - - db_files.sort(key=lambda x: x[2]) # 从小到大 - - print(f"找到 {len(db_files)} 个数据库文件\n") - - success = 0 - failed = 0 - total_bytes = 0 - - for rel, path, sz in db_files: - key_info = get_key_info(keys, rel) - if not key_info: - print(f"SKIP: {rel} (无密钥)") - failed += 1 - continue - - enc_key = bytes.fromhex(key_info["enc_key"]) - out_path = os.path.join(OUT_DIR, rel) - - print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ") - - ok = decrypt_database(path, out_path, enc_key) - if ok: - # SQLite验证 - try: - import sqlite3 - conn = sqlite3.connect(out_path) - tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - conn.close() - table_names = [t[0] for t in tables] - print(f" OK! 表: {', '.join(table_names[:5])}", end="") - if len(table_names) > 5: - print(f" ...共{len(table_names)}个", end="") - print() - success += 1 - total_bytes += sz - except Exception as e: - print(f" [WARN] SQLite验证失败: {e}") - failed += 1 - else: - failed += 1 - - print(f"\n{'='*60}") - print(f"结果: {success} 成功, {failed} 失败, 共 {len(db_files)} 个") - print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB") - print(f"解密文件在: {OUT_DIR}") - - -if __name__ == '__main__': - main() diff --git a/decrypt_images.c b/decrypt_images.c deleted file mode 100644 index 3193680..0000000 --- a/decrypt_images.c +++ /dev/null @@ -1,616 +0,0 @@ -/* - * decrypt_images.c — WeChat V2 image batch decryptor (multi-key) - * - * Decrypts all V2 encrypted .dat files in the WeChat image cache. - * Supports multiple keys via image_keys.json (CT block → AES key mapping). - * - * V2 format: - * [15B header] [AES-128-ECB ciphertext] [XOR encrypted tail] - * Header: \x07\x08V2\x08\x07 (6B) + aes_size:u32LE + xor_size:u32LE + 1B pad - * AES region: ceil(aes_size/16)*16 bytes of AES-128-ECB ciphertext - * XOR tail: xor_size bytes, each XOR'd with a single-byte key - * - * Build: - * cc -O3 -o decrypt_images decrypt_images.c -framework Security - * - * Usage: - * ./decrypt_images # auto from config + image_keys.json - * ./decrypt_images # single-key manual - */ - -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_PATH 4096 -#define V2_MAGIC "\x07\x08V2\x08\x07" -#define V2_MAGIC_LEN 6 -#define HEADER_SIZE 15 -#define MAX_KEYS 4096 - -/* ---- Key mapping: CT block hex → AES key ---- */ -typedef struct { - unsigned char ct[16]; /* CT block 0 pattern */ - unsigned char key[16]; /* AES key for this pattern */ -} key_map_t; - -static key_map_t key_map[MAX_KEYS]; -static int n_keys = 0; - -/* ---- Utility ---- */ - -static int hex2bytes(const char *hex, unsigned char *out, int maxlen) { - int len = 0; - while (*hex && *(hex + 1) && len < maxlen) { - unsigned int b; - if (sscanf(hex, "%2x", &b) != 1) break; - out[len++] = (unsigned char)b; - hex += 2; - } - return len; -} - -/* Minimal JSON string extractor (for simple unescaped string values only). */ -static int json_get_string(const char *json, const char *key, - char *value, int maxlen) { - char pattern[256]; - snprintf(pattern, sizeof(pattern), "\"%s\"", key); - const char *p = strstr(json, pattern); - if (!p) return 0; - p = strchr(p + strlen(pattern), '"'); - if (!p) return 0; - p++; - const char *end = strchr(p, '"'); - if (!end) return 0; - int len = (int)(end - p); - if (len >= maxlen) len = maxlen - 1; - memcpy(value, p, len); - value[len] = '\0'; - return 1; -} - -/* Load image_keys.json: { "ct_hex": "key_hex", ... } */ -static int load_key_map(const char *path) { - FILE *f = fopen(path, "r"); - if (!f) return 0; - fseek(f, 0, SEEK_END); - long sz = ftell(f); - if (sz <= 0) { fclose(f); return 0; } - fseek(f, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(f); return 0; } - size_t rd = fread(json, 1, (size_t)sz, f); - if (rd != (size_t)sz) { - fclose(f); - free(json); - return 0; - } - json[rd] = '\0'; - fclose(f); - - /* Simple parser: find all "32hex": "32hex" pairs */ - const char *p = json; - int warned_capacity = 0; - while ((p = strchr(p, '"')) != NULL) { - if (n_keys >= MAX_KEYS) { - if (!warned_capacity) { - fprintf(stderr, "Warning: image_keys.json exceeds MAX_KEYS=%d, extra keys ignored\n", - MAX_KEYS); - warned_capacity = 1; - } - break; - } - - p++; - const char *end = strchr(p, '"'); - if (!end) break; - int klen = (int)(end - p); - if (klen != 32) { p = end + 1; continue; } - - char ct_hex[33]; - memcpy(ct_hex, p, 32); - ct_hex[32] = '\0'; - const char *colon = end + 1; - while (*colon == ' ' || *colon == '\t' || *colon == '\r' || *colon == '\n') - colon++; - if (*colon != ':') { p = end + 1; continue; } - p = colon + 1; - - /* Find next quoted string (the value) */ - p = strchr(p, '"'); - if (!p) break; - p++; - end = strchr(p, '"'); - if (!end) break; - int vlen = (int)(end - p); - if (vlen != 32) { p = end + 1; continue; } - - char key_hex[33]; - memcpy(key_hex, p, 32); - key_hex[32] = '\0'; - p = end + 1; - - if (hex2bytes(ct_hex, key_map[n_keys].ct, 16) != 16 || - hex2bytes(key_hex, key_map[n_keys].key, 16) != 16) { - continue; - } - n_keys++; - } - free(json); - return n_keys; -} - -/* Find AES key for a given CT block */ -static const unsigned char *find_key_for_ct(const unsigned char *ct) { - for (int i = 0; i < n_keys; i++) - if (memcmp(key_map[i].ct, ct, 16) == 0) return key_map[i].key; - return NULL; -} - -/* Create directory and parents */ -static void mkdirs(const char *path) { - char tmp[MAX_PATH]; - snprintf(tmp, sizeof(tmp), "%s", path); - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { - *p = '\0'; - mkdir(tmp, 0755); - *p = '/'; - } - } - mkdir(tmp, 0755); -} - -static int has_parent_segment(const char *path) { - if (!path || !path[0]) return 1; - if (path[0] == '/' || path[0] == '\\') return 1; - - const char *p = path; - while (*p) { - while (*p == '/' || *p == '\\') p++; - if (!*p) break; - const char *seg = p; - while (*p && *p != '/' && *p != '\\') p++; - if ((p - seg) == 2 && seg[0] == '.' && seg[1] == '.') return 1; - } - return 0; -} - -/* Detect image type from magic bytes */ -static const char *detect_ext(const unsigned char *data, size_t len) { - if (len < 4) return ".bin"; - if (data[0] == 0xFF && data[1] == 0xD8) return ".jpg"; - if (data[0] == 0x89 && data[1] == 0x50 && - data[2] == 0x4E && data[3] == 0x47) return ".png"; - if (data[0] == 'G' && data[1] == 'I' && - data[2] == 'F' && data[3] == '8') return ".gif"; - if (data[0] == 'R' && data[1] == 'I' && - data[2] == 'F' && data[3] == 'F') return ".webp"; - if (data[0] == 0x00 && data[1] == 0x00 && - data[2] == 0x00 && (data[3] == 0x18 || data[3] == 0x1C || - data[3] == 0x20 || data[3] == 0x14)) return ".mp4"; - return ".bin"; -} - -/* Auto-detect XOR key */ -static unsigned char detect_xor_key(const unsigned char *xor_data, size_t xor_size) { - if (xor_size == 0) return 0; - unsigned char candidates[] = {0x80, 0xDC, 0x00}; - for (int i = 0; i < (int)(sizeof(candidates)/sizeof(candidates[0])); i++) { - /* We want a candidate that doesn't produce a leading NUL byte after XOR. */ - unsigned char test = xor_data[0] ^ candidates[i]; - if (test != 0x00 || candidates[i] == 0x00) - return candidates[i]; - } - return 0x80; -} - -/* ---- Decrypt one V2 file ---- */ - -static int decrypt_v2_file(const char *input_path, const char *output_dir, - const char *rel_path, const unsigned char *aes_key, - unsigned char xor_key, int auto_xor, - int *out_xor_detected) { - FILE *fin = fopen(input_path, "rb"); - if (!fin) return -1; - - unsigned char header[HEADER_SIZE]; - if (fread(header, 1, HEADER_SIZE, fin) != HEADER_SIZE) { - fclose(fin); return -1; - } - if (memcmp(header, V2_MAGIC, V2_MAGIC_LEN) != 0) { - fclose(fin); return -2; - } - - uint32_t aes_size, xor_size; - memcpy(&aes_size, header + 6, 4); - memcpy(&xor_size, header + 10, 4); - - if ((uint64_t)aes_size > 100u * 1024u * 1024u || - (uint64_t)xor_size > 100u * 1024u * 1024u) { - fclose(fin); - return -6; - } - - /* PKCS7: when aes_size is already 16-byte aligned, an extra 16-byte - * padding block is present in the ciphertext */ - size_t aes_ct_size = (aes_size % 16 == 0) - ? (size_t)aes_size + 16 - : ((size_t)aes_size + 15) / 16 * 16; - - /* Get total file size and validate header claims fit within it */ - long cur_pos = ftell(fin); - fseek(fin, 0, SEEK_END); - long file_size = ftell(fin); - fseek(fin, cur_pos, SEEK_SET); - - if ((long)aes_ct_size + (long)xor_size > file_size - HEADER_SIZE) { - fclose(fin); - return -6; /* header claims more data than file contains */ - } - - unsigned char *aes_ct = malloc(aes_ct_size); - if (!aes_ct) { fclose(fin); return -1; } - size_t rd = fread(aes_ct, 1, aes_ct_size, fin); - if (rd != aes_ct_size) { - free(aes_ct); - fclose(fin); - return -8; - } - - /* V2 may have unencrypted raw_data between AES and XOR sections */ - long raw_data_size = file_size - HEADER_SIZE - (long)aes_ct_size - (long)xor_size; - if (raw_data_size < 0) raw_data_size = 0; - - unsigned char *raw_data = NULL; - if (raw_data_size > 0) { - raw_data = malloc((size_t)raw_data_size); - if (!raw_data) { free(aes_ct); fclose(fin); return -1; } - rd = fread(raw_data, 1, (size_t)raw_data_size, fin); - if (rd != (size_t)raw_data_size) { - free(aes_ct); free(raw_data); fclose(fin); return -8; - } - } - - unsigned char *xor_data = NULL; - if (xor_size > 0) { - xor_data = malloc(xor_size); - if (!xor_data) { free(aes_ct); free(raw_data); fclose(fin); return -1; } - rd = fread(xor_data, 1, xor_size, fin); - if (rd != xor_size) { - free(aes_ct); free(raw_data); free(xor_data); - fclose(fin); return -8; - } - } - fclose(fin); - - /* Try multi-key lookup (image_keys.json) first, then fall back to provided key */ - if (aes_ct_size >= 16) { - const unsigned char *mk = find_key_for_ct(aes_ct); - if (mk) aes_key = mk; - } - if (!aes_key) { free(aes_ct); free(raw_data); free(xor_data); return -5; } - - unsigned char *aes_pt = malloc(aes_ct_size); - if (!aes_pt) { free(aes_ct); free(raw_data); free(xor_data); return -1; } - - size_t moved = 0; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - aes_key, 16, NULL, - aes_ct, aes_ct_size, aes_pt, aes_ct_size, &moved); - free(aes_ct); - - if (st != kCCSuccess) { - free(aes_pt); free(raw_data); free(xor_data); return -3; - } - - if (auto_xor && xor_data && xor_size > 0) { - xor_key = detect_xor_key(xor_data, xor_size); - if (out_xor_detected) *out_xor_detected = xor_key; - } - - if (xor_data && xor_size > 0) { - for (uint32_t i = 0; i < xor_size; i++) - xor_data[i] ^= xor_key; - } - - const char *ext = detect_ext(aes_pt, aes_size); - - /* Skip unrecognized formats — avoids writing garbage .bin files */ - if (strcmp(ext, ".bin") == 0) { - free(aes_pt); free(raw_data); free(xor_data); - return -9; /* unrecognized image type */ - } - - char out_path[MAX_PATH]; - char rel_noext[MAX_PATH]; - snprintf(rel_noext, sizeof(rel_noext), "%s", rel_path); - char *dot = strrchr(rel_noext, '.'); - if (dot) *dot = '\0'; - if (has_parent_segment(rel_noext)) { - free(aes_pt); free(raw_data); free(xor_data); - return -7; - } - snprintf(out_path, sizeof(out_path), "%s/%s%s", output_dir, rel_noext, ext); - - /* Skip if already decrypted */ - struct stat st_out; - if (stat(out_path, &st_out) == 0 && st_out.st_size > 0) { - free(aes_pt); free(raw_data); free(xor_data); - return 1; /* already exists */ - } - - char parent[MAX_PATH]; - snprintf(parent, sizeof(parent), "%s", out_path); - char *last_slash = strrchr(parent, '/'); - if (last_slash) { *last_slash = '\0'; mkdirs(parent); } - - FILE *fout = fopen(out_path, "wb"); - if (!fout) { free(aes_pt); free(raw_data); free(xor_data); return -4; } - - fwrite(aes_pt, 1, aes_size, fout); - if (raw_data && raw_data_size > 0) fwrite(raw_data, 1, (size_t)raw_data_size, fout); - if (xor_data && xor_size > 0) fwrite(xor_data, 1, xor_size, fout); - - fclose(fout); - free(aes_pt); - free(raw_data); - free(xor_data); - return 0; -} - -/* ---- Directory walking ---- */ - -typedef struct { - const unsigned char *fallback_key; /* single key from config.json (or NULL) */ - int multi_key; /* 1 if using image_keys.json */ - unsigned char xor_key; - int auto_xor; - const char *output_dir; - const char *base_dir; - int success; - int skipped; - int existed; /* already decrypted */ - int no_key; /* V2 files with no matching key */ - int failed; -} walk_ctx; - -static void walk_dir(const char *dir, walk_ctx *ctx) { - DIR *d = opendir(dir); - if (!d) return; - - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name); - - struct stat st; - if (lstat(path, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; - - if (S_ISDIR(st.st_mode)) { - walk_dir(path, ctx); - } else if (S_ISREG(st.st_mode)) { - size_t nlen = strlen(ent->d_name); - if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) - continue; - - const char *rel = path + strlen(ctx->base_dir); - if (*rel == '/') rel++; - - int xor_detected = -1; - /* In multi-key mode, pass fallback_key — decrypt_v2_file tries - * image_keys.json lookup first, falls back to this key if provided */ - const unsigned char *key = ctx->fallback_key; - int ret = decrypt_v2_file(path, ctx->output_dir, rel, - key, ctx->xor_key, - ctx->auto_xor, &xor_detected); - - if (ret == 0) { - ctx->success++; - if (ctx->auto_xor && xor_detected >= 0) { - ctx->xor_key = (unsigned char)xor_detected; - ctx->auto_xor = 0; - printf(" Auto-detected XOR key: 0x%02X\n", ctx->xor_key); - } - if (ctx->success <= 5 || ctx->success % 1000 == 0) { - printf(" [%d] %s\n", ctx->success, rel); - } - } else if (ret == 1) { - ctx->existed++; - } else if (ret == -2) { - ctx->skipped++; - } else if (ret == -5) { - ctx->no_key++; - } else { - ctx->failed++; - if (ctx->failed <= 5) - printf(" FAIL(%d): %s\n", ret, rel); - } - } - } - closedir(d); -} - -/* ---- Main ---- */ - -int main(int argc, char *argv[]) { - unsigned char aes_key[16]; - char image_dir[MAX_PATH] = ""; - char output_dir[MAX_PATH] = ""; - char key_hex[64] = ""; - int have_single_key = 0; - - printf("=== WeChat V2 Image Decryptor ===\n\n"); - - /* Determine exe directory for config file lookup */ - char exe_dir[MAX_PATH] = "."; - const char *last_slash = strrchr(argv[0], '/'); - if (last_slash) { - int len = (int)(last_slash - argv[0]); - snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]); - } - - if (argc >= 4) { - /* Manual single-key mode */ - strncpy(key_hex, argv[1], sizeof(key_hex) - 1); - key_hex[sizeof(key_hex) - 1] = '\0'; - strncpy(image_dir, argv[2], sizeof(image_dir) - 1); - image_dir[sizeof(image_dir) - 1] = '\0'; - strncpy(output_dir, argv[3], sizeof(output_dir) - 1); - output_dir[sizeof(output_dir) - 1] = '\0'; - have_single_key = (key_hex[0] != '\0'); - } else { - /* Load image_keys.json first (multi-key) */ - char keys_path[MAX_PATH]; - snprintf(keys_path, sizeof(keys_path), "%s/image_keys.json", exe_dir); - int loaded = load_key_map(keys_path); - if (loaded > 0) - printf("Loaded %d key mappings from %s\n", loaded, keys_path); - - /* Read config.json for paths (and fallback single key) */ - char cfg_path[MAX_PATH]; - snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir); - FILE *cf = fopen(cfg_path, "r"); - if (!cf) { - fprintf(stderr, "ERROR: Cannot open %s\n", cfg_path); - return 1; - } - - fseek(cf, 0, SEEK_END); - long sz = ftell(cf); - if (sz <= 0) { fclose(cf); return 1; } - fseek(cf, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(cf); return 1; } - size_t rd = fread(json, 1, (size_t)sz, cf); - if (rd != (size_t)sz) { - free(json); - fclose(cf); - return 1; - } - json[sz] = '\0'; - fclose(cf); - - if (json_get_string(json, "image_key", key_hex, sizeof(key_hex)) && - key_hex[0] != '\0') - have_single_key = 1; - else - have_single_key = 0; - - char db_dir[MAX_PATH] = ""; - json_get_string(json, "db_dir", db_dir, sizeof(db_dir)); - - char out_rel[MAX_PATH] = "decrypted_images"; - json_get_string(json, "decrypted_images_dir", out_rel, sizeof(out_rel)); - if (out_rel[0] == '/') - strncpy(output_dir, out_rel, sizeof(output_dir) - 1); - else - snprintf(output_dir, sizeof(output_dir), "%s/%s", exe_dir, out_rel); - output_dir[sizeof(output_dir) - 1] = '\0'; - - if (db_dir[0]) { - char *s = strrchr(db_dir, '/'); - if (!s) s = strrchr(db_dir, '\\'); - if (s) { - int plen = (int)(s - db_dir); - snprintf(image_dir, sizeof(image_dir), - "%.*s/msg", plen, db_dir); - } - } - free(json); - } - - /* Parse single key if available (used as fallback or sole key) */ - if (have_single_key && key_hex[0]) { - if (hex2bytes(key_hex, aes_key, 16) == 16) { - /* If no image_keys.json loaded, add single key to key_map - * by discovering its CT block at runtime */ - } else { - have_single_key = 0; - } - } - - if (n_keys == 0 && !have_single_key) { - fprintf(stderr, "ERROR: No keys available.\n"); - fprintf(stderr, "Run find_image_key first, or set image_key in config.json\n"); - return 1; - } - - /* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */ - if (image_dir[0] == '\0') { - const char *home = getenv("HOME"); - if (!home) home = "/Users"; - char base[MAX_PATH]; - snprintf(base, sizeof(base), - "%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files", - home); - DIR *d = opendir(base); - if (d) { - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char candidate[MAX_PATH]; - snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name); - struct stat st2; - if (stat(candidate, &st2) == 0 && S_ISDIR(st2.st_mode)) { - strncpy(image_dir, candidate, sizeof(image_dir) - 1); - printf("Auto-detected image directory:\n %s\n\n", image_dir); - break; - } - } - closedir(d); - } - } - - if (image_dir[0] == '\0') { - fprintf(stderr, "ERROR: Cannot determine image directory.\n"); - fprintf(stderr, "Tried: command line, config.json, auto-detect.\n"); - fprintf(stderr, "Set db_dir in config.json or pass image_dir as argument.\n"); - return 1; - } - - printf("Mode: %s\n", n_keys > 0 ? "multi-key" : "single-key"); - if (n_keys > 0) printf("Keys: %d pattern→key mappings\n", n_keys); - if (have_single_key) printf("Fallback: %s\n", key_hex); - printf("Image dir: %s\n", image_dir); - printf("Output: %s\n\n", output_dir); - - mkdirs(output_dir); - - walk_ctx ctx = { - .fallback_key = have_single_key ? aes_key : NULL, - .multi_key = (n_keys > 0), - .xor_key = 0, - .auto_xor = 1, - .output_dir = output_dir, - .base_dir = image_dir, - .success = 0, - .skipped = 0, - .existed = 0, - .no_key = 0, - .failed = 0, - }; - - walk_dir(image_dir, &ctx); - - printf("\n==================================================\n"); - printf("Results:\n"); - printf(" Decrypted: %d\n", ctx.success); - printf(" Existed: %d (already decrypted, skipped)\n", ctx.existed); - printf(" No key: %d (run find_image_key to discover more keys)\n", ctx.no_key); - printf(" Skipped: %d (non-V2)\n", ctx.skipped); - printf(" Failed: %d\n", ctx.failed); - printf("Output: %s\n", output_dir); - printf("==================================================\n"); - - return (ctx.success > 0) ? 0 : 1; -} diff --git a/find_all_keys.py b/find_all_keys.py deleted file mode 100644 index eba2191..0000000 --- a/find_all_keys.py +++ /dev/null @@ -1,34 +0,0 @@ -import functools -import platform -import sys - - -@functools.lru_cache(maxsize=1) -def _load_impl(): - system = platform.system().lower() - if system == "windows": - import find_all_keys_windows as impl - return impl - if system == "linux": - import find_all_keys_linux as impl - return impl - raise RuntimeError( - f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n" - f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)" - ) - - -def get_pids(): - return _load_impl().get_pids() - - -def main(): - return _load_impl().main() - - -if __name__ == "__main__": - try: - main() - except RuntimeError as exc: - print(f"\n[ERROR] {exc}") - sys.exit(1) diff --git a/find_all_keys_linux.py b/find_all_keys_linux.py deleted file mode 100644 index b12feb0..0000000 --- a/find_all_keys_linux.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -Linux 版微信数据库密钥提取 - -原理: 与 Windows/macOS 相同 — 扫描微信进程内存,查找 -WCDB 缓存的 x'<64hex_enc_key><32hex_salt>' 模式, -通过匹配数据库 salt + HMAC 校验确认密钥。 - -读取方式: /proc//maps + /proc//mem -权限要求: root 或 CAP_SYS_PTRACE -""" -import functools -import os -import re -import sys -import time - -from key_scan_common import ( - collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results, -) - -print = functools.partial(print, flush=True) - - -def _safe_readlink(path): - try: - return os.path.realpath(os.readlink(path)) - except OSError: - return "" - - -_KNOWN_COMMS = {"wechat", "wechatappex", "weixin"} -_INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby") - - -def _is_wechat_process(pid): - """检查 pid 是否为微信进程。 - - 优先精确匹配 comm 名称(wechat、WeChatAppEx 等), - 再用 exe 路径子串匹配作为 fallback,同时排除解释器进程。 - """ - if pid == os.getpid(): - return False - try: - with open(f"/proc/{pid}/comm") as f: - comm = f.read().strip() - # 优先精确匹配 comm(最可靠) - if comm.lower() in _KNOWN_COMMS: - return True - exe_path = _safe_readlink(f"/proc/{pid}/exe") - exe_name = os.path.basename(exe_path) - # 排除脚本解释器进程(避免匹配 python3.11 wechat-decrypt 等) - if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES): - return False - # fallback: exe 名称子串匹配 - return "wechat" in exe_name.lower() or "weixin" in exe_name.lower() - except (PermissionError, FileNotFoundError, ProcessLookupError): - return False - - -def get_pids(): - """返回所有疑似微信主进程的 (pid, rss_kb) 列表,按内存降序。""" - pids = [] - for pid_str in os.listdir("/proc"): - if not pid_str.isdigit(): - continue - pid = int(pid_str) - try: - if not _is_wechat_process(pid): - continue - with open(f"/proc/{pid}/statm") as f: - rss_pages = int(f.read().split()[1]) - rss_kb = rss_pages * 4 - pids.append((pid, rss_kb)) - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - - if not pids: - raise RuntimeError("未检测到 Linux 微信进程") - - pids.sort(key=lambda item: item[1], reverse=True) - for pid, rss_kb in pids: - exe_path = _safe_readlink(f"/proc/{pid}/exe") - print(f"[+] WeChat PID={pid} ({rss_kb // 1024}MB) {exe_path}") - return pids - - -_SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"} -_SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/") - - -def _get_readable_regions(pid): - """解析 /proc//maps,返回可读内存区域列表。 - - 跳过 [vdso]、[vsyscall] 等特殊映射和系统库映射, - 聚焦匿名映射和堆区(WCDB 密钥缓存所在位置)。 - """ - regions = [] - with open(f"/proc/{pid}/maps") as f: - for line in f: - parts = line.split() - if len(parts) < 2: - continue - if "r" not in parts[1]: - continue - # 跳过特殊映射和无关系统库,但保留 wcdb/wechat 相关库 - if len(parts) >= 6: - mapping_name = parts[5] - if mapping_name in _SKIP_MAPPINGS: - continue - mapping_lower = mapping_name.lower() - if (any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES) - and "wcdb" not in mapping_lower - and "wechat" not in mapping_lower - and "weixin" not in mapping_lower): - continue - start_s, end_s = parts[0].split("-") - start = int(start_s, 16) - size = int(end_s, 16) - start - if 0 < size < 500 * 1024 * 1024: - regions.append((start, size)) - return regions - - -def _check_permissions(): - """检查是否有读取进程内存的权限(root 或 CAP_SYS_PTRACE)。""" - if os.geteuid() == 0: - return - # 检查 CAP_SYS_PTRACE: 读取 /proc/self/status 中的 CapEff - try: - with open("/proc/self/status") as f: - for line in f: - if line.startswith("CapEff:"): - cap_eff = int(line.split(":")[1].strip(), 16) - CAP_SYS_PTRACE = 1 << 19 - if cap_eff & CAP_SYS_PTRACE: - return - break - except (OSError, ValueError): - pass - print("[!] 需要 root 权限或 CAP_SYS_PTRACE 才能读取进程内存") - print(" 请使用: sudo python3 find_all_keys.py") - print(" 或授予 capability: sudo setcap cap_sys_ptrace=ep $(which python3)") - sys.exit(1) - - -def main(): - from config import load_config - _cfg = load_config() - db_dir = _cfg["db_dir"] - out_file = _cfg["keys_file"] - - _check_permissions() - - print("=" * 60) - print(" 提取 Linux 微信数据库密钥(内存扫描)") - print("=" * 60) - - # 1. 收集 DB 文件和 salt - db_files, salt_to_dbs = collect_db_files(db_dir) - if not db_files: - raise RuntimeError(f"在 {db_dir} 未找到可解密的 .db 文件") - - 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_pids() - - hex_re = re.compile(rb"x'([0-9a-fA-F]{64,192})'") - key_map = {} # salt_hex -> enc_key_hex - remaining_salts = set(salt_to_dbs.keys()) - all_hex_matches = 0 - t0 = time.time() - - for pid, rss_kb in pids: - try: - regions = _get_readable_regions(pid) - except PermissionError: - print(f"[WARN] 无法读取 /proc/{pid}/maps,权限不足,跳过") - continue - except (FileNotFoundError, ProcessLookupError): - print(f"[WARN] PID {pid} 已退出,跳过") - continue - - 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 - try: - mem = open(f"/proc/{pid}/mem", "rb") - except PermissionError: - print(f"[WARN] 无法打开 /proc/{pid}/mem,权限不足,跳过") - continue - except (FileNotFoundError, ProcessLookupError): - print(f"[WARN] PID {pid} 已退出,跳过") - continue - - # 防御 TOCTOU: 打开 mem 后再次确认仍为微信进程 - if not _is_wechat_process(pid): - print(f"[WARN] PID {pid} 已不是微信进程,跳过") - mem.close() - continue - - try: - for reg_idx, (base, size) in enumerate(regions): - try: - mem.seek(base) - data = mem.read(size) - except (OSError, ValueError): - continue - scanned_bytes += len(data) - - all_hex_matches += scan_memory_for_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: - mem.close() - - 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_keys(db_files, salt_to_dbs, key_map, print) - save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print) - - -if __name__ == "__main__": - try: - main() - except RuntimeError as exc: - print(f"\n[ERROR] {exc}") - sys.exit(1) diff --git a/find_all_keys_windows.py b/find_all_keys_windows.py deleted file mode 100644 index ebf681d..0000000 --- a/find_all_keys_windows.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -从微信进程内存中提取所有数据库的缓存raw key - -WCDB为每个DB缓存: x'<64hex_enc_key><32hex_salt>' -salt嵌在hex字符串中,可以直接匹配DB文件的salt -""" -import ctypes -import ctypes.wintypes as wt -import os, sys, time, re - -import functools -print = functools.partial(print, flush=True) - -from key_scan_common import ( - collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results, -) - -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 get_pids(): - """返回所有 Weixin.exe 进程的 (pid, mem_kb) 列表,按内存降序""" - import subprocess - r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Weixin.exe", "/FO", "CSV", "/NH"], - capture_output=True, text=True) - pids = [] - for line in r.stdout.strip().split('\n'): - if not line.strip(): - continue - p = line.strip('"').split('","') - if len(p) >= 5: - pid = int(p[1]) - mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0') - pids.append((pid, mem)) - if not pids: - raise RuntimeError("Weixin.exe 未运行") - pids.sort(key=lambda x: x[1], reverse=True) - for pid, mem in pids: - print(f"[+] Weixin.exe PID={pid} ({mem // 1024}MB)") - return pids - - -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 - - -def main(): - from config import load_config - _cfg = load_config() - db_dir = _cfg["db_dir"] - out_file = _cfg["keys_file"] - - print("=" * 60) - print(" 提取所有微信数据库密钥") - print("=" * 60) - - # 1. 收集所有DB文件及其salt - db_files, salt_to_dbs = collect_db_files(db_dir) - - 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_pids() - - hex_re = re.compile(b"x'([0-9a-fA-F]{64,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_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_keys(db_files, salt_to_dbs, key_map, print) - save_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/find_image_key.c b/find_image_key.c deleted file mode 100644 index 2fc4b89..0000000 --- a/find_image_key.c +++ /dev/null @@ -1,917 +0,0 @@ -/* - * find_image_key.c — WeChat V2 image key continuous scanner (macOS) - * - * Discovers all unique V2 encryption patterns from the image cache, - * then continuously scans WeChat process memory to find AES keys. - * User just keeps browsing images in WeChat — the scanner catches - * keys as they transiently appear in memory. - * - * Uses multi-block CCCrypt: one key setup decrypts ALL unsolved - * patterns in a single call (~1.5 min per full scan with 20 patterns). - * - * Build: - * cc -O3 -o find_image_key find_image_key.c -framework Security - * - * Usage: - * sudo ./find_image_key # auto-discover from config.json - * sudo ./find_image_key # explicit image directory - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_PATH 4096 -#define MAX_PATTERNS 8192 -#define V2_MAGIC "\x07\x08V2\x08\x07" -#define V2_MAGIC_LEN 6 -#define REGION_MAX (200 * 1024 * 1024) -#define DEEP_PRIORITY_MAX 10 /* byte-by-byte scan for top N unsolved patterns */ - -/* ---- Strict image magic detection (16 bytes available from decrypted block) ---- */ -static int is_image_magic(const unsigned char *pt) { - if (pt[0] == 0xFF && pt[1] == 0xD8 && pt[2] == 0xFF && - pt[3] >= 0xC0 && pt[3] != 0xFF) { - /* JFIF: verify "JF" at offset 6 */ - if (pt[3] == 0xE0) return (pt[6] == 'J' && pt[7] == 'F'); - /* EXIF: verify "Ex" at offset 6 */ - if (pt[3] == 0xE1) return (pt[6] == 'E' && pt[7] == 'x'); - /* Other markers: verify length field is sane (big-endian, 2..32767) */ - uint16_t len = ((uint16_t)pt[4] << 8) | pt[5]; - return (len >= 2 && len < 0x8000); - } - /* PNG: full 8-byte signature */ - if (pt[0]==0x89 && pt[1]==0x50 && pt[2]==0x4E && pt[3]==0x47 && - pt[4]==0x0D && pt[5]==0x0A && pt[6]==0x1A && pt[7]==0x0A) return 1; - /* GIF: "GIF89a" or "GIF87a" */ - if (pt[0]=='G' && pt[1]=='I' && pt[2]=='F' && pt[3]=='8' && - (pt[4]=='9' || pt[4]=='7') && pt[5]=='a') return 1; - /* WebP: "RIFF....WEBP" */ - if (pt[0]=='R' && pt[1]=='I' && pt[2]=='F' && pt[3]=='F' && - pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P') return 1; - return 0; -} - -/* ---- Pattern tracking ---- */ -typedef struct { - unsigned char ct[16]; /* CT block 0 (first 16 encrypted bytes) */ - unsigned char key[16]; /* found AES key */ - int solved; - int file_count; /* how many .dat files use this pattern */ - char sample_path[MAX_PATH]; -} pattern_t; - -static pattern_t patterns[MAX_PATTERNS]; -static int npatterns = 0; -static int total_v2_files = 0; - -/* ---- Rejected key blacklist (false positives) ---- */ -#define MAX_REJECTED 256 -static unsigned char rejected_keys[MAX_REJECTED][16]; -static int n_rejected = 0; - -static int is_rejected(const unsigned char *key) { - for (int i = 0; i < n_rejected; i++) - if (memcmp(rejected_keys[i], key, 16) == 0) return 1; - return 0; -} -static void add_rejected(const unsigned char *key) { - if (n_rejected < MAX_REJECTED && !is_rejected(key)) { - memcpy(rejected_keys[n_rejected], key, 16); - n_rejected++; - } -} - -/* ---- Global scan mode ---- */ -static int g_deep_mode = 0; - -/* ---- Graceful shutdown ---- */ -static volatile sig_atomic_t stop_flag = 0; -static void sigint_handler(int sig) { (void)sig; stop_flag = 1; } - -/* ---- Utility ---- */ -static void bytes2hex(const unsigned char *d, int n, char *out) { - for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", d[i]); - out[n*2] = '\0'; -} -static int hex2bytes(const char *h, unsigned char *o, int max) { - int n = 0; - while (n < max) { - if (!h[0] || !h[1]) return 0; - if (!((h[0] >= '0' && h[0] <= '9') || (h[0] >= 'a' && h[0] <= 'f') || - (h[0] >= 'A' && h[0] <= 'F'))) return 0; - if (!((h[1] >= '0' && h[1] <= '9') || (h[1] >= 'a' && h[1] <= 'f') || - (h[1] >= 'A' && h[1] <= 'F'))) return 0; - - unsigned int b = 0; - if (sscanf(h, "%2x", &b) != 1) return 0; - o[n++] = (unsigned char)b; h += 2; - } - return n; -} - -/* Minimal JSON string extractor */ -static int json_get_string(const char *json, const char *key, - char *val, int maxlen) { - char pat[256]; - snprintf(pat, sizeof(pat), "\"%s\"", key); - const char *p = strstr(json, pat); - if (!p) return 0; - p = strchr(p + strlen(pat), '"'); - if (!p) return 0; - p++; - const char *end = strchr(p, '"'); - if (!end || (int)(end - p) >= maxlen) return 0; - memcpy(val, p, end - p); - val[end - p] = '\0'; - return 1; -} - -/* ---- Pattern discovery ---- */ -static int find_pattern_index(const unsigned char *ct) { - for (int i = 0; i < npatterns; i++) - if (memcmp(patterns[i].ct, ct, 16) == 0) return i; - return -1; -} - -static void discover_dir(const char *dir) { - DIR *d = opendir(dir); - if (!d) return; - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name); - struct stat st; - if (lstat(path, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; - if (S_ISDIR(st.st_mode)) { - discover_dir(path); - continue; - } - if (!S_ISREG(st.st_mode)) continue; - size_t nlen = strlen(ent->d_name); - if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) continue; - - FILE *f = fopen(path, "rb"); - if (!f) continue; - unsigned char hdr[31]; - size_t rd = fread(hdr, 1, 31, f); - fclose(f); - if (rd < 31 || memcmp(hdr, V2_MAGIC, V2_MAGIC_LEN) != 0) continue; - - unsigned char *ct = hdr + 15; - total_v2_files++; - int idx = find_pattern_index(ct); - if (idx >= 0) { - patterns[idx].file_count++; - } else if (npatterns < MAX_PATTERNS) { - memcpy(patterns[npatterns].ct, ct, 16); - patterns[npatterns].file_count = 1; - patterns[npatterns].solved = 0; - strncpy(patterns[npatterns].sample_path, path, - sizeof(patterns[npatterns].sample_path) - 1); - patterns[npatterns].sample_path[sizeof(patterns[npatterns].sample_path) - 1] = '\0'; - npatterns++; - } - } - closedir(d); -} - -/* Sort patterns by file_count descending */ -static int cmp_patterns(const void *a, const void *b) { - return ((pattern_t*)b)->file_count - ((pattern_t*)a)->file_count; -} - -/* ---- Process discovery ---- */ -static int get_wechat_pids(pid_t *pids, int max) { - int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0}; - size_t sz = 0; - if (sysctl(mib, 4, NULL, &sz, NULL, 0) != KERN_SUCCESS || sz == 0) - return 0; - - size_t alloc_sz = sz + (sz >> 2); - struct kinfo_proc *procs = malloc(alloc_sz); - if (!procs) return 0; - - if (sysctl(mib, 4, procs, &alloc_sz, NULL, 0) != KERN_SUCCESS) { - free(procs); - return 0; - } - - int n = (int)(alloc_sz / sizeof(struct kinfo_proc)), cnt = 0; - for (int i = 0; i < n && cnt < max; i++) - if (strstr(procs[i].kp_proc.p_comm, "WeChat")) - pids[cnt++] = procs[i].kp_proc.p_pid; - free(procs); - return cnt; -} - -/* ---- Verification: decrypt sample file, validate JPEG marker chain ---- */ - -/* Validate JPEG structure: check marker chain (SOI → markers → SOS/EOI) */ -static int verify_jpeg_chain(const unsigned char *data, size_t len) { - if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) return 0; - size_t pos = 2; - int markers = 0; - while (pos + 4 <= len) { - if (data[pos] != 0xFF) return markers >= 2; - unsigned char m = data[pos + 1]; - /* Skip fill bytes (FF FF...) */ - if (m == 0xFF) { pos++; continue; } - if (m == 0x00) return 0; /* stuffed byte outside scan = invalid */ - if (m == 0xD9) return markers >= 1; /* EOI */ - if (m == 0xDA) return markers >= 1; /* SOS = scan data follows */ - if (m < 0xC0) return 0; - uint16_t mlen = ((uint16_t)data[pos+2] << 8) | data[pos+3]; - if (mlen < 2) return 0; - pos += 2 + mlen; - markers++; - } - /* Ran out of data (first marker spans past AES region): accept if >= 1 valid marker */ - return markers >= 1; -} - -/* Validate PNG: 8-byte sig + IHDR chunk */ -static int verify_png_chain(const unsigned char *data, size_t len) { - static const unsigned char sig[8] = {0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A}; - if (len < 24 || memcmp(data, sig, 8) != 0) return 0; - /* IHDR chunk at offset 8: length(4) + "IHDR"(4) + data(13) + CRC(4) */ - return (data[12]=='I' && data[13]=='H' && data[14]=='D' && data[15]=='R'); -} - -static int verify_key(int pat_idx) { - pattern_t *p = &patterns[pat_idx]; - FILE *f = fopen(p->sample_path, "rb"); - if (!f) return 1; /* can't verify, assume ok */ - - unsigned char hdr[15]; - if (fread(hdr, 1, 15, f) != 15) { fclose(f); return 1; } - uint32_t aes_size; - memcpy(&aes_size, hdr + 6, 4); - /* PKCS7: extra padding block when aes_size is 16-byte aligned */ - uint32_t ct_size = (aes_size % 16 == 0) - ? aes_size + 16 - : ((aes_size + 15) / 16) * 16; - if (ct_size > 10 * 1024 * 1024) { fclose(f); return 1; } - - unsigned char *ct = malloc(ct_size); - size_t rd = fread(ct, 1, ct_size, f); - fclose(f); - if (rd < ct_size) { free(ct); return 1; } - - unsigned char *pt = malloc(ct_size); - size_t moved; - CCCryptorStatus st = CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, p->key, 16, NULL, - ct, ct_size, pt, ct_size, &moved); - free(ct); - - if (st != kCCSuccess || moved < 16) { free(pt); return 0; } - - /* Deep validation based on image type */ - int ok = 0; - if (pt[0] == 0xFF && pt[1] == 0xD8) - ok = verify_jpeg_chain(pt, moved); - else if (pt[0] == 0x89 && pt[1] == 0x50) - ok = verify_png_chain(pt, moved); - else if (pt[0] == 'G' && pt[1] == 'I' && pt[2] == 'F') - ok = (moved >= 6 && pt[3] == '8' && (pt[4]=='9'||pt[4]=='7') && pt[5]=='a'); - else if (pt[0] == 'R' && pt[1] == 'I') - ok = (moved >= 12 && pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P'); - - free(pt); - return ok; -} - -/* ---- Memory scanning ---- */ - -/* - * Multi-block scan: for each candidate key, decrypt ALL unsolved - * CT blocks in one CCCrypt call (ECB processes blocks independently). - */ -static int g_task_fail_warned = 0; - -static int scan_pid(pid_t pid) { - mach_port_t task; - kern_return_t kr = task_for_pid(mach_task_self(), pid, &task); - if (kr != KERN_SUCCESS) { - if (!g_task_fail_warned) { - g_task_fail_warned = 1; - fprintf(stderr, - " WARNING: task_for_pid(%d) failed (kr=%d).\n" - " Cannot read WeChat memory. Checklist:\n" - " 1. Run with sudo\n" - " 2. Enable Developer Mode: Settings > Privacy & Security > Developer Mode\n" - " 3. Grant Terminal Full Disk Access: Settings > Privacy & Security > Full Disk Access\n" - " 4. If still failing, try: sudo DevToolsSecurity -enable\n" - " 5. Last resort: disable SIP (boot to Recovery, run: csrutil disable)\n", - pid, kr); - } - return 0; - } - - /* Build batch CT buffer for unsolved patterns */ - int unsolved_idx[MAX_PATTERNS]; - int n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) unsolved_idx[n_unsolved++] = i; - if (n_unsolved == 0) { - mach_port_deallocate(mach_task_self(), task); - return 0; - } - - unsigned char *batch_ct = malloc(n_unsolved * 16); - unsigned char *batch_pt = malloc(n_unsolved * 16); - if (!batch_ct || !batch_pt) { - free(batch_ct); - free(batch_pt); - mach_port_deallocate(mach_task_self(), task); - return 0; - } - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, patterns[unsolved_idx[i]].ct, 16); - - mach_vm_address_t addr = 0; - mach_vm_size_t rsize; - vm_region_basic_info_data_64_t info; - mach_msg_type_number_t count; - mach_port_t obj = MACH_PORT_NULL; - - long regions = 0, found_this_pid = 0; - long long total_bytes = 0, tests = 0; - - while (!stop_flag) { - count = VM_REGION_BASIC_INFO_COUNT_64; - kr = mach_vm_region(task, &addr, &rsize, VM_REGION_BASIC_INFO_64, - (vm_region_info_t)&info, &count, &obj); - if (kr != KERN_SUCCESS) break; - regions++; - if (obj != MACH_PORT_NULL) { - mach_port_deallocate(mach_task_self(), obj); - obj = MACH_PORT_NULL; - } - - if ((info.protection & VM_PROT_READ) && rsize > 0 && rsize < REGION_MAX) { - vm_offset_t data; - mach_msg_type_number_t data_cnt; - kr = mach_vm_read(task, addr, rsize, &data, &data_cnt); - if (kr == KERN_SUCCESS) { - unsigned char *buf = (unsigned char *)data; - total_bytes += data_cnt; - - /* Method 1: every 16-byte aligned position (raw binary keys) */ - for (mach_msg_type_number_t j = 0; - j + 16 <= data_cnt && !stop_flag; j += 16) { - tests++; - size_t moved; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - buf + j, 16, NULL, - batch_ct, n_unsolved * 16, - batch_pt, n_unsolved * 16, &moved); - if (st != kCCSuccess) continue; - - for (int p = 0; p < n_unsolved; p++) { - if (is_image_magic(batch_pt + p*16)) { - if (is_rejected(buf + j)) continue; - int idx = unsolved_idx[p]; - memcpy(patterns[idx].key, buf + j, 16); - patterns[idx].solved = 1; - - char kh[33]; bytes2hex(buf + j, 16, kh); - char ch[33]; bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY: %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - printf(" PID %d, addr=0x%llx+0x%x\n", - pid, addr, j); - - /* Cross-check: does this key solve OTHER patterns? */ - for (int q = 0; q < n_unsolved; q++) { - if (q == p || patterns[unsolved_idx[q]].solved) - continue; - unsigned char tpt[16]; - size_t tm; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf + j, 16, NULL, - patterns[unsolved_idx[q]].ct, 16, - tpt, 16, &tm); - if (is_image_magic(tpt)) { - int qi = unsolved_idx[q]; - memcpy(patterns[qi].key, buf + j, 16); - patterns[qi].solved = 1; - char qch[33]; - bytes2hex(patterns[qi].ct, 16, qch); - printf(" Also solves: %s (%d files)\n", - qch, patterns[qi].file_count); - } - } - - found_this_pid++; - /* Rebuild batch for remaining unsolved */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; /* restart block check with new batch */ - } - } - } - - /* Method 2: hex string [0-9a-f]{16+} at unaligned positions. - * WeChat may store the AES key as a hex-encoded ASCII string - * in memory (e.g. "cfcd208495d565ef" = 16 ASCII bytes). - * We use the raw ASCII bytes directly as the 16-byte AES key, - * since the key is arbitrary bytes and the hex representation - * itself is 16 bytes for a 64-bit key half. */ - int run = 0, run_start = 0; - for (mach_msg_type_number_t j = 0; - j <= data_cnt && !stop_flag; j++) { - int is_hex = (j < data_cnt) && - ((buf[j]>='a' && buf[j]<='f') || - (buf[j]>='0' && buf[j]<='9')); - if (is_hex) { - if (!run) run_start = j; - run++; - } else { - if (run >= 16) { - for (int k = run_start; k+16 <= run_start+run; k++) { - if (k % 16 == 0) continue; /* already tested */ - tests++; - size_t moved; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf+k, 16, NULL, - batch_ct, n_unsolved*16, - batch_pt, n_unsolved*16, &moved); - for (int p = 0; p < n_unsolved; p++) { - if (is_image_magic(batch_pt + p*16)) { - if (is_rejected(buf+k)) continue; - int idx = unsolved_idx[p]; - memcpy(patterns[idx].key, buf+k, 16); - patterns[idx].solved = 1; - char kh[33]; bytes2hex(buf+k, 16, kh); - char ch[33]; - bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY: %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - int ctx_len = data_cnt - run_start; - if (ctx_len > 32) ctx_len = 32; - printf(" ASCII context: %.*s\n", - ctx_len, buf + run_start); - found_this_pid++; - /* Rebuild */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; - } - } - } - } - run = 0; - } - } - - /* Method 3 (deep mode): byte-by-byte scan for top priority patterns */ - if (g_deep_mode && n_unsolved > 0) { - /* Build priority batch: top N unsolved by file_count */ - int prio_idx[DEEP_PRIORITY_MAX]; - int n_prio = 0; - for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) { - int pi = unsolved_idx[i]; - if (patterns[pi].file_count >= 10) - prio_idx[n_prio++] = pi; - } - if (n_prio > 0) { - unsigned char prio_ct[DEEP_PRIORITY_MAX * 16]; - unsigned char prio_pt[DEEP_PRIORITY_MAX * 16]; - for (int i = 0; i < n_prio; i++) - memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16); - - for (mach_msg_type_number_t j = 0; - j + 16 <= data_cnt && !stop_flag; j++) { - if (j % 16 == 0) continue; /* already tested in Method 1 */ - tests++; - size_t moved; - CCCryptorStatus st = CCCrypt( - kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode, - buf + j, 16, NULL, - prio_ct, n_prio * 16, - prio_pt, n_prio * 16, &moved); - if (st != kCCSuccess) continue; - - for (int p = 0; p < n_prio; p++) { - if (!is_image_magic(prio_pt + p*16)) continue; - if (is_rejected(buf + j)) continue; - int idx = prio_idx[p]; - if (patterns[idx].solved) continue; - memcpy(patterns[idx].key, buf + j, 16); - patterns[idx].solved = 1; - - char kh[33]; bytes2hex(buf + j, 16, kh); - char ch[33]; bytes2hex(patterns[idx].ct, 16, ch); - printf("\n *** FOUND KEY (deep): %s ***\n", kh); - printf(" Pattern: %s (%d files)\n", - ch, patterns[idx].file_count); - printf(" PID %d, addr=0x%llx+0x%x (unaligned)\n", - pid, addr, j); - found_this_pid++; - - /* Cross-check against all unsolved */ - for (int q = 0; q < n_unsolved; q++) { - int qi = unsolved_idx[q]; - if (qi == idx || patterns[qi].solved) continue; - unsigned char tpt[16]; - size_t tm; - CCCrypt(kCCDecrypt, kCCAlgorithmAES128, - kCCOptionECBMode, buf + j, 16, NULL, - patterns[qi].ct, 16, tpt, 16, &tm); - if (is_image_magic(tpt)) { - memcpy(patterns[qi].key, buf + j, 16); - patterns[qi].solved = 1; - char qch[33]; - bytes2hex(patterns[qi].ct, 16, qch); - printf(" Also solves: %s (%d files)\n", - qch, patterns[qi].file_count); - } - } - - /* Rebuild main batch */ - n_unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) - unsolved_idx[n_unsolved++] = i; - for (int i = 0; i < n_unsolved; i++) - memcpy(batch_ct + i*16, - patterns[unsolved_idx[i]].ct, 16); - /* Rebuild priority batch */ - n_prio = 0; - for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) { - int pi2 = unsolved_idx[i]; - if (patterns[pi2].file_count >= 10) - prio_idx[n_prio++] = pi2; - } - for (int i = 0; i < n_prio; i++) - memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16); - if (n_unsolved == 0) goto done; - break; - } - } - } - } - - done: - mach_vm_deallocate(mach_task_self(), data, data_cnt); - if (n_unsolved == 0) break; - } - } - addr += rsize; - if (regions % 500 == 0) { - printf(" [%ld regions, %lld MB, %lld tests]\r", - regions, total_bytes/(1024*1024), tests); - fflush(stdout); - } - } - - printf(" PID %d: %ld regions, %lld MB, %lld tests, %ld keys found \n", - pid, regions, total_bytes/(1024*1024), tests, found_this_pid); - - free(batch_ct); - free(batch_pt); - mach_port_deallocate(mach_task_self(), task); - return (int)found_this_pid; -} - -/* ---- Save results ---- */ -static void save_keys(const char *dir) { - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/image_keys.json", dir); - - int solved = 0; - for (int i = 0; i < npatterns; i++) - if (patterns[i].solved) solved++; - if (solved == 0) return; - - FILE *f = fopen(path, "w"); - if (!f) { fprintf(stderr, "Cannot write %s\n", path); return; } - - fprintf(f, "{\n"); - int first = 1; - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved) continue; - char ct_hex[33], key_hex[33]; - bytes2hex(patterns[i].ct, 16, ct_hex); - bytes2hex(patterns[i].key, 16, key_hex); - fprintf(f, "%s \"%s\": \"%s\"", - first ? "" : ",\n", ct_hex, key_hex); - first = 0; - } - fprintf(f, "\n}\n"); - fclose(f); - printf("\nSaved %d keys to %s\n", solved, path); -} - -/* ---- Load existing keys from image_keys.json ---- */ -static int load_keys(const char *dir) { - char path[MAX_PATH]; - snprintf(path, sizeof(path), "%s/image_keys.json", dir); - FILE *f = fopen(path, "r"); - if (!f) return 0; - fseek(f, 0, SEEK_END); - long sz = ftell(f); - if (sz <= 0) { fclose(f); return 0; } - fseek(f, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(f); return 0; } - size_t rd = fread(json, 1, (size_t)sz, f); - if (rd != (size_t)sz) { - free(json); - fclose(f); - return 0; - } - fclose(f); - json[rd] = '\0'; - - int loaded = 0; - /* Parse "ct_hex": "key_hex" pairs */ - const char *p = json; - while ((p = strchr(p, '"')) != NULL) { - p++; - const char *ct_end = strchr(p, '"'); - if (!ct_end || ct_end - p != 32) { p = ct_end ? ct_end + 1 : p; continue; } - char ct_str[33]; memcpy(ct_str, p, 32); ct_str[32] = '\0'; - unsigned char ct[16]; - if (hex2bytes(ct_str, ct, 16) != 16) { p = ct_end + 1; continue; } - - p = ct_end + 1; - p = strchr(p, '"'); - if (!p) break; - p++; - const char *key_end = strchr(p, '"'); - if (!key_end || key_end - p != 32) { p = key_end ? key_end + 1 : p; continue; } - char key_str[33]; memcpy(key_str, p, 32); key_str[32] = '\0'; - unsigned char key[16]; - if (hex2bytes(key_str, key, 16) != 16) { p = key_end + 1; continue; } - - /* Match to pattern */ - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved && memcmp(patterns[i].ct, ct, 16) == 0) { - memcpy(patterns[i].key, key, 16); - patterns[i].solved = 1; - loaded++; - break; - } - } - p = key_end + 1; - } - free(json); - return loaded; -} - -/* ---- Main ---- */ -int main(int argc, char *argv[]) { - signal(SIGINT, sigint_handler); - - printf("=== WeChat V2 Image Key Scanner ===\n\n"); - if (getuid() != 0) { - fprintf(stderr, "ERROR: Run with sudo!\n"); return 1; - } - - /* Determine image directory */ - char image_dir[MAX_PATH] = ""; - char exe_dir[MAX_PATH] = "."; - int deep_mode = 0; - const char *last_slash = strrchr(argv[0], '/'); - if (last_slash) { - int len = (int)(last_slash - argv[0]); - snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]); - } - - for (int i = 1; i < argc; i++) { - if (strcmp(argv[i], "--deep") == 0) - deep_mode = 1; - else if (image_dir[0] == '\0') { - strncpy(image_dir, argv[i], sizeof(image_dir) - 1); - image_dir[sizeof(image_dir) - 1] = '\0'; - } - } - - if (image_dir[0] == '\0') { - /* Read config.json */ - char cfg_path[MAX_PATH]; - snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir); - FILE *cf = fopen(cfg_path, "r"); - if (cf) { - fseek(cf, 0, SEEK_END); - long sz = ftell(cf); - if (sz <= 0) { fclose(cf); return 1; } - fseek(cf, 0, SEEK_SET); - char *json = malloc((size_t)sz + 1); - if (!json) { fclose(cf); return 1; } - size_t rd = fread(json, 1, (size_t)sz, cf); - if (rd != (size_t)sz) { - free(json); - fclose(cf); - return 1; - } - json[rd] = '\0'; - fclose(cf); - char db_dir[MAX_PATH]; - if (json_get_string(json, "db_dir", db_dir, sizeof(db_dir))) { - char *s = strrchr(db_dir, '/'); - if (!s) s = strrchr(db_dir, '\\'); - if (s) { - int plen = (int)(s - db_dir); - snprintf(image_dir, sizeof(image_dir), - "%.*s/msg", plen, db_dir); - } - } - free(json); - } - } - - /* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */ - if (image_dir[0] == '\0') { - const char *home = getenv("HOME"); - if (!home) home = "/Users"; - char base[MAX_PATH]; - snprintf(base, sizeof(base), - "%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files", - home); - DIR *d = opendir(base); - if (d) { - struct dirent *ent; - while ((ent = readdir(d))) { - if (ent->d_name[0] == '.') continue; - char candidate[MAX_PATH]; - snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name); - struct stat st; - if (stat(candidate, &st) == 0 && S_ISDIR(st.st_mode)) { - strncpy(image_dir, candidate, sizeof(image_dir) - 1); - printf("Auto-detected image directory:\n %s\n\n", image_dir); - break; - } - } - closedir(d); - } - } - - if (image_dir[0] == '\0') { - fprintf(stderr, "ERROR: Cannot determine image directory.\n"); - fprintf(stderr, "Tried:\n"); - fprintf(stderr, " 1. Command line argument\n"); - fprintf(stderr, " 2. config.json db_dir\n"); - fprintf(stderr, " 3. Auto-detect ~/Library/Containers/com.tencent.xinWeChat/...\n\n"); - fprintf(stderr, "Usage: sudo %s [--deep] [image_dir]\n", argv[0]); - fprintf(stderr, " image_dir: path to .../xwechat_files//msg\n"); - return 1; - } - - /* Phase 1: Discover patterns */ - printf("Discovering encryption patterns in:\n %s\n\n", image_dir); - discover_dir(image_dir); - if (npatterns == 0) { - fprintf(stderr, "No V2 .dat files found!\n"); return 1; - } - qsort(patterns, npatterns, sizeof(pattern_t), cmp_patterns); - - int total_covered = 0; - printf("Found %d patterns across %d V2 files:\n", npatterns, total_v2_files); - for (int i = 0; i < npatterns; i++) { - char ch[33]; bytes2hex(patterns[i].ct, 16, ch); - printf(" #%-2d %s (%d files)\n", i+1, ch, patterns[i].file_count); - total_covered += patterns[i].file_count; - } - if (total_covered < total_v2_files) - printf(" ... and %d files in overflow patterns\n", - total_v2_files - total_covered); - - /* Load previously found keys */ - int preloaded = load_keys(exe_dir); - if (preloaded > 0) - printf("\nLoaded %d existing keys from image_keys.json\n", preloaded); - - if (deep_mode) { - g_deep_mode = 1; - printf("\n*** DEEP MODE: byte-by-byte scan for top %d unsolved patterns ***\n", - DEEP_PRIORITY_MAX); - } - - /* Phase 2: Continuous scanning */ - printf("\nScanning WeChat memory — keep browsing images! (Ctrl+C to stop)\n"); - int round = 0; - while (!stop_flag) { - int unsolved = 0; - for (int i = 0; i < npatterns; i++) - if (!patterns[i].solved) unsolved++; - if (unsolved == 0) break; - - round++; - pid_t pids[64]; - int npids = get_wechat_pids(pids, 64); - if (npids == 0) { - printf(" No WeChat processes found, waiting...\n"); - sleep(3); - continue; - } - - printf("\n--- Round %d: %d unsolved / %d total, %d PIDs ---\n", - round, unsolved, npatterns, npids); - - int found_round = 0; - for (int i = 0; i < npids && !stop_flag; i++) { - found_round += scan_pid(pids[i]); - } - - unsolved = 0; - int solved_files = 0; - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) solved_files += patterns[i].file_count; - else unsolved++; - } - - if (found_round > 0) { - printf("\n Progress: %d/%d patterns solved (%d/%d files)\n", - npatterns - unsolved, npatterns, - solved_files, total_v2_files); - /* Verify newly found keys */ - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved && !verify_key(i)) { - char kh[33]; bytes2hex(patterns[i].key, 16, kh); - printf(" REJECTED: %s (failed verification)\n", kh); - add_rejected(patterns[i].key); - patterns[i].solved = 0; - memset(patterns[i].key, 0, 16); - } - } - /* Save after each find */ - save_keys(exe_dir); - } - - if (unsolved > 0 && !stop_flag) { - printf(" Keep browsing images in different chats...\n"); - sleep(1); - } - } - - /* Phase 3: Summary */ - save_keys(exe_dir); - - int solved = 0, solved_files = 0; - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) { - solved++; - solved_files += patterns[i].file_count; - } - } - - printf("\n==================================================\n"); - if (solved == npatterns) { - printf("ALL %d patterns solved! (%d files)\n", npatterns, total_v2_files); - } else { - printf("%d/%d patterns solved (%d/%d files)\n", - solved, npatterns, solved_files, total_v2_files); - printf("Unsolved:\n"); - for (int i = 0; i < npatterns; i++) { - if (patterns[i].solved) continue; - char ch[33]; bytes2hex(patterns[i].ct, 16, ch); - printf(" %s (%d files)\n", ch, patterns[i].file_count); - } - } - - /* Count unique keys */ - int unique_keys = 0; - for (int i = 0; i < npatterns; i++) { - if (!patterns[i].solved) continue; - int dup = 0; - for (int j = 0; j < i; j++) - if (patterns[j].solved && - memcmp(patterns[i].key, patterns[j].key, 16) == 0) { dup = 1; break; } - if (!dup) unique_keys++; - } - printf("%d unique key(s) found.\n", unique_keys); - printf("==================================================\n"); - - return (solved > 0) ? 0 : 1; -} diff --git a/find_image_key.py b/find_image_key.py deleted file mode 100644 index afb9ad1..0000000 --- a/find_image_key.py +++ /dev/null @@ -1,410 +0,0 @@ -"""从微信进程内存中提取图片 AES 密钥 (V2 .dat 格式) - -V2 .dat 文件结构: - [6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding] - [aes_size bytes AES-ECB encrypted] [raw_data unencrypted] [xor_size bytes XOR encrypted] - -AES key: 16-byte ASCII string found in Weixin.exe process memory -XOR key: single byte, same as old format (derived from JPEG FF D9 ending) - -Usage: - 1. 打开微信, 进入聊天/朋友圈, 点击查看 2-3 张图片 - 2. 立即运行: python find_image_key.py -""" -import os -import sys -import re -import struct -import glob -import json -import time -import ctypes -from ctypes import wintypes -from Crypto.Cipher import AES -from Crypto.Util import Padding - -# Windows API constants -PROCESS_ALL_ACCESS = 0x1F0FFF -PROCESS_VM_READ = 0x0010 -PROCESS_QUERY_INFORMATION = 0x0400 -MEM_COMMIT = 0x1000 -PAGE_NOACCESS = 0x01 -PAGE_GUARD = 0x100 -PAGE_READWRITE = 0x04 -PAGE_WRITECOPY = 0x08 -PAGE_EXECUTE_READWRITE = 0x40 -PAGE_EXECUTE_WRITECOPY = 0x80 - -class MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", wintypes.DWORD), - ("RegionSize", ctypes.c_size_t), - ("State", wintypes.DWORD), - ("Protect", wintypes.DWORD), - ("Type", wintypes.DWORD), - ] - -kernel32 = ctypes.windll.kernel32 - -# 正则: 精确 32 字符 alphanum (前后是非 alphanum 或边界) -RE_KEY32 = re.compile(rb'(?= 2: - pids.append(int(parts[1])) - return pids - - -def find_v2_ciphertext(attach_dir): - """从多个 V2 .dat 文件中提取第一个 AES 密文块 (16 bytes)""" - v2_magic = b'\x07\x08V2\x08\x07' - - # Search _t.dat (thumbnails, likely JPEG) - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:100]: - try: - with open(f, 'rb') as fp: - header = fp.read(31) - if header[:6] == v2_magic and len(header) >= 31: - return header[15:31], os.path.basename(f) - except: - continue - return None, None - - -def find_xor_key(attach_dir): - """从缩略图文件末尾推导 XOR key (JPEG 结尾 FF D9)""" - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - tail_counts = {} - for f in dat_files[:32]: - try: - sz = os.path.getsize(f) - with open(f, 'rb') as fp: - head = fp.read(6) - fp.seek(sz - 2) - tail = fp.read(2) - if head == v2_magic and len(tail) == 2: - key = (tail[0], tail[1]) - tail_counts[key] = tail_counts.get(key, 0) + 1 - except: - continue - - if not tail_counts: - return None - - most_common = max(tail_counts, key=tail_counts.get) - x, y = most_common - xor_key = x ^ 0xFF - check = y ^ 0xD9 - - if xor_key == check: - return xor_key - return xor_key # return best guess anyway - - -def try_key(key_bytes, ciphertext): - """Try decrypting ciphertext with key, return format name if successful""" - try: - cipher = AES.new(key_bytes, AES.MODE_ECB) - dec = cipher.decrypt(ciphertext) - if dec[:3] == b'\xFF\xD8\xFF': - return 'JPEG' - if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): - return 'PNG' - if dec[:4] == b'RIFF': - return 'WEBP' - if dec[:4] == b'wxgf': - return 'WXGF' - if dec[:3] == b'GIF': - return 'GIF' - except: - pass - return None - - -def is_rw_protect(protect): - """Check if memory region is readable/writable (where string keys live)""" - rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY | - PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY) - return (protect & rw_flags) != 0 - - -def scan_memory_for_aes_key(pid, ciphertext): - """扫描微信进程内存寻找 AES key (regex 加速版)""" - access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION - h_process = kernel32.OpenProcess(access, False, pid) - if not h_process: - print(f" 无法打开进程 {pid} (尝试以管理员运行)", flush=True) - return None - - try: - # Enumerate memory regions - address = 0 - mbi = MEMORY_BASIC_INFORMATION() - rw_regions = [] - all_regions = [] - - while address < 0x7FFFFFFFFFFF: - result = kernel32.VirtualQueryEx( - h_process, ctypes.c_void_p(address), - ctypes.byref(mbi), ctypes.sizeof(mbi) - ) - if result == 0: - break - if (mbi.State == MEM_COMMIT and - mbi.Protect != PAGE_NOACCESS and - (mbi.Protect & PAGE_GUARD) == 0 and - mbi.RegionSize <= 50 * 1024 * 1024): - region = (mbi.BaseAddress, mbi.RegionSize, mbi.Protect) - all_regions.append(region) - if is_rw_protect(mbi.Protect): - rw_regions.append(region) - next_addr = address + mbi.RegionSize - if next_addr <= address: - break - address = next_addr - - rw_mb = sum(r[1] for r in rw_regions) / 1024 / 1024 - all_mb = sum(r[1] for r in all_regions) / 1024 / 1024 - print(f" RW 区域: {len(rw_regions)} ({rw_mb:.0f} MB), 总计: {len(all_regions)} ({all_mb:.0f} MB)", flush=True) - - # Phase 1: 只扫描 RW 区域 (key 字符串最可能在这里) - print(" === Phase 1: 扫描 RW 内存 ===", flush=True) - result = _scan_regions(h_process, rw_regions, ciphertext) - if result: - return result - - # Phase 2: 扫描所有可读区域 - print(" === Phase 2: 扫描所有内存 ===", flush=True) - # 排除已扫描的 RW 区域 - rw_set = set((r[0], r[1]) for r in rw_regions) - other_regions = [r for r in all_regions if (r[0], r[1]) not in rw_set] - result = _scan_regions(h_process, other_regions, ciphertext) - if result: - return result - - return None - - finally: - kernel32.CloseHandle(h_process) - - -def _scan_regions(h_process, regions, ciphertext): - """扫描指定内存区域列表,返回找到的 key 或 None""" - candidates_32 = 0 - candidates_16 = 0 - t0 = time.time() - - for idx, (base_addr, region_size, _protect) in enumerate(regions): - if idx % 100 == 0: - elapsed = time.time() - t0 - print(f" 扫描 {idx}/{len(regions)} ({elapsed:.1f}s)", end='\r', flush=True) - - buffer = ctypes.create_string_buffer(region_size) - bytes_read = ctypes.c_size_t(0) - ok = kernel32.ReadProcessMemory( - h_process, ctypes.c_void_p(base_addr), - buffer, region_size, ctypes.byref(bytes_read) - ) - if not ok or bytes_read.value < 32: - continue - - data = buffer.raw[:bytes_read.value] - - # 用正则找 32 字符 alphanum (C 级速度) - for m in RE_KEY32.finditer(data): - key_bytes = m.group() - candidates_32 += 1 - - # 前 16 字符作为 AES-128 key - fmt = try_key(key_bytes[:16], ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (32-char)! → {fmt} ***", flush=True) - print(f" 完整: {key_str}", flush=True) - print(f" AES key: {key_str[:16]}", flush=True) - return key_str[:16] - - # 也试完整 32 字节作 AES-256 - fmt = try_key(key_bytes, ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (32-byte)! → {fmt} ***", flush=True) - print(f" 完整: {key_str}", flush=True) - return key_str - - # 也找独立的 16 字符 alphanum - for m in RE_KEY16.finditer(data): - key_bytes = m.group() - candidates_16 += 1 - - fmt = try_key(key_bytes, ciphertext) - if fmt: - key_str = key_bytes.decode('ascii') - print(f"\n*** 找到 AES key (16-char)! → {fmt} ***", flush=True) - print(f" AES key: {key_str}", flush=True) - return key_str - - elapsed = time.time() - t0 - print(f"\n 测试: {candidates_32} x 32-char + {candidates_16} x 16-char ({elapsed:.1f}s)", flush=True) - return None - - -def verify_and_decrypt(attach_dir, aes_key_str, xor_key): - """完整解密一个 V2 文件作为验证""" - v2_magic = b'\x07\x08V2\x08\x07' - key = aes_key_str.encode('ascii')[:16] - - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:10]: - try: - with open(f, 'rb') as fp: - data = fp.read() - if data[:6] != v2_magic: - continue - - sig, aes_size, xor_size = struct.unpack_from('<6sLL', data) - - # AES 对齐: 向上取整到 16 的倍数 (PKCS7 填充) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) - - offset = 15 - aes_data = data[offset:offset + aligned_aes_size] - cipher = AES.new(key, AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - offset += aligned_aes_size - - # Raw portion - raw_data = data[offset:len(data) - xor_size] - offset += len(raw_data) - - # XOR portion - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data - - result = dec_aes + raw_data + dec_xor - - fmt = "unknown" - ext = ".bin" - if result[:3] == b'\xFF\xD8\xFF': - fmt, ext = "JPEG", ".jpg" - elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): - fmt, ext = "PNG", ".png" - elif result[:4] == b'RIFF': - fmt, ext = "WEBP", ".webp" - elif result[:4] == b'wxgf': - fmt, ext = "WXGF", ".hevc" - - print(f" {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True) - - if fmt != "unknown": - out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images") - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext) - with open(out_path, 'wb') as fp: - fp.write(result) - print(f" saved: {out_path}", flush=True) - return True - except Exception as e: - continue - return False - - -def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) - - db_dir = config['db_dir'] - base_dir = os.path.dirname(db_dir) - attach_dir = os.path.join(base_dir, 'msg', 'attach') - - # 1. XOR key - print("=== XOR Key ===", flush=True) - xor_key = find_xor_key(attach_dir) - if xor_key is not None: - print(f"XOR key: 0x{xor_key:02x}", flush=True) - - # 2. V2 ciphertext - print("\n=== V2 ciphertext ===", flush=True) - ciphertext, ct_file = find_v2_ciphertext(attach_dir) - if ciphertext is None: - print("No V2 .dat files found") - return - print(f"File: {ct_file}", flush=True) - print(f"Cipher: {ciphertext.hex()}", flush=True) - - # 3. Check if already have key in config - if config.get('image_aes_key'): - print(f"\nExisting image_aes_key: {config['image_aes_key']}", flush=True) - fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext) - if fmt: - print(f"Key valid! -> {fmt}", flush=True) - print("\n=== Verify decrypt ===", flush=True) - verify_and_decrypt(attach_dir, config['image_aes_key'], xor_key) - return - else: - print("Saved key invalid, re-scanning...", flush=True) - - # 4. Scan memory - print("\n=== Scanning WeChat process memory ===", flush=True) - pids = get_wechat_pids() - if not pids: - print("WeChat not running!") - return - print(f"PIDs: {pids}", flush=True) - print("Tip: View 2-3 images in WeChat first, then run this script immediately\n", flush=True) - - aes_key = None - for pid in pids: - print(f"Scanning PID {pid}...", flush=True) - aes_key = scan_memory_for_aes_key(pid, ciphertext) - if aes_key: - break - - if aes_key: - print(f"\n=== Result ===", flush=True) - print(f"AES key: {aes_key}", flush=True) - print(f"XOR key: 0x{xor_key:02x}" if xor_key is not None else "XOR key: unknown", flush=True) - - config['image_aes_key'] = aes_key - if xor_key is not None: - config['image_xor_key'] = xor_key - with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - print(f"Saved to {config_path}", flush=True) - - print("\n=== Verify decrypt ===", flush=True) - verify_and_decrypt(attach_dir, aes_key, xor_key) - else: - print("\nAES key not found!", flush=True) - print("Steps:", flush=True) - print(" 1. Login WeChat and keep it running", flush=True) - print(" 2. Open Moments or a chat, view 2-3 images (tap to open full size)", flush=True) - print(" 3. Immediately re-run this script", flush=True) - - -if __name__ == '__main__': - main() diff --git a/find_image_key_monitor.py b/find_image_key_monitor.py deleted file mode 100644 index 437a47f..0000000 --- a/find_image_key_monitor.py +++ /dev/null @@ -1,318 +0,0 @@ -"""持续监控微信进程内存,捕获图片 AES 密钥 - -运行此脚本后,在微信中打开查看几张图片。 -脚本会自动检测到 key 并保存到 config.json。 - -按 Ctrl+C 退出。 -""" -import os -import sys -import re -import struct -import glob -import json -import time -import ctypes -from ctypes import wintypes -from Crypto.Cipher import AES -from Crypto.Util import Padding - -# Windows API constants -PROCESS_VM_READ = 0x0010 -PROCESS_QUERY_INFORMATION = 0x0400 -MEM_COMMIT = 0x1000 -PAGE_NOACCESS = 0x01 -PAGE_GUARD = 0x100 -PAGE_READWRITE = 0x04 -PAGE_WRITECOPY = 0x08 -PAGE_EXECUTE_READWRITE = 0x40 -PAGE_EXECUTE_WRITECOPY = 0x80 - -class MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", wintypes.DWORD), - ("RegionSize", ctypes.c_size_t), - ("State", wintypes.DWORD), - ("Protect", wintypes.DWORD), - ("Type", wintypes.DWORD), - ] - -kernel32 = ctypes.windll.kernel32 - -# Regex for key patterns -RE_KEY32 = re.compile(rb'(?= 2: - pids.append(int(parts[1])) - return pids - - -def find_v2_ciphertext(attach_dir): - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - for f in dat_files[:100]: - try: - with open(f, 'rb') as fp: - header = fp.read(31) - if header[:6] == v2_magic and len(header) >= 31: - return header[15:31], os.path.basename(f) - except: - continue - return None, None - - -def find_xor_key(attach_dir): - v2_magic = b'\x07\x08V2\x08\x07' - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - tail_counts = {} - for f in dat_files[:32]: - try: - sz = os.path.getsize(f) - with open(f, 'rb') as fp: - head = fp.read(6) - fp.seek(sz - 2) - tail = fp.read(2) - if head == v2_magic and len(tail) == 2: - key = (tail[0], tail[1]) - tail_counts[key] = tail_counts.get(key, 0) + 1 - except: - continue - if not tail_counts: - return None - most_common = max(tail_counts, key=tail_counts.get) - return most_common[0] ^ 0xFF - - -def try_key(key_bytes, ciphertext): - try: - cipher = AES.new(key_bytes, AES.MODE_ECB) - dec = cipher.decrypt(ciphertext) - if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG' - if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): return 'PNG' - if dec[:4] == b'RIFF': return 'WEBP' - if dec[:4] == b'wxgf': return 'WXGF' - if dec[:3] == b'GIF': return 'GIF' - except: - pass - return None - - -def is_rw_protect(protect): - rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY | - PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY) - return (protect & rw_flags) != 0 - - -def get_rw_regions(h_process): - """Get RW committed memory regions""" - address = 0 - mbi = MEMORY_BASIC_INFORMATION() - regions = [] - while address < 0x7FFFFFFFFFFF: - result = kernel32.VirtualQueryEx( - h_process, ctypes.c_void_p(address), - ctypes.byref(mbi), ctypes.sizeof(mbi) - ) - if result == 0: - break - if (mbi.State == MEM_COMMIT and - mbi.Protect != PAGE_NOACCESS and - (mbi.Protect & PAGE_GUARD) == 0 and - mbi.RegionSize <= 50 * 1024 * 1024 and - is_rw_protect(mbi.Protect)): - regions.append((mbi.BaseAddress, mbi.RegionSize)) - next_addr = address + mbi.RegionSize - if next_addr <= address: - break - address = next_addr - return regions - - -def quick_scan(h_process, regions, ciphertext): - """Fast scan of RW regions, return key or None""" - for base_addr, region_size in regions: - buffer = ctypes.create_string_buffer(region_size) - bytes_read = ctypes.c_size_t(0) - ok = kernel32.ReadProcessMemory( - h_process, ctypes.c_void_p(base_addr), - buffer, region_size, ctypes.byref(bytes_read) - ) - if not ok or bytes_read.value < 32: - continue - - data = buffer.raw[:bytes_read.value] - - # 32-char keys (first 16 as AES-128) - for m in RE_KEY32.finditer(data): - key_bytes = m.group() - fmt = try_key(key_bytes[:16], ciphertext) - if fmt: - return key_bytes.decode('ascii')[:16], fmt - fmt = try_key(key_bytes, ciphertext) - if fmt: - return key_bytes.decode('ascii'), fmt - - # Standalone 16-char keys - for m in RE_KEY16.finditer(data): - key_bytes = m.group() - fmt = try_key(key_bytes, ciphertext) - if fmt: - return key_bytes.decode('ascii'), fmt - - return None, None - - -def verify_and_decrypt(attach_dir, aes_key_str, xor_key): - """Decrypt one V2 file as verification""" - v2_magic = b'\x07\x08V2\x08\x07' - key = aes_key_str.encode('ascii')[:16] - pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat") - dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True) - - for f in dat_files[:10]: - try: - with open(f, 'rb') as fp: - data = fp.read() - if data[:6] != v2_magic: - continue - sig, aes_size, xor_size = struct.unpack_from('<6sLL', data) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) - offset = 15 - aes_data = data[offset:offset + aligned_aes_size] - cipher = AES.new(key, AES.MODE_ECB) - dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) - offset += aligned_aes_size - raw_data = data[offset:len(data) - xor_size] - offset += len(raw_data) - xor_data = data[offset:] - dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data - result = dec_aes + raw_data + dec_xor - - fmt, ext = "unknown", ".bin" - if result[:3] == b'\xFF\xD8\xFF': fmt, ext = "JPEG", ".jpg" - elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): fmt, ext = "PNG", ".png" - elif result[:4] == b'RIFF': fmt, ext = "WEBP", ".webp" - elif result[:4] == b'wxgf': fmt, ext = "WXGF", ".hevc" - - if fmt != "unknown": - out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images") - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext) - with open(out_path, 'wb') as fp: - fp.write(result) - print(f" Verified: {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True) - print(f" Saved: {out_path}", flush=True) - return True - except: - continue - return False - - -def main(): - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') - with open(config_path, encoding="utf-8") as f: - config = json.load(f) - - db_dir = config['db_dir'] - base_dir = os.path.dirname(db_dir) - attach_dir = os.path.join(base_dir, 'msg', 'attach') - - xor_key = find_xor_key(attach_dir) - print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True) - - ciphertext, ct_file = find_v2_ciphertext(attach_dir) - if ciphertext is None: - print("No V2 .dat files found") - return - print(f"V2 cipher: {ciphertext.hex()} ({ct_file})", flush=True) - - # Check existing key - if config.get('image_aes_key'): - fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext) - if fmt: - print(f"Existing key valid: {config['image_aes_key']} -> {fmt}", flush=True) - return - - pids = get_wechat_pids() - if not pids: - print("WeChat not running!") - return - - # Find the main PID (largest memory footprint) - main_pid = pids[0] - print(f"\nMonitoring PID {main_pid} (main WeChat process)", flush=True) - print("=" * 60, flush=True) - print("NOW: Open WeChat and tap to view 2-3 images (full size)", flush=True) - print("The script will automatically detect the key...", flush=True) - print("=" * 60, flush=True) - - access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION - h_process = kernel32.OpenProcess(access, False, main_pid) - if not h_process: - print(f"Cannot open process {main_pid} (run as admin?)", flush=True) - return - - try: - # Get regions once (they don't change much) - regions = get_rw_regions(h_process) - total_mb = sum(r[1] for r in regions) / 1024 / 1024 - print(f"RW regions: {len(regions)} ({total_mb:.0f} MB)", flush=True) - - scan_count = 0 - while True: - scan_count += 1 - t0 = time.time() - aes_key, fmt = quick_scan(h_process, regions, ciphertext) - elapsed = time.time() - t0 - - if aes_key: - print(f"\n{'='*60}", flush=True) - print(f"*** FOUND AES key! -> {fmt} ***", flush=True) - print(f"AES key: {aes_key}", flush=True) - print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True) - print(f"{'='*60}", flush=True) - - config['image_aes_key'] = aes_key - if xor_key is not None: - config['image_xor_key'] = xor_key - with open(config_path, 'w', encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - print(f"Saved to {config_path}", flush=True) - - verify_and_decrypt(attach_dir, aes_key, xor_key) - return - - print(f" Scan #{scan_count}: no key found ({elapsed:.1f}s)", end='\r', flush=True) - - # Wait 5 seconds before next scan - time.sleep(5) - - # Refresh regions periodically (every 5 scans) - if scan_count % 5 == 0: - regions = get_rw_regions(h_process) - - except KeyboardInterrupt: - print("\nStopped by user", flush=True) - finally: - kernel32.CloseHandle(h_process) - - -if __name__ == '__main__': - main() diff --git a/key_scan_common.py b/key_scan_common.py deleted file mode 100644 index 7975328..0000000 --- a/key_scan_common.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -跨平台共享的内存扫描逻辑:HMAC 验证、DB 收集、hex 模式匹配与结果输出。 - -Windows / Linux 版分别实现进程发现和内存读取,共用此模块的核心算法。 -""" -import hashlib -import hmac as hmac_mod -import json -import os -import re -import struct -import time - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 - - -def verify_enc_key(enc_key, db_page1): - """通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。""" - salt = db_page1[:SALT_SZ] - mac_salt = bytes(b ^ 0x3A for b in salt) - mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - hmac_data = db_page1[SALT_SZ: PAGE_SZ - 80 + 16] - stored_hmac = db_page1[PAGE_SZ - 64: PAGE_SZ] - hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512) - hm.update(struct.pack(" 96 and hex_len % 2 == 0: - enc_key_hex = hex_str[:64] - salt_hex = hex_str[-32:] - if salt_hex in remaining_salts: - enc_key = bytes.fromhex(enc_key_hex) - for rel, path, sz, s, page1 in db_files: - if s == salt_hex and verify_enc_key(enc_key, page1): - 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} (long hex {hex_len})") - print_fn(f" enc_key={enc_key_hex}") - print_fn(f" PID={pid} 地址: 0x{addr:016X}") - print_fn(f" 数据库: {', '.join(dbs)}") - break - - return matches - - -def cross_verify_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) - if verify_enc_key(enc_key, page1): - 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_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}") diff --git a/latency_test.py b/latency_test.py deleted file mode 100644 index 62a670e..0000000 --- a/latency_test.py +++ /dev/null @@ -1,175 +0,0 @@ -"""测量消息延迟 - 用mtime检测WAL变化(WAL文件是预分配固定大小的)""" -import time, os, sys, io, hashlib, struct, sqlite3, json -from datetime import datetime -from Crypto.Cipher import AES - -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -PAGE_SZ = 4096; KEY_SZ = 32; SALT_SZ = 16; RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32; WAL_FRAME_HEADER_SZ = 24 - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -DECRYPTED = os.path.join(_cfg["decrypted_dir"], "session", "session.db") - -with open(KEYS_FILE, encoding="utf-8") as f: - keys = json.load(f) -enc_key = bytes.fromhex(keys["session/session.db"]["enc_key"]) - -session_db = os.path.join(DB_DIR, "session", "session.db") -wal_path = session_db + "-wal" - - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(src, dst): - t0 = time.perf_counter() - total = os.path.getsize(src) // PAGE_SZ - with open(src, 'rb') as fin, open(dst, 'wb') as fout: - for pgno in range(1, total + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: break - fout.write(decrypt_page(enc_key, page, pgno)) - return total, (time.perf_counter() - t0) * 1000 - - -def decrypt_wal_full(wal_path, dst): - """解密WAL当前有效frame,patch到dst (校验salt跳过旧周期遗留frame)""" - t0 = time.perf_counter() - wal_sz = os.path.getsize(wal_path) - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ - patched = 0 - - with open(wal_path, 'rb') as wf, open(dst, 'r+b') as df: - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - - while wf.tell() + frame_size <= wal_sz: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: break - if pgno == 0 or pgno > 1000000: continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: continue - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - - return patched, (time.perf_counter() - t0) * 1000 - - -# 初始化: 全量解密 -print("初始全量解密...", flush=True) -pages, ms = full_decrypt(session_db, DECRYPTED) -print(f" DB: {pages}页 {ms:.0f}ms", flush=True) -if os.path.exists(wal_path): - patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED) - print(f" WAL: {patched}页 {ms2:.0f}ms", flush=True) - -# 获取初始状态 -conn = sqlite3.connect(DECRYPTED) -prev_sessions = {} -for r in conn.execute("SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp>0"): - prev_sessions[r[0]] = r[1] -conn.close() - -# 记录初始mtime -prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 -prev_db_mtime = os.path.getmtime(session_db) -wal_sz = os.path.getsize(wal_path) if os.path.exists(wal_path) else 0 - -print(f"\nWAL大小: {wal_sz} bytes (固定预分配)", flush=True) -print(f"跟踪 {len(prev_sessions)} 个会话", flush=True) -print(f"\n等待微信新消息... (60秒超时, 30ms轮询)\n", flush=True) - -start = time.time() - -while time.time() - start < 60: - time.sleep(0.03) - - # 用mtime检测变化 - try: - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - db_mtime = os.path.getmtime(session_db) - except: - continue - - if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime: - continue - - t_detect = time.perf_counter() - detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] - - wal_changed = wal_mtime != prev_wal_mtime - db_changed = db_mtime != prev_db_mtime - print(f"[{detect_str}] 变化检测: WAL={'变' if wal_changed else '不变'} DB={'变' if db_changed else '不变'}", flush=True) - - # 如果DB变了(checkpoint), 全量重解密 - if db_changed and not wal_changed: - pages, ms = full_decrypt(session_db, DECRYPTED) - print(f" 全量解密: {pages}页 {ms:.0f}ms", flush=True) - else: - # WAL变了, 重新patch所有WAL frame (因为不知道哪些是新的) - # 先全量解密DB基础 - pages, ms = full_decrypt(session_db, DECRYPTED) - patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED) - print(f" DB {pages}页/{ms:.0f}ms + WAL {patched}页/{ms2:.0f}ms", flush=True) - - t_decrypt = time.perf_counter() - - # 查询变化 - conn = sqlite3.connect(DECRYPTED) - new_msgs = [] - for r in conn.execute(""" - SELECT username, last_timestamp, summary, last_sender_display_name - FROM SessionTable WHERE last_timestamp > 0 - """): - uname, ts, summary, sender = r - if ts > prev_sessions.get(uname, 0): - delay = time.time() - ts - new_msgs.append((uname, ts, summary or '', sender or '', delay)) - prev_sessions[uname] = ts - conn.close() - - t_query = time.perf_counter() - - decrypt_ms = (t_decrypt - t_detect) * 1000 - query_ms = (t_query - t_decrypt) * 1000 - total_ms = (t_query - t_detect) * 1000 - - print(f" 处理总耗时: {total_ms:.1f}ms (解密{decrypt_ms:.1f}ms + 查询{query_ms:.1f}ms)", flush=True) - - for uname, ts, summary, sender, delay in sorted(new_msgs, key=lambda x: x[1]): - if ':\n' in summary: - summary = summary.split(':\n', 1)[1] - msg_time = datetime.fromtimestamp(ts).strftime('%H:%M:%S') - print(f" >>> 消息时间={msg_time} | 微信→DB延迟={delay:.1f}s | {sender}: {summary}", flush=True) - - if not new_msgs: - print(f" (无新消息变化)", flush=True) - - prev_wal_mtime = wal_mtime - prev_db_mtime = db_mtime - print(flush=True) - -print("超时退出", flush=True) diff --git a/main.py b/main.py deleted file mode 100644 index 885f975..0000000 --- a/main.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -WeChat Decrypt 一键启动 - -python main.py # 提取密钥 + 启动 Web UI -python main.py decrypt # 提取密钥 + 解密全部数据库 -""" -import json -import os -import sys - -import functools -print = functools.partial(print, flush=True) - -from key_utils import strip_key_metadata - - -def check_wechat_running(): - """检查微信是否在运行,返回 True/False""" - from find_all_keys import get_pids - try: - get_pids() - return True - except RuntimeError: - return False - - -def ensure_keys(keys_file, db_dir): - """确保密钥文件存在且匹配当前 db_dir,否则重新提取""" - if os.path.exists(keys_file): - try: - with open(keys_file, encoding="utf-8") as f: - keys = json.load(f) - except (json.JSONDecodeError, ValueError): - keys = {} - # 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥) - saved_dir = keys.pop("_db_dir", None) - if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)): - print(f"[!] 密钥文件对应的目录已变更,需要重新提取") - print(f" 旧: {saved_dir}") - print(f" 新: {db_dir}") - keys = {} - keys = strip_key_metadata(keys) - if keys: - print(f"[+] 已有 {len(keys)} 个数据库密钥") - return - - print("[*] 密钥文件不存在,正在从微信进程提取...") - print() - from find_all_keys import main as extract_keys - try: - extract_keys() - except RuntimeError as e: - print(f"\n[!] 密钥提取失败: {e}") - sys.exit(1) - print() - - # 提取后再次检查 - if not os.path.exists(keys_file): - print("[!] 密钥提取失败") - sys.exit(1) - try: - with open(keys_file, encoding="utf-8") as f: - keys = json.load(f) - except (json.JSONDecodeError, ValueError): - keys = {} - if not strip_key_metadata(keys): - print("[!] 未能提取到任何密钥") - print(" 可能原因:选择了错误的微信数据目录,或微信需要重启") - print(" 请检查 config.json 中的 db_dir 是否与当前登录的微信账号匹配") - sys.exit(1) - - -def main(): - print("=" * 60) - print(" WeChat Decrypt") - print("=" * 60) - print() - - # 1. 加载配置(自动检测 db_dir) - from config import load_config - cfg = load_config() - - # 2. 检查微信进程 - if not check_wechat_running(): - print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})") - print(" 请先启动微信并登录,然后重新运行") - sys.exit(1) - print("[+] 微信进程运行中") - - # 3. 提取密钥 - ensure_keys(cfg["keys_file"], cfg["db_dir"]) - - # 4. 根据子命令执行 - cmd = sys.argv[1] if len(sys.argv) > 1 else "web" - - if cmd == "decrypt": - print("[*] 开始解密全部数据库...") - print() - from decrypt_db import main as decrypt_all - decrypt_all() - elif cmd == "web": - print("[*] 启动 Web UI...") - print() - from monitor_web import main as start_web - start_web() - else: - print(f"[!] 未知命令: {cmd}") - print() - print("用法:") - print(" python main.py 启动实时消息监听 (Web UI)") - print(" python main.py decrypt 解密全部数据库到 decrypted/") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/mcp_server.py b/mcp_server.py deleted file mode 100644 index 5c5101d..0000000 --- a/mcp_server.py +++ /dev/null @@ -1,1738 +0,0 @@ -r""" -WeChat MCP Server - query WeChat messages, contacts via Claude - -Based on FastMCP (stdio transport), reuses existing decryption. -Runs on Windows Python (needs access to D:\ WeChat databases). -""" - -import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re -import hmac as hmac_mod -from contextlib import closing -from datetime import datetime -import xml.etree.ElementTree as ET -from Crypto.Cipher import AES -from mcp.server.fastmcp import FastMCP -import zstandard as zstd -from decode_image import ImageResolver -from key_utils import get_key_info, key_path_variants, strip_key_metadata - -# ============ 加密常量 ============ -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32 -WAL_FRAME_HEADER_SZ = 24 - -# ============ 配置加载 ============ -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json") - -with open(CONFIG_FILE, encoding="utf-8") as f: - _cfg = json.load(f) -for _key in ("keys_file", "decrypted_dir"): - if _key in _cfg and not os.path.isabs(_cfg[_key]): - _cfg[_key] = os.path.join(SCRIPT_DIR, _cfg[_key]) - -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -DECRYPTED_DIR = _cfg["decrypted_dir"] - -# 图片相关路径 -_db_dir = _cfg["db_dir"] -if os.path.basename(_db_dir) == "db_storage": - WECHAT_BASE_DIR = os.path.dirname(_db_dir) -else: - WECHAT_BASE_DIR = _db_dir - -DECODED_IMAGE_DIR = _cfg.get("decoded_image_dir") -if not DECODED_IMAGE_DIR: - DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, "decoded_images") -elif not os.path.isabs(DECODED_IMAGE_DIR): - DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, DECODED_IMAGE_DIR) - -with open(KEYS_FILE, encoding="utf-8") as f: - ALL_KEYS = strip_key_metadata(json.load(f)) - -# ============ 解密函数 ============ - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytes(bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)) - else: - encrypted = page_data[: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(db_path, out_path, enc_key): - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout: - for pgno in range(1, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - fout.write(decrypt_page(enc_key, page, pgno)) - return total_pages - - -def decrypt_wal(wal_path, out_path, enc_key): - if not os.path.exists(wal_path): - return 0 - wal_size = os.path.getsize(wal_path) - if wal_size <= WAL_HEADER_SZ: - return 0 - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ - patched = 0 - with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df: - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - while wf.tell() + frame_size <= wal_size: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: - break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: - break - if pgno == 0 or pgno > 1000000: - continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: - continue - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - return patched - - -# ============ DB 缓存 ============ - -class DBCache: - """缓存解密后的 DB,通过 mtime 检测变化。使用固定文件名,重启后可复用。""" - - CACHE_DIR = os.path.join(tempfile.gettempdir(), "wechat_mcp_cache") - MTIME_FILE = os.path.join(tempfile.gettempdir(), "wechat_mcp_cache", "_mtimes.json") - - def __init__(self): - self._cache = {} # rel_key -> (db_mtime, wal_mtime, tmp_path) - os.makedirs(self.CACHE_DIR, exist_ok=True) - self._load_persistent_cache() - - def _cache_path(self, rel_key): - """rel_key -> 固定的缓存文件路径""" - h = hashlib.md5(rel_key.encode()).hexdigest()[:12] - return os.path.join(self.CACHE_DIR, f"{h}.db") - - def _load_persistent_cache(self): - """启动时从磁盘恢复缓存映射,验证 mtime 后复用""" - if not os.path.exists(self.MTIME_FILE): - return - try: - with open(self.MTIME_FILE, encoding="utf-8") as f: - saved = json.load(f) - except (json.JSONDecodeError, OSError): - return - reused = 0 - for rel_key, info in saved.items(): - tmp_path = info["path"] - if not os.path.exists(tmp_path): - continue - rel_path = rel_key.replace('\\', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - continue - if db_mtime == info["db_mt"] and wal_mtime == info["wal_mt"]: - self._cache[rel_key] = (db_mtime, wal_mtime, tmp_path) - reused += 1 - if reused: - print(f"[DBCache] reused {reused} cached decrypted DBs from previous run", flush=True) - - def _save_persistent_cache(self): - """持久化缓存映射到磁盘""" - data = {} - for rel_key, (db_mt, wal_mt, path) in self._cache.items(): - data[rel_key] = {"db_mt": db_mt, "wal_mt": wal_mt, "path": path} - try: - with open(self.MTIME_FILE, 'w', encoding="utf-8") as f: - json.dump(data, f) - except OSError: - pass - - def get(self, rel_key): - key_info = get_key_info(ALL_KEYS, rel_key) - if not key_info: - return None - rel_path = rel_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - if not os.path.exists(db_path): - return None - - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - return None - - if rel_key in self._cache: - c_db_mt, c_wal_mt, c_path = self._cache[rel_key] - if c_db_mt == db_mtime and c_wal_mt == wal_mtime and os.path.exists(c_path): - return c_path - - tmp_path = self._cache_path(rel_key) - enc_key = bytes.fromhex(key_info["enc_key"]) - full_decrypt(db_path, tmp_path, enc_key) - if os.path.exists(wal_path): - decrypt_wal(wal_path, tmp_path, enc_key) - self._cache[rel_key] = (db_mtime, wal_mtime, tmp_path) - self._save_persistent_cache() - return tmp_path - - def cleanup(self): - """正常退出时保存缓存映射(不删文件,下次启动可复用)""" - self._save_persistent_cache() - - -_cache = DBCache() -atexit.register(_cache.cleanup) - - -# ============ 联系人缓存 ============ - -_contact_names = None # {username: display_name} -_contact_full = None # [{username, nick_name, remark}] -_contact_tags = None # {label_id: {name, sort_order, members: [{username, display_name}]}} -_self_username = None -_XML_UNSAFE_RE = re.compile(r'> 3 - wire_type = tag & 0x07 - if wire_type == 0: # varint - while pos < n and data[pos] & 0x80: - pos += 1 - pos += 1 - elif wire_type == 2: # length-delimited - length = 0; shift = 0 - while pos < n: - b = data[pos]; pos += 1 - length |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - if field_num == 30: - try: - return data[pos:pos + length].decode('utf-8') - except Exception: - return None - pos += length - elif wire_type == 1: # 64-bit - pos += 8 - elif wire_type == 5: # 32-bit - pos += 4 - else: - break - return None - - -def _load_contact_tags(): - """加载并缓存联系人标签数据""" - global _contact_tags - if _contact_tags is not None: - return _contact_tags - - db_path = _get_contact_db_path() - if not db_path: - return {} - - try: - conn = sqlite3.connect(db_path) - except Exception: - return {} - - try: - # 1. 加载标签定义 - try: - label_rows = conn.execute( - "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" - ).fetchall() - except sqlite3.OperationalError: - return {} - if not label_rows: - return {} - - labels = {} - for lid, lname, sort_order in label_rows: - labels[lid] = {'name': lname, 'sort_order': sort_order, 'members': []} - - # 2. 扫描联系人的标签关联 - names = get_contact_names() - rows = conn.execute( - "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" - ).fetchall() - - for username, buf in rows: - label_str = _extract_pb_field_30(buf) - if not label_str: - continue - display = names.get(username, username) - for lid_s in label_str.split(','): - try: - lid = int(lid_s.strip()) - except (ValueError, AttributeError): - continue - if lid in labels: - labels[lid]['members'].append({'username': username, 'display_name': display}) - - _contact_tags = labels - return _contact_tags - except Exception: - return {} - finally: - conn.close() - - -# ============ 辅助函数 ============ - -def format_msg_type(t): - base_type, _ = _split_msg_type(t) - return { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '通话', 10000: '系统', 10002: '撤回', - }.get(base_type, f'type={t}') - - -def _split_msg_type(t): - try: - t = int(t) - except (TypeError, ValueError): - return 0, 0 - # WeChat packs the base type into the low 32 bits and app subtype into the high 32 bits. - if t > 0xFFFFFFFF: - return t & 0xFFFFFFFF, t >> 32 - return t, 0 - - -def resolve_username(chat_name): - """将聊天名/备注名/wxid 解析为 username""" - names = get_contact_names() - - # 直接是 username - if chat_name in names or chat_name.startswith('wxid_') or '@chatroom' in chat_name: - return chat_name - - # 模糊匹配(优先精确包含) - chat_lower = chat_name.lower() - for uname, display in names.items(): - if chat_lower == display.lower(): - return uname - for uname, display in names.items(): - if chat_lower in display.lower(): - return uname - - return None - - -_zstd_dctx = zstd.ZstdDecompressor() - - -def _decompress_content(content, ct): - """解压 zstd 压缩的消息内容""" - if ct and ct == 4 and isinstance(content, bytes): - try: - return _zstd_dctx.decompress(content).decode('utf-8', errors='replace') - except Exception: - return None - if isinstance(content, bytes): - try: - return content.decode('utf-8', errors='replace') - except Exception: - return None - return content - - -def _parse_message_content(content, local_type, is_group): - """解析消息内容,返回 (sender_id, text)""" - if content is None: - return '', '' - if isinstance(content, bytes): - return '', '(二进制内容)' - - sender = '' - text = content - if is_group and ':\n' in content: - sender, text = content.split(':\n', 1) - - return sender, text - - -def _collapse_text(text): - if not text: - return '' - return re.sub(r'\s+', ' ', text).strip() - - -def _get_self_username(): - global _self_username - if _self_username: - return _self_username - - if not DB_DIR: - return '' - - names = get_contact_names() - account_dir = os.path.basename(os.path.dirname(DB_DIR)) - candidates = [account_dir] - - m = re.fullmatch(r'(.+)_([0-9a-fA-F]{4,})', account_dir) - if m: - candidates.insert(0, m.group(1)) - - for candidate in candidates: - if candidate and candidate in names: - _self_username = candidate - return _self_username - - return '' - - -def _load_name2id_maps(conn): - id_to_username = {} - try: - rows = conn.execute("SELECT rowid, user_name FROM Name2Id").fetchall() - except sqlite3.Error: - return id_to_username - - for rowid, user_name in rows: - if not user_name: - continue - id_to_username[rowid] = user_name - return id_to_username - - -def _display_name_for_username(username, names): - if not username: - return '' - if username == _get_self_username(): - return 'me' - return names.get(username, username) - - -def _resolve_sender_label(real_sender_id, sender_from_content, is_group, chat_username, chat_display_name, names, id_to_username): - sender_username = id_to_username.get(real_sender_id, '') - - if is_group: - if sender_username and sender_username != chat_username: - return _display_name_for_username(sender_username, names) - if sender_from_content: - return _display_name_for_username(sender_from_content, names) - return '' - - if sender_username == chat_username: - return chat_display_name - if sender_username: - return _display_name_for_username(sender_username, names) - return '' - - -def _resolve_quote_sender_label(ref_user, ref_display_name, is_group, chat_username, chat_display_name, names): - if is_group: - if ref_user: - return _display_name_for_username(ref_user, names) - return ref_display_name or '' - - self_username = _get_self_username() - if ref_user: - if ref_user == chat_username: - return chat_display_name - if self_username and ref_user == self_username: - return 'me' - return names.get(ref_user, ref_display_name or ref_user) - if ref_display_name: - if ref_display_name == chat_display_name: - return chat_display_name - self_display_name = names.get(self_username, self_username) if self_username else '' - if self_display_name and ref_display_name == self_display_name: - return 'me' - return ref_display_name - return '' - - -def _parse_xml_root(content): - if not content or len(content) > _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content): - return None - - try: - return ET.fromstring(content) - except ET.ParseError: - return None - - -def _parse_int(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names): - if not content or ' 160: - ref_content = ref_content[:160] + "..." - - quote_text = title or "[引用消息]" - if ref_content: - ref_label = _resolve_quote_sender_label( - ref_user, ref_display_name, is_group, chat_username, chat_display_name, names - ) - prefix = f"回复 {ref_label}: " if ref_label else "回复: " - quote_text += f"\n ↳ {prefix}{ref_content}" - return quote_text - - if app_type == 6: - return f"[文件] {title}" if title else "[文件]" - if app_type == 5: - return f"[链接] {title}" if title else "[链接]" - if app_type in (33, 36, 44): - return f"[小程序] {title}" if title else "[小程序]" - if title: - return f"[链接/文件] {title}" - return "[链接/文件]" - - -def _format_voip_message_text(content): - if not content or ' limit_max: - raise ValueError(f"limit 不能大于 {limit_max}") - if offset < 0: - raise ValueError("offset 不能小于 0") - - -def _parse_time_value(value, field_name, is_end=False): - value = (value or '').strip() - if not value: - return None - - formats = [ - ('%Y-%m-%d %H:%M:%S', False), - ('%Y-%m-%d %H:%M', False), - ('%Y-%m-%d', True), - ] - for fmt, date_only in formats: - try: - dt = datetime.strptime(value, fmt) - if date_only and is_end: - dt = dt.replace(hour=23, minute=59, second=59) - return int(dt.timestamp()) - except ValueError: - continue - - raise ValueError( - f"{field_name} 格式无效: {value}。支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS" - ) - - -def _parse_time_range(start_time='', end_time=''): - start_ts = _parse_time_value(start_time, 'start_time', is_end=False) - end_ts = _parse_time_value(end_time, 'end_time', is_end=True) - if start_ts is not None and end_ts is not None and start_ts > end_ts: - raise ValueError('start_time 不能晚于 end_time') - return start_ts, end_ts - - -def _build_message_filters(start_ts=None, end_ts=None, keyword=''): - clauses = [] - params = [] - if start_ts is not None: - clauses.append('create_time >= ?') - params.append(start_ts) - if end_ts is not None: - clauses.append('create_time <= ?') - params.append(end_ts) - if keyword: - clauses.append('message_content LIKE ?') - params.append(f'%{keyword}%') - return clauses, params - - -def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0): - if not _is_safe_msg_table_name(table_name): - raise ValueError(f'非法消息表名: {table_name}') - - clauses, params = _build_message_filters(start_ts, end_ts, keyword) - where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else '' - sql = f""" - SELECT local_id, local_type, create_time, real_sender_id, message_content, - WCDB_CT_message_content - FROM [{table_name}] - {where_sql} - ORDER BY create_time DESC - """ - if limit is None: - return conn.execute(sql, params).fetchall() - sql += "\n LIMIT ? OFFSET ?" - return conn.execute(sql, (*params, limit, offset)).fetchall() - - -def _resolve_chat_context(chat_name): - username = resolve_username(chat_name) - if not username: - return None - - names = get_contact_names() - display_name = names.get(username, username) - message_tables = _find_msg_tables_for_user(username) - if not message_tables: - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': None, - 'table_name': None, - 'message_tables': [], - 'is_group': '@chatroom' in username, - } - - primary = message_tables[0] - return { - 'query': chat_name, - 'username': username, - 'display_name': display_name, - 'db_path': primary['db_path'], - 'table_name': primary['table_name'], - 'message_tables': message_tables, - 'is_group': '@chatroom' in username, - } - - -def _resolve_chat_contexts(chat_names): - if not chat_names: - raise ValueError('chat_names 不能为空') - - resolved = [] - unresolved = [] - missing_tables = [] - seen = set() - - for chat_name in chat_names: - name = (chat_name or '').strip() - if not name: - unresolved.append('(空)') - continue - ctx = _resolve_chat_context(name) - if not ctx: - unresolved.append(name) - continue - if not ctx['message_tables']: - missing_tables.append(ctx['display_name']) - continue - if ctx['username'] in seen: - continue - seen.add(ctx['username']) - resolved.append(ctx) - - return resolved, unresolved, missing_tables - - -def _normalize_chat_names(chat_name): - if chat_name is None: - return [] - if isinstance(chat_name, str): - value = chat_name.strip() - return [value] if value else [] - if isinstance(chat_name, (list, tuple, set)): - normalized = [] - for item in chat_name: - if item is None: - continue - value = str(item).strip() - if value: - normalized.append(value) - return normalized - value = str(chat_name).strip() - return [value] if value else [] - - -def _format_history_lines(rows, username, display_name, is_group, names, id_to_username): - lines = [] - ctx = { - 'username': username, - 'display_name': display_name, - 'is_group': is_group, - } - for row in reversed(rows): - _, line = _build_history_line(row, ctx, names, id_to_username) - lines.append(line) - return lines - - -def _build_search_entry(row, ctx, names, id_to_username): - local_id, local_type, create_time, real_sender_id, content, ct = row - content = _decompress_content(content, ct) - if content is None: - return None - - sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names - ) - if text and len(text) > 300: - text = text[:300] + '...' - - sender_label = _resolve_sender_label( - real_sender_id, - sender, - ctx['is_group'], - ctx['username'], - ctx['display_name'], - names, - id_to_username, - ) - time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') - entry = f"[{time_str}] [{ctx['display_name']}]" - if sender_label: - entry += f" {sender_label}:" - entry += f" {text}" - return create_time, entry - - -def _build_history_line(row, ctx, names, id_to_username): - local_id, local_type, create_time, real_sender_id, content, ct = row - time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M') - content = _decompress_content(content, ct) - if content is None: - content = '(无法解压)' - - sender, text = _format_message_text( - local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names - ) - - sender_label = _resolve_sender_label( - real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username - ) - if sender_label: - return create_time, f'[{time_str}] {sender_label}: {text}' - return create_time, f'[{time_str}] {text}' - - -def _get_chat_message_tables(ctx): - if ctx.get('message_tables'): - return ctx['message_tables'] - if ctx.get('db_path') and ctx.get('table_name'): - return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}] - return [] - - -def _iter_table_contexts(ctx): - for table in _get_chat_message_tables(ctx): - yield { - 'query': ctx['query'], - 'username': ctx['username'], - 'display_name': ctx['display_name'], - 'db_path': table['db_path'], - 'table_name': table['table_name'], - 'is_group': ctx['is_group'], - } - - -def _candidate_page_size(limit, offset): - return limit + offset - - -def _message_query_batch_size(candidate_limit): - return candidate_limit - - -def _history_query_batch_size(candidate_limit): - return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE) - - -def _page_ranked_entries(entries, limit, offset): - ordered = sorted(entries, key=lambda item: item[0], reverse=True) - paged = ordered[offset:offset + limit] - paged.sort(key=lambda item: item[0]) - return paged - - -def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0): - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - batch_size = _history_query_batch_size(candidate_limit) - - for table_ctx in _iter_table_contexts(ctx): - try: - with closing(sqlite3.connect(table_ctx['db_path'])) as conn: - id_to_username = _load_name2id_maps(conn) - fetch_offset = 0 - collected_before_table = len(collected) - # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - table_ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - try: - collected.append(_build_history_line(row, table_ctx, names, id_to_username)) - except Exception as e: - failures.append( - f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}" - ) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{table_ctx['db_path']}: {e}") - - paged = _page_ranked_entries(collected, limit, offset) - return [line for _, line in paged], failures - - -def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - contexts_by_db = {} - for table_ctx in _iter_table_contexts(ctx): - contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx) - - for db_path, db_contexts in contexts_by_db.items(): - try: - with closing(sqlite3.connect(db_path)) as conn: - db_entries, db_failures = _collect_search_entries( - conn, - db_contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts) - - return collected, failures - - -def _load_search_contexts_from_db(conn, db_path, names): - tables = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ).fetchall() - - table_to_username = {} - try: - for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall(): - if not user_name: - continue - table_hash = hashlib.md5(user_name.encode()).hexdigest() - table_to_username[f"Msg_{table_hash}"] = user_name - except sqlite3.Error: - pass - - contexts = [] - for (table_name,) in tables: - username = table_to_username.get(table_name, '') - display_name = names.get(username, username) if username else table_name - contexts.append({ - 'query': display_name, - 'username': username, - 'display_name': display_name, - 'db_path': db_path, - 'table_name': table_name, - 'is_group': '@chatroom' in username, - }) - return contexts - - -def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20): - collected = [] - failures = [] - id_to_username = _load_name2id_maps(conn) - batch_size = _message_query_batch_size(candidate_limit) - - for ctx in contexts: - try: - fetch_offset = 0 - collected_before_table = len(collected) - # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。 - while len(collected) - collected_before_table < candidate_limit: - rows = _query_messages( - conn, - ctx['table_name'], - start_ts=start_ts, - end_ts=end_ts, - keyword=keyword, - limit=batch_size, - offset=fetch_offset, - ) - if not rows: - break - fetch_offset += len(rows) - - for row in rows: - formatted = _build_search_entry(row, ctx, names, id_to_username) - if formatted: - collected.append(formatted) - if len(collected) - collected_before_table >= candidate_limit: - break - - if len(rows) < batch_size: - break - except Exception as e: - failures.append(f"{ctx['display_name']}: {e}") - - return collected, failures - - -def _page_search_entries(entries, limit, offset): - return _page_ranked_entries(entries, limit, offset) - - -def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - - entries, failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - - paged = _page_search_entries(entries, limit, offset) - - if not paged: - if failures: - return "查询失败: " + ";".join(failures) - return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息" - - header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset): - try: - resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names) - except ValueError as e: - return f"错误: {e}" - - if not resolved_contexts: - details = [] - if unresolved: - details.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - details.append("无消息表: " + "、".join(missing_tables)) - suffix = f"\n{chr(10).join(details)}" if details else "" - return f"错误: 没有可查询的聊天对象{suffix}" - - names = get_contact_names() - candidate_limit = _candidate_page_size(limit, offset) - collected = [] - failures = [] - for ctx in resolved_contexts: - chat_entries, chat_failures = _collect_chat_search_entries( - ctx, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(chat_entries) - failures.extend(chat_failures) - - paged = _page_search_entries(collected, limit, offset) - - notes = [] - if unresolved: - notes.append("未找到联系人: " + "、".join(unresolved)) - if missing_tables: - notes.append("无消息表: " + "、".join(missing_tables)) - if failures: - notes.append("查询失败: " + ";".join(failures)) - - if not paged: - header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header - - header = ( - f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果" - f"(offset={offset}, limit={limit})" - ) - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if notes: - header += "\n" + "\n".join(notes) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset): - names = get_contact_names() - collected = [] - failures = [] - candidate_limit = _candidate_page_size(limit, offset) - - for rel_key in MSG_DB_KEYS: - path = _cache.get(rel_key) - if not path: - continue - - try: - with closing(sqlite3.connect(path)) as conn: - contexts = _load_search_contexts_from_db(conn, path, names) - db_entries, db_failures = _collect_search_entries( - conn, - contexts, - names, - keyword, - start_ts=start_ts, - end_ts=end_ts, - candidate_limit=candidate_limit, - ) - collected.extend(db_entries) - failures.extend(db_failures) - except Exception as e: - failures.append(f"{rel_key}: {e}") - - paged = _page_search_entries(collected, limit, offset) - - if not paged: - header = f"未找到包含 \"{keyword}\" 的消息" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header - - header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n\n".join(item[1] for item in paged) - - -# ============ MCP Server ============ - -mcp = FastMCP("wechat", instructions="查询微信消息、联系人等数据") - -# 新消息追踪 -_last_check_state = {} # {username: last_timestamp} - - -@mcp.tool() -def get_recent_sessions(limit: int = 20) -> str: - """获取微信最近会话列表,包含最新消息摘要、未读数、时间等。 - 用于了解最近有哪些人/群在聊天。 - - Args: - limit: 返回的会话数量,默认20 - """ - path = _cache.get(os.path.join("session", "session.db")) - if not path: - return "错误: 无法解密 session.db" - - names = get_contact_names() - with closing(sqlite3.connect(path)) as conn: - rows = conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable - WHERE last_timestamp > 0 - ORDER BY last_timestamp DESC - LIMIT ? - """, (limit,)).fetchall() - - results = [] - for r in rows: - username, unread, summary, ts, msg_type, sender, sender_name = r - display = names.get(username, username) - is_group = '@chatroom' in username - - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if isinstance(summary, str) and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - - sender_display = '' - if is_group and sender: - sender_display = names.get(sender, sender_name or sender) - - time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M') - - entry = f"[{time_str}] {display}" - if is_group: - entry += " [群]" - if unread and unread > 0: - entry += f" ({unread}条未读)" - entry += f"\n {format_msg_type(msg_type)}: " - if sender_display: - entry += f"{sender_display}: " - entry += str(summary or "(无内容)") - - results.append(entry) - - return f"最近 {len(results)} 个会话:\n\n" + "\n\n".join(results) - - -@mcp.tool() -def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str: - """获取指定聊天的消息记录。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配 - limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用 - offset: 分页偏移量,默认0 - start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS - """ - try: - _validate_pagination(limit, offset, limit_max=None) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" - - ctx = _resolve_chat_context(chat_name) - if not ctx: - return f"找不到聊天对象: {chat_name}\n提示: 可以用 get_contacts(query='{chat_name}') 搜索联系人" - if not ctx['db_path']: - return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - - names = get_contact_names() - lines, failures = _collect_chat_history_lines( - ctx, - names, - start_ts=start_ts, - end_ts=end_ts, - limit=limit, - offset=offset, - ) - - if not lines: - if failures: - return "查询失败: " + ";".join(failures) - return f"{ctx['display_name']} 无消息记录" - - header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})" - if ctx['is_group']: - header += " [群聊]" - if start_time or end_time: - header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}" - if failures: - header += "\n查询失败: " + ";".join(failures) - return header + ":\n\n" + "\n".join(lines) - - -@mcp.tool() -def search_messages( - keyword: str, - chat_name: str | list[str] | None = None, - start_time: str = "", - end_time: str = "", - limit: int = 20, - offset: int = 0, -) -> str: - """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。 - - Args: - keyword: 搜索关键词 - chat_name: 聊天对象名称,可为空、单个字符串或字符串列表 - start_time: 起始时间,可为空 - end_time: 结束时间,可为空 - limit: 返回的结果数量,默认20,最大500 - offset: 分页偏移量,默认0 - """ - if not keyword or len(keyword) < 1: - return "请提供搜索关键词" - - chat_names = _normalize_chat_names(chat_name) - - try: - _validate_pagination(limit, offset) - start_ts, end_ts = _parse_time_range(start_time, end_time) - except ValueError as e: - return f"错误: {e}" - - if len(chat_names) == 1: - ctx = _resolve_chat_context(chat_names[0]) - if not ctx: - return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人" - if not ctx['db_path']: - return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)" - return _search_single_chat( - ctx, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - if len(chat_names) > 1: - return _search_multiple_chats( - chat_names, - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - - return _search_all_messages( - keyword, - start_ts, - end_ts, - start_time, - end_time, - limit, - offset, - ) - -@mcp.tool() -def get_contacts(query: str = "", limit: int = 50) -> str: - """搜索或列出微信联系人。 - - Args: - query: 搜索关键词(匹配昵称、备注名、wxid),留空列出所有 - limit: 返回数量,默认50 - """ - contacts = get_contact_full() - if not contacts: - return "错误: 无法加载联系人数据" - - if query: - q = query.lower() - filtered = [ - c for c in contacts - if q in c['nick_name'].lower() - or q in c['remark'].lower() - or q in c['username'].lower() - ] - else: - filtered = contacts - - filtered = filtered[:limit] - - if not filtered: - return f"未找到匹配 \"{query}\" 的联系人" - - lines = [] - for c in filtered: - line = c['username'] - if c['remark']: - line += f" 备注: {c['remark']}" - if c['nick_name']: - line += f" 昵称: {c['nick_name']}" - lines.append(line) - - header = f"找到 {len(filtered)} 个联系人" - if query: - header += f"(搜索: {query})" - return header + ":\n\n" + "\n".join(lines) - - -@mcp.tool() -def get_contact_tags() -> str: - """列出所有微信联系人标签及成员数量。""" - tags = _load_contact_tags() - if not tags: - return "未找到标签数据(contact_label 表可能不存在)" - - sorted_tags = sorted(tags.values(), key=lambda t: t['sort_order']) - total_assoc = sum(len(t['members']) for t in sorted_tags) - - lines = [f"共 {len(sorted_tags)} 个标签,{total_assoc} 个关联:\n"] - for t in sorted_tags: - lines.append(f" [{t['name']}] {len(t['members'])}人") - return "\n".join(lines) - - -@mcp.tool() -def get_tag_members(tag_name: str) -> str: - """获取指定标签下的所有联系人。支持模糊匹配标签名。 - - Args: - tag_name: 标签名称,支持精确和模糊匹配 - """ - tags = _load_contact_tags() - if not tags: - return "未找到标签数据(contact_label 表可能不存在)" - - q = tag_name.strip().lower() - - # 精确匹配 - exact = [t for t in tags.values() if t['name'].lower() == q] - if exact: - matched = exact[0] - else: - # 模糊匹配 (contains) - fuzzy = [t for t in tags.values() if q in t['name'].lower()] - if not fuzzy: - all_names = [t['name'] for t in sorted(tags.values(), key=lambda t: t['sort_order'])] - return f"未找到匹配 \"{tag_name}\" 的标签。\n\n现有标签: {', '.join(all_names)}" - if len(fuzzy) == 1: - matched = fuzzy[0] - else: - names = [t['name'] for t in fuzzy] - return f"找到 {len(fuzzy)} 个匹配的标签,请指定:\n" + "\n".join(f" [{n}]" for n in names) - - members = matched['members'] - if not members: - return f"标签 [{matched['name']}] 没有成员" - - lines = [f"标签 [{matched['name']}] 共 {len(members)} 人:\n"] - for m in members: - line = m['username'] - if m['display_name'] != m['username']: - line += f" {m['display_name']}" - lines.append(f" {line}") - return "\n".join(lines) - - -@mcp.tool() -def get_new_messages() -> str: - """获取自上次调用以来的新消息。首次调用返回最近的会话状态。""" - global _last_check_state - - path = _cache.get(os.path.join("session", "session.db")) - if not path: - return "错误: 无法解密 session.db" - - names = get_contact_names() - with closing(sqlite3.connect(path)) as conn: - rows = conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable - WHERE last_timestamp > 0 - ORDER BY last_timestamp DESC - """).fetchall() - - curr_state = {} - for r in rows: - username, unread, summary, ts, msg_type, sender, sender_name = r - curr_state[username] = { - 'unread': unread, 'summary': summary, 'timestamp': ts, - 'msg_type': msg_type, 'sender': sender or '', 'sender_name': sender_name or '', - } - - if not _last_check_state: - _last_check_state = {u: s['timestamp'] for u, s in curr_state.items()} - # 首次调用,返回有未读的会话 - unread_msgs = [] - for username, s in curr_state.items(): - if s['unread'] and s['unread'] > 0: - display = names.get(username, username) - is_group = '@chatroom' in username - summary = s['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if isinstance(summary, str) and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - time_str = datetime.fromtimestamp(s['timestamp']).strftime('%H:%M') - tag = "[群]" if is_group else "" - unread_msgs.append(f"[{time_str}] {display}{tag} ({s['unread']}条未读): {summary}") - - if unread_msgs: - return f"当前 {len(unread_msgs)} 个未读会话:\n\n" + "\n".join(unread_msgs) - return "当前无未读消息(已记录状态,下次调用将返回新消息)" - - # 对比上次状态 - new_msgs = [] - for username, s in curr_state.items(): - prev_ts = _last_check_state.get(username, 0) - if s['timestamp'] > prev_ts: - display = names.get(username, username) - is_group = '@chatroom' in username - summary = s['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if isinstance(summary, str) and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - - sender_display = '' - if is_group and s['sender']: - sender_display = names.get(s['sender'], s['sender_name'] or s['sender']) - - time_str = datetime.fromtimestamp(s['timestamp']).strftime('%H:%M:%S') - entry = f"[{time_str}] {display}" - if is_group: - entry += " [群]" - entry += f": {format_msg_type(s['msg_type'])}" - if sender_display: - entry += f" ({sender_display})" - entry += f" - {summary}" - new_msgs.append((s['timestamp'], entry)) - - _last_check_state = {u: s['timestamp'] for u, s in curr_state.items()} - - if not new_msgs: - return "无新消息" - - new_msgs.sort(key=lambda x: x[0]) - entries = [m[1] for m in new_msgs] - return f"{len(entries)} 条新消息:\n\n" + "\n".join(entries) - - -# ============ 图片解密 ============ - -_image_resolver = ImageResolver(WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache) - - -@mcp.tool() -def decode_image(chat_name: str, local_id: int) -> str: - """解密微信聊天中的一张图片。 - - 先用 get_chat_history 查看消息,图片消息会显示 local_id, - 然后用此工具解密对应图片。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid - local_id: 图片消息的 local_id(从 get_chat_history 获取) - """ - username = resolve_username(chat_name) - if not username: - return f"找不到聊天对象: {chat_name}" - - result = _image_resolver.decode_image(username, local_id) - if result['success']: - return ( - f"解密成功!\n" - f" 文件: {result['path']}\n" - f" 格式: {result['format']}\n" - f" 大小: {result['size']:,} bytes\n" - f" MD5: {result['md5']}" - ) - else: - error = result['error'] - if 'md5' in result: - error += f"\n MD5: {result['md5']}" - return f"解密失败: {error}" - - -@mcp.tool() -def get_chat_images(chat_name: str, limit: int = 20) -> str: - """列出某个聊天中的图片消息。 - - 返回图片的时间、local_id、MD5、文件大小等信息。 - 可以配合 decode_image 工具解密指定图片。 - - Args: - chat_name: 聊天对象的名字、备注名或wxid - limit: 返回数量,默认20 - """ - username = resolve_username(chat_name) - if not username: - return f"找不到聊天对象: {chat_name}" - - names = get_contact_names() - display_name = names.get(username, username) - - db_path, table_name = _find_msg_table_for_user(username) - if not db_path: - return f"找不到 {display_name} 的消息记录" - - images = _image_resolver.list_chat_images(db_path, table_name, username, limit) - if not images: - return f"{display_name} 无图片消息" - - lines = [] - for img in images: - time_str = datetime.fromtimestamp(img['create_time']).strftime('%Y-%m-%d %H:%M') - line = f"[{time_str}] local_id={img['local_id']}" - if img.get('md5'): - line += f" MD5={img['md5']}" - if img.get('size'): - size_kb = img['size'] / 1024 - line += f" {size_kb:.0f}KB" - if not img.get('md5'): - line += " (无资源信息)" - lines.append(line) - - return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines) - - -if __name__ == "__main__": - mcp.run() diff --git a/monitor.py b/monitor.py deleted file mode 100644 index 7408684..0000000 --- a/monitor.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -微信实时消息监听器 - -原理: 定期解密 session.db (2MB, <1秒), 检测新消息 -session.db 包含每个聊天的最新消息摘要、发送者、时间戳 -""" -import hashlib, struct, os, sys, json, time, sqlite3, io -import hmac as hmac_mod -from datetime import datetime -from Crypto.Cipher import AES -import zstandard as zstd -from key_utils import get_key_info, strip_key_metadata - -_zstd_dctx = zstd.ZstdDecompressor() - -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -import functools -print = functools.partial(print, flush=True) - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -IV_SZ = 16 -HMAC_SZ = 64 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") - -POLL_INTERVAL = 3 # 秒 - - -def derive_mac_key(enc_key, salt): - mac_salt = bytes(b ^ 0x3a for b in salt) - return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) - - -def decrypt_page(enc_key, page_data, pgno): - iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ] - if pgno == 1: - encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - return bytes(page) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def decrypt_db_to_memory(db_path, enc_key): - """解密DB到内存中的bytes, 返回可用于sqlite3的数据""" - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - if file_size % PAGE_SZ != 0: - total_pages += 1 - - chunks = [] - with open(db_path, 'rb') as fin: - for pgno in range(1, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - decrypted = decrypt_page(enc_key, page, pgno) - chunks.append(decrypted) - - return b''.join(chunks) - - -def decrypt_db_to_sqlite(db_path, enc_key): - """解密DB并返回sqlite3连接 (内存数据库)""" - data = decrypt_db_to_memory(db_path, enc_key) - - # 写临时文件 (sqlite3不支持直接从bytes打开) - tmp_path = db_path + ".tmp_monitor" - with open(tmp_path, 'wb') as f: - f.write(data) - - conn = sqlite3.connect(tmp_path) - conn.row_factory = sqlite3.Row - return conn, tmp_path - - -def load_contact_names(): - """从已解密的contact.db加载联系人昵称映射""" - names = {} - if not os.path.exists(CONTACT_CACHE): - return names - try: - conn = sqlite3.connect(CONTACT_CACHE) - rows = conn.execute( - "SELECT username, nick_name, remark FROM contact" - ).fetchall() - for r in rows: - username, nick, remark = r - names[username] = remark if remark else nick if nick else username - conn.close() - except Exception as e: - print(f"[WARN] 加载联系人失败: {e}") - return names - - -def get_session_state(conn): - """获取当前session状态""" - state = {} - try: - rows = conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable - WHERE last_timestamp > 0 - """).fetchall() - for r in rows: - state[r[0]] = { - 'unread': r[1], - 'summary': r[2] or '', - 'timestamp': r[3], - 'msg_type': r[4], - 'sender': r[5] or '', - 'sender_name': r[6] or '', - } - except Exception as e: - print(f"[ERROR] 读取session失败: {e}") - return state - - -def format_msg_type(t): - types = { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '语音/视频通话', 10000: '系统消息', 10002: '撤回', - } - return types.get(t, f'type={t}') - - -def main(): - print("=" * 60) - print(" 微信实时消息监听器") - print("=" * 60) - - # 加载密钥 - with open(KEYS_FILE, encoding="utf-8") as f: - keys = strip_key_metadata(json.load(f)) - - session_key_info = get_key_info(keys, os.path.join("session", "session.db")) - if not session_key_info: - print("[ERROR] 找不到session.db的密钥") - sys.exit(1) - - enc_key = bytes.fromhex(session_key_info["enc_key"]) - session_db = os.path.join(DB_DIR, "session", "session.db") - - # 加载联系人 - print("加载联系人...") - contact_names = load_contact_names() - print(f"已加载 {len(contact_names)} 个联系人") - - # 初始状态 - print("读取初始状态...") - conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key) - prev_state = get_session_state(conn) - conn.close() - os.remove(tmp_path) - - print(f"跟踪 {len(prev_state)} 个会话") - print(f"轮询间隔: {POLL_INTERVAL}秒") - print(f"\n{'='*60}") - print("开始监听... (Ctrl+C 停止)\n") - - poll_count = 0 - try: - while True: - time.sleep(POLL_INTERVAL) - poll_count += 1 - - try: - conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key) - curr_state = get_session_state(conn) - conn.close() - os.remove(tmp_path) - except Exception as e: - if poll_count % 10 == 0: - print(f"[{datetime.now().strftime('%H:%M:%S')}] 读取失败: {e}") - continue - - # 比较差异 - for username, curr in curr_state.items(): - prev = prev_state.get(username) - - if prev is None: - # 新会话 - display = contact_names.get(username, username) - ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S') - print(f"[{ts}] 新会话 [{display}]") - print(f" {curr['summary']}") - print() - continue - - # 检查时间戳变化 (有新消息) - if curr['timestamp'] > prev['timestamp']: - display = contact_names.get(username, username) - ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S') - msg_type = format_msg_type(curr['msg_type']) - sender = curr['sender_name'] or curr['sender'] or '' - - # 群聊显示发送者 - if '@chatroom' in username and sender: - sender_display = contact_names.get(curr['sender'], sender) - print(f"[{ts}] [{display}] {sender_display}:") - else: - print(f"[{ts}] [{display}]") - - # 消息内容 - summary = curr['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if summary: - # 群消息格式: "wxid_xxx:\n内容" - 提取内容部分 - if ':\n' in summary: - summary = summary.split(':\n', 1)[1] - print(f" [{msg_type}] {summary}") - else: - print(f" [{msg_type}]") - - # 未读数变化 - if curr['unread'] > 0: - print(f" (未读: {curr['unread']})") - print() - - prev_state = curr_state - - # 心跳 - if poll_count % 20 == 0: - now = datetime.now().strftime('%H:%M:%S') - print(f"--- {now} 运行中 (第{poll_count}次轮询) ---") - - except KeyboardInterrupt: - print(f"\n监听结束, 共 {poll_count} 次轮询") - - # 清理 - tmp = session_db + ".tmp_monitor" - if os.path.exists(tmp): - os.remove(tmp) - - -if __name__ == '__main__': - main() diff --git a/monitor_web.py b/monitor_web.py deleted file mode 100644 index 69b95aa..0000000 --- a/monitor_web.py +++ /dev/null @@ -1,2095 +0,0 @@ -""" -微信实时消息监听器 - Web UI (SSE推送 + mtime检测) - -http://localhost:5678 -- 30ms轮询WAL/DB文件的mtime变化(WAL是预分配固定大小,不能用size检测) -- 检测到变化后:全量解密DB + 全量WAL patch -- SSE 服务器推送 -""" -import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback -import hmac as hmac_mod -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn -from Crypto.Cipher import AES -import urllib.parse -import glob as glob_mod -import zstandard as zstd -from decode_image import extract_md5_from_packed_info, decrypt_dat_file, is_v2_format -from key_utils import get_key_info, strip_key_metadata - -_zstd_dctx = zstd.ZstdDecompressor() - -PAGE_SZ = 4096 -KEY_SZ = 32 -SALT_SZ = 16 -RESERVE_SZ = 80 -SQLITE_HDR = b'SQLite format 3\x00' -WAL_HEADER_SZ = 32 -WAL_FRAME_HEADER_SZ = 24 - -from config import load_config -_cfg = load_config() -DB_DIR = _cfg["db_dir"] -KEYS_FILE = _cfg["keys_file"] -CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db") -DECRYPTED_SESSION = os.path.join(_cfg["decrypted_dir"], "session", "session.db") -DECODED_IMAGE_DIR = _cfg.get("decoded_image_dir", os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")) -MONITOR_CACHE_DIR = os.path.join(_cfg["decrypted_dir"], "_monitor_cache") -WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "") -IMAGE_AES_KEY = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取) -IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) # XOR key - -POLL_MS = 30 # 高频轮询WAL/DB的mtime,30ms一次 -PORT = 5678 - -sse_clients = [] -sse_lock = threading.Lock() -messages_log = [] -messages_lock = threading.Lock() -MAX_LOG = 500 -_img_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix='img') -_hidden_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='hidden') - -# ---- Emoji 缓存 (md5 → {cdn_url, aes_key, encrypt_url}) ---- -_emoji_lookup = {} # md5 → dict -_emoji_lookup_lock = threading.Lock() - -_emoji_keys_dict = None # 保存 keys 引用供刷新用 -_emoji_last_refresh = 0 - -def _build_emoji_lookup(keys_dict): - """从 emoticon.db 构建 emoji md5 → URL 映射(直接解密,不走 cache)""" - global _emoji_lookup, _emoji_keys_dict, _emoji_last_refresh - _emoji_keys_dict = keys_dict - key_info = get_key_info(keys_dict, os.path.join("emoticon", "emoticon.db")) - if not key_info: - print("[emoji] 无 emoticon.db key,跳过", flush=True) - return - - src = os.path.join(DB_DIR, "emoticon", "emoticon.db") - if not os.path.exists(src): - return - - import tempfile - dst = os.path.join(tempfile.gettempdir(), "wechat_emoticon_dec.db") - enc_key = bytes.fromhex(key_info["enc_key"]) - - try: - full_decrypt(src, dst, enc_key) - wal = src + "-wal" - if os.path.exists(wal): - decrypt_wal_full(wal, dst, enc_key) - except Exception as e: - print(f"[emoji] emoticon.db 解密失败: {e}", flush=True) - return - - try: - conn = sqlite3.connect(f"file:{dst}?mode=ro", uri=True) - new_lookup = {} - - # 1. NonStore 表情(有独立 cdn_url) - rows = conn.execute( - "SELECT md5, aes_key, cdn_url, encrypt_url, product_id FROM kNonStoreEmoticonTable" - ).fetchall() - # 收集每个 package 的 cdn_url 模板 - pkg_cdn_template = {} # package_id → cdn_url (任意一个) - for md5, aes_key, cdn_url, encrypt_url, product_id in rows: - if md5: - new_lookup[md5] = { - 'cdn_url': cdn_url or '', - 'aes_key': aes_key or '', - 'encrypt_url': encrypt_url or '', - } - if product_id and cdn_url: - pkg_cdn_template[product_id] = cdn_url - - non_store_count = len(new_lookup) - - # 2. Store 表情(尝试构造 cdn_url) - store_rows = conn.execute( - "SELECT package_id_, md5_ FROM kStoreEmoticonFilesTable" - ).fetchall() - store_added = 0 - for pkg_id, md5 in store_rows: - if md5 and md5 not in new_lookup: - # 尝试用同 package 的模板构造 URL - template = pkg_cdn_template.get(pkg_id, '') - if template and '&' in template: - # 替换 m= 参数为新 md5 - import re - constructed = re.sub(r'm=[0-9a-f]+', f'm={md5}', template) - new_lookup[md5] = { - 'cdn_url': constructed, - 'aes_key': '', - 'encrypt_url': '', - } - store_added += 1 - - conn.close() - with _emoji_lookup_lock: - _emoji_lookup = new_lookup - _emoji_last_refresh = time.time() - print(f"[emoji] 已加载 {non_store_count} NonStore + {store_added} Store = {len(new_lookup)} 个表情映射", flush=True) - except Exception as e: - print(f"[emoji] 构建映射失败: {e}", flush=True) - finally: - try: - os.unlink(dst) - except OSError: - pass - -def _download_emoji(md5): - """从 CDN 下载表情并缓存到 decoded_images/,返回文件名或 None""" - with _emoji_lookup_lock: - info = _emoji_lookup.get(md5) - if not info: - # Lookup miss: 刷新 emoticon.db(最多每60秒一次) - if _emoji_keys_dict and time.time() - _emoji_last_refresh > 60: - print(f" [emoji] lookup miss, 刷新 emoticon.db...", flush=True) - _build_emoji_lookup(_emoji_keys_dict) - with _emoji_lookup_lock: - info = _emoji_lookup.get(md5) - if not info: - return None - - # 先检查是否已缓存 - for ext in ('.gif', '.png', '.jpg', '.webp'): - cached = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}{ext}") - if os.path.exists(cached): - return f"emoji_{md5}{ext}" - - cdn_url = info.get('cdn_url', '') - aes_key = info.get('aes_key', '') - encrypt_url = info.get('encrypt_url', '') - - data = None - # 方法1: 从 cdn_url 直接下载(未加密) - if cdn_url: - try: - import urllib.request - req = urllib.request.Request(cdn_url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=15) - data = resp.read() - except Exception as e: - print(f" [emoji] cdn下载失败 {md5[:12]}: {e}", flush=True) - - # 方法2: 从 encrypt_url 下载 + AES-CBC 解密 - if not data and encrypt_url and aes_key: - try: - import urllib.request - req = urllib.request.Request(encrypt_url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=15) - enc_data = resp.read() - key_bytes = bytes.fromhex(aes_key) - cipher = AES.new(key_bytes, AES.MODE_CBC, iv=key_bytes) - data = cipher.decrypt(enc_data) - # 去除 PKCS7 padding - if data: - pad = data[-1] - if 1 <= pad <= 16 and data[-pad:] == bytes([pad]) * pad: - data = data[:-pad] - except Exception as e: - print(f" [emoji] encrypt下载解密失败 {md5[:12]}: {e}", flush=True) - - if not data or len(data) < 4: - return None - - # 检测格式 - if data[:3] == b'\xff\xd8\xff': - ext = '.jpg' - elif data[:4] == b'\x89PNG': - ext = '.png' - elif data[:3] == b'GIF': - ext = '.gif' - elif data[:4] == b'RIFF': - ext = '.webp' - elif data[:4] in (b'wxgf', b'wxam'): - # WXGF/WXAM 需要转换 - ext = '.gif' - tmp_path = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.wxgf") - with open(tmp_path, 'wb') as f: - f.write(data) - jpg_path = _convert_hevc_to_jpeg(tmp_path, os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.jpg")) - try: - os.unlink(tmp_path) - except OSError: - pass - if jpg_path: - return f"emoji_{md5}.jpg" - return None - else: - ext = '.bin' - - out_name = f"emoji_{md5}{ext}" - out_path = os.path.join(DECODED_IMAGE_DIR, out_name) - with open(out_path, 'wb') as f: - f.write(data) - print(f" [emoji] 下载缓存: {out_name} ({len(data)//1024}KB)", flush=True) - return out_name - - -class MonitorDBCache: - """轻量 DB 缓存,mtime 检测变化时重新解密(线程安全)""" - - def __init__(self, keys, tmp_dir): - self.keys = keys - self.tmp_dir = tmp_dir - os.makedirs(tmp_dir, exist_ok=True) - self._state = {} # rel_key → (db_mtime, wal_mtime) - self._locks = {} # per-key 锁,防止并发解密同一 DB - self._meta_lock = threading.Lock() - - def _get_lock(self, rel_key): - with self._meta_lock: - if rel_key not in self._locks: - self._locks[rel_key] = threading.Lock() - return self._locks[rel_key] - - def invalidate(self, rel_key): - """强制清除缓存状态,下次 get() 会重新全量解密""" - lock = self._get_lock(rel_key) - with lock: - self._state.pop(rel_key, None) - - def get(self, rel_key): - """返回解密后的临时文件路径,mtime 变化时自动重新解密""" - key_info = get_key_info(self.keys, rel_key) - if not key_info: - return None - - lock = self._get_lock(rel_key) - with lock: - enc_key = bytes.fromhex(key_info["enc_key"]) - rel_path = rel_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - - if not os.path.exists(db_path): - return None - - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - return None - - out_name = rel_key.replace('\\', '_').replace('/', '_') - out_path = os.path.join(self.tmp_dir, out_name) - - prev = self._state.get(rel_key) - - if prev is None or db_mtime != prev[0]: - t0 = time.perf_counter() - for _retry in range(3): - try: - full_decrypt(db_path, out_path, enc_key) - break - except PermissionError: - if _retry < 2: - time.sleep(1) - else: - raise - if os.path.exists(wal_path): - decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} 全量解密 {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) - elif wal_mtime != prev[1]: - t0 = time.perf_counter() - decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} WAL patch {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) - - return out_path - - -def build_username_db_map(): - """从已解密的 Name2Id 表构建 username → [db_keys] 映射 - - 同一个 username 可能存在于多个 message_N.db 中, - 按 DB 文件修改时间倒序排列(最新的排前面)。 - """ - # 先获取每个 DB 的 mtime 用于排序 - db_mtimes = {} - for i in range(5): - rel_key = os.path.join("message", f"message_{i}.db") - db_path = os.path.join(DB_DIR, "message", f"message_{i}.db") - try: - db_mtimes[rel_key] = os.path.getmtime(db_path) - except OSError: - db_mtimes[rel_key] = 0 - - mapping = {} # username → [db_keys], 最新的在前 - decrypted_msg_dir = os.path.join(_cfg["decrypted_dir"], "message") - for i in range(5): - db_path = os.path.join(decrypted_msg_dir, f"message_{i}.db") - if not os.path.exists(db_path): - continue - rel_key = os.path.join("message", f"message_{i}.db") - try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) - for row in conn.execute("SELECT user_name FROM Name2Id").fetchall(): - if row[0] not in mapping: - mapping[row[0]] = [] - mapping[row[0]].append(rel_key) - conn.close() - except Exception as e: - print(f" [WARN] Name2Id message_{i}.db: {e}", flush=True) - - # 对每个 username 的 db_keys 按 mtime 倒序(最新的优先) - for username in mapping: - mapping[username].sort(key=lambda k: db_mtimes.get(k, 0), reverse=True) - - return mapping - - -def decrypt_page(enc_key, page_data, pgno): - """解密单个加密页面""" - iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16] - if pgno == 1: - encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ) - else: - encrypted = page_data[:PAGE_SZ - RESERVE_SZ] - cipher = AES.new(enc_key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(encrypted) - return decrypted + b'\x00' * RESERVE_SZ - - -def full_decrypt(db_path, out_path, enc_key): - """首次全量解密""" - t0 = time.perf_counter() - file_size = os.path.getsize(db_path) - total_pages = file_size // PAGE_SZ - - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout: - for pgno in range(1, total_pages + 1): - page = fin.read(PAGE_SZ) - if len(page) < PAGE_SZ: - if len(page) > 0: - page = page + b'\x00' * (PAGE_SZ - len(page)) - else: - break - fout.write(decrypt_page(enc_key, page, pgno)) - - ms = (time.perf_counter() - t0) * 1000 - return total_pages, ms - - -def decrypt_wal_full(wal_path, out_path, enc_key): - """解密WAL当前有效frame,patch到已解密的DB副本 - - WAL是预分配固定大小(4MB),包含当前有效frame和上一轮遗留的旧frame。 - 通过WAL header中的salt值区分:只有frame header的salt匹配WAL header的才是有效frame。 - - 返回: (patched_pages, elapsed_ms) - """ - t0 = time.perf_counter() - - if not os.path.exists(wal_path): - return 0, 0 - - wal_size = os.path.getsize(wal_path) - if wal_size <= WAL_HEADER_SZ: - return 0, 0 - - frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ # 24 + 4096 = 4120 - patched = 0 - - with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df: - # 读WAL header,获取当前salt值 - wal_hdr = wf.read(WAL_HEADER_SZ) - wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0] - wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0] - - while wf.tell() + frame_size <= wal_size: - fh = wf.read(WAL_FRAME_HEADER_SZ) - if len(fh) < WAL_FRAME_HEADER_SZ: - break - pgno = struct.unpack('>I', fh[0:4])[0] - frame_salt1 = struct.unpack('>I', fh[8:12])[0] - frame_salt2 = struct.unpack('>I', fh[12:16])[0] - - ep = wf.read(PAGE_SZ) - if len(ep) < PAGE_SZ: - break - - # 校验: pgno有效 且 salt匹配当前WAL周期 - if pgno == 0 or pgno > 1000000: - continue - if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: - continue # 旧周期遗留的frame,跳过 - - dec = decrypt_page(enc_key, ep, pgno) - df.seek((pgno - 1) * PAGE_SZ) - df.write(dec) - patched += 1 - - ms = (time.perf_counter() - t0) * 1000 - return patched, ms - - -def load_contact_names(): - names = {} - try: - conn = sqlite3.connect(CONTACT_CACHE) - for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall(): - names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0] - conn.close() - except: - pass - return names - - -def _extract_pb_field_30(data): - """从 extra_buffer (protobuf) 中提取 Field #30 的字符串值(联系人标签ID)""" - if not data: - return None - pos = 0 - n = len(data) - while pos < n: - tag = 0 - shift = 0 - while pos < n: - b = data[pos]; pos += 1 - tag |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - field_num = tag >> 3 - wire_type = tag & 0x07 - if wire_type == 0: - while pos < n and data[pos] & 0x80: - pos += 1 - pos += 1 - elif wire_type == 2: - length = 0; shift = 0 - while pos < n: - b = data[pos]; pos += 1 - length |= (b & 0x7f) << shift - if not (b & 0x80): - break - shift += 7 - if field_num == 30: - try: - return data[pos:pos + length].decode('utf-8') - except Exception: - return None - pos += length - elif wire_type == 1: - pos += 8 - elif wire_type == 5: - pos += 4 - else: - break - return None - - -def load_contact_tags(): - """加载联系人标签及其成员""" - try: - conn = sqlite3.connect(CONTACT_CACHE) - try: - label_rows = conn.execute( - "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_" - ).fetchall() - except Exception: - conn.close() - return [] - if not label_rows: - conn.close() - return [] - - labels = {} - for lid, lname, sort_order in label_rows: - labels[lid] = {'id': lid, 'name': lname, 'sort_order': sort_order, 'members': []} - - names = load_contact_names() - rows = conn.execute( - "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL" - ).fetchall() - conn.close() - - for username, buf in rows: - label_str = _extract_pb_field_30(buf) - if not label_str: - continue - display = names.get(username, username) - for lid_s in label_str.split(','): - try: - lid = int(lid_s.strip()) - except (ValueError, AttributeError): - continue - if lid in labels: - labels[lid]['members'].append({'username': username, 'display_name': display}) - - result = sorted(labels.values(), key=lambda t: t['sort_order']) - for t in result: - t['member_count'] = len(t['members']) - return result - except Exception: - return [] - - -def format_msg_type(t): - return { - 1: '文本', 3: '图片', 34: '语音', 42: '名片', - 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 50: '通话', 10000: '系统', 10002: '撤回', - }.get(t, f'type={t}') - - -def msg_type_icon(t): - return { - 1: '💬', 3: '🖼️', 34: '🎤', 42: '👤', - 43: '🎬', 47: '😀', 48: '📍', 49: '🔗', - 50: '📞', 10000: '⚙️', 10002: '↩️', - }.get(t, '📨') - - -def broadcast_sse(msg_data): - event_type = msg_data.get('event', '') - data_line = f"data: {json.dumps(msg_data, ensure_ascii=False)}\n" - if event_type: - payload = f"event: {event_type}\n{data_line}\n" - else: - payload = f"{data_line}\n" - with sse_lock: - dead = [] - for q in sse_clients: - try: - q.put_nowait(payload) - except: - dead.append(q) - for q in dead: - sse_clients.remove(q) - - -def _convert_hevc_to_jpeg(hevc_path, jpeg_path): - """将 wxgf/HEVC 文件转为 JPEG - - wxgf 是微信自有格式: wxgf header + ICC profile + HEVC NAL units - 通过扫描 HEVC VPS start code (00 00 00 01 40 01) 定位 Annex B 流, - 再用 PyAV (ffmpeg) 解码首帧为 JPEG。 - """ - try: - import av - - with open(hevc_path, 'rb') as f: - data = f.read() - - # 扫描 HEVC Annex B VPS start code: 00 00 00 01 40 01 - vps_sig = b'\x00\x00\x00\x01\x40\x01' - hevc_start = data.find(vps_sig) - if hevc_start < 0: - # fallback: 找 SPS (00 00 00 01 42 01) - hevc_start = data.find(b'\x00\x00\x00\x01\x42\x01') - if hevc_start < 0: - print(f" [img] wxgf 中未找到 HEVC VPS/SPS", flush=True) - return None - - # 提取 HEVC Annex B 流并用 PyAV 解码 - h265_path = hevc_path + '.h265' - with open(h265_path, 'wb') as f: - f.write(data[hevc_start:]) - - try: - container = av.open(h265_path, format='hevc') - for frame in container.decode(video=0): - img = frame.to_image() - img.save(jpeg_path, "JPEG", quality=90) - container.close() - return jpeg_path - container.close() - finally: - if os.path.exists(h265_path): - os.unlink(h265_path) - - except ImportError: - print(f" [img] 需要 PyAV: pip install av", flush=True) - except Exception as e: - print(f" [img] HEVC→JPEG 失败: {e}", flush=True) - return None - - -# ============ 监听器 ============ - -class SessionMonitor: - def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None): - self.enc_key = enc_key - self.session_db = session_db - self.wal_path = session_db + "-wal" - self.contact_names = contact_names - self.db_cache = db_cache - self.username_db_map = username_db_map or {} - self.prev_state = {} - self.decrypt_ms = 0 - self.patched_pages = 0 - # 已显示消息去重: {(username, timestamp, base_msg_type), ...} - self._shown_keys = set() - - def resolve_image(self, username, timestamp): - """解密图片: username+timestamp → 解密后的图片文件名,失败返回 None""" - if not self.db_cache or not self.username_db_map: - return None - - # 1. 找到 username 对应的所有 message_N.db(按 mtime 倒序) - db_keys = self.username_db_map.get(username) - if not db_keys: - return None - - # 2. 遍历候选 DB,找到包含该 timestamp 消息的那个 - table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - local_id = None - for db_key in db_keys: - for _try in range(2): - msg_db_path = self.db_cache.get(db_key) - if not msg_db_path: - break - try: - conn = sqlite3.connect(f"file:{msg_db_path}?mode=ro", uri=True) - # 微信4.0 图片的 local_type 可能是复合编码: (sub<<32)|3 - row = conn.execute(f""" - SELECT local_id FROM [{table_name}] - WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) - AND create_time = ? - """, (timestamp,)).fetchone() - if not row: - row = conn.execute(f""" - SELECT local_id FROM [{table_name}] - WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) - AND ABS(create_time - ?) <= 3 - ORDER BY ABS(create_time - ?) LIMIT 1 - """, (timestamp, timestamp)).fetchone() - conn.close() - if row: - local_id = row[0] - break - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [img] {db_key} malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(db_key) - continue - if 'no such table' not in str(e): - print(f" [img] 查询 {db_key}/{table_name} 失败: {e}", flush=True) - break - if local_id: - break - - if not local_id: - print(f" [img] 未找到 local_id: {username} t={timestamp}", flush=True) - return None - - # 4. 查 message_resource.db 获取 MD5 - # local_id 不全局唯一,需要同时匹配 create_time - file_md5 = None - for _try in range(2): - res_path = self.db_cache.get(os.path.join("message", "message_resource.db")) - if not res_path: - return None - try: - conn = sqlite3.connect(f"file:{res_path}?mode=ro", uri=True) - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_local_id = ? AND message_create_time = ? " - "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", - (local_id, timestamp) - ).fetchone() - if not row: - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_create_time = ? " - "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", - (timestamp,) - ).fetchone() - conn.close() - if row and row[0]: - file_md5 = extract_md5_from_packed_info(row[0]) - break - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [img] resource DB malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(os.path.join("message", "message_resource.db")) - continue - print(f" [img] 查询 message_resource 失败: {e}", flush=True) - return None - - if not file_md5: - print(f" [img] 未找到 MD5: local_id={local_id} t={timestamp}", flush=True) - return None - - # 5. 查找 .dat 文件 - attach_dir = os.path.join(WECHAT_BASE_DIR, "msg", "attach") - username_hash = hashlib.md5(username.encode()).hexdigest() - search_base = os.path.join(attach_dir, username_hash) - - if not os.path.isdir(search_base): - print(f" [img] attach 目录不存在: {search_base}", flush=True) - return None - - pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat") - dat_files = sorted(glob_mod.glob(pattern)) - if not dat_files: - print(f" [img] 未找到 .dat: MD5={file_md5}", flush=True) - return None - - # 分类 .dat 文件 - # 优先级: 原图.dat(最大) > _h.dat > _W.dat > _t.dat(缩略图) - ranked = [] - for f in dat_files: - fname = os.path.basename(f).lower() - sz = os.path.getsize(f) - if '_t_' in fname: - rank = 5 # _t_W.dat 缩略图变体 - elif '_t.' in fname: - rank = 4 # _t.dat 缩略图 - elif '_w.' in fname: - rank = 2 # _W.dat (V2 可转 JPEG) - elif '_h.' in fname: - rank = 1 # 高清 - elif fname == f"{file_md5}.dat".lower(): - rank = 0 # 原图 (最优先) - else: - rank = 0 - ranked.append((rank, sz, f)) - ranked.sort(key=lambda x: (x[0], -x[1])) - - # 6. 解密图片 - os.makedirs(DECODED_IMAGE_DIR, exist_ok=True) - out_base = os.path.join(DECODED_IMAGE_DIR, file_md5) - rank_names = {0: 'orig', 1: 'h', 2: 'W', 4: 't', 5: 't_W'} - browser_formats = ('jpg', 'png', 'gif', 'webp') - - # 已有可用缓存则跳过 - for ext in browser_formats: - candidate = f"{out_base}.{ext}" - if os.path.exists(candidate): - cached_sz = os.path.getsize(candidate) - best_rank = ranked[0][0] if ranked else 99 - if cached_sz > 20480 or best_rank >= 4: - return os.path.basename(candidate) - os.unlink(candidate) - print(f" [img] 缩略图升级: {cached_sz/1024:.0f}KB → 重解密", flush=True) - break - - for rank, sz, selected in ranked: - sel_type = rank_names.get(rank, '?') - print(f" [img] 尝试 {sel_type}({sz/1024:.0f}KB): {os.path.basename(selected)}", flush=True) - - if is_v2_format(selected) and not IMAGE_AES_KEY: - print(f" [img] V2 格式缺少 AES key, 跳过", flush=True) - continue - - result_path, fmt = decrypt_dat_file(selected, f"{out_base}.tmp", IMAGE_AES_KEY, IMAGE_XOR_KEY) - if not result_path: - print(f" [img] 解密失败, 跳过", flush=True) - continue - - # HEVC/wxgf → 用 pillow-heif 转 JPEG - if fmt in ('hevc', 'bin'): - jpg_path = _convert_hevc_to_jpeg(result_path, f"{out_base}.jpg") - os.unlink(result_path) - if jpg_path: - size_kb = os.path.getsize(jpg_path) / 1024 - print(f" [img] HEVC→JPEG 成功: {os.path.basename(jpg_path)} ({size_kb:.0f}KB)", flush=True) - return os.path.basename(jpg_path) - print(f" [img] HEVC→JPEG 转换失败, 尝试下一个", flush=True) - continue - - final = f"{out_base}.{fmt}" - if os.path.exists(final): - os.unlink(final) - os.rename(result_path, final) - size_kb = os.path.getsize(final) / 1024 - print(f" [img] 解密成功: {os.path.basename(final)} ({size_kb:.0f}KB)", flush=True) - return os.path.basename(final) - - print(f" [img] 所有 .dat 均无法解密", flush=True) - return '__v2_unsupported__' - - def _async_resolve_image(self, username, timestamp, msg_data): - """后台线程: 解密图片并通过 SSE 推送更新""" - delays = [0.3, 1.0, 2.0] - for attempt in range(3): - try: - img_name = self.resolve_image(username, timestamp) - if img_name == '__v2_unsupported__': - msg_data['content'] = '[图片 - 新加密格式暂不支持预览]' - broadcast_sse({ - 'event': 'image_update', - 'timestamp': timestamp, - 'username': username, - 'v2_unsupported': True, - }) - return - elif img_name: - image_url = f'/img/{img_name}' - msg_data['image_url'] = image_url - broadcast_sse({ - 'event': 'image_update', - 'timestamp': timestamp, - 'username': username, - 'image_url': image_url, - }) - print(f" [img] 异步解密成功: {img_name}", flush=True) - return - elif attempt < 2: - time.sleep(delays[attempt]) - except Exception as e: - print(f" [img] 异步解密失败(attempt={attempt}): {e}", flush=True) - if attempt < 2: - time.sleep(delays[attempt]) - - def _fresh_decrypt_query(self, db_key, table_name, prev_ts, curr_ts): - """独立解密 message DB 到临时文件并查询,避免共享缓存竞态""" - key_info = get_key_info(self.db_cache.keys, db_key) - if not key_info: - return [] - enc_key = bytes.fromhex(key_info["enc_key"]) - rel_path = db_key.replace('\\', '/').replace('/', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" - if not os.path.exists(db_path): - return [] - - import tempfile - fd, tmp_path = tempfile.mkstemp(suffix='.db') - os.close(fd) - try: - t0 = time.perf_counter() - full_decrypt(db_path, tmp_path, enc_key) - if os.path.exists(wal_path): - decrypt_wal_full(wal_path, tmp_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [hidden] {db_key} 独立解密 {ms:.0f}ms", flush=True) - - conn = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True) - rows = conn.execute(f""" - SELECT create_time, local_type, message_content, WCDB_CT_message_content - FROM [{table_name}] - WHERE create_time >= ? AND create_time <= ? - ORDER BY create_time ASC - """, (prev_ts, curr_ts)).fetchall() - conn.close() - return rows - except Exception as e: - print(f" [hidden] {db_key} 独立解密失败: {e}", flush=True) - return [] - finally: - try: - os.unlink(tmp_path) - except OSError: - pass - - def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender): - """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) - - 先用共享缓存查询(快),失败或可疑时用独立解密(慢但可靠)。 - """ - if not self.username_db_map: - return - db_keys = self.username_db_map.get(username) - if not db_keys: - return - - table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - print(f" [hidden] 检查 {display[:15]} prev_ts={prev_ts} curr_ts={curr_ts} type={curr_msg_type}", flush=True) - - # 等待 message DB 写入完成 - time.sleep(1.0) - - # 快速路径: 用共享缓存查询(带重试) - all_rows = [] - cache_failed = False - for _try in range(3): - all_rows.clear() - if self.db_cache: - for db_key in db_keys: - dec_path = self.db_cache.get(db_key) - if not dec_path: - continue - try: - conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) - rows = conn.execute(f""" - SELECT create_time, local_type, message_content, WCDB_CT_message_content - FROM [{table_name}] - WHERE create_time >= ? AND create_time <= ? - ORDER BY create_time ASC - """, (prev_ts, curr_ts)).fetchall() - conn.close() - all_rows.extend(rows) - except Exception as e: - print(f" [hidden] 缓存查询失败 {db_key}: {e}", flush=True) - cache_failed = True - break - # 检查是否找到了 curr_ts 的消息(说明缓存是最新的) - has_curr = any(r[0] == curr_ts for r in all_rows) - if has_curr or cache_failed: - break - # 缓存可能还没更新到最新数据,短暂等待后重试 - if _try < 2: - time.sleep(1.5) - print(f" [hidden] 缓存未包含最新消息,重试({_try+1})...", flush=True) - - # 仅在缓存查询出错时才用昂贵的独立解密 - if cache_failed: - print(f" [hidden] 缓存异常,启动独立解密...", flush=True) - all_rows = [] - for db_key in db_keys: - rows = self._fresh_decrypt_query(db_key, table_name, prev_ts, curr_ts) - all_rows.extend(rows) - if rows: - break - else: - print(f" [hidden] 缓存查到 {len(all_rows)} 条", flush=True) - - # 过滤出隐藏消息 - hidden_msgs = [] - for ts, lt, mc, ct in all_rows: - base = lt % 4294967296 if lt > 4294967296 else lt - # 跳过已显示的消息(精确匹配 username+timestamp+type) - if (username, ts, base) in self._shown_keys: - continue - # 解压 zstd - if isinstance(mc, bytes) and ct == 4: - try: - mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') - except Exception: - mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else '' - elif isinstance(mc, bytes): - mc = mc.decode('utf-8', errors='replace') - hidden_msgs.append((ts, base, mc or '')) - - print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True) - - if not hidden_msgs: - return - - global messages_log - for ts, base, mc in hidden_msgs: - self._shown_keys.add((username, ts, base)) - msg_data = { - 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), - 'timestamp': ts, - 'chat': display, - 'username': username, - 'is_group': is_group, - 'sender': sender, - } - if base == 3: - # 隐藏的图片消息 - time.sleep(0.5) - img_name = self.resolve_image(username, ts) - if img_name and img_name != '__v2_unsupported__': - msg_data.update({ - 'type': '图片', 'type_icon': '\U0001f5bc\ufe0f', - 'content': '', 'image_url': f'/img/{img_name}', - }) - print(f" [hidden] 补充图片: {img_name} t={ts}", flush=True) - else: - continue - elif base == 1: - # 隐藏的文字消息 - msg_data.update({ - 'type': '文本', 'type_icon': '\U0001f4ac', - 'content': mc, - }) - print(f" [hidden] 补充文字: {mc[:30]} t={ts}", flush=True) - elif base == 47: - # 隐藏的表情消息 - rich = self.resolve_rich_content(username, ts, 47) - msg_data.update({ - 'type': '表情', 'type_icon': '\U0001f600', - 'content': '[表情]', - }) - if rich: - msg_data['rich_content'] = rich - print(f" [hidden] 补充表情 t={ts}", flush=True) - elif base == 49: - # 隐藏的富媒体消息 - rich = self.resolve_rich_content(username, ts, 49) - msg_data.update({ - 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), - 'content': mc[:100] if mc else '', - }) - if rich: - msg_data['rich_content'] = rich - print(f" [hidden] 补充富媒体 t={ts}", flush=True) - else: - # 其他类型 - msg_data.update({ - 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), - 'content': mc[:100] if mc else f'[{format_msg_type(base)}]', - }) - print(f" [hidden] 补充type={base} t={ts}", flush=True) - - with messages_lock: - messages_log.append(msg_data) - if len(messages_log) > MAX_LOG: - messages_log = messages_log[-MAX_LOG:] - broadcast_sse(msg_data) - - def _query_msg_content(self, username, timestamp, base_type): - """通用: 从 message_*.db 查找指定类型消息的 XML 内容 - - base_type: 基础类型 (47, 49, 43, 34 等) - 微信4.0 的 local_type 是复合编码: (sub_type << 32) | base_type - """ - db_keys = self.username_db_map.get(username, []) - if not db_keys: - return None - - tbl = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" - for dk in db_keys: - for _try in range(2): - dec_path = self.db_cache.get(dk) - if not dec_path: - break - try: - conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) - row = conn.execute(f''' - SELECT message_content, WCDB_CT_message_content, local_type - FROM "{tbl}" - WHERE (local_type = ? OR (local_type > 4294967296 AND local_type % 4294967296 = ?)) - AND create_time BETWEEN ? AND ? - ORDER BY create_time DESC LIMIT 1 - ''', (base_type, base_type, timestamp - 5, timestamp + 5)).fetchone() - conn.close() - - if not row: - break # 表存在但没找到匹配行,换下一个 DB - mc, ct_flag, full_type = row - if isinstance(mc, bytes) and ct_flag == 4: - mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') - elif isinstance(mc, bytes): - mc = mc.decode('utf-8', errors='replace') - if not mc: - break - - xml_start = mc.find('') - if xml_start < 0: - xml_start = mc.find(' 0: - mc = mc[xml_start:] - - return mc, full_type - - except Exception as e: - if 'malformed' in str(e) and _try == 0: - print(f" [rich] {dk} malformed, 强制刷新...", flush=True) - self.db_cache.invalidate(dk) - continue - if 'no such table' not in str(e): - print(f" [rich] 查询 {dk} 失败: {e}", flush=True) - break - return None - - def _parse_rich_content(self, username, timestamp, msg_type): - """解析富媒体消息, 返回 dict 或 None""" - import xml.etree.ElementTree as ET - - if msg_type == 47: - # --- 表情 --- - result = self._query_msg_content(username, timestamp, 47) - if not result: - print(f" [emoji] 查询失败 user={username[:10]} ts={timestamp}", flush=True) - return None - mc, _ = result - if '> 32 if full_type > 4294967296 else 0 - if '= 20: - break - except ET.ParseError: - pass - return { - 'type': 'chatlog', - 'title': title, - 'des': des[:200] if des else '', - 'items': items, - } - else: - # 其他子类型: 用 title 显示 - if title: - return { - 'type': 'link', - 'title': title, - 'des': des[:200] if des else '', - 'url': url, - } - except ET.ParseError: - pass - return None - - elif msg_type == 43: - # --- 视频 --- - result = self._query_msg_content(username, timestamp, 43) - if not result: - return None - mc, _ = result - try: - root = ET.fromstring(mc) - video = root.find('.//videomsg') - if video is None: - return None - length = int(video.get('playlength') or 0) - return { - 'type': 'video', - 'duration': length, - } - except ET.ParseError: - pass - return None - - elif msg_type == 34: - # --- 语音 --- - result = self._query_msg_content(username, timestamp, 34) - if not result: - return None - mc, _ = result - try: - root = ET.fromstring(mc) - voice = root.find('.//voicemsg') - if voice is None: - return None - length_ms = int(voice.get('voicelength') or 0) - return { - 'type': 'voice', - 'duration': round(length_ms / 1000, 1), - } - except ET.ParseError: - pass - return None - - return None - - def _async_resolve_rich(self, username, timestamp, msg_type, msg_data): - """后台线程: 解析富媒体内容并推送 SSE(带重试)""" - delays = [0.5, 1.5, 3.0] - for attempt in range(3): - try: - time.sleep(delays[attempt]) - info = self._parse_rich_content(username, timestamp, msg_type) - if info: - msg_data['rich'] = info - broadcast_sse({ - 'event': 'rich_update', - 'timestamp': timestamp, - 'username': username, - 'rich': info, - }) - print(f" [rich] {info['type']} 解析成功", flush=True) - return - except Exception as e: - print(f" [rich] 解析失败: {e}", flush=True) - print(f" [rich] type={msg_type} 3次重试均失败: {username}", flush=True) - - def query_state(self): - """查询已解密副本的session状态""" - conn = sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro", uri=True) - state = {} - for r in conn.execute(""" - SELECT username, unread_count, summary, last_timestamp, - last_msg_type, last_msg_sender, last_sender_display_name - FROM SessionTable WHERE last_timestamp > 0 - """).fetchall(): - state[r[0]] = { - 'unread': r[1], 'summary': r[2] or '', 'timestamp': r[3], - 'msg_type': r[4], 'sender': r[5] or '', 'sender_name': r[6] or '', - } - conn.close() - return state - - def do_full_refresh(self): - """全量解密DB + 全量WAL patch""" - # 先解密主DB - pages, ms = full_decrypt(self.session_db, DECRYPTED_SESSION, self.enc_key) - total_ms = ms - wal_patched = 0 - - # 再patch所有WAL frames - if os.path.exists(self.wal_path): - wal_patched, ms2 = decrypt_wal_full(self.wal_path, DECRYPTED_SESSION, self.enc_key) - total_ms += ms2 - - self.decrypt_ms = total_ms - self.patched_pages = pages + wal_patched - return self.patched_pages - - def check_updates(self): - global messages_log - try: - t0 = time.perf_counter() - self.do_full_refresh() - t1 = time.perf_counter() - curr_state = self.query_state() - t2 = time.perf_counter() - print(f" [perf] decrypt={self.patched_pages}页/{(t1-t0)*1000:.1f}ms, query={(t2-t1)*1000:.1f}ms", flush=True) - except Exception as e: - print(f" [ERROR] check_updates: {e}", flush=True) - return - - # 收集所有新消息,按时间排序后再推送 - new_msgs = [] - for username, curr in curr_state.items(): - prev = self.prev_state.get(username) - # 检测: 时间戳变化 OR 同一秒内消息类型变化(文字+图片组合) - is_new = prev and (curr['timestamp'] > prev['timestamp'] or - (curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type'))) - if is_new: - display = self.contact_names.get(username, username) - is_group = '@chatroom' in username - # 新群/新联系人不在缓存中时,重新加载联系人 - if display == username and username not in self.contact_names: - refreshed = load_contact_names() - self.contact_names.update(refreshed) - display = self.contact_names.get(username, username) - sender = '' - if is_group: - sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender']) - - summary = curr['summary'] - if isinstance(summary, bytes): - try: - summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace') - except Exception: - summary = '(压缩内容)' - if summary and ':\n' in summary: - summary = summary.split(':\n', 1)[1] - - msg_data = { - 'time': datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S'), - 'timestamp': curr['timestamp'], - 'chat': display, - 'username': username, - 'is_group': is_group, - 'sender': sender, - 'type': format_msg_type(curr['msg_type']), - 'type_icon': msg_type_icon(curr['msg_type']), - 'content': summary, - 'unread': curr['unread'], - 'decrypt_ms': round(self.decrypt_ms, 1), - 'pages': self.patched_pages, - } - - new_msgs.append(msg_data) - self._shown_keys.add((username, curr['timestamp'], curr['msg_type'])) - - # 图片消息: 后台异步解密(不阻塞轮询) - if curr['msg_type'] == 3: - _img_executor.submit( - self._async_resolve_image, - username, curr['timestamp'], msg_data - ) - - # 富媒体消息: 后台解析内容 - if curr['msg_type'] in (47, 49, 43, 34): - _img_executor.submit( - self._async_resolve_rich, - username, curr['timestamp'], curr['msg_type'], msg_data - ) - - # 检查时间窗口内是否有被 session 摘要覆盖的消息 - # (比如用户发了 图片+文字,session只记录最后一条) - prev_ts = prev['timestamp'] if prev else curr['timestamp'] - 5 - _hidden_executor.submit( - self._check_hidden_messages, - username, prev_ts, curr['timestamp'], curr['msg_type'], - display, is_group, sender - ) - - # 按时间排序 - new_msgs.sort(key=lambda m: m['timestamp']) - - for msg in new_msgs: - with messages_lock: - messages_log.append(msg) - if len(messages_log) > MAX_LOG: - messages_log = messages_log[-MAX_LOG:] - - broadcast_sse(msg) - - try: - now = time.time() - msg_age = now - msg['timestamp'] - tag = f"{self.patched_pages}pg/{self.decrypt_ms:.0f}ms" - sender = msg['sender'] - now_str = datetime.fromtimestamp(now).strftime('%H:%M:%S') - if sender: - print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {sender}: {msg['content']} ({tag})", flush=True) - else: - print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {msg['content']} ({tag})", flush=True) - except Exception: - pass # Windows CMD编码问题,不影响SSE推送 - - self.prev_state = curr_state - - # 清理过期的去重 key(保留最近 5 分钟) - cutoff = int(time.time()) - 300 - self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff} - -def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None): - mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map) - wal_path = mon.wal_path - - # 初始全量解密 - pages, ms = full_decrypt(session_db, DECRYPTED_SESSION, enc_key) - wal_patched = 0 - wal_ms = 0 - if os.path.exists(wal_path): - wal_patched, wal_ms = decrypt_wal_full(wal_path, DECRYPTED_SESSION, enc_key) - print(f"[init] DB {pages}页/{ms:.0f}ms + WAL {wal_patched}页/{wal_ms:.0f}ms", flush=True) - else: - print(f"[init] DB {pages}页/{ms:.0f}ms", flush=True) - - mon.prev_state = mon.query_state() - print(f"[monitor] 跟踪 {len(mon.prev_state)} 个会话", flush=True) - print(f"[monitor] mtime轮询模式 (每{POLL_MS}ms)", flush=True) - - # mtime-based 轮询: WAL是预分配固定大小,不能用size检测 - poll_interval = POLL_MS / 1000 - prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - prev_db_mtime = os.path.getmtime(session_db) - - while True: - time.sleep(poll_interval) - try: - # 用mtime检测WAL和DB变化 - try: - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - db_mtime = os.path.getmtime(session_db) - except OSError: - continue - - if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime: - continue # 无变化 - - t_detect = time.perf_counter() - wal_changed = wal_mtime != prev_wal_mtime - db_changed = db_mtime != prev_db_mtime - - mon.check_updates() - - t_done = time.perf_counter() - try: - detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3] - print(f" [{detect_str}] WAL={'变' if wal_changed else '-'} DB={'变' if db_changed else '-'} 总耗时={(t_done-t_detect)*1000:.1f}ms", flush=True) - except Exception: - pass - - prev_wal_mtime = wal_mtime - prev_db_mtime = db_mtime - - except Exception as e: - print(f"[poll] 错误: {e}", flush=True) - time.sleep(1) - - -# ============ Web ============ - -HTML_PAGE = ''' - - - - -微信消息监听 - - - -
-

WeChat Monitor

-
SSE 实时
-
0 消息
- -
-
-
-

通知设置

-
-
-

全局

-
-
-
-
-

规则

-
- -
-
-
- -
-
📡

等待新消息...

WAL增量解密 · SSE推送

-
- - -''' - - -class Handler(BaseHTTPRequestHandler): - def log_message(self, *a): pass - def handle(self): - try: - super().handle() - except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError): - pass # 浏览器关闭连接,正常 - - def do_GET(self): - if self.path in ('/', '/index.html'): - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.end_headers() - self.wfile.write(HTML_PAGE.encode('utf-8')) - - elif self.path.startswith('/api/history'): - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - filter_chat = params.get('chat', [''])[0].strip().lower() - since_ts = 0 - try: - since_ts = int(params.get('since', ['0'])[0]) - except (ValueError, TypeError): - pass - limit_val = 500 - try: - limit_val = min(int(params.get('limit', ['500'])[0]), 2000) - except (ValueError, TypeError): - pass - - with messages_lock: - data = sorted(messages_log, key=lambda m: m.get('timestamp', 0)) - - if since_ts: - data = [m for m in data if m.get('timestamp', 0) > since_ts] - if filter_chat: - data = [m for m in data if filter_chat in m.get('chat', '').lower() - or filter_chat in m.get('username', '').lower()] - data = data[-limit_val:] - - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.end_headers() - self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) - - elif self.path.startswith('/img/'): - filename = urllib.parse.unquote(self.path[5:]) - # 安全: 防目录穿越 - if '/' in filename or '\\' in filename or '..' in filename: - self.send_error(403) - return - filepath = os.path.join(DECODED_IMAGE_DIR, filename) - if not os.path.isfile(filepath): - self.send_error(404) - return - ext = os.path.splitext(filename)[1].lower() - ct = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.gif': 'image/gif', - '.webp': 'image/webp', '.bmp': 'image/bmp', - '.tif': 'image/tiff', - }.get(ext, 'application/octet-stream') - with open(filepath, 'rb') as f: - data = f.read() - self.send_response(200) - self.send_header('Content-Type', ct) - self.send_header('Content-Length', str(len(data))) - self.send_header('Cache-Control', 'public, max-age=86400') - self.end_headers() - self.wfile.write(data) - - elif self.path.startswith('/api/tags'): - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - name_filter = params.get('name', [''])[0].strip().lower() - - tags = load_contact_tags() - if name_filter: - tags = [t for t in tags if name_filter in t['name'].lower()] - - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.end_headers() - self.wfile.write(json.dumps(tags, ensure_ascii=False).encode('utf-8')) - - elif self.path == '/stream': - self.send_response(200) - self.send_header('Content-Type', 'text/event-stream') - self.send_header('Cache-Control', 'no-cache') - self.send_header('Connection', 'keep-alive') - self.end_headers() - - q = queue.Queue() - with sse_lock: - sse_clients.append(q) - try: - while True: - try: - payload = q.get(timeout=15) - self.wfile.write(payload.encode('utf-8')) - self.wfile.flush() - except queue.Empty: - self.wfile.write(b': hb\n\n') - self.wfile.flush() - except: - pass - finally: - with sse_lock: - if q in sse_clients: - sse_clients.remove(q) - else: - self.send_error(404) - - -class ThreadedServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - allow_reuse_address = True - - -def main(): - print("=" * 60, flush=True) - print(" 微信实时监听 (WAL增量 + SSE推送)", flush=True) - print("=" * 60, flush=True) - - with open(KEYS_FILE, encoding="utf-8") as f: - keys = strip_key_metadata(json.load(f)) - - session_key_info = get_key_info(keys, os.path.join("session", "session.db")) - if not session_key_info: - print("[ERROR] 找不到 session.db 的密钥", flush=True) - sys.exit(1) - enc_key = bytes.fromhex(session_key_info["enc_key"]) - session_db = os.path.join(DB_DIR, "session", "session.db") - - print("加载联系人...", flush=True) - contact_names = load_contact_names() - print(f"已加载 {len(contact_names)} 个联系人", flush=True) - - print("构建 username→DB 映射...", flush=True) - username_db_map = build_username_db_map() - print(f"已映射 {len(username_db_map)} 个用户名", flush=True) - - # 启动时清理可能损坏的缓存 - if os.path.isdir(MONITOR_CACHE_DIR): - for f in os.listdir(MONITOR_CACHE_DIR): - fp = os.path.join(MONITOR_CACHE_DIR, f) - if f.endswith('.db'): - try: - c = sqlite3.connect(fp) - c.execute("SELECT 1 FROM sqlite_master LIMIT 1") - c.close() - except Exception: - try: - os.unlink(fp) - print(f"[cleanup] 删除损坏缓存: {f}", flush=True) - except PermissionError: - print(f"[cleanup] 缓存被占用跳过: {f}", flush=True) - - db_cache = MonitorDBCache(keys, MONITOR_CACHE_DIR) - - # 后台预热所有 message DB(图片/emoji 解密必需) - def _warmup(): - try: - t0 = time.perf_counter() - warmup_keys = [os.path.join("message", "message_resource.db")] - for i in range(5): - k = os.path.join("message", f"message_{i}.db") - if get_key_info(keys, k): - warmup_keys.append(k) - for k in warmup_keys: - t1 = time.perf_counter() - try: - db_cache.get(k) - print(f"[warmup] {k} {(time.perf_counter()-t1)*1000:.0f}ms", flush=True) - except Exception as e: - print(f"[warmup] {k} 失败: {e}", flush=True) - except Exception as e: - print(f"[warmup] 异常: {e}", flush=True) - # 构建 emoji 映射(独立解密,不走 cache) - _build_emoji_lookup(keys) - print(f"[warmup] 全部完成 {(time.perf_counter()-t0)*1000:.0f}ms", flush=True) - threading.Thread(target=_warmup, daemon=True).start() - - t = threading.Thread(target=monitor_thread, args=(enc_key, session_db, contact_names, db_cache, username_db_map), daemon=True) - t.start() - - server = ThreadedServer(('0.0.0.0', PORT), Handler) - print(f"\n=> http://localhost:{PORT}", flush=True) - print("Ctrl+C 停止\n", flush=True) - - try: - os.system(f'cmd.exe /c start http://localhost:{PORT}') - except Exception: - pass - - try: - server.serve_forever() - except KeyboardInterrupt: - print("\n已停止") - - -if __name__ == '__main__': - main()