Files
zWorkFlow/find_image_key_macos.py
Belugary b37d440f47 feat: macOS 图片 AES key 从磁盘 kvcomm 缓存派生(issue #23)
macOS 用户长期无法用 C 版 find_image_key_macos 从微信进程内存提取
V2 图片密钥(issue #23 报告 197K 候选全部失败)。新增
find_image_key_macos.py 走完全不同的路径:从磁盘 kvcomm 缓存
文件名派生密钥,无需扫描内存、无需 root、无需重签名。

派生算法
--------
- 扫 ~/.../app_data/net/kvcomm/key_<code>_*.statistic 文件名
- 对每个 (code, wxid) 候选:
    xor_key = code & 0xFF
    aes_key = MD5(str(code) + cleaned_wxid).hex()[:16]   # ASCII 字符串
- 用 V2 _t.dat 文件 [0xF:0x1F] 16 字节做 AES-128-ECB 模板验证:
  解出来必须是图像 magic(JPEG / PNG / GIF / WebP / wxgf)
- 为防短 magic 偶然命中,要求多个不同模板都通过验证才算成功
- 命中后写回 config.json 的 image_aes_key / image_xor_key,
  monitor_web.py 自动加载

致谢
----
派生算法源自 @hicccc77 在 issue #23 的评论;参考实现见其 WeFlow
项目 (CC BY-NC-SA 4.0)。本模块是独立的 Python clean-room 实现,
未复制其 TypeScript 源码;函数边界与变量命名沿用算法的自然结构
(regex 模式 / MD5 调用顺序 / magic 字节表等不可避免地相同)。

健壮性细节
----------
- 多候选 kvcomm 路径:枚举 5 个不同的 macOS 微信版本路径布局
- 多模板交叉验证:默认收集 3 个不同密文,全部通过才算命中
- 已有 image_aes_key 仍有效时短路返回,不重写 config
- 原子写 config.json:tmp + os.replace + finally 清理 .tmp
- 多 wxid 候选:同时试 raw 和归一化后的 wxid(A_Hare_626a → A_Hare)
- print(flush=True) 逐次显式(与 find_image_key.py 风格一致)

测试
----
新增 tests/test_find_image_key_macos.py,53 个测试覆盖:
派生算法 / wxid 归一化 / kvcomm 路径推算(含多候选)/ 模板收集
(去重 / 子目录 / max_files 边界)/ AES 验证(5 种 magic / 短输入
/ 空 key)/ 多模板交叉验证 / 端到端集成(命中 / 各种失败分支)/
原子写 / main 短路(已有有效 key 不重写 / 已有错 key 落到派生)。
全部通过:python -m unittest discover tests → 88/88。

兼容性
------
- 无新增依赖(pycryptodome 已在 requirements.txt)
- 不改任何现有 Python 文件,零回归风险
- 现有 Windows / Linux 路径 (find_image_key.py / find_image_key_monitor.py) 不受影响
2026-04-27 14:10:15 +08:00

363 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""macOS WeChat 4.x 图片 AES key 派生(无需扫描进程内存)。
通过 macOS 微信 4.x 在磁盘上的 kvcomm 缓存文件命名约定,派生出 V2 .dat
图片解密所需的 (xor_key, aes_key)。解决 issue #23macOS 用户无法用
C 版扫描器从进程内存提取图片密钥197K 候选全部失败)。
派生算法
--------
- 扫 ~/.../app_data/net/kvcomm/key_<code>_*.statistic 文件名
- 对每个 (code, wxid) 候选:
xor_key = code & 0xFF
aes_key = MD5(str(code) + cleaned_wxid).hex()[:16] # ASCII 字符串
- 用 V2 _t.dat 文件 [0xF:0x1F] 16 字节做模板验证:派生出的 aes_key 把
密文 AES-128-ECB 解出图像 magicJPEG / PNG / GIF / WebP / wxgf即视为命中
- 为防短 magic 偶然命中,要求多个不同模板都通过验证才视为成功
- 命中后写回 config.json 的 image_aes_key / image_xor_key 字段,
monitor_web.py 启动时自动加载,图片消息显示内联预览
致谢
----
派生算法源自 @hicccc77 在 issue #23 的评论,参考实现位于
https://github.com/hicccc77/WeFlow CC BY-NC-SA 4.0)。本模块是独立的
Python 实现,未复制其 TypeScript 源码;函数边界与变量命名沿用算法的自然
结构regex 模式 / MD5 调用顺序 / magic 字节表等不可避免地相同)。
用法
----
python find_image_key_macos.py
"""
import glob
import hashlib
import json
import os
import platform
import re
import sys
from Crypto.Cipher import AES
# V2 .dat 文件 magic与 decode_image.py 中 V2_MAGIC_FULL 一致)
V2_MAGIC = bytes.fromhex("070856320807")
# kvcomm 文件名格式key_<code>_<其他段>.statistic
# code 必须紧跟在 "key_" 之后(不能是 "key_reportnow_..." 这种带前缀的)
_KVCOMM_FILENAME_RE = re.compile(r"^key_(\d+)_.+\.statistic$", re.IGNORECASE)
# AES 解密结果允许的图像 magic
_IMAGE_MAGICS = (
b"\xff\xd8\xff", # JPEG
b"\x89\x50\x4e\x47", # PNG
b"GIF", # GIF
b"RIFF", # WebP container首块只能看前 16B全检需 [8:12]==b"WEBP"
b"wxgf", # 微信 HEVC GIF / Live Photo
)
def normalize_wxid(account_id):
"""归一化账号 ID。
- wxid_<seg> 形式:保留 wxid_<seg>,丢弃后续下划线分段
- <base>_<4 alnum> 形式:丢弃 _<4 alnum> 后缀macOS 路径目录名常见)
- 其他:原样返回
"""
aid = (account_id or "").strip()
if not aid:
return ""
if aid.lower().startswith("wxid_"):
m = re.match(r"^(wxid_[^_]+)", aid, re.IGNORECASE)
return m.group(1) if m else aid
m = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", aid)
return m.group(1) if m else aid
def derive_image_keys(code, wxid):
"""从 (code, wxid) 派生 (xor_key, aes_key_ascii)。
aes_key_ascii 是 16 字符 hex 字符串;调用方按 ASCII 编码取前 16 字节作为
AES-128 密钥。本函数不做 wxid 归一化(由调用方枚举原值与归一化值)。
"""
xor_key = int(code) & 0xFF
aes_key = hashlib.md5(f"{code}{wxid}".encode("utf-8")).hexdigest()[:16]
return xor_key, aes_key
def derive_kvcomm_dir_candidates(db_dir):
"""从 db_dir 推算所有可能的 kvcomm 缓存目录(按优先级排序)。
微信 4.x 在不同版本 / 安装方式下 kvcomm 路径不固定,需要枚举多个候选。
返回的列表里至少有一项被 os.path.isdir 确认存在时才算可用。
"""
parts = db_dir.rstrip(os.sep).split(os.sep)
candidates = []
if "xwechat_files" in parts:
idx = parts.index("xwechat_files")
documents_root = os.sep.join(parts[:idx])
# 1) 与 xwechat_files 兄弟目录的 app_data
candidates.append(os.path.join(documents_root, "app_data", "net", "kvcomm"))
# 2) 旧版可能放 xwechat 子目录
candidates.append(os.path.join(documents_root, "xwechat", "net", "kvcomm"))
# 3) 容器内 Application Support 路径(部分版本)
if idx >= 1:
container_root = os.sep.join(parts[:idx - 1]) # Documents 之上
candidates.append(os.path.join(
container_root, "Library", "Application Support",
"com.tencent.xinWeChat", "xwechat", "net", "kvcomm"))
candidates.append(os.path.join(
container_root, "Library", "Application Support",
"com.tencent.xinWeChat", "net", "kvcomm"))
# 4) 兜底HOME 下默认沙盒路径
home = os.path.expanduser("~")
candidates.append(os.path.join(
home, "Library", "Containers", "com.tencent.xinWeChat", "Data",
"Documents", "app_data", "net", "kvcomm"))
# 去重,保留顺序
seen = set()
deduped = []
for c in candidates:
if c not in seen:
seen.add(c)
deduped.append(c)
return deduped
def find_existing_kvcomm_dir(db_dir):
"""从候选路径中返回第一个存在的 kvcomm 目录;都不存在返回 None。"""
for candidate in derive_kvcomm_dir_candidates(db_dir):
if os.path.isdir(candidate):
return candidate
return None
def collect_kvcomm_codes(kvcomm_dir):
"""扫 kvcomm 目录,返回去重排序的 code 列表。"""
if not kvcomm_dir or not os.path.isdir(kvcomm_dir):
return []
codes = set()
try:
names = os.listdir(kvcomm_dir)
except OSError:
return []
for name in names:
m = _KVCOMM_FILENAME_RE.match(name)
if not m:
continue
try:
code = int(m.group(1))
except ValueError:
continue
if 0 < code <= 0xFFFFFFFF:
codes.add(code)
return sorted(codes)
def collect_wxid_candidates(db_dir):
"""从 db_dir 提取候选 wxid含原值和归一化值"""
parts = db_dir.rstrip(os.sep).split(os.sep)
if "xwechat_files" not in parts:
return []
idx = parts.index("xwechat_files")
if idx + 1 >= len(parts):
return []
raw = parts[idx + 1]
candidates = [raw]
normalized = normalize_wxid(raw)
if normalized and normalized != raw:
candidates.append(normalized)
return candidates
def find_v2_template_ciphertexts(attach_dir, max_templates=3, max_files=64):
"""在 attach_dir 下找 V2 .dat 文件的模板密文([0xF:0x1F] 16 字节)。
优先 _t.dat缩略图小、读得快找不到再降级用任意 .dat。
返回最多 max_templates 个**不同**的密文,用于交叉验证防止短 magic 偶然命中。
"""
if not attach_dir or not os.path.isdir(attach_dir):
return []
def _scan(suffix):
# 出口条件只看是否凑够 max_templates 个**不同**密文;不因为
# examined 达到 max_files 提前退出 —— 否则若前 64 个文件都是同一
# 张图的副本,结果只有 1 个 template交叉验证就退化成单模板。
out, seen = [], set()
examined = 0
for root, _, files in os.walk(attach_dir):
for f in files:
if not f.endswith(suffix):
continue
examined += 1
try:
with open(os.path.join(root, f), "rb") as fp:
data = fp.read(0x20)
except OSError:
continue
if len(data) >= 0x1F and data[:6] == V2_MAGIC:
ct = data[0xF:0x1F]
if ct not in seen:
seen.add(ct)
out.append(ct)
if len(out) >= max_templates:
return out
# 兜底:扫了 max_files 个文件还凑不齐 max_templates 个不同的,
# 提前停止以免在巨型 attach 目录里跑很久(只在 out 不空时才能停)
if examined >= max_files and out:
return out
return out
return _scan("_t.dat") or _scan(".dat")
def verify_aes_key(aes_key_ascii, template_ct):
"""AES-128-ECB 解 template_ct16 字节),检查头部是否是图像 magic。"""
if not aes_key_ascii or not template_ct or len(template_ct) != 16:
return False
key_bytes = aes_key_ascii.encode("ascii", errors="ignore")[:16]
if len(key_bytes) < 16:
return False
try:
cipher = AES.new(key_bytes, AES.MODE_ECB)
decrypted = cipher.decrypt(template_ct)
except (ValueError, KeyError):
return False
return any(decrypted.startswith(m) for m in _IMAGE_MAGICS)
def verify_aes_key_against_all(aes_key_ascii, templates):
"""在多个模板上交叉验证 aes_key。全部通过才算命中防短 magic 偶然碰撞)。"""
if not templates:
return False
return all(verify_aes_key(aes_key_ascii, ct) for ct in templates)
def find_image_key_macos(db_dir):
"""在 macOS 上派生并交叉验证 V2 图片密钥。
Returns:
(xor_key, aes_key_ascii) on success失败返回 None 并打印诊断信息。
"""
kvcomm_dir = find_existing_kvcomm_dir(db_dir)
if not kvcomm_dir:
print(f"[!] 找不到 kvcomm 缓存目录,已尝试以下候选:", flush=True)
for c in derive_kvcomm_dir_candidates(db_dir):
print(f" {c}", flush=True)
print(" 通常意味着微信尚未生成密钥缓存,请先在微信中查看 1-2 张图片",
flush=True)
return None
print(f"[+] 使用 kvcomm 目录: {kvcomm_dir}", flush=True)
codes = collect_kvcomm_codes(kvcomm_dir)
if not codes:
print(f"[!] kvcomm 目录无 key_*.statistic 文件: {kvcomm_dir}", flush=True)
return None
print(f"[+] 找到 {len(codes)} 个 code 候选", flush=True)
wxid_candidates = collect_wxid_candidates(db_dir)
if not wxid_candidates:
print(f"[!] 无法从 db_dir 提取 wxid: {db_dir}", flush=True)
return None
print(f"[+] wxid 候选: {wxid_candidates}", flush=True)
base_dir = os.path.dirname(db_dir) # 去掉 db_storage
attach_dir = os.path.join(base_dir, "msg", "attach")
templates = find_v2_template_ciphertexts(attach_dir)
if not templates:
print(f"[!] 在 {attach_dir} 下找不到 V2 模板文件", flush=True)
print(" 请先在微信中查看 1-2 张图片,让微信生成 V2 .dat 文件",
flush=True)
return None
print(f"[+] 找到 {len(templates)} 个不同模板用于交叉验证", flush=True)
# 穷举顺序wxid 外、code 内。这样多账号系统下当前账号的所有 code 优先尝试。
for wxid in wxid_candidates:
for code in codes:
xor_key, aes_key = derive_image_keys(code, wxid)
if verify_aes_key_against_all(aes_key, templates):
print()
print("[✓] 验证成功(所有模板均通过):", flush=True)
print(f" code = {code}", flush=True)
print(f" wxid = {wxid}", flush=True)
print(f" xor_key = 0x{xor_key:02x}", flush=True)
print(f" aes_key = {aes_key}", flush=True)
return xor_key, aes_key
print()
print("[!] 所有 (wxid × code) 组合都未通过交叉验证", flush=True)
print(" 可能原因:微信版本变更了派生算法 / 缓存已失效 / 模板文件损坏",
flush=True)
return None
def _save_config_atomic(config_path, config):
"""原子写 config.jsontmp + os.replace 防止中断留下半截文件。
若 json.dump 或 os.replace 抛错,向上抛出(让 main 给出 stacktrace
而不是默默写坏 config同时清理可能残留的 .tmp 文件。
"""
tmp_path = config_path + ".tmp"
try:
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, config_path)
finally:
# 失败路径上 .tmp 可能残留;成功路径上 os.replace 已经把 tmp 移走了
if os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
def main(config_path=None):
"""CLI 入口。`config_path` 默认是脚本同目录下的 config.json
暴露此参数主要为方便单元测试注入隔离的临时配置。"""
if platform.system().lower() != "darwin":
print("此脚本只在 macOS 上工作。其他平台请用 find_image_key.py内存扫描",
file=sys.stderr, flush=True)
sys.exit(1)
if config_path is None:
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"config.json")
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"[!] 读取 {config_path} 失败: {e}", file=sys.stderr, flush=True)
sys.exit(1)
db_dir = config.get("db_dir", "")
if not db_dir:
print("[!] config.json 中未配置 db_dir", file=sys.stderr, flush=True)
sys.exit(1)
print(f"[*] db_dir = {db_dir}", flush=True)
# 短路:如果已有 image_aes_key 且仍能在所有模板上验证通过,直接退出
# (沿用 find_image_key.py 的 UX 约定,避免无谓重写 config.json
existing_aes = config.get("image_aes_key")
if existing_aes:
base_dir = os.path.dirname(db_dir)
attach_dir = os.path.join(base_dir, "msg", "attach")
templates = find_v2_template_ciphertexts(attach_dir)
if templates and verify_aes_key_against_all(existing_aes, templates):
print(f"[+] 已有 image_aes_key={existing_aes}"
f"{len(templates)} 个模板上仍然有效,无需重新派生", flush=True)
return
result = find_image_key_macos(db_dir)
if result is None:
sys.exit(1)
xor_key, aes_key = result
config["image_aes_key"] = aes_key
config["image_xor_key"] = xor_key
_save_config_atomic(config_path, config)
print()
print(f"[+] 已写入 {config_path}", flush=True)
print(" 下次启动 monitor_web.py 时会自动加载新密钥,图片消息显示内联预览",
flush=True)
if __name__ == "__main__":
main()