From c29e8dd868de31443462358fe0bee9b3ca86fcbb Mon Sep 17 00:00:00 2001 From: Belugary <53219544+Belugary@users.noreply.github.com> Date: Tue, 5 May 2026 17:05:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20ImageResolver=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=204.0+=20V2=20=E5=8A=A0=E5=AF=86=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=A0=BC=E5=BC=8F=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageResolver.decode_image 之前只调 xor_decrypt_file(老格式 XOR-only 路径),微信 4.0+(2025-08+)已经改用 V2 AES-128-ECB + XOR 混合加密, 导致 mcp_server.py 注册的 decode_image MCP 工具对 V2 .dat 文件返回的 "解密"内容是错的——Claude AI 通过 MCP 调用看不到 V2 时代的图片。 monitor_web.py 早已正确处理 V2(line 41-42, 791-795:从 _cfg 读 image_aes_key / image_xor_key 后调 decrypt_dat_file 自动 magic 分发), 本次把 MCP 路径补齐,行为与 monitor_web.py 对齐。 改动: - ImageResolver.__init__ 增加 aes_key=None, xor_key=0x88 关键字参数 (默认值保持向后兼容,老调用方无需改动) - ImageResolver.decode_image 把 xor_decrypt_file 换成 decrypt_dat_file, 按 magic 自动分发 V2 / V1 / 老 XOR - V2 文件 + 缺 aes_key 时早期返回结构化错误信息,避免在 v2_decrypt_file 内静默失败成笼统的"解密失败" - v2_decrypt_file 入口接受 xor_key 字符串形式(int(_, 0) 解析), 与 aes_key 已有的 str→bytes 处理对称,允许 config.json 写 "0x88" - mcp_server.py 实例化时从 _cfg 读 image_aes_key / image_xor_key 注入 兼容性: - ImageResolver 老调用方(不传 keys)继续走老 XOR 路径,零 breaking - V1 magic(\x07\x08V1)不会被 is_v2_format 拦截,走 decrypt_dat_file 内置固定 key,所以 aes_key=None 也能解 V1 文件 - 整 repo 只有 mcp_server.py 一处生产调用 ImageResolver(...),已 grep 确认 测试覆盖(11 个新测试,tests/test_decode_image_v2.py): - v2_decrypt_file 合成数据 round-trip 字节级相等 - decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径 - aes_key 接受 str(来自 config.json)和 bytes 两种形式 - xor_key 接受 str(如 "0x88")和 int 两种形式 - V2 wxgf 裸流返回 fmt='hevc'(HEVC→JPEG 转换是 monitor_web 职责, 不在 ImageResolver 内做,保留 .hevc 输出) - ImageResolver 端到端:from local_id to decrypted file - ImageResolver(aes_key=None) + V1 文件走固定 key 路径 - ImageResolver(aes_key=None) + V2 文件返回 success=False + 友好错误 - ImageResolver 默认参数 + 老 XOR .dat 保持向后兼容 测试 46 个全部通过(11 新 + 35 旧)。 Co-authored-by: Claude Opus 4.7 (1M context) --- decode_image.py | 22 ++- mcp_server.py | 7 +- tests/test_decode_image_v2.py | 280 ++++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 tests/test_decode_image_v2.py diff --git a/decode_image.py b/decode_image.py index f9edbc4..870bca2 100644 --- a/decode_image.py +++ b/decode_image.py @@ -118,7 +118,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): dat_path: V2 .dat 文件路径 out_path: 输出路径 (None 则自动命名) aes_key: 16 字节 AES key (bytes 或 str) - xor_key: XOR key (int, 默认 0x88) + xor_key: XOR key (int 或可被 int(_, 0) 解析的 str, 默认 0x88) Returns: (output_path, format) 或 (None, None) @@ -135,6 +135,10 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88): if len(aes_key) < 16: return None, None + # 与 aes_key 的 str→bytes 处理对称: 允许 config.json 写 "0x88" / "136" 等字符串形式 + if isinstance(xor_key, str): + xor_key = int(xor_key, 0) + with open(dat_path, 'rb') as f: data = f.read() @@ -299,17 +303,21 @@ def extract_md5_from_packed_info(blob): class ImageResolver: """封装从 local_id 到图片文件的完整解析链""" - def __init__(self, wechat_base_dir, decoded_image_dir, cache): + def __init__(self, wechat_base_dir, decoded_image_dir, cache, aes_key=None, xor_key=0x88): """ Args: wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\) decoded_image_dir: 解密图片输出目录 cache: DBCache 实例,用于解密 message_resource.db + aes_key: V2 格式的 AES key (16 字节 str/bytes),None 表示不支持 V2 文件 + xor_key: XOR key (int, 默认 0x88),用于 V2 文件的 XOR 段 """ 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 + self.aes_key = aes_key + self.xor_key = xor_key def get_image_md5(self, local_id): """通过 local_id 查 message_resource.db 获取图片文件 MD5""" @@ -379,13 +387,17 @@ class ImageResolver: selected = f break - # 3. 解密 + # 3. 解密 (decrypt_dat_file 会按 magic 自动分发 V2 / V1 / 老 XOR) 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") + # 提前拦截以给出具体错误信息;否则会在 v2_decrypt_file 内 silent-fail 成笼统的"解密失败" + if is_v2_format(selected) and not self.aes_key: + return {'success': False, 'error': f'V2 格式 .dat 文件需要 AES key (文件: {selected})', 'md5': file_md5} + + result_path, fmt = decrypt_dat_file(selected, f"{out_path_base}.tmp", self.aes_key, self.xor_key) if not result_path: - return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5} + return {'success': False, 'error': f'解密失败 (文件: {selected})', 'md5': file_md5} # 重命名为正确扩展名 final_path = f"{out_path_base}.{fmt}" diff --git a/mcp_server.py b/mcp_server.py index 71e75a8..901dd5b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -1664,7 +1664,12 @@ def get_new_messages() -> str: # ============ 图片解密 ============ -_image_resolver = ImageResolver(WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache) +_image_aes_key = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取) +_image_xor_key = _cfg.get("image_xor_key", 0x88) +_image_resolver = ImageResolver( + WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache, + aes_key=_image_aes_key, xor_key=_image_xor_key, +) @mcp.tool() diff --git a/tests/test_decode_image_v2.py b/tests/test_decode_image_v2.py new file mode 100644 index 0000000..2c4112d --- /dev/null +++ b/tests/test_decode_image_v2.py @@ -0,0 +1,280 @@ +"""ImageResolver 在 V2 加密格式下的端到端解密测试。 + +覆盖: +- v2_decrypt_file 能正确还原 AES-ECB + XOR 混合加密的合成数据 +- decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径 +- ImageResolver 通过 __init__ 注入 aes_key/xor_key 后,能端到端解密 V2 .dat +- 没传 aes_key 时遇到 V2 文件返回结构化错误,而不是 crash 或返回错误数据 +- 默认参数下老 XOR 路径不受影响,保持向后兼容 +""" +import hashlib +import os +import sqlite3 +import struct +import tempfile +import unittest + +from Crypto.Cipher import AES +from Crypto.Util import Padding + +from decode_image import ( + V1_MAGIC_FULL, + V2_MAGIC_FULL, + ImageResolver, + decrypt_dat_file, + v2_decrypt_file, +) + + +# 测试用 16 字节 AES key (任意值,仅用于合成测试数据) +TEST_AES_KEY = b'1234567890abcdef' +TEST_XOR_KEY = 0x37 +# 最小可识别的 PNG payload (含 IHDR 和 IEND chunk),长度 88 字节 +TEST_PNG_PAYLOAD = ( + b'\x89PNG\r\n\x1a\n' + + b'\x00\x00\x00\rIHDR' + + b'\x00' * 64 + + b'IEND\xaeB`\x82' +) + + +def _build_v2_dat(plaintext, aes_size, xor_size, + aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY, + magic=V2_MAGIC_FULL): + """构造合成的 V2 / V1 .dat 字节串。 + + 布局: [6B magic][4B aes_size LE][4B xor_size LE][1B pad][AES-ECB][raw][XOR] + aes_size / xor_size 是明文字段长度,AES 段做 PKCS7 padding 后向上对齐到 16 倍数。 + """ + if aes_size + xor_size > len(plaintext): + raise ValueError("aes_size + xor_size 超过 plaintext 长度") + aes_plain = plaintext[:aes_size] + raw_plain = plaintext[aes_size:len(plaintext) - xor_size] + xor_plain = plaintext[len(plaintext) - xor_size:] + + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + aes_cipher = cipher.encrypt(Padding.pad(aes_plain, AES.block_size)) + xor_cipher = bytes(b ^ xor_key for b in xor_plain) + + header = magic + struct.pack('