Add image decryption and inline preview for WeChat V2 format

Support all three .dat encryption formats:
- Old XOR format: single-byte XOR, auto-detect key from magic bytes
- V1 format: AES-ECB with fixed key (md5("0")[:16]) + XOR tail
- V2 format (2025-08+): AES-128-ECB + raw middle + XOR tail

New files:
- decode_image.py: unified image decryption module (XOR/V1/V2)
- find_image_key.py: extract AES key from WeChat process memory
- find_image_key_monitor.py: continuous monitoring version for key capture

monitor_web.py changes:
- Inline image preview in Web UI with async decryption
- MonitorDBCache for mtime-based DB decryption caching
- username-to-DB mapping for image resolution chain
- /img/ endpoint for serving decoded images
- SSE image_update events for real-time preview updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ylytdeng
2026-03-02 00:30:01 +08:00
parent 05b8ba4d45
commit da7525db95
8 changed files with 1729 additions and 25 deletions

470
decode_image.py Normal file
View File

@@ -0,0 +1,470 @@
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\<wxid>\msg\attach\<md5(username)>\<YYYY-MM>\Img\<file_md5>[_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('<I', dec, 2)[0]
bmp_offset = struct.unpack_from('<I', dec, 10)[0]
file_size = os.path.getsize(dat_path)
# BMP file_size 字段应与实际文件大小接近offset 应在合理范围
if (abs(bmp_size - file_size) < 1024 and 14 <= bmp_offset <= 1078):
return key
return None
def detect_image_format(header_bytes):
"""根据解密后的文件头检测图片格式"""
if header_bytes[:3] == bytes([0xFF, 0xD8, 0xFF]):
return 'jpg'
if header_bytes[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
return 'png'
if header_bytes[:3] == b'GIF':
return 'gif'
if header_bytes[:2] == b'BM':
return 'bmp'
if header_bytes[:4] == b'RIFF' and len(header_bytes) >= 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('<LL', data, 6)
# V1 用固定 key
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 的公式
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\\<wxid>)
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/<md5(username)>/<YYYY-MM>/Img/<file_md5>[_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 <dat_file> [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)
key = detect_xor_key(dat_file)
if key is None:
print("无法检测 XOR key文件可能不是微信加密图片")
sys.exit(1)
print(f"检测到 XOR key: 0x{key:02X}")
result_path, fmt = xor_decrypt_file(dat_file, out_file, key)
if result_path:
size = os.path.getsize(result_path)
print(f"解密成功: {result_path}")
print(f"格式: {fmt}, 大小: {size:,} bytes")
else:
print("解密失败")