针对 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` 同模式的过滤(拒 `<!DOCTYPE>` / `<!ENTITY>`) + 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/ <wxid>/`,联系人下还是 `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)
274 lines
9.2 KiB
Python
274 lines
9.2 KiB
Python
"""解密微信朋友圈图片缓存
|
|
来源1: WeChat Files/FileStorage/Sns/Cache/<YYYY-MM>/<hash>[_t|_d]
|
|
来源2: xwechat_files/cache/<YYYY-MM>/Sns/Img/<hex>/<hash>
|
|
输出目录: <output_base_dir>/朋友圈图片/<YYYY-MM>/
|
|
_t 后缀为缩略图(跳过)
|
|
"""
|
|
import os
|
|
import sys
|
|
import glob
|
|
import struct
|
|
|
|
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", "")
|
|
XWECHAT_CACHE_DIR = _cfg.get("xwechat_cache_dir", "")
|
|
OUTPUT_DIR = os.path.join(_cfg["output_base_dir"], "朋友圈图片")
|
|
IMAGE_AES_KEY = _cfg.get("image_aes_key")
|
|
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88)
|
|
|
|
# ── V2/V1 magic ──────────────────────────────────────────────────────────────
|
|
_V2_MAGIC_FULL = b'\x07\x08V2\x08\x07'
|
|
_V1_MAGIC_FULL = b'\x07\x08V1\x08\x07'
|
|
|
|
_IMAGE_MAGICS = {
|
|
'jpg': [0xFF, 0xD8, 0xFF],
|
|
'png': [0x89, 0x50, 0x4E, 0x47],
|
|
'gif': [0x47, 0x49, 0x46, 0x38],
|
|
'webp': [0x52, 0x49, 0x46, 0x46],
|
|
'bmp': [0x42, 0x4D],
|
|
'tif': [0x49, 0x49, 0x2A, 0x00],
|
|
}
|
|
|
|
|
|
def _detect_format(header):
|
|
if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
|
|
return 'jpg'
|
|
if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
|
return 'png'
|
|
if header[:3] == b'GIF':
|
|
return 'gif'
|
|
if header[:2] == b'BM':
|
|
return 'bmp'
|
|
if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
|
|
return 'webp'
|
|
if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
|
|
return 'tif'
|
|
if header[:4] == b'wxgf':
|
|
return 'hevc'
|
|
return 'bin'
|
|
|
|
|
|
def decrypt_dat(dat_path):
|
|
"""解密单个 .dat 文件,返回 (bytes, format) 或 (None, None)"""
|
|
with open(dat_path, 'rb') as f:
|
|
data = f.read()
|
|
if len(data) < 6:
|
|
return None, None
|
|
|
|
head6 = data[:6]
|
|
|
|
# V2 / V1 格式
|
|
if head6 in (_V2_MAGIC_FULL, _V1_MAGIC_FULL):
|
|
if head6 == _V1_MAGIC_FULL:
|
|
aes_key = b'cfcd208495d565ef'
|
|
elif IMAGE_AES_KEY:
|
|
aes_key = IMAGE_AES_KEY.encode('ascii')[:16] if isinstance(IMAGE_AES_KEY, str) else IMAGE_AES_KEY[:16]
|
|
else:
|
|
return None, None
|
|
if not aes_key or len(aes_key) < 16:
|
|
return None, None
|
|
try:
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Util import Padding
|
|
if len(data) < 15:
|
|
return None, None
|
|
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
|
aligned = aligned_aes_block_size(aes_size)
|
|
offset = 15
|
|
if offset + aligned > len(data):
|
|
return None, None
|
|
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
|
|
dec_aes = Padding.unpad(cipher.decrypt(data[offset:offset+aligned]), AES.block_size)
|
|
offset += aligned
|
|
raw_end = len(data) - xor_size
|
|
raw_data = data[offset:raw_end] if offset < raw_end else b''
|
|
xor_data = data[raw_end:]
|
|
xor_key = IMAGE_XOR_KEY if isinstance(IMAGE_XOR_KEY, int) else 0x88
|
|
dec_xor = bytes(b ^ xor_key for b in xor_data)
|
|
result = dec_aes + raw_data + dec_xor
|
|
fmt = _detect_format(result[:16])
|
|
return result, fmt
|
|
except Exception as e:
|
|
print(f" AES 解密失败: {e}")
|
|
return None, None
|
|
|
|
# 旧 XOR 格式
|
|
for fmt_name, magic in _IMAGE_MAGICS.items():
|
|
key = data[0] ^ magic[0]
|
|
match = all(i < len(data) and (data[i] ^ key) == magic[i] for i in range(len(magic)))
|
|
if match:
|
|
result = bytes(b ^ key for b in data)
|
|
fmt = _detect_format(result[:16])
|
|
return result, fmt
|
|
|
|
return None, None
|
|
|
|
|
|
def _collect_xwechat_sns_files():
|
|
"""收集 xwechat cache/<YYYY-MM>/Sns/Img/<hex>/ 下的所有文件
|
|
返回 {month: [(file_path, basename), ...], ...}
|
|
"""
|
|
result = {}
|
|
if not XWECHAT_CACHE_DIR or not os.path.isdir(XWECHAT_CACHE_DIR):
|
|
return result
|
|
try:
|
|
months = sorted(os.listdir(XWECHAT_CACHE_DIR))
|
|
except OSError:
|
|
return result
|
|
for month in months:
|
|
sns_img = os.path.join(XWECHAT_CACHE_DIR, month, "Sns", "Img")
|
|
if not os.path.isdir(sns_img):
|
|
continue
|
|
files = []
|
|
try:
|
|
hex_dirs = os.listdir(sns_img)
|
|
except OSError:
|
|
continue
|
|
for hd in hex_dirs:
|
|
hd_path = os.path.join(sns_img, hd)
|
|
if not os.path.isdir(hd_path):
|
|
continue
|
|
try:
|
|
for fname in os.listdir(hd_path):
|
|
fp = os.path.join(hd_path, fname)
|
|
if os.path.isfile(fp):
|
|
files.append((fp, fname))
|
|
except OSError:
|
|
continue
|
|
if files:
|
|
result[month] = files
|
|
return result
|
|
|
|
|
|
def main():
|
|
has_wechat = SNS_CACHE_DIR and os.path.isdir(SNS_CACHE_DIR)
|
|
has_xwechat = XWECHAT_CACHE_DIR and os.path.isdir(XWECHAT_CACHE_DIR)
|
|
|
|
if not has_wechat and not has_xwechat:
|
|
print(f"朋友圈缓存目录不存在:")
|
|
print(f" WeChat Files: {SNS_CACHE_DIR}")
|
|
print(f" xwechat: {XWECHAT_CACHE_DIR}")
|
|
print("请确认 config.json 中的路径配置正确")
|
|
return
|
|
|
|
print(f"输出目录: {OUTPUT_DIR}")
|
|
|
|
total = 0
|
|
success = 0
|
|
skipped_thumb = 0
|
|
skipped_exist = 0
|
|
failed = 0
|
|
|
|
# ── 来源1: WeChat Files/FileStorage/Sns/Cache/<YYYY-MM>/ ──
|
|
if has_wechat:
|
|
print(f"\n[来源1] WeChat Files: {SNS_CACHE_DIR}")
|
|
months = sorted(d for d in os.listdir(SNS_CACHE_DIR)
|
|
if os.path.isdir(os.path.join(SNS_CACHE_DIR, d)))
|
|
has_month_dirs = any(len(m) == 7 and m[4] == '-' for m in months)
|
|
|
|
if has_month_dirs:
|
|
print(f" 时间目录: {len(months)} 个")
|
|
for month in months:
|
|
month_src = os.path.join(SNS_CACHE_DIR, month)
|
|
month_out = os.path.join(OUTPUT_DIR, month)
|
|
stats = _process_dir_stats(month_src, month_out, month)
|
|
total += stats[0]; success += stats[1]; skipped_thumb += stats[2]
|
|
skipped_exist += stats[3]; failed += stats[4]
|
|
else:
|
|
stats = _process_dir_stats(SNS_CACHE_DIR, OUTPUT_DIR, "")
|
|
total, success, skipped_thumb, skipped_exist, failed = stats
|
|
|
|
# ── 来源2: xwechat cache/<YYYY-MM>/Sns/Img/<hex>/ ──
|
|
if has_xwechat:
|
|
print(f"\n[来源2] xwechat: {XWECHAT_CACHE_DIR}")
|
|
xw_files = _collect_xwechat_sns_files()
|
|
if not xw_files:
|
|
print(" 未找到 Sns/Img 文件")
|
|
else:
|
|
print(f" 时间目录: {len(xw_files)} 个")
|
|
for month, file_list in sorted(xw_files.items()):
|
|
month_out = os.path.join(OUTPUT_DIR, month)
|
|
stats = _process_file_list(file_list, month_out, month)
|
|
total += stats[0]; success += stats[1]; skipped_thumb += stats[2]
|
|
skipped_exist += stats[3]; failed += stats[4]
|
|
|
|
print(f"\n完成: 共 {total} 个文件")
|
|
print(f" 成功解密: {success}")
|
|
print(f" 跳过缩略图(_t): {skipped_thumb}")
|
|
print(f" 跳过已存在: {skipped_exist}")
|
|
print(f" 解密失败: {failed}")
|
|
print(f"输出: {os.path.abspath(OUTPUT_DIR)}")
|
|
|
|
|
|
def _process_dir_stats(src_dir, out_dir, label):
|
|
"""处理一个目录中的所有文件,返回 (total, success, skipped_thumb, skipped_exist, failed)"""
|
|
try:
|
|
all_files = sorted(os.listdir(src_dir))
|
|
except OSError:
|
|
return (0, 0, 0, 0, 0)
|
|
|
|
dat_files = [(os.path.join(src_dir, f), f) for f in all_files
|
|
if os.path.isfile(os.path.join(src_dir, f))]
|
|
return _process_file_list(dat_files, out_dir, label)
|
|
|
|
|
|
def _process_file_list(file_list, out_dir, label):
|
|
"""处理文件列表 [(file_path, basename), ...], 返回 (total, success, skipped_thumb, skipped_exist, failed)"""
|
|
total = 0
|
|
success = 0
|
|
skipped_thumb = 0
|
|
skipped_exist = 0
|
|
failed = 0
|
|
|
|
if not file_list:
|
|
return (0, 0, 0, 0, 0)
|
|
|
|
if label:
|
|
print(f" [{label}] {len(file_list)} 个文件")
|
|
|
|
month_ok = 0
|
|
for file_path, fname in file_list:
|
|
total += 1
|
|
# 跳过缩略图
|
|
if fname.endswith('_t'):
|
|
skipped_thumb += 1
|
|
continue
|
|
|
|
# 去掉 _d 后缀得到基础名
|
|
base_name = fname
|
|
if base_name.endswith('_d'):
|
|
base_name = base_name[:-2]
|
|
|
|
# 检查是否已存在
|
|
existing = glob.glob(os.path.join(out_dir, f"{base_name}.*"))
|
|
if existing:
|
|
skipped_exist += 1
|
|
continue
|
|
|
|
img_bytes, fmt = decrypt_dat(file_path)
|
|
if not img_bytes or fmt in ('bin', 'hevc'):
|
|
failed += 1
|
|
continue
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
out_path = os.path.join(out_dir, f"{base_name}.{fmt}")
|
|
with open(out_path, 'wb') as f:
|
|
f.write(img_bytes)
|
|
success += 1
|
|
month_ok += 1
|
|
|
|
if month_ok > 0 and label:
|
|
print(f" 解密成功: {month_ok} 张")
|
|
|
|
return (total, success, skipped_thumb, skipped_exist, failed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|