feat: 离线计算图片密钥 (#69)

* feat: 离线计算图片密钥

* fix(find_all_keys): address review feedback on #69

Apply 5 fixes per @ylytdeng's review:

- find_xor_key: return None when last-byte ^ 0xD9 doesn't match the
  first-byte-derived xor_key (was returning xor_key in both branches,
  so the validation was a no-op)
- multiprocessing cleanup: split single-line terminate, add
  p.join(timeout=1) loop to avoid orphan workers
- replace 3 bare `except:` with `except Exception:` so KeyboardInterrupt
  can break the brute-force loop
- add actionable hint ("请先在微信中查看 2-3 张图片") when xor_key or
  ciphertext can't be derived from attach_dir
- drop try/except ImportError fallback on `from Crypto.Cipher import AES`
  (and the now-dead `if not AES` guards); pycryptodome is already a hard
  dependency elsewhere in the project

Original algorithm and multiprocessing implementation by @H3CoF6 in #69.
Review by @ylytdeng: https://github.com/ylytdeng/wechat-decrypt/pull/69

Co-authored-by: H3CoF6 <190114211+H3CoF6@users.noreply.github.com>

---------

Co-authored-by: Belugary <53219544+Belugary@users.noreply.github.com>
Co-authored-by: H3CoF6 <190114211+H3CoF6@users.noreply.github.com>
This commit is contained in:
H3CoF6
2026-05-05 22:46:48 +08:00
committed by GitHub
parent 4be1ac4713
commit e8de1249a4

View File

@@ -1,6 +1,179 @@
import functools import functools
import platform import platform
import sys import sys
import os
import glob
import json
import hashlib
import multiprocessing
import time
from config import load_config
from Crypto.Cipher import AES
def find_v2_ciphertext(attach_dir):
v2_magic = b'\x07\x08V2\x08\x07'
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
for f in dat_files[:100]:
try:
with open(f, 'rb') as fp:
header = fp.read(31)
if header[:6] == v2_magic and len(header) >= 31:
return header[15:31], os.path.basename(f)
except Exception:
continue
return None, None
def find_xor_key(attach_dir):
v2_magic = b'\x07\x08V2\x08\x07'
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
tail_counts = {}
for f in dat_files[:32]:
try:
sz = os.path.getsize(f)
with open(f, 'rb') as fp:
head = fp.read(6)
fp.seek(sz - 2)
tail = fp.read(2)
if head == v2_magic and len(tail) == 2:
key = (tail[0], tail[1])
tail_counts[key] = tail_counts.get(key, 0) + 1
except Exception:
continue
if not tail_counts:
return None
most_common = max(tail_counts, key=tail_counts.get)
x, y = most_common
xor_key = x ^ 0xFF
if (y ^ 0xD9) == xor_key:
return xor_key
return None
def try_key(key_bytes, ciphertext):
try:
cipher = AES.new(key_bytes, AES.MODE_ECB)
dec = cipher.decrypt(ciphertext)
if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG'
if dec[:4] == b'\x89PNG': return 'PNG'
if dec[:4] == b'RIFF': return 'WEBP'
if dec[:4] == b'wxgf': return 'WXGF'
if dec[:3] == b'GIF': return 'GIF'
except Exception:
pass
return None
def _brute_worker(start_i, end_i, xor_key, bin_suffix, base_wxid_bytes, ciphertext_16, result_queue):
for i in range(start_i, end_i):
uin = (i << 8) | xor_key
uin_bytes = str(uin).encode('ascii')
if hashlib.md5(uin_bytes).digest()[:2] == bin_suffix:
h_aes = hashlib.md5(uin_bytes + base_wxid_bytes).hexdigest()
aes_key_16 = h_aes[:16].encode('ascii')
if try_key(aes_key_16, ciphertext_16):
result_queue.put((uin, aes_key_16.decode('ascii')))
return
def find_image_key_offline(cfg):
print("\n" + "=" * 60)
print(" 尝试提取图片 AES 密钥")
print("=" * 60)
db_dir = cfg.get("db_dir", "")
if not db_dir:
print("未配置 db_dir")
return
base_dir = os.path.dirname(db_dir)
attach_dir = os.path.join(base_dir, 'msg', 'attach')
folder = os.path.basename(base_dir)
base_wxid, suffix = "", ""
if '_' in folder:
parts = folder.rsplit('_', 1)
if len(parts) == 2 and len(parts[1]) == 4:
base_wxid, suffix = parts
if not base_wxid or not suffix:
print(f"[!] 目录名不符合 wxid_..._suffix 格式: {folder},跳过爆破")
return
print(f"[*] 解析到 wxid={base_wxid}, suffix={suffix}")
xor_key = find_xor_key(attach_dir)
if xor_key is None:
print("[!] 找不到足够的 _t.dat 文件推导 XOR key跳过爆破")
print(" 请先在微信中查看 2-3 张图片,让缩略图缓存到本地后再重试。")
return
print(f"[*] 找到 XOR key: 0x{xor_key:02x}")
ciphertext, ct_file = find_v2_ciphertext(attach_dir)
if not ciphertext:
print("[!] 找不到 V2 加密的图片文件,跳过爆破")
print(" 请先在微信中查看 2-3 张图片,让缩略图缓存到本地后再重试。")
return
print(f"[*] 启动多进程 UIN 空间爆破...")
t0 = time.time()
bin_suffix = bytes.fromhex(suffix)
base_wxid_bytes = base_wxid.encode('ascii')
cpu_count = multiprocessing.cpu_count()
total = 1 << 24
chunk = total // cpu_count
result_queue = multiprocessing.Queue()
processes = []
for i in range(cpu_count):
start, end = i * chunk, (i + 1) * chunk if i != cpu_count - 1 else total
p = multiprocessing.Process(
target=_brute_worker,
args=(start, end, xor_key, bin_suffix, base_wxid_bytes, ciphertext, result_queue)
)
p.start()
processes.append(p)
found = None
try:
while any(p.is_alive() for p in processes):
if not result_queue.empty():
found = result_queue.get()
break
time.sleep(0.1)
finally:
for p in processes:
p.terminate()
for p in processes:
p.join(timeout=1)
elapsed = time.time() - t0
if found:
print(f"[+] 爆破成功! UIN={found[0]}, 耗时={elapsed:.1f}s")
aes_key = found[1]
print(f" image_aes_key = {aes_key}")
cfg['image_aes_key'] = aes_key
cfg['image_xor_key'] = xor_key
config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"[+] 已保存到 config.json")
else:
print(f"[-] 未能在 UIN 空间找到有效密钥 (耗时={elapsed:.1f}s)")
print(" 可能原因: 目录名被重命名过,或者不是标准账号目录。")
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
@@ -14,14 +187,14 @@ def _load_impl():
return impl return impl
if system == "darwin": if system == "darwin":
raise RuntimeError( raise RuntimeError(
"macOS 请先运行 C 版扫描器提取密钥:\n" "macOS 请先运行 C 版扫描器提取数据库密钥:\n"
"\n" "\n"
" sudo ./find_all_keys_macos\n" " sudo ./find_all_keys_macos\n"
"\n" "\n"
" 完成后再运行 python main.py decrypt" " 完成后再运行 python main.py decrypt"
) )
raise RuntimeError( raise RuntimeError(
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}" f"当前平台暂不支持通过 find_all_keys.py 提取内存数据库密钥: {platform.system()}"
) )
@@ -30,10 +203,15 @@ def get_pids():
def main(): def main():
cfg = load_config()
find_image_key_offline(cfg)
return _load_impl().main() return _load_impl().main()
if __name__ == "__main__": if __name__ == "__main__":
multiprocessing.freeze_support()
try: try:
main() main()
except RuntimeError as exc: except RuntimeError as exc: