Files
zWorkFlow/find_wxwork_keys.py
xincheng 16940a8771 update
2026-05-17 06:50:11 +08:00

655 lines
25 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.

"""
从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key
企业微信的本地数据库与个人微信不同。实测 Windows 版使用 wxSQLite3
AES-128-CBC 页面加密16 字节 raw key每页按 page index 派生 AES key
和 IV页面没有 SQLCipher HMAC/reserve 区。
"""
import ctypes
import ctypes.wintypes as wt
import bisect
import functools
import hashlib
import hmac as hmac_mod
import json
import os
import re
import struct
import subprocess
import sys
import time
from key_scan_common import collect_db_files
from wxwork_crypto import (
is_plain_sqlite_page,
is_wxsqlite3_aes128_page1,
verify_wxsqlite3_aes128_key,
)
print = functools.partial(print, flush=True)
# ── Windows 内存读取原语 ──────────────────────────────────────────────
kernel32 = ctypes.windll.kernel32
MEM_COMMIT = 0x1000
READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}
class MBI(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64),
("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD),
("RegionSize", ctypes.c_uint64), ("State", wt.DWORD),
("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD),
]
def read_mem(h, addr, sz):
buf = ctypes.create_string_buffer(sz)
n = ctypes.c_size_t(0)
if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)):
return buf.raw[:n.value]
return None
def enum_regions(h):
regs = []
addr = 0
mbi = MBI()
while addr < 0x7FFFFFFFFFFF:
if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0:
break
if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024:
regs.append((mbi.BaseAddress, mbi.RegionSize))
nxt = mbi.BaseAddress + mbi.RegionSize
if nxt <= addr:
break
addr = nxt
return regs
# ── 常量 ─────────────────────────────────────────────────────────────
WXWORK_PROCESS = "WXWork.exe"
PAGE_SZ = 4096
SALT_SZ = 16
# 旧版本/其他平台可能回落到 SQLCipher 参数,保留作兼容验证。
# (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz)
VERIFY_CONFIGS = [
# WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能)
(16, "sha512", 64, 2, 80),
# WCDB with AES-128, HMAC-SHA256
(16, "sha256", 32, 2, 48),
# SQLCipher 3 defaults with AES-128
(16, "sha512", 64, 4000, 80),
(16, "sha256", 32, 4000, 48),
# AES-256 回退 (与个人微信相同参数)
(32, "sha512", 64, 2, 80),
]
def verify_enc_key_wxwork(enc_key, db_page1):
"""尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)"""
if len(enc_key) == 16 and verify_wxsqlite3_aes128_key(enc_key, db_page1):
return True, "wxSQLite3 AES-128-CBC, per-page MD5 key/IV, no HMAC"
key_sz = len(enc_key)
for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS:
if key_sz != cfg_key_sz:
continue
salt = db_page1[:SALT_SZ]
mac_salt = bytes(b ^ 0x3A for b in salt)
mac_key = hashlib.pbkdf2_hmac(hmac_hash, enc_key, mac_salt, iterations, dklen=cfg_key_sz)
hmac_data = db_page1[SALT_SZ: PAGE_SZ - reserve_sz + 16]
stored_hmac = db_page1[PAGE_SZ - hmac_sz: PAGE_SZ]
hash_fn = getattr(hashlib, hmac_hash)
hm = hmac_mod.new(mac_key, hmac_data, hash_fn)
hm.update(struct.pack("<I", 1))
if hm.digest() == stored_hmac:
desc = f"AES-{cfg_key_sz * 8}, HMAC-{hmac_hash.upper()}, iter={iterations}"
return True, desc
return False, ""
# ── WXWork 进程发现 ──────────────────────────────────────────────────
def get_wxwork_pids():
"""返回所有 WXWork.exe 进程的 (pid, mem_kb) 列表,按内存降序"""
r = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {WXWORK_PROCESS}", "/FO", "CSV", "/NH"],
capture_output=True, text=True,
)
pids = []
for line in r.stdout.strip().split('\n'):
if not line.strip():
continue
p = line.strip('"').split('","')
if len(p) >= 5:
pid = int(p[1])
mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0')
pids.append((pid, mem))
if not pids:
raise RuntimeError(f"{WXWORK_PROCESS} 未运行")
pids.sort(key=lambda x: x[1], reverse=True)
for pid, mem in pids:
print(f"[+] {WXWORK_PROCESS} PID={pid} ({mem // 1024}MB)")
return pids
# ── WXWork 数据目录自动检测 ──────────────────────────────────────────
def _wxwork_data_dir_mtime(data_dir):
"""返回企业微信 Data 目录最近活跃时间,用于多账号自动选择。"""
latest = 0
for root, dirs, files in os.walk(data_dir):
dirs[:] = [d for d in dirs if d not in ("-journal",)]
for name in files:
if not name.endswith((".db", ".db-wal", ".db-shm")):
continue
path = os.path.join(root, name)
try:
latest = max(latest, os.path.getmtime(path))
except OSError:
pass
try:
latest = max(latest, os.path.getmtime(data_dir))
except OSError:
pass
return latest
def _is_noninteractive_mode():
return (
os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1"
or os.environ.get("WXWORK_AUTO_SELECT_DB") == "1"
or os.environ.get("WECHAT_DECRYPT_GUI") == "1"
or not sys.stdin.isatty()
)
def auto_detect_wxwork_db_dir():
"""扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录"""
docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork")
if not os.path.isdir(docs):
return None
candidates = []
for name in os.listdir(docs):
data_dir = os.path.join(docs, name, "Data")
if not os.path.isdir(data_dir):
continue
has_encrypted = False
for fname in os.listdir(data_dir):
if not fname.endswith(".db"):
continue
fpath = os.path.join(data_dir, fname)
if os.path.getsize(fpath) < PAGE_SZ:
continue
with open(fpath, "rb") as f:
header = f.read(16)
if header != b"SQLite format 3\x00":
has_encrypted = True
break
if has_encrypted:
candidates.append(data_dir)
if not candidates:
return None
candidates.sort(key=_wxwork_data_dir_mtime, reverse=True)
if len(candidates) == 1:
return candidates[0]
if _is_noninteractive_mode():
selected = candidates[0]
print("[!] 检测到多个企业微信数据目录,非交互模式下自动选择最近活跃目录:")
for i, c in enumerate(candidates, 1):
marker = " *" if c == selected else " "
print(f" {marker} {i}. {c}")
return candidates[0]
print("[!] 检测到多个企业微信数据目录:")
for i, c in enumerate(candidates, 1):
print(f" {i}. {c}")
print(" 0. 跳过,稍后手动配置")
try:
while True:
choice = input(f"请选择 [0-{len(candidates)}]: ").strip()
if choice == "0":
return None
if choice.isdigit() and 1 <= int(choice) <= len(candidates):
return candidates[int(choice) - 1]
print(" 无效输入,请重新选择")
except (EOFError, KeyboardInterrupt):
print()
return None
def filter_encrypted_dbs(db_files, salt_to_dbs):
"""过滤掉未加密的数据库。"""
filtered_files = [
entry for entry in db_files if not is_plain_sqlite_page(entry[4])
]
filtered_salts = {
s: dbs for s, dbs in salt_to_dbs.items()
if any(entry[3] == s and not is_plain_sqlite_page(entry[4]) for entry in db_files)
}
removed = len(db_files) - len(filtered_files)
if removed:
print(f"[*] 跳过 {removed} 个未加密数据库")
wxsqlite3_count = sum(1 for entry in filtered_files if is_wxsqlite3_aes128_page1(entry[4]))
if wxsqlite3_count:
print(f"[*] 检测到 {wxsqlite3_count} 个 wxSQLite3 AES-128 格式数据库")
return filtered_files, filtered_salts
# ── 企业微信内存扫描 ─────────────────────────────────────────────────
def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map,
remaining_salts, base_addr, pid, print_fn):
"""扫描内存,匹配 hex 模式并用企业微信参数验证密钥。
企业微信 key=16字节(32 hex), salt=16字节(32 hex)
可能的缓存格式:
- x'<32hex_key><32hex_salt>' = 64 hex total
- x'<32hex_key>' = 32 hex (key only)
- x'<64hex_key><32hex_salt>' = 96 hex (AES-256 回退)
"""
matches = 0
for m in hex_re.finditer(data):
hex_str = m.group(1).decode()
addr = base_addr + m.start()
matches += 1
hex_len = len(hex_str)
# 尝试不同的解释方式
candidates = []
if hex_len == 32:
# 纯 16字节 key
candidates.append((hex_str, None))
elif hex_len == 64:
# 优先: 32hex key + 32hex salt (WeCom AES-128)
candidates.append((hex_str[:32], hex_str[32:]))
# 回退: 64hex = 32字节 key (personal WeChat AES-256)
candidates.append((hex_str, None))
elif hex_len == 96:
# 优先: 64hex key + 32hex salt (personal WeChat AES-256)
candidates.append((hex_str[:64], hex_str[64:]))
# 也尝试: 32hex key + ... + 32hex salt
candidates.append((hex_str[:32], hex_str[-32:]))
elif hex_len > 96 and hex_len % 2 == 0:
candidates.append((hex_str[:64], hex_str[-32:]))
candidates.append((hex_str[:32], hex_str[-32:]))
for enc_key_hex, salt_hex in candidates:
if len(enc_key_hex) not in (32, 64):
continue
enc_key = bytes.fromhex(enc_key_hex)
if salt_hex and salt_hex in remaining_salts:
# salt 匹配已知数据库
for rel, path, sz, s, page1 in db_files:
if s == salt_hex:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = enc_key_hex
remaining_salts.discard(salt_hex)
dbs = salt_to_dbs[salt_hex]
print_fn(f"\n [FOUND] salt={salt_hex}")
print_fn(f" enc_key={enc_key_hex}")
print_fn(f" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
elif not salt_hex and remaining_salts:
# 没有 salt暴力尝试所有未匹配的数据库
for rel, path, sz, salt_hex_db, page1 in db_files:
if salt_hex_db in remaining_salts:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex_db] = enc_key_hex
remaining_salts.discard(salt_hex_db)
dbs = salt_to_dbs[salt_hex_db]
print_fn(f"\n [FOUND] salt={salt_hex_db}")
print_fn(f" enc_key={enc_key_hex}")
print_fn(f" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
if not remaining_salts:
break
return matches
def _find_region(memory_regions, starts, addr, length=4):
idx = bisect.bisect_right(starts, addr) - 1
if idx < 0:
return None
base, end, data = memory_regions[idx]
if base <= addr and addr + length <= end:
return base, end, data
return None
def _read_u32(memory_regions, starts, addr):
region = _find_region(memory_regions, starts, addr, 4)
if not region:
return None
base, _end, data = region
return struct.unpack_from("<I", data, addr - base)[0]
def _valid_ptr(memory_regions, starts, addr, length=4):
return _find_region(memory_regions, starts, addr, length) is not None
def _wxwork_page_size_chain(memory_regions, starts, cipher_addr):
"""Validate the AES cipher object by following the page-size pointer chain.
In WXWork 5.x's inlined wxSQLite3 AES-128 code, the decrypt path uses:
raw_key = cipher + 0x08
aes_ctx = *(cipher + 0x2c)
page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24)
"""
page_size_holder = _read_u32(memory_regions, starts, cipher_addr + 0x30)
if page_size_holder is None or not _valid_ptr(memory_regions, starts, page_size_holder, 8):
return None
page_size_obj = _read_u32(memory_regions, starts, page_size_holder + 4)
if page_size_obj is None or not _valid_ptr(memory_regions, starts, page_size_obj + 0x24, 4):
return None
return _read_u32(memory_regions, starts, page_size_obj + 0x24)
def _record_candidate_key(enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, addr, desc, print_fn):
matched = []
params_desc = desc
for rel, path, sz, salt_hex, page1 in db_files:
if salt_hex not in remaining_salts:
continue
ok, verified_desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = enc_key.hex()
remaining_salts.discard(salt_hex)
params_desc = verified_desc or params_desc
matched.extend(salt_to_dbs[salt_hex])
if matched:
print_fn(f"\n [FOUND-STRUCT] enc_key={enc_key.hex()}")
print_fn(f" params: {params_desc}")
print_fn(f" PID={pid} cipher对象地址: 0x{addr:08X}")
print_fn(f" 数据库: {', '.join(sorted(set(matched)))}")
return bool(matched)
def scan_memory_for_wxwork_cipher_structs(h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid,
print_fn, max_seconds=120):
"""Scan WXWork heap objects for the in-memory wxSQLite3 AES-128 cipher.
This is intentionally targeted: instead of brute-forcing every 16-byte
window as a key, it looks for the cipher object layout used by WXWork 5.x.
"""
t0 = time.time()
memory_regions = []
total_bytes = 0
for base, size in regions:
data = read_mem(h, base, size)
if data:
memory_regions.append((int(base), int(base) + len(data), data))
total_bytes += len(data)
memory_regions.sort(key=lambda item: item[0])
starts = [item[0] for item in memory_regions]
print_fn(f"[*] 结构体扫描内存: {total_bytes / 1024 / 1024:.0f}MB, {len(memory_regions)} 区域")
checked = 0
ptr_hits = 0
chain_hits = 0
key_tests = 0
page_sizes = {512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
for base, end, data in memory_regions:
max_off = len(data) - 0x40
off = 0
while off >= 0 and off < max_off:
if time.time() - t0 > max_seconds:
print_fn(
f"[WARN] 结构体扫描超时: checked={checked}, "
f"ptr_hits={ptr_hits}, chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
# The AES-128 decrypt branch checks two non-zero flags at +0 and +4.
flag0, flag4 = struct.unpack_from("<II", data, off)
if flag0 in (1, 2) and flag4 in (1, 2, 4096, 8192, 16384):
cipher_addr = base + off
aes_ctx = struct.unpack_from("<I", data, off + 0x2C)[0]
if _valid_ptr(memory_regions, starts, aes_ctx, 0x40):
ptr_hits += 1
page_size = _wxwork_page_size_chain(memory_regions, starts, cipher_addr)
if page_size in page_sizes:
chain_hits += 1
enc_key = data[off + 8 : off + 24]
if enc_key != b"\x00" * 16 and len(set(enc_key)) >= 6:
key_tests += 1
if _record_candidate_key(
enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, cipher_addr,
f"wxSQLite3 AES-128-CBC, page_size={page_size}",
print_fn,
):
if not remaining_salts:
return key_tests
checked += 1
off += 4
print_fn(
f"[*] 结构体扫描完成: checked={checked}, ptr_hits={ptr_hits}, "
f"chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
def cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print_fn):
"""用已找到的 key 交叉验证未匹配的 salt。"""
missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys())
if not missing_salts or not key_map:
return
print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...")
for salt_hex in list(missing_salts):
for rel, path, sz, s, page1 in db_files:
if s == salt_hex:
for known_salt, known_key_hex in key_map.items():
enc_key = bytes.fromhex(known_key_hex)
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = known_key_hex
print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}")
missing_salts.discard(salt_hex)
break
def save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn):
"""输出扫描结果并保存 JSON。"""
print_fn(f"\n{'=' * 60}")
print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥")
result = {}
for rel, path, sz, salt_hex, page1 in db_files:
if salt_hex in key_map:
result[rel] = {
"enc_key": key_map[salt_hex],
"salt": salt_hex,
"size_mb": round(sz / 1024 / 1024, 1)
}
print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)")
else:
print_fn(f" MISSING: {rel} (salt={salt_hex})")
if not result:
print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)")
raise RuntimeError("未能从任何企业微信进程中提取到密钥")
result["_db_dir"] = db_dir
with open(out_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print_fn(f"\n密钥保存到: {out_file}")
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
if missing:
print_fn(f"\n未找到密钥的数据库:")
for rel in missing:
print_fn(f" {rel}")
# ── 配置加载 ─────────────────────────────────────────────────────────
def _load_wxwork_config():
"""从 config.json 加载企业微信配置,必要时自动检测"""
from config import _config_file_path, _app_base_dir
config_file = _config_file_path()
cfg = {}
if os.path.exists(config_file):
try:
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
except json.JSONDecodeError:
cfg = {}
db_dir = cfg.get("wxwork_db_dir", "")
if not db_dir or not os.path.isdir(db_dir):
detected = auto_detect_wxwork_db_dir()
if detected:
print(f"[+] 自动检测到企业微信数据目录: {detected}")
cfg["wxwork_db_dir"] = detected
with open(config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"[+] 已保存到: {config_file}")
db_dir = detected
else:
print("[!] 未能自动检测企业微信数据目录")
print(f" 请在 {config_file} 中设置 wxwork_db_dir 字段")
print(" 路径格式: C:\\Users\\<用户>\\Documents\\WXWork\\<account_id>\\Data")
sys.exit(1)
keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json")
base = _app_base_dir()
if not os.path.isabs(keys_file):
keys_file = os.path.join(base, keys_file)
return {"wxwork_db_dir": db_dir, "wxwork_keys_file": keys_file}
# ── 主流程 ───────────────────────────────────────────────────────────
def main():
cfg = _load_wxwork_config()
db_dir = cfg["wxwork_db_dir"]
out_file = cfg["wxwork_keys_file"]
print("=" * 60)
print(" 提取所有企业微信数据库密钥")
print("=" * 60)
# 1. 收集所有DB文件及其salt
db_files, salt_to_dbs = collect_db_files(db_dir)
db_files, salt_to_dbs = filter_encrypted_dbs(db_files, salt_to_dbs)
print(f"\n找到 {len(db_files)} 个加密数据库, {len(salt_to_dbs)} 个不同的salt")
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
print(f" salt {salt_hex}: {', '.join(dbs)}")
# 2. 打开所有企业微信进程
pids = get_wxwork_pids()
# Some versions do not keep the key as SQL literal x'...'. Bare ASCII
# hex scanning is much slower, so keep it behind an explicit switch.
hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'")
scan_bare_hex = "--scan-bare-hex" in sys.argv
bare_hex_re = re.compile(b"(?<![0-9a-fA-F])([0-9a-fA-F]{32})(?![0-9a-fA-F])")
if scan_bare_hex:
print("[*] 已启用裸 32-hex key 扫描,速度会明显变慢")
key_map = {}
remaining_salts = set(salt_to_dbs.keys())
all_hex_matches = 0
all_bare_hex_matches = 0
t0 = time.time()
for pid, mem_kb in pids:
h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid)
if not h:
print(f"[WARN] 无法打开进程 PID={pid},跳过")
continue
try:
regions = enum_regions(h)
total_bytes = sum(s for _, s in regions)
total_mb = total_bytes / 1024 / 1024
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
scanned_bytes = 0
for reg_idx, (base, size) in enumerate(regions):
data = read_mem(h, base, size)
scanned_bytes += size
if not data:
continue
all_hex_matches += scan_memory_for_wxwork_keys(
data, hex_re, db_files, salt_to_dbs,
key_map, remaining_salts, base, pid, print,
)
if scan_bare_hex and remaining_salts:
all_bare_hex_matches += scan_memory_for_wxwork_keys(
data, bare_hex_re, db_files, salt_to_dbs,
key_map, remaining_salts, base, pid, print,
)
if (reg_idx + 1) % 200 == 0:
elapsed = time.time() - t0
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
print(
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
f"{all_hex_matches} x'...' patterns, "
f"{all_bare_hex_matches} bare hex patterns, {elapsed:.1f}s"
)
if remaining_salts:
print("\n[*] 未找到 x'...' 形式 key尝试 WXWork 5.x cipher 结构体扫描...")
scan_memory_for_wxwork_cipher_structs(
h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid, print,
)
finally:
kernel32.CloseHandle(h)
if not remaining_salts:
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
break
elapsed = time.time() - t0
print(
f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, "
f"{all_hex_matches} x'...' 模式, {all_bare_hex_matches} bare hex 模式"
)
cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print)
save_wxwork_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
if __name__ == '__main__':
try:
main()
except RuntimeError as e:
print(f"\n[ERROR] {e}")
sys.exit(1)