From e5e2269947c575998f4430ed04ac8d34eb0dd7f7 Mon Sep 17 00:00:00 2001 From: ylytdeng Date: Sun, 17 May 2026 17:00:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20PR=20#107=20=E5=90=8E=E7=BB=AD=E6=B8=85?= =?UTF-8?q?=E7=90=86=20(security/=E6=AD=A3=E7=A1=AE=E6=80=A7/=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 4 个 review agent 在 PR #107 (5649 行巨型 PR) 找到的关键问题做最小 侵入修复。已合并代码本身能跑,这次是收紧 security + 消重 + 文档一致性。 ## 安全修复 ### wxwork_keys.json 落盘权限 (find_wxwork_keys.py) 含明文 16-byte raw key 的 keys 文件,之前 default umask 落盘。改成: 1. 写 tmp 文件 2. chmod 0o600 (Unix 严格 owner-only; Windows 上 chmod 控制只读位, 至少避免世界可读最差情况) 3. atomic rename 旧产物自然过期,新生成的都受保护。 ### SNS XXE 防护 (export_sns.py) 朋友圈 XML 来源是不可信输入(他人发的 content),原 `ET.fromstring()` 完全没过滤,可被恶意 entity expansion / 外部实体引用攻击。加跟 `mcp_server._XML_UNSAFE_RE` 同模式的过滤(拒 `` / ``) + 200KB 大小上限。`_parse_timeline_xml` 检查后才进 ET.fromstring。 ## 正确性 / 消重 ### AES 对齐公式统一 (decode_image.py + decrypt_sns.py + export_sns.py) 原本三处各写一份: - decode_image.py: aes_size -= ~(~aes_size % 16) ← bitwise trick - decrypt_sns.py: 同上 - export_sns.py: aes_size + (16 - aes_size%16) if … else aes_size+16 两个公式数学等价(对 0/1/15/16/17/100/1000/12345 全部验证一致),但 bitwise trick 难读且漂移风险高。抽 `aligned_aes_block_size()` 到 decode_image.py 作 canonical 实现, 另两处 import 复用。 ### 32-bit pointer 假设明确化 (find_wxwork_keys.py) reviewer 担心 `_read_u32` 在 64-bit 进程上错位,实测 WXWork.exe 5.0.x 是 **32-bit 进程** (`Program Files (x86)\WXWork\` + PE Machine = x86), 所以 4 字节读指针是对的。加注释明确这个假设,腾讯如果升级到 64-bit 要重做整套逆向, 当前实测全部 17 db 解密通过印证。 ## 一致性 ### main.py show_status() 走 _config_file_path() (main.py) 原硬编码 `config_file = "config.json"` 绕开 PR #107 新引入的 `_config_file_path()`,打包成 exe 后 cwd 不一定是 exe 目录,会读到错 位置。改成 `from config import _config_file_path`。 ### EXE_USAGE.md 输出目录写错 (EXE_USAGE.md) EXE_USAGE 说导出到 `export/`,代码实际 `output_base_dir = wechat_files/ /`,联系人下还是 `messages.csv/html/json` 而不是 `message_0.db.csv`。修正成真实结构。 ## 文档 README 加两段: - 安全提示: keys 文件 chmod 0600 + 不要 commit 到 git - 朋友圈 XML XXE 防护说明 ## 测试 185/185 通过 (含已有 wxsqlite3 roundtrip + image v2 + msg types filter + pagination hint + chat export helpers 等)。 aligned_aes_block_size 单独验证跟旧公式等价(0/1/15/16/17/100/1000/12345)。 ## 未跟进 (后续 follow-up issue) - 3 处 V1/V2/XOR 解密代码完全重复(decode_image / decrypt_sns / export_messages 各自实现)——抽出来工作量大,本次先抽 helper 不动 完整解密路径,后续单独 PR - export_messages HTML base64 内联图片可能爆几 GB,应改成可选 flag - SNS / wxwork export / batch_decrypt_images / voice_to_mp3 测试缺位 (0 个 test) --- EXE_USAGE.md | 26 ++++++++++++-------------- README.md | 9 +++++++++ decode_image.py | 20 ++++++++++++++++---- decrypt_sns.py | 3 ++- export_sns.py | 19 +++++++++++++++++-- find_wxwork_keys.py | 18 ++++++++++++++++-- main.py | 10 +++++++--- 7 files changed, 79 insertions(+), 26 deletions(-) diff --git a/EXE_USAGE.md b/EXE_USAGE.md index df4c55e..649dc1a 100644 --- a/EXE_USAGE.md +++ b/EXE_USAGE.md @@ -7,7 +7,7 @@ 3. 根据需要点击按钮: - **① 微信解密** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录 - **② 图片密钥** → 从微信进程提取新版图片 AES 密钥 - - **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录 + - **③ 导出数据** → 将聊天记录导出为 CSV / HTML / JSON 到 `wechat_files//<联系人>/` 目录 - **④ 朋友圈图片** → 解密朋友圈缓存图片 - **⑤ 企业微信解密** → 从企业微信进程提取密钥并解密数据库到 `wxwork_decrypted/` 目录 - **⑥ 企业微信导出** → 选择某个人或群,导出 CSV / HTML / JSON 到 `wxwork_export/` 目录 @@ -42,19 +42,17 @@ wxwork_export/ ← ⑥ 导出的企业微信聊天记录 messages.csv messages.html messages.json -export/ ← ③ 导出的聊天记录 - 张三/ - .info ← 联系人信息(username/alias/remark/nick_name) - message_0.db.csv ← CSV 格式(Excel 可直接打开) - message_0.db.html← HTML 格式(浏览器打开,微信气泡样式) - message_0.db.json← JSON 格式(程序处理用) - 李四/ - ... -data/ ← 导出时选择“同时转换语音为 MP3”后的输出 - 张三/ - .info - 20250101_120000_1.mp3 - ... +wechat_files/ ← ③ 导出的聊天记录 (按 wxid + 联系人组织) + / + 张三/ + .info ← 联系人信息(username/alias/remark/nick_name) + messages.csv ← CSV 格式(Excel 可直接打开) + messages.html ← HTML 格式(浏览器打开,微信气泡样式) + messages.json ← JSON 格式(程序处理用) + image/ ← 该联系人聊天涉及的图片 + 朋友圈图片/ ← ④ 解密后的朋友圈缓存图片 + 李四/ + ... ``` ## 导出格式说明 diff --git a/README.md b/README.md index 6c5e3fe..55d8c00 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,15 @@ make help # 列出所有命令 WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。 +#### 安全提示 + +- `all_keys.json` / `wxwork_keys.json` 包含明文 raw key,落盘时已 `chmod 0600`(Unix)或保留默认 ACL(Windows)。**勿提交到 git 或与人共享**——拿到 key 等于拿到全部聊天解密能力。 +- 解密后的 `.db` 文件是明文 SQLite,内容包括所有联系人、群、消息,**同样需要小心备份和分享**。 + +### 朋友圈解密的 XML 安全 + +`export_sns.py` 解析 SnsTimeLine 的 XML 时已加 **XXE 防护**(拒绝 `` / `` + 200KB 大小上限),避免恶意朋友圈 XML 通过 entity expansion 或外部实体引用执行 SSRF / 读取本地文件。`mcp_server.py` 解析其他类型 appmsg XML 同样有这层保护。 + ### GUI 工具箱 & 单 exe 打包 提供 tkinter 图形界面 (`app_gui.py`),集成核心功能: diff --git a/decode_image.py b/decode_image.py index 4a4442b..f387f1b 100644 --- a/decode_image.py +++ b/decode_image.py @@ -28,6 +28,21 @@ 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) + +def aligned_aes_block_size(aes_size): + """V1/V2 .dat AES 区段的实际字节数 (AES-CBC + PKCS7 总额外加 16 字节 padding)。 + + aes_size 不是 16 倍数: aligned = 向上对齐到 16 (aes_size + (16 - aes_size%16)) + aes_size 是 16 倍数: aligned = aes_size + 16 (完整 padding 块) + + canonical 实现, 给 decrypt_sns.py / export_sns.py / decode_image.py 共用。 + 早期 wx-dat 风格的 bitwise trick `aes_size - ~(~aes_size % 16)` 数学等价但 + 可读性差, 现在用清晰版本。 + """ + if aes_size % 16: + return aes_size + (16 - aes_size % 16) + return aes_size + 16 + # 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性) IMAGE_MAGIC = { 'png': [0x89, 0x50, 0x4E, 0x47], @@ -156,10 +171,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): if sig == V1_MAGIC_FULL: aes_key = b'cfcd208495d565ef' # md5("0")[:16] - # AES 对齐: PKCS7 填充使实际密文 >= aes_size,向上对齐到 16 - # 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块) - aligned_aes_size = aes_size - aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式 + aligned_aes_size = aligned_aes_block_size(aes_size) offset = 15 if offset + aligned_aes_size > len(data): diff --git a/decrypt_sns.py b/decrypt_sns.py index 174ec0f..02b39bf 100644 --- a/decrypt_sns.py +++ b/decrypt_sns.py @@ -13,6 +13,7 @@ if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") from config import load_config +from decode_image import aligned_aes_block_size _cfg = load_config() SNS_CACHE_DIR = _cfg.get("sns_cache_dir", "") @@ -78,7 +79,7 @@ def decrypt_dat(dat_path): if len(data) < 15: return None, None aes_size, xor_size = struct.unpack_from(' len(data): return None, None diff --git a/export_sns.py b/export_sns.py index c24a178..fe28ab2 100644 --- a/export_sns.py +++ b/export_sns.py @@ -21,6 +21,13 @@ if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") from config import load_config +from decode_image import aligned_aes_block_size + +# 朋友圈 XML 来源是不可信输入 (他人朋友圈的 content), 必须挡 XXE。 +# 跟 mcp_server._XML_UNSAFE_RE 保持同一过滤模式; max_len 比 mcp_server 宽松 +# (朋友圈 timeline XML 含媒体列表 + 评论, 实测可达几十KB; 给 200K 余量)。 +_SNS_XML_UNSAFE_RE = re.compile(r' len(data): return None @@ -228,7 +235,7 @@ def _build_sns_cache_index(): try: from Crypto.Cipher import AES as _AES aes_size, xor_size = struct.unpack_from(' _SNS_XML_MAX_LEN: + return None + if _SNS_XML_UNSAFE_RE.search(content_xml): + # XXE 防护: 拒绝 DOCTYPE/ENTITY,避免恶意朋友圈 XML 通过 entity expansion + # 或外部实体引用执行 SSRF/读取本地文件 + return None try: root = ET.fromstring(content_xml) except ET.ParseError: diff --git a/find_wxwork_keys.py b/find_wxwork_keys.py index cc98cf1..f259161 100644 --- a/find_wxwork_keys.py +++ b/find_wxwork_keys.py @@ -357,6 +357,11 @@ def _wxwork_page_size_chain(memory_regions, starts, cipher_addr): raw_key = cipher + 0x08 aes_ctx = *(cipher + 0x2c) page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24) + + NOTE: WXWork.exe (5.0.x) 是 **32-bit** 进程,所有指针 = 4 字节 (`_read_u32`)。 + 腾讯如果升级到 64-bit (`Program Files\WXWork\`),结构偏移和指针大小都要重做 + 逆向,这套代码会直接失效——届时需要扫描入口加 IsWow64Process 检测并给出 + 友好报错。当前实测 5.0.8.6009 全部 17 个 db 解密成功,该 32-bit 假设有效。 """ page_size_holder = _read_u32(memory_regions, starts, cipher_addr + 0x30) if page_size_holder is None or not _valid_ptr(memory_regions, starts, page_size_holder, 8): @@ -500,9 +505,18 @@ def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_ raise RuntimeError("未能从任何企业微信进程中提取到密钥") result["_db_dir"] = db_dir - with open(out_file, 'w', encoding='utf-8') as f: + # 写文件含明文 raw key,先 atomic write 到 tmp 再 rename,中途 chmod 0600 + # 限本用户读写。Windows 上 os.chmod 主要控制只读位,严格 ACL 需 win32security + # 这里至少避免世界可读的最差情况。 + tmp_file = out_file + ".tmp" + with open(tmp_file, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2, ensure_ascii=False) - print_fn(f"\n密钥保存到: {out_file}") + try: + os.chmod(tmp_file, 0o600) + except OSError: + pass # Windows 上某些场景 chmod 可能失败,不阻塞主流程 + os.replace(tmp_file, out_file) + print_fn(f"\n密钥保存到: {out_file} (权限已收紧为 0600)") missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map] if missing: diff --git a/main.py b/main.py index 60bd251..5163fe7 100644 --- a/main.py +++ b/main.py @@ -171,13 +171,17 @@ def ensure_keys(keys_file, db_dir): def show_status(): """显示当前数据状态""" cfg = {} - config_file = "config.json" + # 走 config._config_file_path() 而不是硬编码 "config.json" + # 这样打包成 exe 后 (cwd 可能任意位置) 仍能找到正确的 config + from config import _config_file_path + config_file = _config_file_path() if os.path.exists(config_file): with open(config_file, encoding="utf-8") as f: cfg = json.load(f) - print(f"[config] db_dir = {cfg.get('db_dir', '?')}") + print(f"[config] {config_file}") + print(f" db_dir = {cfg.get('db_dir', '?')}") else: - print("[config] 未找到 config.json") + print(f"[config] 未找到 {config_file}") keys_files = sorted(glob.glob("all_keys*.json")) print(f"[keys] {len(keys_files)} 个密钥文件")