* fix: find_image_key 三个 fallback CLI 入口对齐 config.load_config 路径展开 PR #63 给 config.load_config() 加了 expanduser + expandvars, 但 find_image_key.py / find_image_key_macos.py / find_image_key_monitor.py 三个 CLI 入口的 main() 为了让单测注入隔离 config (find_image_key_macos.py docstring 明文写着), 走 raw json.load(config_path), 绕开了 load_config 那层路径展开. 用户 config.json 里写 "db_dir": "~/Documents/..." 或 "$HOME/Documents/..." 时, 下游拼出的 attach_dir 仍带 ~ / $HOME 字面字符, glob *_t.dat 扫不到 → find_image_key.py / monitor.py 报 "No V2 .dat files found", macOS 文件 dispatcher 还会误报"请先在微信中查看 1-2 张图片让微信生成 V2 .dat 文件", 但磁盘上 attach 已经塞满了 .dat. 三个文件各加 1 行 expanduser(expandvars(...)), 与 PR #63 / config.py:213 对齐, 不动 main() 既有的 raw json.load(为保留单测注入口子). * fix(find_image_key_macos): 方案2 V2 _t.dat 样本不足时补一行下一步指引 dispatcher 层 (找不到 V2 模板分支) 已有"请先在微信中查看 1-2 张图片让微信 生成 V2 .dat 文件"指引, 但 _find_via_bruteforce 子路径在 V2 _t.dat 样本 < 3 时只 print "样本不足 (需 >= 3 个), 无法投票反推 xor_key", 用户不知该做什么. 加一行等价 hint, 与 dispatcher 层 UX 风格保持一致.
643 lines
25 KiB
Python
643 lines
25 KiB
Python
"""macOS WeChat 4.x 图片 AES key 派生(无需读运行进程)。
|
||
|
||
通过 macOS 微信 4.x 在磁盘上的命名约定派生出 V2 .dat 图片解密所需的
|
||
(xor_key, aes_key)。解决 issue #23:macOS 用户无法用 C 版扫描器从运行
|
||
进程读取出有效的访问凭据(197K 候选全部失败)。
|
||
|
||
派生算法(共享核心)
|
||
--------------------
|
||
- xor_key = uin & 0xFF
|
||
- aes_key = MD5(str(uin) + cleaned_wxid).hex()[:16] # ASCII 字符串
|
||
- 用 V2 _t.dat 文件 [0xF:0x1F] 16 字节做模板验证:派生出的 aes_key 把
|
||
密文 AES-128-ECB 解出图像 magic(JPEG / PNG / GIF / WebP / wxgf)即视为命中
|
||
- 为防短 magic 偶然命中,要求多个不同模板都通过验证才视为成功
|
||
|
||
uin 来源(两条路径,dispatcher 自动 fallback)
|
||
----------------------------------------------
|
||
方案1(kvcomm 缓存文件名,主路径):
|
||
读 ~/.../app_data/net/kvcomm/key_<uin>_*.statistic 提 uin。
|
||
优点:~毫秒级;缺点:依赖缓存文件,多账号下可能歧义。
|
||
|
||
方案2(wxid 后缀候选搜索,fallback 路径):
|
||
关键洞察:wxid 目录后 4 位 hex == md5(str(uin))[:4]。
|
||
流程:从 V2 .dat 末字节投票反推 xor_key (假设 JPG EOI = 0xD9) →
|
||
枚举 (uin & 0xff == xor_key) 的 2^24 个候选 → md5 前缀匹配
|
||
得 ~256 个 uin 候选 → AES 模板验证唯一定位。
|
||
优点:不依赖 kvcomm,多账号无歧义;缺点:~7 秒(单核 2^24 MD5)。
|
||
|
||
命中后写回 config.json 的 image_aes_key / image_xor_key,monitor_web.py
|
||
启动时自动加载,图片消息显示内联预览。
|
||
|
||
致谢
|
||
----
|
||
- 方案1(kvcomm 派生)算法源自 @hicccc77 在 issue #23 的评论,参考实现
|
||
位于 https://github.com/hicccc77/WeFlow (CC BY-NC-SA 4.0)。
|
||
- 方案2(wxid 后缀候选搜索)思路源自 @H3CoF6 在 issue #68 的评论,
|
||
提供了 "wxid 后 4 位 == md5(uin)[:4]" 这一关键结构性洞察。
|
||
|
||
本模块是独立的 Python 实现,未复制任何上游 TypeScript / C 源码;函数
|
||
边界与变量命名沿用算法的自然结构(regex / MD5 调用顺序 / magic 字节表
|
||
等不可避免地相同)。
|
||
|
||
用法
|
||
----
|
||
python find_image_key_macos.py
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import multiprocessing
|
||
import os
|
||
import platform
|
||
import queue as _queue
|
||
import re
|
||
import sys
|
||
import time
|
||
from collections import Counter
|
||
|
||
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_ct(16 字节),检查头部是否是图像 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)
|
||
|
||
|
||
# ---------- 方案2 (wxid 后缀候选搜索, fallback) ---------- #
|
||
|
||
# md5 hex 后缀只可能是 [0-9a-f]; 严格匹配避免误吃非 hex 字符的 wxid 后缀
|
||
# (microsoft 改方案 / 异常路径) 后悄悄返回空候选误导用户。
|
||
_WXID_HEX_SUFFIX_RE = re.compile(r"^(.+)_([0-9a-fA-F]{4})$")
|
||
|
||
|
||
def extract_wxid_parts(db_dir):
|
||
"""从 db_dir 提取 (wxid_full, wxid_norm, suffix)。
|
||
|
||
db_dir 形如 .../xwechat_files/<wxid>_<4hex>/db_storage
|
||
返回 ('your_wxid_a1b2', 'your_wxid', 'a1b2') 或 None(不匹配 _<4 hex> 后缀)。
|
||
|
||
suffix 是 4 位小写 hex(macOS 路径目录名固定格式 = md5(str(uin))[:4]),
|
||
用作方案2 中候选搜索的 md5 前缀目标。
|
||
"""
|
||
wxid_candidates = collect_wxid_candidates(db_dir)
|
||
if not wxid_candidates:
|
||
return None
|
||
wxid_full = wxid_candidates[0] # raw 总是第一个
|
||
m = _WXID_HEX_SUFFIX_RE.match(wxid_full)
|
||
if not m:
|
||
return None
|
||
return wxid_full, m.group(1), m.group(2).lower()
|
||
|
||
|
||
def derive_xor_key_from_v2_dat(attach_dir, sample=10, min_samples=3):
|
||
"""扫多个 V2 .dat 末字节投票反推 xor_key(假设 JPG EOI = 0xD9)。
|
||
|
||
macOS 缩略图 _t.dat 几乎都是 JPG,末字节 = 0xD9 ^ xor_key 反推稳定。
|
||
投票多数一致才信;分歧大说明假设破灭(不全是 JPG)。
|
||
|
||
Args:
|
||
attach_dir: 微信 attach 目录
|
||
sample: 扫到 N 个 V2 .dat 即停止(性能上限)
|
||
min_samples: 至少 N 个样本才视为"投票可信"。低于此返回 None,
|
||
避免 1-2 个样本时一旦撞到非 JPG 就 lock 错 xor_key。
|
||
Returns:
|
||
(xor_key, votes, total) 或 None (样本不足 / 找不到 V2 .dat)。
|
||
votes < total 时调用方应警告 (假设可能破灭)。
|
||
"""
|
||
if not attach_dir or not os.path.isdir(attach_dir):
|
||
return None
|
||
last_bytes = []
|
||
for root, _, files in os.walk(attach_dir):
|
||
for f in files:
|
||
if not f.endswith(".dat"):
|
||
continue
|
||
path = os.path.join(root, f)
|
||
try:
|
||
if os.path.getsize(path) < 0x20:
|
||
continue
|
||
with open(path, "rb") as fp:
|
||
head = fp.read(6)
|
||
if head != V2_MAGIC:
|
||
continue
|
||
fp.seek(-1, 2)
|
||
last = fp.read(1)[0]
|
||
last_bytes.append(last ^ 0xD9)
|
||
if len(last_bytes) >= sample:
|
||
break
|
||
except OSError:
|
||
continue
|
||
if len(last_bytes) >= sample:
|
||
break
|
||
if len(last_bytes) < min_samples:
|
||
return None
|
||
top, votes = Counter(last_bytes).most_common(1)[0]
|
||
return top, votes, len(last_bytes)
|
||
|
||
|
||
def bruteforce_uin_candidates(xor_key, wxid_suffix):
|
||
"""枚举 0~2^32 中 (uin & 0xff == xor_key) 且 md5(str(uin))[:4] == suffix 的 uin。
|
||
|
||
单核 ~7-8 秒(2^24 = 16M MD5)。期望命中数 ~256(2^24 / 16^4)。
|
||
|
||
注意 uin 上限假设为 2^32(4 字节无符号整数)。函数命名沿用密码学
|
||
候选搜索的 brute-force 术语;中文 prose 用 "枚举 / 候选搜索" 表述。
|
||
|
||
本函数是单进程 + hex 比较版本, 主要用作算法金标准 (测试) 与
|
||
parallel 路径不可用时的 fallback。生产 dispatcher 走 parallel
|
||
版本 (见 `_bruteforce_with_aes_parallel`)。
|
||
"""
|
||
target = wxid_suffix.lower()
|
||
out = []
|
||
for uin in range(xor_key, 2 ** 32, 256):
|
||
if hashlib.md5(str(uin).encode()).hexdigest()[:4] == target:
|
||
out.append(uin)
|
||
return out
|
||
|
||
|
||
def _aes_template_match(aes_bytes, ciphertext):
|
||
"""worker 进程内: AES-128-ECB 解 ciphertext 并检查图像 magic。
|
||
|
||
放模块顶层是为了 multiprocessing pickle (worker 函数必须可 import).
|
||
比 verify_aes_key 更紧凑 (省去 try-except 默认通过短路) — 在百万次
|
||
调用循环里这点开销有意义。
|
||
"""
|
||
try:
|
||
decrypted = AES.new(aes_bytes, AES.MODE_ECB).decrypt(ciphertext)
|
||
except (ValueError, KeyError):
|
||
return False
|
||
return any(decrypted.startswith(m) for m in _IMAGE_MAGICS)
|
||
|
||
|
||
def _bruteforce_worker_chunk(start, end, xor_key, suffix_bytes, wxid_bytes,
|
||
templates, result_queue):
|
||
"""worker: 扫候选区间, 命中 (md5 前缀 + 全模板 AES) 推入 queue 即返回。
|
||
|
||
内联做 md5 + AES 验证 (不分两 pass) 让早停在 worker 内有效。
|
||
suffix 用 binary 比 (digest()[:2] vs hexdigest()[:4]), 节省 hex 转换。
|
||
"""
|
||
for i in range(start, end):
|
||
uin = (i << 8) | xor_key
|
||
uin_bytes = str(uin).encode("ascii")
|
||
if hashlib.md5(uin_bytes).digest()[:2] == suffix_bytes:
|
||
aes_hex = hashlib.md5(uin_bytes + wxid_bytes).hexdigest()[:16]
|
||
aes_bytes = aes_hex.encode("ascii")
|
||
if all(_aes_template_match(aes_bytes, ct) for ct in templates):
|
||
result_queue.put((uin, aes_hex))
|
||
return
|
||
|
||
|
||
def _bruteforce_with_aes_parallel(xor_key, suffix_hex, wxid_norm, templates,
|
||
workers=None, timeout=60):
|
||
"""方案2 多进程实现 — 加速思路借鉴自 @H3CoF6 PR #69.
|
||
|
||
与单进程版本的差异:
|
||
- cpu_count 个 worker 并行扫 0~2^32 候选 (~5-8x 加速)
|
||
- 二进制 md5 digest()[:2] 替代 hexdigest()[:4] (省 hex 转换)
|
||
- 内联多模板 AES 验证 (无两 pass; PR #69 是单模板, 本实现保留多模板
|
||
交叉验证防短 magic 偶然命中)
|
||
- 任一 worker 命中即推 queue, 主进程 terminate 其他 (早停)
|
||
|
||
Returns:
|
||
(uin, aes_key_hex) 或 None (timeout / 全 worker 跑完未命中)
|
||
"""
|
||
suffix_bytes = bytes.fromhex(suffix_hex)
|
||
wxid_bytes = wxid_norm.encode("ascii")
|
||
if workers is None:
|
||
workers = max(1, multiprocessing.cpu_count())
|
||
total = 1 << 24
|
||
chunk = total // workers
|
||
|
||
queue = multiprocessing.Queue()
|
||
procs = []
|
||
for i in range(workers):
|
||
start_i = i * chunk
|
||
end_i = (i + 1) * chunk if i != workers - 1 else total
|
||
p = multiprocessing.Process(
|
||
target=_bruteforce_worker_chunk,
|
||
args=(start_i, end_i, xor_key, suffix_bytes, wxid_bytes,
|
||
templates, queue),
|
||
daemon=True,
|
||
)
|
||
p.start()
|
||
procs.append(p)
|
||
|
||
found = None
|
||
deadline = time.time() + timeout
|
||
try:
|
||
while any(p.is_alive() for p in procs) and time.time() < deadline:
|
||
try:
|
||
found = queue.get(timeout=0.1)
|
||
break
|
||
except _queue.Empty:
|
||
continue
|
||
# 所有 worker 死亡后 queue 仍可能有最后入队的数据
|
||
if not found:
|
||
try:
|
||
found = queue.get_nowait()
|
||
except _queue.Empty:
|
||
pass
|
||
finally:
|
||
for p in procs:
|
||
if p.is_alive():
|
||
p.terminate()
|
||
for p in procs:
|
||
p.join(timeout=1)
|
||
return found
|
||
|
||
|
||
# ---------- Dispatcher + 两条路径 ---------- #
|
||
|
||
def _find_via_kvcomm(db_dir, templates):
|
||
"""方案1:从 kvcomm 缓存文件名提 uin 候选。
|
||
|
||
要求:~/.../app_data/net/kvcomm/key_<uin>_*.statistic 存在。
|
||
返回 (xor_key, aes_key) 或 None(kvcomm 缺失 / 无 code / wxid 提不出 /
|
||
所有组合都验证失败)。
|
||
"""
|
||
kvcomm_dir = find_existing_kvcomm_dir(db_dir)
|
||
if not kvcomm_dir:
|
||
print("[!] 方案1: 找不到 kvcomm 缓存目录,已尝试以下候选:", flush=True)
|
||
for c in derive_kvcomm_dir_candidates(db_dir):
|
||
print(f" {c}", flush=True)
|
||
return None
|
||
print(f"[+] 方案1: 使用 kvcomm 目录 {kvcomm_dir}", flush=True)
|
||
|
||
codes = collect_kvcomm_codes(kvcomm_dir)
|
||
if not codes:
|
||
print("[!] 方案1: kvcomm 目录无 key_*.statistic 文件", flush=True)
|
||
return None
|
||
print(f"[+] 方案1: 找到 {len(codes)} 个 uin 候选", flush=True)
|
||
|
||
wxid_candidates = collect_wxid_candidates(db_dir)
|
||
if not wxid_candidates:
|
||
print("[!] 方案1: 无法从 db_dir 提取 wxid", flush=True)
|
||
return None
|
||
print(f"[+] 方案1: wxid 候选 {wxid_candidates}", flush=True)
|
||
|
||
# 穷举顺序:wxid 外、uin 内。多账号系统下当前账号的所有 uin 优先尝试。
|
||
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("[OK] 方案1 验证成功(所有模板均通过):", flush=True)
|
||
print(f" uin = {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("[!] 方案1: 所有 (wxid × uin) 组合都未通过交叉验证", flush=True)
|
||
return None
|
||
|
||
|
||
def _find_via_bruteforce(db_dir, attach_dir, templates):
|
||
"""方案2 (fallback):从 wxid 后缀候选搜索 uin(不依赖 kvcomm)。
|
||
|
||
流程:wxid 后缀 + V2 .dat 末字节投票反推 xor_key → 枚举 2^24 个 uin
|
||
候选 → 用 templates 跑 AES 验证唯一定位。
|
||
"""
|
||
parts = extract_wxid_parts(db_dir)
|
||
if not parts:
|
||
print("[!] 方案2: wxid 路径不含 _<4 hex> 后缀,无法应用方案2", flush=True)
|
||
return None
|
||
wxid_full, wxid_norm, suffix = parts
|
||
print(f"[+] 方案2: wxid_full={wxid_full}, suffix={suffix}", flush=True)
|
||
|
||
xres = derive_xor_key_from_v2_dat(attach_dir)
|
||
if not xres:
|
||
print("[!] 方案2: V2 .dat 样本不足 (需 >= 3 个), 无法投票反推 xor_key",
|
||
flush=True)
|
||
print(" 请先在微信中再看 1-2 张图片,让微信生成更多 V2 .dat 文件",
|
||
flush=True)
|
||
return None
|
||
xor_key, votes, total = xres
|
||
if votes == total:
|
||
print(f"[+] 方案2: xor_key=0x{xor_key:02x} ({votes}/{total} 一致, 假设 JPG)",
|
||
flush=True)
|
||
else:
|
||
print(f"[!] 方案2: xor_key 投票分歧 {votes}/{total}, 取多数 0x{xor_key:02x} "
|
||
f"(可能 attach 不全是 JPG)", flush=True)
|
||
|
||
workers = max(1, multiprocessing.cpu_count())
|
||
print(f"[*] 方案2: 多进程枚举 (workers={workers}, 预计 ~1-2 秒)...",
|
||
flush=True)
|
||
|
||
# 同时试 wxid_full 和 wxid_norm(normalize_wxid 可能去掉后缀)
|
||
wxid_tries = [wxid_norm]
|
||
if wxid_full != wxid_norm:
|
||
wxid_tries.append(wxid_full)
|
||
|
||
t0 = time.time()
|
||
for wxid_try in wxid_tries:
|
||
result = _bruteforce_with_aes_parallel(
|
||
xor_key, suffix, wxid_try, templates, workers=workers
|
||
)
|
||
if result:
|
||
uin, aes_key = result
|
||
elapsed = time.time() - t0
|
||
print()
|
||
print(f"[OK] 方案2 (fallback) 验证成功 (耗时 {elapsed:.1f}s):",
|
||
flush=True)
|
||
print(f" uin = {uin}", flush=True)
|
||
print(f" wxid = {wxid_try}", 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
|
||
|
||
elapsed = time.time() - t0
|
||
print(f"[!] 方案2: 所有 uin 候选都未通过 AES 验证 (耗时 {elapsed:.1f}s)",
|
||
flush=True)
|
||
return None
|
||
|
||
|
||
def find_image_key_macos(db_dir):
|
||
"""在 macOS 上派生并交叉验证 V2 图片密钥。
|
||
|
||
Dispatcher:先尝试方案1 (kvcomm),失败 fallback 到方案2 (候选搜索)。
|
||
两条路径都需要 V2 .dat 模板做 AES 验证 — 模板缺失就直接失败。
|
||
|
||
Returns:
|
||
(xor_key, aes_key_ascii) on success;失败返回 None 并打印诊断信息。
|
||
"""
|
||
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)
|
||
|
||
# 方案1 (主路径): kvcomm 缓存
|
||
result = _find_via_kvcomm(db_dir, templates)
|
||
if result is not None:
|
||
return result
|
||
|
||
# 方案2 (fallback): wxid 后缀候选搜索
|
||
print()
|
||
print("[*] 方案1 失败, 尝试方案2 (wxid 后缀候选搜索, fallback)", flush=True)
|
||
return _find_via_bruteforce(db_dir, attach_dir, templates)
|
||
|
||
|
||
def _save_config_atomic(config_path, config):
|
||
"""原子写 config.json:tmp + 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)
|
||
db_dir = os.path.expanduser(os.path.expandvars(db_dir))
|
||
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()
|